diff --git a/AUTOMATION_CONTEXT.md b/AUTOMATION_CONTEXT.md new file mode 100644 index 00000000..99e51352 --- /dev/null +++ b/AUTOMATION_CONTEXT.md @@ -0,0 +1,293 @@ +# Automation System — Context Document + +This document is written for AI assistants working on this codebase. It captures architectural decisions, the full vision, and the current state of the automation/tools system built during a long design and implementation session. Read this before touching anything in `src/servers/api/pi/`, `src/servers/api/skills/`, `src/servers/api/tasks/`, `src/servers/api/processes/`, `seed/tools/`, `seed/extensions/`, or `src/servers/sync-*.ts`. + +--- + +## What Officer Is + +Officer is a self-hosted AI-native intranet for small and medium businesses. Every user gets a personal AI assistant, terminal, file browser, code editor, workspaces, and automation tools. The platform is multi-user with role-based access: `Member → Admin → Owner → Super Admin`. + +The AI backend was originally Claude SDK / OpenCode. It has been migrated to **Pi** (`@mariozechner/pi-coding-agent`) as the primary agent harness, running in RPC mode as a subprocess managed by `src/servers/api/pi/pi-bridge.ts`. + +--- + +## The Automation Ladder + +The owner has a clear conceptual model for automation capabilities, ordered from atomic to orchestrated: + +| Rung | Description | Status | +|------|-------------|--------| +| **Skills** | Passive markdown documentation the agent reads as context. Reference for CLIs, APIs, services. | ✅ Implemented | +| **Tools** | Executable TypeScript functions Pi can call directly during agent execution. Registered via Pi extensions. | ✅ Implemented | +| **Tasks** | Structured instructions (TASK.md) to accomplish an atomic goal. Uses skills + tools. Has inputs, triggers, steps. | ✅ Skeleton exists | +| **Pipelines** | A linear sequence of tasks, run one after the other (think pipe). | 🔲 Phase 2 | +| **Processes** | A sequence of tasks that forks based on the result of the previous one (conditional branching). | 🔲 Phase 2 | +| **Workflows** | Arbitrary task graphs, like n8n. | 🔲 Phase 3 | +| **Services** | Event-driven execution — watches directories or events, triggers tasks based on configuration. | 🔲 Phase 3 | +| **Crons** | Scheduled execution of a single task, pipeline, process, or workflow. | 🔲 Phase 3 | + +**When the owner says "tool", they mean any rung of this ladder**, not just the Pi tool concept. + +### Key distinction: agent-interpreted vs headless execution + +Currently all tasks are **agent-interpreted** — the agent reads the TASK.md and reasons about how to execute it. In phase 2, well-defined tasks (deterministic inputs → tool call → result) should be executable **headlessly** without an LLM, directly by a task runner. The tool implementations are already standalone TypeScript functions that support this future — they have no dependency on Pi or the agent. + +--- + +## How Pi Is Integrated + +Pi runs as a subprocess in **RPC mode** (`pi --mode rpc`). The server communicates via stdin/stdout JSON. See `src/servers/api/pi/pi-bridge.ts` and `src/servers/api/pi/websocket.ts`. + +### Spawning Pi (host, non-sandboxed) + +``` +pi --mode rpc + --no-skills --no-prompt-templates --no-themes + --skill /data/skills/{name} (one per global skill) + --skill /data/{email}/skills/{name} (one per user skill) + --extension /data/extensions/{name}/index.ts + --model {model} + +env: + PI_CODING_AGENT_DIR = DATA_PATH/pi-config + PI_TOOLS_DIRS = DATA_PATH/tools:DATA_PATH/{email}/tools + PI_SEARXNG_URL = https://searxng.home.pastilhas.eu + + all stored API keys +``` + +Note: `--no-extensions` was intentionally removed to allow our tool-loader extension to work. `--no-skills` is kept because we pass skills explicitly via `--skill` flags to control scope correctly. + +### Spawning Pi (sandboxed, Docker container) + +Same pattern but via `docker exec`, using container-side paths: + +``` +docker exec -i -w {workdir} \ + -e PI_CODING_AGENT_DIR=/home/{username}/.pi/agent \ + -e PI_TOOLS_DIRS=/officer/tools:/officer/user/tools \ + -e PI_SEARXNG_URL=... \ + -e {API_KEYS} \ + {containerId} \ + pi --mode rpc --no-skills --no-prompt-templates --no-themes \ + --skill /officer/skills/{name} \ + --skill /officer/user/skills/{name} \ + --extension /officer/extensions/tool-loader/index.ts +``` + +--- + +## Directory Structure + +``` +DATA_PATH/ # Default: ./data, override with DATA_PATH env +├── pi-config/ # Pi's global config (models.json, settings.json) +├── skills/ # Global skills (org-wide) +├── tools/ # Global tools (org-wide) +├── extensions/ # Global extensions (managed by server, not users) +├── {email}/ +│ ├── home/ # Mounted as /home/{username} in Docker container +│ ├── skills/ # User-specific skills +│ ├── tools/ # User-specific tools +│ └── extensions/ # User-specific extensions (future) +└── searxng.json # SearXNG instance URL config + +seed/ # Bundled with the app (read-only source of truth) +├── skills/ # Native/officerdev skills +├── tools/ # Native/officerdev tools +├── extensions/ # Native/officerdev extensions +│ └── tool-loader/index.ts # The Pi extension that registers tools +└── tasks/ # Native/officerdev tasks +``` + +### Sync on server start (`src/servers/bootstrap.ts`) + +- `syncSeedSkills()` → copies `seed/skills/*` → `DATA_PATH/skills/` (skip if exists, preserves user edits) +- `syncSeedTools()` → copies `seed/tools/*` → `DATA_PATH/tools/` (skip if exists) +- `syncSeedExtensions()` → copies `seed/extensions/*` → `DATA_PATH/extensions/` (**always overwrites** — extensions are server-managed code, not user-editable) + +--- + +## The Tool System + +### How tools are defined + +Each tool lives in a directory with two files: + +``` +seed/tools/web-fetch/ +├── TOOL.md # Metadata + input schema (read by tool-loader, registered with Pi) +└── index.ts # Implementation (lazy-loaded when the tool is actually called) +``` + +**TOOL.md frontmatter format:** +```yaml +--- +name: tool_name # Pi tool name (snake_case) +label: Tool Label # Human-readable label +description: ... # What Pi sees in its context (keep concise — this is in every session prompt) +language: typescript # typescript | bash | python +inputs: + param_name: + type: string # string | number | boolean | enum + description: ... + optional: true # omit if required + mode: + type: enum + values: single,batch # comma-separated for enum (parser limitation) + description: ... +--- +``` + +**index.ts export signature:** +```typescript +export async function execute( + toolCallId: string, + params: { [key: string]: any }, + signal: AbortSignal | undefined, + onUpdate?: (partial: { content: Array<{ type: string; text: string }> }) => void, +): Promise<{ content: Array<{ type: string; text: string }>; details?: object; isError?: boolean }> +``` + +The `onUpdate` callback streams progress to the agent. Use it liberally — the frontend shows it in real time. + +### How the tool-loader extension works (`seed/extensions/tool-loader/index.ts`) + +- Loaded by Pi as an extension via `--extension` flag +- Reads `PI_TOOLS_DIRS` env var (colon-separated list of directories) +- Discovers `TOOL.md` in each directory, parses frontmatter, builds TypeBox schema +- Registers each tool synchronously in the extension factory function (so tools appear in the system prompt) +- Lazy-loads `index.ts` implementation only when the tool is actually called +- User dirs come after global — later registration wins (user tools override global by name) + +### Existing tools + +| Tool | Location | Description | +|------|----------|-------------| +| `web_fetch` | `seed/tools/web-fetch/` | Fetch URL content. Tries `.md` suffix → `/llms.txt` → plain text → HTML strip | +| `web_search` | `seed/tools/web-search/` | Search via SearXNG. Returns titles, URLs, snippets. Pair with web_fetch | +| `convert_audio_to_mp3` | `seed/tools/convert-audio-to-mp3/` | Convert audio to MP3 via ffmpeg. Single file (% progress) or batch directory (per-file progress) | + +### Adding a new tool + +1. Create `seed/tools/{name}/TOOL.md` and `seed/tools/{name}/index.ts` +2. Restart server — `syncSeedTools()` copies it to `DATA_PATH/tools/` +3. New Pi sessions automatically get the tool registered + +--- + +## Docker Container Setup + +Each user has a persistent Docker container (`officer-terminal-{userId}`). Containers are managed by `src/servers/api/terminal/websocket.ts`. + +### Read-only resource mounts + +Added to every container at creation time: + +``` +/officer/skills → DATA_PATH/skills (global skills, ro) +/officer/tools → DATA_PATH/tools (global tools, ro) +/officer/extensions → DATA_PATH/extensions (global extensions, ro) +/officer/user/skills → DATA_PATH/{email}/skills (user skills, ro) +/officer/user/tools → DATA_PATH/{email}/tools (user tools, ro) +``` + +The user's home directory (`DATA_PATH/{email}/home`) is mounted read-write as `/home/{username}`. + +### Mount migration + +`ensureDockerContainer` checks `containerHasResourceMounts()` before reusing an existing container. If the mounts are missing (old container created before this feature), the container is removed and recreated automatically. This is a one-time migration. + +--- + +## Scope & Permission Model + +All automation entities (skills, tasks, processes, and future rungs) follow the same three-tier scope model: + +| Scope | Location | Who creates | Who can see | +|-------|----------|-------------|-------------| +| `native` | `seed/` (read-only) | officerdev (us) | Everyone | +| `global` | `DATA_PATH/{type}/` | Admins+ | Everyone | +| `user` | `DATA_PATH/{email}/{type}/` | Anyone | Owner + SAs (future) | + +### Current API behavior (skills, tasks, processes) + +```typescript +function isPrivileged(role: string) { + return role === 'Admin' || role === 'Owner' || role === 'Super Admin'; +} +``` + +- `POST /` (create): Members → user scope. Admins+ → global scope. +- `DELETE /:name`: Members can only delete their own (user scope). Admins+ can delete global. +- `PUT /:name/chat` / `DELETE /:name/chat`: Members blocked from writing to native/global entries. + +### Full vision (phase 2/3 — not yet implemented) + +- **Super Admins** can see all users' personal items (currently everyone only sees their own) +- **Members can submit** their personal item for SA review (`status: draft | submitted | approved | rejected` — a flag in frontmatter, not a new scope) +- **SAs can promote** any user's item to global — this is a **move** (not a copy), item leaves user scope +- **SAs can see and use** everyone's tools +- The `CapabilitySummary` type already has `scope` surfaced in the frontend list with a badge + +--- + +## SearXNG Integration + +- Config stored in `DATA_PATH/searxng.json` +- Default URL: `https://searxng.home.pastilhas.eu` (hardcoded default in `src/servers/api/server-settings/searxng.ts`) +- API: `GET /api/server-settings/searxng`, `PUT /api/server-settings/searxng` +- Injected into Pi processes as `PI_SEARXNG_URL` env var (both host and container) +- UI configuration is a future task — the API is already there + +--- + +## Attribution & Marketplace Vision (future) + +The owner has a clear attribution model: + +- **Native/officerdev**: `officerdev/` — built-in, shipped with the product +- **User-created org tools**: attribution via `author: ` in frontmatter +- **Marketplace — officerdev official**: `officerdev/` +- **Marketplace — third party**: `/` or `/` + +Future marketplace will cover: Skills, Tools, Tasks, Pipelines, Processes, Workflows, Services, Crons, **Widgets**, **Apps**, and **Themes**. Widgets and Apps are already bootstrapped in the platform. Each self-hosted instance will connect to a central webstore. + +The `native` scope covers both built-in and future marketplace-installed items. When marketplace is implemented, `native` may split into `native` (local seed) and `marketplace` (installed from store, with version + source metadata). + +--- + +## Files Changed / Created + +### New files +- `src/servers/sync-tools.ts` — mirrors sync-skills pattern for tools +- `src/servers/sync-extensions.ts` — same for extensions (always overwrites) +- `src/servers/api/server-settings/searxng.ts` — SearXNG config router +- `seed/tools/web-fetch/TOOL.md` + `index.ts` +- `seed/tools/web-search/TOOL.md` + `index.ts` +- `seed/tools/convert-audio-to-mp3/TOOL.md` + `index.ts` +- `seed/extensions/tool-loader/index.ts` + +### Modified files +- `src/servers/data-path.ts` — added tool/extension dir helpers +- `src/servers/bootstrap.ts` — calls syncSeedTools, syncSeedExtensions +- `src/servers/api/pi/pi-bridge.ts` — removed `--no-extensions`, added extension/tool flags, PI_TOOLS_DIRS, PI_SEARXNG_URL, container-aware path support +- `src/servers/api/terminal/websocket.ts` — added 5 read-only resource mounts to containers, mount migration check +- `src/servers/api/skills/skills.ts` — fixed scope-aware POST/DELETE/chat endpoints +- `src/servers/api/tasks/tasks.ts` — same fixes +- `src/servers/api/processes/processes.ts` — same fixes +- `src/servers/api/server-settings/server-settings.ts` — registered searxng router +- `seed/skills/convert-audio-to-mp3/SKILL.md` — removed hardcoded paths, references tool instead +- `seed/tasks/convert-to-mp3/TASK.md` — updated to use tool, added `tools:` frontmatter field +- `src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx` — fixed `scope` type to include `'native'` + +--- + +## What To Do Next (Rough Priority) + +1. **Wire `tool_execution_update` events** through `pi-bridge.ts` → websocket → frontend so `onUpdate` progress actually shows in the chat UI +2. **Container support testing** — verify tools and extensions work correctly inside Docker after the read-only mount changes +3. **SA cross-user visibility** — API + UI for Super Admins to see all users' personal items +4. **Submit/approve workflow** — `status` flag in frontmatter, submission UI for members, review UI for SAs +5. **SearXNG UI** — settings panel to configure the URL (API is already done) +6. **Headless task runner** — execute deterministic tasks without an agent (phase 2) diff --git a/seed/extensions/tool-loader/index.ts b/seed/extensions/tool-loader/index.ts index 456d7321..40ede778 100644 --- a/seed/extensions/tool-loader/index.ts +++ b/seed/extensions/tool-loader/index.ts @@ -114,7 +114,14 @@ function buildSchema(inputs: Record): TSchema { switch (param.type) { case 'enum': { - const values = param.values ?? []; + // values can be a string[] from deeper YAML parsing, + // or a comma-separated string like "single,batch" from flat YAML + const raw = param.values; + const values = Array.isArray(raw) + ? raw + : typeof raw === 'string' + ? raw.split(',').map((v) => v.trim()) + : []; schema = Type.Union(values.map((v) => Type.Literal(v)), { description: param.description, }); diff --git a/seed/tasks/convert-to-mp3/TASK.md b/seed/tasks/convert-to-mp3/TASK.md index bc3e6740..798e2dff 100644 --- a/seed/tasks/convert-to-mp3/TASK.md +++ b/seed/tasks/convert-to-mp3/TASK.md @@ -1,13 +1,15 @@ --- name: Convert To MP3 description: Convert audio files to MP3 320kbps, preserving metadata. -version: 1 +version: 2 author: pastilhas tags: - audio - conversion skills: - convert-audio-to-mp3 +tools: + - convert_audio_to_mp3 trigger: - type: file extensions: @@ -39,6 +41,7 @@ Convert audio files to MP3 320kbps, preserving metadata. ## Steps 1. Determine whether `file_path` points to a single audio file or a directory. -2. If it is a single file, use the Convert Audio To MP3 skill's single-file script to convert it. The source file is deleted on success. -3. If it is a directory, use the Convert Audio To MP3 skill's batch script with the directory name as the artist name. All audio files within subdirectories are converted recursively. -4. Verify the conversion completed successfully and report the result to the user. +2. Call `convert_audio_to_mp3` with the appropriate mode: + - Single file: `mode="single"`, `path=file_path` + - Directory: `mode="batch"`, `path=file_path` +3. Report the result to the user. diff --git a/seed/tools/convert-audio-to-mp3/TOOL.md b/seed/tools/convert-audio-to-mp3/TOOL.md new file mode 100644 index 00000000..1ecfb84a --- /dev/null +++ b/seed/tools/convert-audio-to-mp3/TOOL.md @@ -0,0 +1,31 @@ +--- +name: convert_audio_to_mp3 +label: Convert Audio to MP3 +description: Convert audio files to MP3 320kbps using ffmpeg, preserving metadata. Supports single file conversion with real-time percentage progress, and batch conversion of an entire artist directory with per-file progress. Use when the user wants to convert FLAC, WAV, OGG, or other audio formats to MP3. +language: typescript +inputs: + mode: + type: enum + values: single,batch + description: "single: convert one file. batch: convert all audio files recursively in a directory" + path: + type: string + description: "single mode: absolute path to the audio file. batch mode: absolute path to the artist directory" +--- + +# Convert Audio to MP3 + +Converts audio to MP3 320kbps CBR via libmp3lame, preserving all metadata tags. + +## Modes + +### single +Converts one file. Reports ffmpeg percentage progress in real time. Deletes source on success. + +### batch +Scans a directory recursively for all supported audio files. Reports per-file progress. +- All succeed → deletes all source files +- Any failure → deletes all successfully created MP3s for a clean retry + +## Supported formats +flac, wav, ogg, wma, aac, m4a, opus, aiff, aif, ape, wv, alac, dsf, dff diff --git a/seed/tools/convert-audio-to-mp3/index.ts b/seed/tools/convert-audio-to-mp3/index.ts new file mode 100644 index 00000000..d89e3c8c --- /dev/null +++ b/seed/tools/convert-audio-to-mp3/index.ts @@ -0,0 +1,267 @@ +import { readdirSync, existsSync, unlinkSync, statSync } from 'node:fs'; +import { join, extname, basename } from 'node:path'; + +const AUDIO_EXTS = new Set([ + 'flac', 'wav', 'ogg', 'wma', 'aac', 'm4a', 'opus', + 'aiff', 'aif', 'ape', 'wv', 'alac', 'dsf', 'dff', +]); + +type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void; + +function update(onUpdate: OnUpdate | undefined, text: string): void { + onUpdate?.({ content: [{ type: 'text', text }] }); +} + +async function getDuration(filePath: string): Promise { + const proc = Bun.spawn( + ['ffprobe', '-v', 'quiet', '-show_entries', 'format=duration', '-of', 'csv=p=0', filePath], + { stdout: 'pipe', stderr: 'pipe' }, + ); + const text = await new Response(proc.stdout).text(); + await proc.exited; + const n = parseFloat(text.trim()); + return isNaN(n) ? null : n; +} + +function scanAudioFiles(dir: string): string[] { + const files: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...scanAudioFiles(full)); + } else if (entry.isFile()) { + const ext = extname(entry.name).slice(1).toLowerCase(); + if (AUDIO_EXTS.has(ext)) files.push(full); + } + } + return files; +} + +type ConvertResult = { + outputFile: string; + success: boolean; + skipped?: boolean; + error?: string; +}; + +async function convertFile( + inputFile: string, + duration: number | null, + label: string, + onUpdate: OnUpdate | undefined, +): Promise { + const ext = extname(inputFile); + const outputFile = inputFile.slice(0, -ext.length) + '.mp3'; + + if (existsSync(outputFile)) { + return { outputFile, success: true, skipped: true }; + } + + const proc = Bun.spawn( + [ + 'ffmpeg', + '-i', inputFile, + '-progress', 'pipe:1', // progress data → stdout + '-nostats', + '-loglevel', 'error', // only errors → stderr + '-codec:a', 'libmp3lame', + '-b:a', '320k', + outputFile, + '-y', + ], + { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }, + ); + + // Parse -progress output from stdout for real-time percentage + const reader = (proc.stdout as ReadableStream).getReader(); + const decoder = new TextDecoder(); + let buf = ''; + let lastPercent = -1; + + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop() ?? ''; + for (const line of lines) { + const m = line.match(/^out_time_us=(\d+)$/); + if (m && duration) { + const pct = Math.min(100, Math.round((parseInt(m[1]!, 10) / 1_000_000 / duration) * 100)); + if (pct >= lastPercent + 5) { + lastPercent = pct; + update(onUpdate, `${label} ${pct}%`); + } + } + } + } + } catch { + // stream closed + } + })(); + + const stderrText = await new Response(proc.stderr).text(); + await proc.exited; + + if (proc.exitCode !== 0) { + if (existsSync(outputFile)) unlinkSync(outputFile); + return { outputFile, success: false, error: stderrText.trim() || 'ffmpeg failed' }; + } + + return { outputFile, success: true }; +} + +export async function execute( + _toolCallId: string, + params: { mode: 'single' | 'batch'; path: string }, + _signal: AbortSignal | undefined, + onUpdate?: OnUpdate, +) { + const { mode, path } = params; + + // ── Single file ────────────────────────────────────────────────────────── + if (mode === 'single') { + if (!existsSync(path)) { + return { + content: [{ type: 'text', text: `File not found: ${path}` }], + details: { error: 'file_not_found' }, + isError: true, + }; + } + + const ext = extname(path).slice(1).toLowerCase(); + if (!AUDIO_EXTS.has(ext)) { + return { + content: [{ type: 'text', text: `Unsupported format: .${ext}\nSupported: ${[...AUDIO_EXTS].join(', ')}` }], + details: { error: 'unsupported_format' }, + isError: true, + }; + } + + update(onUpdate, `Getting duration of ${basename(path)}...`); + const duration = await getDuration(path); + + update(onUpdate, `Converting ${basename(path)}...`); + const result = await convertFile(path, duration, 'Progress:', onUpdate); + + if (result.skipped) { + return { + content: [{ type: 'text', text: `Skipped: ${result.outputFile} already exists` }], + details: { skipped: true, outputFile: result.outputFile }, + }; + } + + if (!result.success) { + return { + content: [{ type: 'text', text: `Failed to convert ${basename(path)}:\n${result.error}` }], + details: { error: result.error }, + isError: true, + }; + } + + unlinkSync(path); + + return { + content: [{ type: 'text', text: `Done.\nConverted: ${basename(result.outputFile)}\nDeleted source: ${basename(path)}` }], + details: { outputFile: result.outputFile }, + }; + } + + // ── Batch ───────────────────────────────────────────────────────────────── + if (!existsSync(path)) { + return { + content: [{ type: 'text', text: `Directory not found: ${path}` }], + details: { error: 'dir_not_found' }, + isError: true, + }; + } + + if (!statSync(path).isDirectory()) { + return { + content: [{ type: 'text', text: `Not a directory: ${path}\nUse mode="single" for individual files.` }], + details: { error: 'not_a_directory' }, + isError: true, + }; + } + + update(onUpdate, `Scanning ${basename(path)} for audio files...`); + const audioFiles = scanAudioFiles(path); + + if (audioFiles.length === 0) { + return { + content: [{ type: 'text', text: `No audio files found in: ${path}` }], + details: { found: 0 }, + }; + } + + update(onUpdate, `Found ${audioFiles.length} audio files. Starting conversion...`); + + type BatchResult = ConvertResult & { input: string }; + const results: BatchResult[] = []; + + for (let i = 0; i < audioFiles.length; i++) { + const inputFile = audioFiles[i]!; + const label = `[${i + 1}/${audioFiles.length}] ${basename(inputFile)}`; + + update(onUpdate, `Converting ${label}...`); + const duration = await getDuration(inputFile); + const result = await convertFile(inputFile, duration, label, onUpdate); + + results.push({ ...result, input: inputFile }); + + if (result.skipped) { + update(onUpdate, `→ Skipped ${label} (MP3 already exists)`); + } else if (result.success) { + update(onUpdate, `✓ Done ${label}`); + } else { + update(onUpdate, `✗ Failed ${label}: ${result.error}`); + } + } + + const converted = results.filter((r) => r.success && !r.skipped); + const failed = results.filter((r) => !r.success); + const skipped = results.filter((r) => r.skipped); + + if (failed.length === 0) { + // All succeeded — delete source files + update(onUpdate, `All conversions succeeded. Deleting ${converted.length} source files...`); + for (const r of converted) unlinkSync(r.input); + + const lines = [ + `Conversion complete.`, + `Converted: ${converted.length}`, + skipped.length > 0 ? `Skipped (already existed): ${skipped.length}` : null, + `Source files deleted: ${converted.length}`, + ].filter(Boolean); + + return { + content: [{ type: 'text', text: lines.join('\n') }], + details: { converted: converted.length, failed: 0, skipped: skipped.length }, + }; + } + + // Some failed — delete created MP3s for a clean retry + update(onUpdate, `${failed.length} failure(s). Rolling back ${converted.length} created MP3(s) for clean retry...`); + for (const r of converted) { + if (existsSync(r.outputFile)) unlinkSync(r.outputFile); + } + + const failLines = failed.map((r) => ` - ${basename(r.input)}: ${r.error}`).join('\n'); + + return { + content: [{ + type: 'text', + text: [ + `Conversion failed. ${failed.length}/${audioFiles.length} file(s) could not be converted.`, + `Successfully created MP3s have been removed — the directory is unchanged for a clean retry.`, + ``, + `Failed files:`, + failLines, + ].join('\n'), + }], + details: { converted: 0, failed: failed.length, rolledBack: converted.length }, + isError: true, + }; +} diff --git a/seed/tools/web-search/TOOL.md b/seed/tools/web-search/TOOL.md new file mode 100644 index 00000000..9e26b0b7 --- /dev/null +++ b/seed/tools/web-search/TOOL.md @@ -0,0 +1,27 @@ +--- +name: web_search +label: Web Search +description: Search the web using a private SearXNG instance and return a list of results with titles, URLs, and snippets. Use when you need to find information or URLs without already knowing where to look. Pair with web_fetch to read the full content of any result. +language: typescript +inputs: + query: + type: string + description: The search query + max_results: + type: number + description: Maximum number of results to return (default 10, max 20) + optional: true +--- + +# Web Search + +Searches the web via a self-hosted SearXNG instance. Returns titles, URLs, and content snippets. + +## Usage pattern + +1. Call `web_search` with a query to get a list of results +2. Call `web_fetch` on any result URL to read its full content + +## Configuration + +The SearXNG instance URL is read from the `PI_SEARXNG_URL` environment variable. diff --git a/seed/tools/web-search/index.ts b/seed/tools/web-search/index.ts new file mode 100644 index 00000000..1cb559b5 --- /dev/null +++ b/seed/tools/web-search/index.ts @@ -0,0 +1,124 @@ +const TIMEOUT_MS = 10_000; +const DEFAULT_MAX_RESULTS = 10; +const HARD_MAX_RESULTS = 20; + +type SearxngResult = { + title: string; + url: string; + content?: string; + engine?: string; + score?: number; +}; + +type SearxngResponse = { + query: string; + number_of_results: number; + results: SearxngResult[]; +}; + +type OnUpdate = (partial: { content: Array<{ type: string; text: string }> }) => void; + +function update(onUpdate: OnUpdate | undefined, text: string): void { + onUpdate?.({ content: [{ type: 'text', text }] }); +} + +function formatResults(results: SearxngResult[]): string { + if (results.length === 0) return 'No results found.'; + + return results + .map((r, i) => { + const lines = [`${i + 1}. **${r.title}**`, ` ${r.url}`]; + if (r.content?.trim()) lines.push(` ${r.content.trim()}`); + return lines.join('\n'); + }) + .join('\n\n'); +} + +export async function execute( + _toolCallId: string, + params: { query: string; max_results?: number }, + _signal: AbortSignal | undefined, + onUpdate?: OnUpdate, +) { + const searxngUrl = process.env.PI_SEARXNG_URL; + + if (!searxngUrl) { + return { + content: [{ type: 'text', text: 'Web search is not configured. PI_SEARXNG_URL is not set.' }], + details: { error: 'not_configured' }, + isError: true, + }; + } + + const { query } = params; + const maxResults = Math.min(params.max_results ?? DEFAULT_MAX_RESULTS, HARD_MAX_RESULTS); + + if (!query?.trim()) { + return { + content: [{ type: 'text', text: 'Query cannot be empty.' }], + details: { error: 'empty_query' }, + isError: true, + }; + } + + update(onUpdate, `Searching for: ${query}`); + + const searchUrl = new URL('/search', searxngUrl); + searchUrl.searchParams.set('q', query); + searchUrl.searchParams.set('format', 'json'); + searchUrl.searchParams.set('categories', 'general'); + + let response: Response; + try { + response = await fetch(searchUrl.toString(), { + signal: AbortSignal.timeout(TIMEOUT_MS), + headers: { Accept: 'application/json' }, + }); + } catch (err) { + return { + content: [{ type: 'text', text: `Failed to reach SearXNG at ${searxngUrl}: ${String(err)}` }], + details: { error: 'fetch_failed', url: searxngUrl }, + isError: true, + }; + } + + if (!response.ok) { + return { + content: [{ type: 'text', text: `SearXNG returned HTTP ${response.status}` }], + details: { error: 'http_error', status: response.status }, + isError: true, + }; + } + + let data: SearxngResponse; + try { + data = (await response.json()) as SearxngResponse; + } catch { + return { + content: [{ type: 'text', text: 'SearXNG returned an invalid response.' }], + details: { error: 'invalid_json' }, + isError: true, + }; + } + + const results = (data.results ?? []).slice(0, maxResults); + + update(onUpdate, `Found ${data.number_of_results ?? results.length} results, returning top ${results.length}`); + + const output = [ + `Search: "${query}"`, + `Results: ${results.length}`, + ``, + formatResults(results), + ].join('\n'); + + return { + content: [{ type: 'text', text: output }], + details: { + query, + total: data.number_of_results ?? results.length, + returned: results.length, + results: results.map((r) => ({ title: r.title, url: r.url })), + }, + }; +} diff --git a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx index 14903751..cdefa6d8 100644 --- a/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx +++ b/src/apps/officer-web/Screens/Dashboard/CapabilityPage.tsx @@ -14,7 +14,7 @@ type CapabilitySummary = { dirName: string; name: string; description: string; - scope: 'global' | 'user'; + scope: 'native' | 'global' | 'user'; }; export type CapabilityDetail = CapabilitySummary & { diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts index 8d06a8ee..43e6becc 100644 --- a/src/servers/api/pi/pi-bridge.ts +++ b/src/servers/api/pi/pi-bridge.ts @@ -3,24 +3,28 @@ import { readdirSync, existsSync, mkdirSync } from "node:fs"; import type { Subprocess } from "bun"; import type { PiEvent, MessageCost } from "./types"; import { readApiKeys } from "../server-settings/pi-mono"; +import { readSearxngConfig } from "../server-settings/searxng"; import { PI_CONFIG_DIR, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir } from "../../data-path"; import { ensureDockerContainer } from "../terminal/websocket"; import { logger } from "./logger"; export type PiEventHandler = (event: PiEvent) => void; -function collectSkillFlags(email: string): string[] { - const flags: string[] = []; - const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)]; +type PathOverrides = { global: string; user: string }; - for (const dir of dirs) { - if (!existsSync(dir)) continue; - const entries = readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { +function collectSkillFlags(email: string, containerPaths?: PathOverrides): string[] { + const flags: string[] = []; + const pairs: Array<[hostDir: string, outputDir: string]> = [ + [getGlobalSkillsDir(), containerPaths?.global ?? getGlobalSkillsDir()], + [getUserSkillsDir(email), containerPaths?.user ?? getUserSkillsDir(email)], + ]; + + for (const [hostDir, outputDir] of pairs) { + if (!existsSync(hostDir)) continue; + for (const entry of readdirSync(hostDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; - const skillFile = join(dir, entry.name, 'SKILL.md'); - if (existsSync(skillFile)) { - flags.push('--skill', join(dir, entry.name)); + if (existsSync(join(hostDir, entry.name, 'SKILL.md'))) { + flags.push('--skill', `${outputDir}/${entry.name}`); } } } @@ -28,18 +32,19 @@ function collectSkillFlags(email: string): string[] { return flags; } -function collectExtensionFlags(email: string): string[] { +function collectExtensionFlags(email: string, containerPaths?: PathOverrides): string[] { const flags: string[] = []; - const dirs = [getGlobalExtensionsDir(), getUserExtensionsDir(email)]; + const pairs: Array<[hostDir: string, outputDir: string]> = [ + [getGlobalExtensionsDir(), containerPaths?.global ?? getGlobalExtensionsDir()], + [getUserExtensionsDir(email), containerPaths?.user ?? getUserExtensionsDir(email)], + ]; - for (const dir of dirs) { - if (!existsSync(dir)) continue; - const entries = readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { + for (const [hostDir, outputDir] of pairs) { + if (!existsSync(hostDir)) continue; + for (const entry of readdirSync(hostDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; - const entryFile = join(dir, entry.name, 'index.ts'); - if (existsSync(entryFile)) { - flags.push('--extension', entryFile); + if (existsSync(join(hostDir, entry.name, 'index.ts'))) { + flags.push('--extension', `${outputDir}/${entry.name}/index.ts`); } } } @@ -66,17 +71,35 @@ export async function spawnPi( if (sandbox) { const container = await ensureDockerContainer(sandbox.email, sandbox.userId, sandbox.homeDir, sandbox.username); const storedKeys = await readApiKeys(); + const searxng = await readSearxngConfig(); const dockerPath = Bun.which('docker') ?? 'docker'; const containerId = container.dockerId; const containerHome = `/home/${sandbox.username}`; const containerPiConfig = `${containerHome}/.pi/agent`; - const piArgs = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes']; + + // Collect skill/extension flags using container-side paths + const skillFlags = collectSkillFlags(sandbox.email, { + global: '/officer/skills', + user: '/officer/user/skills', + }); + const extensionFlags = collectExtensionFlags(sandbox.email, { + global: '/officer/extensions', + user: '/officer/user/extensions', + }); + + const piArgs = [ + 'pi', '--mode', 'rpc', + '--no-skills', '--no-prompt-templates', '--no-themes', + ...skillFlags, + ...extensionFlags, + ]; if (model) piArgs.push('--model', model); - // Build env flags: Pi config dir + all stored API keys const envFlags = [ '-e', `PI_CODING_AGENT_DIR=${containerPiConfig}`, '-e', `HOME=${containerHome}`, + '-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`, + '-e', `PI_SEARXNG_URL=${searxng.url}`, ]; for (const [key, value] of Object.entries(storedKeys)) { if (value?.trim()) envFlags.push('-e', `${key}=${value.trim()}`); @@ -96,9 +119,15 @@ export async function spawnPi( stderr: 'pipe', }); - logger.info('Spawned Pi in container', { containerId, model }); + logger.info('Spawned Pi in container', { + containerId, + model, + skills: skillFlags.filter((f) => f !== '--skill').length, + extensions: extensionFlags.filter((f) => f !== '--extension').length, + }); } else { const storedKeys = await readApiKeys(); + const searxng = await readSearxngConfig(); const skillFlags = collectSkillFlags(email); const extensionFlags = collectExtensionFlags(email); const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags]; @@ -115,7 +144,7 @@ export async function spawnPi( stdin: 'pipe', stdout: 'pipe', stderr: 'pipe', - env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs }, + env: { ...process.env, ...storedKeys, PI_CODING_AGENT_DIR: PI_CONFIG_DIR, PI_TOOLS_DIRS: toolsDirs, PI_SEARXNG_URL: searxng.url }, }); logger.info('Spawned Pi locally', { diff --git a/src/servers/api/processes/processes.ts b/src/servers/api/processes/processes.ts index 50257bdd..2aee650a 100644 --- a/src/servers/api/processes/processes.ts +++ b/src/servers/api/processes/processes.ts @@ -53,6 +53,10 @@ function resolveFile(name: string, native: Map, global: Map { @@ -134,6 +138,8 @@ processesRouter.put('/:name/chat', async (ctx) => { const resolved = resolveFile(name, nativeProcesses, globalProcesses, userProcesses); if (!resolved) return ctx.text('Not found', 404); + if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403); + const chatDir = join(dirname(resolved.filePath), 'chat'); const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>(); @@ -155,6 +161,8 @@ processesRouter.delete('/:name/chat', async (ctx) => { const resolved = resolveFile(name, nativeProcesses, globalProcesses, userProcesses); if (!resolved) return ctx.text('Not found', 404); + if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403); + const chatDir = join(dirname(resolved.filePath), 'chat'); await rm(chatDir, { recursive: true, force: true }); @@ -162,13 +170,17 @@ processesRouter.delete('/:name/chat', async (ctx) => { }); processesRouter.post('/', async (ctx) => { + const user = ctx.get('user'); const { name } = await ctx.req.json<{ name: string }>(); if (!name?.trim()) return ctx.text('Name is required', 400); const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); if (!dirName) return ctx.text('Invalid name', 400); - const dir = join(getGlobalProcessesDir(), dirName); + const targetDir = isPrivileged(user.role) ? getGlobalProcessesDir() : getUserProcessesDir(user.email); + const scope: Scope = isPrivileged(user.role) ? 'global' : 'user'; + + const dir = join(targetDir, dirName); const filePath = join(dir, 'PROCESS.md'); if (await Bun.file(filePath).exists()) { @@ -178,18 +190,22 @@ processesRouter.post('/', async (ctx) => { await mkdir(dir, { recursive: true }); await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`); - return ctx.json({ name: name.trim(), dirName, filePath }); + return ctx.json({ name: name.trim(), dirName, filePath, scope }); }); processesRouter.delete('/:name', async (ctx) => { + const user = ctx.get('user'); const name = ctx.req.param('name'); - const globalDir = join(getGlobalProcessesDir(), name); - const globalFile = join(globalDir, 'PROCESS.md'); - if (!(await Bun.file(globalFile).exists())) { - return ctx.text('Not found', 404); - } + const nativeProcesses = await readProcessDirs(getNativeProcessesDir()); + const globalProcesses = await readProcessDirs(getGlobalProcessesDir()); + const userProcesses = await readProcessDirs(getUserProcessesDir(user.email)); - await rm(globalDir, { recursive: true }); + const resolved = resolveFile(name, nativeProcesses, globalProcesses, userProcesses); + if (!resolved) return ctx.text('Not found', 404); + + if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403); + + await rm(dirname(resolved.filePath), { recursive: true }); return ctx.json({ ok: true }); }); diff --git a/src/servers/api/server-settings/searxng.ts b/src/servers/api/server-settings/searxng.ts new file mode 100644 index 00000000..85f37f50 --- /dev/null +++ b/src/servers/api/server-settings/searxng.ts @@ -0,0 +1,39 @@ +import { join } from 'node:path'; +import { createRouter } from '../../create-router'; +import { DATA_PATH } from '../../data-path'; + +export const searxngRouter = createRouter(); + +const SEARXNG_FILE = join(DATA_PATH, 'searxng.json'); + +const DEFAULT_URL = 'https://searxng.home.pastilhas.eu'; + +export type SearxngConfig = { + url: string; +}; + +export async function readSearxngConfig(): Promise { + try { + const file = Bun.file(SEARXNG_FILE); + if (!(await file.exists())) return { url: DEFAULT_URL }; + return (await file.json()) as SearxngConfig; + } catch { + return { url: DEFAULT_URL }; + } +} + +async function writeSearxngConfig(config: SearxngConfig) { + await Bun.write(SEARXNG_FILE, JSON.stringify(config, null, 2)); +} + +searxngRouter.get('/', async (ctx) => { + return ctx.json(await readSearxngConfig()); +}); + +searxngRouter.put('/', async (ctx) => { + const { url } = await ctx.req.json<{ url: string }>(); + if (!url?.trim()) return ctx.json({ error: 'URL is required' }, 400); + const config: SearxngConfig = { url: url.trim().replace(/\/+$/, '') }; + await writeSearxngConfig(config); + return ctx.json(config); +}); diff --git a/src/servers/api/server-settings/server-settings.ts b/src/servers/api/server-settings/server-settings.ts index 6d2be075..b98cf242 100644 --- a/src/servers/api/server-settings/server-settings.ts +++ b/src/servers/api/server-settings/server-settings.ts @@ -12,6 +12,7 @@ import { smtpRouter } from './smtp'; import { ttsRouter } from './tts'; import { sttRouter } from './stt'; import { ocrRouter } from './ocr'; +import { searxngRouter } from './searxng'; const configDir = `${homedir()}/.config/officer.dev`; export const settingsPath = `${configDir}/server-settings.json`; @@ -33,6 +34,7 @@ serverSettingsRouter.route('/smtp', smtpRouter); serverSettingsRouter.route('/tts', ttsRouter); serverSettingsRouter.route('/stt', sttRouter); serverSettingsRouter.route('/ocr', ocrRouter); +serverSettingsRouter.route('/searxng', searxngRouter); const readSettings = async () => { try { return await Bun.file(settingsPath).json(); } catch { return {}; } diff --git a/src/servers/api/skills/skills.ts b/src/servers/api/skills/skills.ts index 3fe0a5eb..54ded039 100644 --- a/src/servers/api/skills/skills.ts +++ b/src/servers/api/skills/skills.ts @@ -40,6 +40,10 @@ export async function readSkillDirs(dir: string): Promise> { type Scope = 'native' | 'global' | 'user'; +function isPrivileged(role: string) { + return role === 'Admin' || role === 'Owner' || role === 'Super Admin'; +} + function resolveScope(dirName: string, native: Map, global: Map, user: Map): Scope { if (user.has(dirName)) return 'user'; if (global.has(dirName)) return 'global'; @@ -134,6 +138,8 @@ skillsRouter.put('/:name/chat', async (ctx) => { const resolved = resolveFile(name, nativeSkills, globalSkills, userSkills); if (!resolved) return ctx.text('Not found', 404); + if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403); + const chatDir = join(dirname(resolved.filePath), 'chat'); const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>(); @@ -155,6 +161,8 @@ skillsRouter.delete('/:name/chat', async (ctx) => { const resolved = resolveFile(name, nativeSkills, globalSkills, userSkills); if (!resolved) return ctx.text('Not found', 404); + if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403); + const chatDir = join(dirname(resolved.filePath), 'chat'); await rm(chatDir, { recursive: true, force: true }); @@ -162,13 +170,18 @@ skillsRouter.delete('/:name/chat', async (ctx) => { }); skillsRouter.post('/', async (ctx) => { + const user = ctx.get('user'); const { name } = await ctx.req.json<{ name: string }>(); if (!name?.trim()) return ctx.text('Name is required', 400); const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); if (!dirName) return ctx.text('Invalid name', 400); - const dir = join(getGlobalSkillsDir(), dirName); + // Members save to their own scope; Admins and above save to global + const targetDir = isPrivileged(user.role) ? getGlobalSkillsDir() : getUserSkillsDir(user.email); + const scope: Scope = isPrivileged(user.role) ? 'global' : 'user'; + + const dir = join(targetDir, dirName); const filePath = join(dir, 'SKILL.md'); if (await Bun.file(filePath).exists()) { @@ -178,18 +191,24 @@ skillsRouter.post('/', async (ctx) => { await mkdir(dir, { recursive: true }); await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`); - return ctx.json({ name: name.trim(), dirName, filePath }); + return ctx.json({ name: name.trim(), dirName, filePath, scope }); }); skillsRouter.delete('/:name', async (ctx) => { + const user = ctx.get('user'); const name = ctx.req.param('name'); - const globalDir = join(getGlobalSkillsDir(), name); - const globalFile = join(globalDir, 'SKILL.md'); - if (!(await Bun.file(globalFile).exists())) { - return ctx.text('Not found', 404); - } + const nativeSkills = await readSkillDirs(getNativeSkillsDir()); + const globalSkills = await readSkillDirs(getGlobalSkillsDir()); + const userSkills = await readSkillDirs(getUserSkillsDir(user.email)); - await rm(globalDir, { recursive: true }); + const resolved = resolveFile(name, nativeSkills, globalSkills, userSkills); + if (!resolved) return ctx.text('Not found', 404); + + // Members can only delete their own skills + if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403); + + const dir = dirname(resolved.filePath); + await rm(dir, { recursive: true }); return ctx.json({ ok: true }); }); diff --git a/src/servers/api/tasks/tasks.ts b/src/servers/api/tasks/tasks.ts index 70e735aa..195809c9 100644 --- a/src/servers/api/tasks/tasks.ts +++ b/src/servers/api/tasks/tasks.ts @@ -24,7 +24,6 @@ export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: const triggers: TriggerConfig[] = []; const triggerMatch = yaml.match(/^trigger:\s*\n((?:[ \t]+.+\n?)*)/m); if (triggerMatch) { - // Split on top-level list items (lines starting with " - type:") const items = triggerMatch[1]!.split(/(?=^\s+-\s*type:)/m); for (const item of items) { const type = item.match(/type:\s*(.+)/)?.[1]?.trim(); @@ -73,6 +72,10 @@ function resolveFile(name: string, native: Map, global: Map { @@ -161,6 +164,8 @@ tasksRouter.put('/:name/chat', async (ctx) => { const resolved = resolveFile(name, nativeTasks, globalTasks, userTasks); if (!resolved) return ctx.text('Not found', 404); + if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403); + const chatDir = join(dirname(resolved.filePath), 'chat'); const { sessionId, messages } = await ctx.req.json<{ sessionId: string; messages: unknown[] }>(); @@ -182,6 +187,8 @@ tasksRouter.delete('/:name/chat', async (ctx) => { const resolved = resolveFile(name, nativeTasks, globalTasks, userTasks); if (!resolved) return ctx.text('Not found', 404); + if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403); + const chatDir = join(dirname(resolved.filePath), 'chat'); await rm(chatDir, { recursive: true, force: true }); @@ -189,13 +196,17 @@ tasksRouter.delete('/:name/chat', async (ctx) => { }); tasksRouter.post('/', async (ctx) => { + const user = ctx.get('user'); const { name } = await ctx.req.json<{ name: string }>(); if (!name?.trim()) return ctx.text('Name is required', 400); const dirName = name.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); if (!dirName) return ctx.text('Invalid name', 400); - const dir = join(getGlobalTasksDir(), dirName); + const targetDir = isPrivileged(user.role) ? getGlobalTasksDir() : getUserTasksDir(user.email); + const scope: Scope = isPrivileged(user.role) ? 'global' : 'user'; + + const dir = join(targetDir, dirName); const filePath = join(dir, 'TASK.md'); if (await Bun.file(filePath).exists()) { @@ -205,18 +216,22 @@ tasksRouter.post('/', async (ctx) => { await mkdir(dir, { recursive: true }); await Bun.write(filePath, `---\nname: ${name.trim()}\ndescription: \n---\n`); - return ctx.json({ name: name.trim(), dirName, filePath }); + return ctx.json({ name: name.trim(), dirName, filePath, scope }); }); tasksRouter.delete('/:name', async (ctx) => { + const user = ctx.get('user'); const name = ctx.req.param('name'); - const globalDir = join(getGlobalTasksDir(), name); - const globalFile = join(globalDir, 'TASK.md'); - if (!(await Bun.file(globalFile).exists())) { - return ctx.text('Not found', 404); - } + const nativeTasks = await readTaskDirs(getNativeTasksDir()); + const globalTasks = await readTaskDirs(getGlobalTasksDir()); + const userTasks = await readTaskDirs(getUserTasksDir(user.email)); - await rm(globalDir, { recursive: true }); + const resolved = resolveFile(name, nativeTasks, globalTasks, userTasks); + if (!resolved) return ctx.text('Not found', 404); + + if (resolved.scope !== 'user' && !isPrivileged(user.role)) return ctx.text('Forbidden', 403); + + await rm(dirname(resolved.filePath), { recursive: true }); return ctx.json({ ok: true }); }); diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index 849d8cc3..08237792 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -2,7 +2,7 @@ import type { ServerWebSocket } from 'bun'; import { mkdirSync, statSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { getHomeDir } from '@@/data-path'; +import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir } from '@@/data-path'; import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config'; import { getUsers } from 'officerdb'; @@ -101,7 +101,20 @@ const ensureDockerImage = () => { dockerImageReady = true; }; -const startDockerSidecar = (port: number, homeDir: string, userId: number, username: string): { dockerId: string } => { +// Check whether a container already has the officer resource mounts. +// We test for the global skills dir as a proxy for all mounts being present. +const containerHasResourceMounts = (dockerId: string): boolean => { + const dockerPath = Bun.which('docker') ?? 'docker'; + const result = Bun.spawnSync({ + cmd: [dockerPath, 'inspect', '--format', '{{range .Mounts}}{{.Source}}\n{{end}}', dockerId], + stdout: 'pipe', + stderr: 'ignore', + }); + if (result.exitCode !== 0) return false; + return result.stdout.toString().includes(getGlobalSkillsDir()); +}; + +const startDockerSidecar = (port: number, homeDir: string, userId: number, username: string, email: string): { dockerId: string } => { ensureDockerImage(); const dockerPath = Bun.which('docker') ?? 'docker'; const dockerId = `officer-terminal-${userId}`; @@ -144,10 +157,13 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, usern `TERMINAL_UID=${uid}`, '-e', `TERMINAL_GID=${gid}`, - '-v', - `${homeDir}:${containerHome}`, - '-w', - containerHome, + '-v', `${homeDir}:${containerHome}`, + '-v', `${getGlobalSkillsDir()}:/officer/skills:ro`, + '-v', `${getGlobalToolsDir()}:/officer/tools:ro`, + '-v', `${getGlobalExtensionsDir()}:/officer/extensions:ro`, + '-v', `${getUserSkillsDir(email)}:/officer/user/skills:ro`, + '-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`, + '-w', containerHome, tag, ], stdout: 'inherit', @@ -211,17 +227,35 @@ const dockerStart = (dockerId: string) => { }; export const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string) => { + // Ensure user-specific resource dirs exist before mounting (Docker creates them as root if missing) + mkdirSync(getUserSkillsDir(email), { recursive: true }); + mkdirSync(getUserToolsDir(email), { recursive: true }); + const map = await loadContainerMap(); const existing = map[email]; - if (existing && dockerContainerRunning(existing.dockerId)) return existing; + + if (existing && dockerContainerRunning(existing.dockerId)) { + // Recreate if resource mounts are missing (e.g. first run after feature was added) + if (!containerHasResourceMounts(existing.dockerId)) { + console.log(`[terminal] recreating container for ${email} — resource mounts missing`); + stopDockerSidecar(existing.dockerId); + } else { + return existing; + } + } if (existing && dockerContainerExists(existing.dockerId)) { - if (dockerStart(existing.dockerId)) return existing; - stopDockerSidecar(existing.dockerId); + if (!containerHasResourceMounts(existing.dockerId)) { + stopDockerSidecar(existing.dockerId); + } else if (dockerStart(existing.dockerId)) { + return existing; + } else { + stopDockerSidecar(existing.dockerId); + } } const port = existing?.port ?? getAvailablePort(map, userId); - const docker = startDockerSidecar(port, homeDir, userId, username); + const docker = startDockerSidecar(port, homeDir, userId, username, email); const next = { userId, email, dockerId: docker.dockerId, port }; map[email] = next; await saveContainerMap(map); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx index 9effb360..dd3e4814 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx @@ -4,6 +4,30 @@ import rehypeRaw from 'rehype-raw'; import type { ChatMessage } from '../types'; import { ToolActivity } from './ToolActivity'; import { QuestionActivity } from './QuestionActivity'; +import { getRawUrl } from '../../FileViewer/file-types'; + +// Matches absolute image file paths, e.g. /home/user/pic.png or /tmp/photo.jpg +const IMAGE_PATH_RE = /(\/(?:home\/[^/\s]+\/)?[^\s`"'<>\n\r[\]()]+\.(?:png|jpg|jpeg|gif|webp|svg|bmp|ico))/gi; + +function pathToImageUrl(filePath: string): string { + // Strip /home/{username} prefix — the file browser root=home serves from the user's home dir + const relative = filePath.replace(/^\/home\/[^/]+/, '') || '/'; + return getRawUrl(relative, 'home'); +} + +function injectImages(text: string): string { + // Split on fenced code blocks and inline code so we don't touch paths inside backticks + const parts = text.split(/(```[\s\S]*?```|`[^`\n]+`)/g); + return parts + .map((part, i) => { + if (i % 2 === 1) return part; // inside code — leave untouched + return part.replace(IMAGE_PATH_RE, (match) => { + const filename = match.split('/').pop() ?? 'image'; + return `![${filename}](${pathToImageUrl(match)})`; + }); + }) + .join(''); +} const FRONTMATTER_RE = /^([\s\S]*?)<\/frontmatter>\s*/; @@ -56,7 +80,7 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
- {message.text} + {injectImages(message.text)}
@@ -100,7 +124,7 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
- {text} + {injectImages(text)}