diff --git a/AGENTS.md b/AGENTS.md index cb96531..42903aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,7 +69,7 @@ cd kde && ./test-e2e.sh - **Go** (root module): Go 1.25+, standard library preferred, minimal dependencies. - **C++20/Qt6/KF6** (kde): CMake build, dual Qt5/Qt6 support where noted. - **Elisp** (el): single file `ellie.el`. -- **Tool scripts**: Python 3 or Bash. Must be executable. Header comments declare metadata (`# ollie:parallel read`, `# ollie:tier cold`, `# description:`, `# args:`). +- **Tool scripts**: Python 3, Bash, or compiled binaries. Must be executable. Metadata lives in a `.meta` sidecar JSON file (see `data/tools/*.meta`). ### Code style - Go: `gofmt`, short variable names, error returns (no panics), table-driven tests. - Tool scripts: emit structured output (`STATUS=ok`, `STATUS=error`). Image/LSP tools return JSON content blocks. @@ -105,12 +105,15 @@ Config lives in `~/.config/ollie/env`. Key variables: - `OLLIE_MEMORY_PATH` — persistent memory directory - `OLLIE_ENABLED_BACKENDS` — backends available for routing ## Adding a new tool -1. Create an executable script in `data/tools/` -2. Add header comments: `# description:`, `# args:`, optionally `# ollie:parallel read` or `# ollie:tier cold` +1. Create an executable in `data/tools/` +2. Create a `.meta` sidecar file (`data/tools/.meta`) with JSON metadata: + ```json + {"description":"...","prompt":"...","args":{...},"tier":"hot","readOnly":false} + ``` 3. Run `just install-data` to install ## Adding a new prompt 1. Write the markdown file in `data/prompts/` -2. If it should be loaded by default, add a `$OLLIE_CFG_PATH/scripts/x/prime ` entry to `agents/default.json` +2. If it should be loaded by default, reference it in `agents/default.json` 3. Run `just install-data` to install ## Submodule workflow The only remaining submodule is `kde/`. For KDE: diff --git a/README.md b/README.md index 671c120..6f201c4 100644 --- a/README.md +++ b/README.md @@ -248,9 +248,9 @@ GUI frontends subscribe once and react — no timers, no file watches, no busy l ## Tool registry: dynamic native tools -Tools are executable scripts in `~/.config/ollie/tools/`. Each one declares its -metadata — JSON schema, documentation, concurrency class — in header comments. -No server restart needed. No MCP server. No protocol. Just a file with a header. +Tools are executables in `~/.config/ollie/tools/`. Each one has a `.meta` sidecar +file declaring its JSON schema, documentation, concurrency class, and tier. +No server restart needed. No MCP server. No protocol. Just a file with metadata. ```bash # Discover all available tools @@ -262,15 +262,15 @@ echo file_glob | 9p write session/{id}/tools # The model sees it as a first-class tool on the next turn. ``` -The header: +The `.meta` file (`file_glob.meta`): -```bash -#!/usr/bin/env bash -# args_json: {"type":"object","properties":{"pattern":{"type":"string"}},"required":["pattern"]} -# ollie:prompt -# ## file_glob -# Find files by glob pattern. -# ollie:end +```json +{ + "description": "Find files by glob pattern, sorted by mtime (newest first).", + "args": {"type":"object","properties":{"pattern":{"type":"string"}},"required":["pattern"]}, + "tier": "cold", + "readOnly": true +} ``` Everything is sandboxed by default via Landlock. Tools that need to escape diff --git a/data/tools/browser_screencap.meta b/data/tools/browser_screencap.meta new file mode 100644 index 0000000..d1a0d54 --- /dev/null +++ b/data/tools/browser_screencap.meta @@ -0,0 +1,17 @@ +{ + "description": "Capture a screenshot of a browser window showing a specific URL.", + "prompt": "## browser_screencap\n\nCapture a screenshot of a browser window showing a specific URL.\n\n```\nbrowser_screencap(output=\"/tmp/page.png\", url_pattern=\"github.com\")\n```", + "args": { + "type": "object", + "properties": { + "output": { + "type": "string", + "description": "Output file path (default: /tmp/browser_capture.png)" + }, + "url_pattern": { + "type": "string", + "description": "URL pattern to match browser tab (default: localhost)" + } + } + } +} diff --git a/data/tools/file_edit.meta b/data/tools/file_edit.meta new file mode 100644 index 0000000..6aa8200 --- /dev/null +++ b/data/tools/file_edit.meta @@ -0,0 +1,30 @@ +{ + "description": "Replace text in a file. Has fuzzy matching (whitespace/indent flexible).", + "prompt": "## file_edit\n\nReplace text in a file. Has fuzzy matching (whitespace/indent flexible).\n\n**Args**: `path`, `old_string`, `new_string` (required); `replace_all` (optional)\n\n```\nfile_edit(path=\"/abs/path\", old_string=\"old text\", new_string=\"new text\")\nfile_edit(path=\"/abs/path\", old_string=\"old text\", new_string=\"new text\", replace_all=\"true\")\n```\n\n**Constraints**: Absolute paths only. Errors if `old_string` matches multiple locations — add surrounding context to disambiguate, or use `replace_all=true`.", + "args": { + "type": "object", + "required": [ + "path", + "old_string", + "new_string" + ], + "properties": { + "path": { + "type": "string", + "description": "Absolute path to the file to edit" + }, + "old_string": { + "type": "string", + "description": "Text to find (fuzzy whitespace matching)" + }, + "new_string": { + "type": "string", + "description": "Replacement text" + }, + "replace_all": { + "type": "string", + "description": "Set to true/1/yes to replace all occurrences" + } + } + } +} diff --git a/data/tools/file_glob.meta b/data/tools/file_glob.meta new file mode 100644 index 0000000..49a5c63 --- /dev/null +++ b/data/tools/file_glob.meta @@ -0,0 +1,21 @@ +{ + "description": "Find files by glob pattern, sorted by mtime (newest first).", + "prompt": "## file_glob\n\nFind files by glob pattern, sorted by mtime (newest first).\n\n**Args**: `[pattern]` or `[pattern, search_dir]`\n\n- First arg: glob pattern (e.g. `\"*.go\"`, `\"**/*.ts\"`).\n- Second arg (optional): absolute search directory. Defaults to cwd.\n\n```\nfile_glob(pattern=\"*.go\", search_dir=\"/abs/search/dir\")\nfile_glob(pattern=\"**/*.go\")\n```", + "args": { + "type": "object", + "required": [ + "pattern" + ], + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern (e.g. \"*.go\", \"**/*.ts\")" + }, + "search_dir": { + "type": "string", + "description": "Absolute search directory path (optional, defaults to cwd)" + } + } + }, + "readOnly": true +} diff --git a/data/tools/file_grep.meta b/data/tools/file_grep.meta new file mode 100644 index 0000000..4dbccb5 --- /dev/null +++ b/data/tools/file_grep.meta @@ -0,0 +1,61 @@ +{ + "description": "Search file contents with ripgrep. Capped at 50 matches by default.", + "prompt": "## file_grep\n\nSearch file contents with ripgrep. Capped at 50 matches by default.\n\n**Args**: `pattern` (required), plus optional named params.\n\n```\nfile_grep(pattern=\"pattern\", path=\"/abs/dir\")\nfile_grep(pattern=\"TODO\", path=\"src/\", glob=\"*.go\", case_insensitive=true)\nfile_grep(pattern=\"func main\", path=\"/home/user/project\")\n```\n\n- `path`: search directory (not a positional arg)\n- `glob`: file glob filter\n- `case_insensitive`: boolean\n\n**Params**: `path`, `mode` (content|files_with_matches|count), `glob`, `type`, `case_insensitive`, `before`, `after`, `context`, `multiline`, `head`, `offset`.", + "args": { + "type": "object", + "required": [ + "pattern" + ], + "properties": { + "pattern": { + "type": "string", + "description": "Regex pattern to search" + }, + "path": { + "type": "string", + "description": "Search directory (default: cwd)" + }, + "mode": { + "type": "string", + "description": "content|files_with_matches|count (default: content)" + }, + "glob": { + "type": "string", + "description": "File glob filter" + }, + "type": { + "type": "string", + "description": "File type filter (rg --type)" + }, + "case_insensitive": { + "type": "boolean", + "description": "Case-insensitive search" + }, + "after": { + "type": "integer", + "description": "Lines after match (-A)" + }, + "before": { + "type": "integer", + "description": "Lines before match (-B)" + }, + "context": { + "type": "integer", + "description": "Lines around match (-C)" + }, + "multiline": { + "type": "boolean", + "description": "Enable multiline matching" + }, + "head": { + "type": "integer", + "description": "Max results to return (default: 50)" + }, + "offset": { + "type": "integer", + "description": "Skip first N results" + } + } + }, + "readOnly": true +} diff --git a/data/tools/file_read.meta b/data/tools/file_read.meta new file mode 100644 index 0000000..f4b6fc6 --- /dev/null +++ b/data/tools/file_read.meta @@ -0,0 +1,30 @@ +{ + "description": "Read a file with line numbers. Prefer segments over full reads.", + "prompt": "## file_read\n\nRead a file with line numbers. Prefer segments over full reads.\n\n**Args**: `path` (required), `start`, `end`, or `head`\n\n- `start`/`end`: 1-indexed line numbers, inclusive. Must be strings. Max segment: 2000 lines.\n- No start/end: reads up to 500 lines from line 1.\n- `head`: read first N lines (alternative to start/end).\n\n```\nfile_read(path=\"/abs/path\")\nfile_read(path=\"/abs/path\", start=\"100\", end=\"200\")\nfile_read(path=\"/abs/path\", head=\"50\")\n```\n\n- `start`/`end` are strings even though they represent numbers.\n- Use `head` as alternative to `start`/`end`.\n\n**Constraints**: Absolute paths only.", + "args": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "description": "Absolute path to file" + }, + "start": { + "type": "string", + "description": "1-indexed start line (default: 1)" + }, + "end": { + "type": "string", + "description": "1-indexed end line, inclusive (max 2000 line segment)" + }, + "head": { + "type": "string", + "description": "Read first N lines (alternative to start/end)" + } + } + }, + "tier": "cold", + "readOnly": true +} diff --git a/data/tools/file_write.meta b/data/tools/file_write.meta new file mode 100644 index 0000000..c258f5b --- /dev/null +++ b/data/tools/file_write.meta @@ -0,0 +1,21 @@ +{ + "description": "Create or overwrite a file. Produces a unified diff.", + "prompt": "## file_write\n\nCreate or overwrite a file. Produces a unified diff.\n\n**Args**: `[path, content]`\n\n```\nfile_write(path=\"/abs/path\", content=\"file content here\")\n```\n\n**Constraints**: Absolute paths only. Parent directory must exist.", + "args": { + "type": "object", + "required": [ + "path", + "content" + ], + "properties": { + "path": { + "type": "string", + "description": "Absolute path to create or overwrite" + }, + "content": { + "type": "string", + "description": "File content to write" + } + } + } +} diff --git a/data/tools/gui_accessibility.meta b/data/tools/gui_accessibility.meta new file mode 100644 index 0000000..c77842e --- /dev/null +++ b/data/tools/gui_accessibility.meta @@ -0,0 +1,44 @@ +{ + "description": "GUI automation via AT-SPI2 accessibility tree.", + "prompt": "## gui_accessibility\n\nGUI automation via AT-SPI2 accessibility tree.\n\n**Args**: `[action, app, query...]`\n- Actions: `inspect`, `find`, `click`, `type`, `read`, `tree`, `action`\n\n```\ngui_accessibility(action=\"find\", app=\"Firefox\", path=\"button\", role=\"Close\")\ngui_accessibility(action=\"tree\", app=\"Kate\")\n```", + "args": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "description": "apps|tree|inspect|find|read|click|type|action" + }, + "app": { + "type": "string", + "description": "Application name" + }, + "path": { + "type": "string", + "description": "Element path" + }, + "role": { + "type": "string", + "description": "Element role (for find)" + }, + "name": { + "type": "string", + "description": "Element name (for find)" + }, + "text": { + "type": "string", + "description": "Text to type (for type action)" + }, + "action_name": { + "type": "string", + "description": "Action to perform (for action command)" + }, + "depth": { + "type": "integer", + "description": "Tree depth (for tree/inspect, default: 4/2)" + } + } + } +} diff --git a/data/tools/gui_activities.meta b/data/tools/gui_activities.meta new file mode 100644 index 0000000..23f7c4e --- /dev/null +++ b/data/tools/gui_activities.meta @@ -0,0 +1,20 @@ +{ + "description": "Manage KDE Plasma Activities.", + "prompt": "## gui_activities\n\nManage KDE Plasma Activities.\n\n**Args**: `[action]` or `[action, name_or_id]`\n- Actions: `list`, `current`, `switch`, `create`, `remove`\n\n```\ngui_activities(action=\"list\")\ngui_activities(action=\"switch\", name=\"Development\")\n```", + "args": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "description": "list|current|switch|create|remove" + }, + "name": { + "type": "string", + "description": "Activity name or ID" + } + } + } +} diff --git a/data/tools/gui_apps.meta b/data/tools/gui_apps.meta new file mode 100644 index 0000000..f784735 --- /dev/null +++ b/data/tools/gui_apps.meta @@ -0,0 +1,20 @@ +{ + "description": "Launch/list/activate applications.", + "prompt": "## gui_apps\n\nLaunch/list/activate applications.\n\n**Args**: `[action]` or `[action, app_name]`\n- Actions: `list`, `launch`, `activate`\n\n```\ngui_apps(action=\"launch\", name=\"firefox\")\ngui_apps(action=\"list\")\n```", + "args": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "description": "list|launch|activate" + }, + "name": { + "type": "string", + "description": "Application name" + } + } + } +} diff --git a/data/tools/gui_brightness.meta b/data/tools/gui_brightness.meta new file mode 100644 index 0000000..6ecb42b --- /dev/null +++ b/data/tools/gui_brightness.meta @@ -0,0 +1,20 @@ +{ + "description": "Get/set screen brightness.", + "prompt": "## gui_brightness\n\nGet/set screen brightness.\n\n- Actions: `get`, `set`, `up`, `down`\n\n```\ngui_brightness(action=\"get\")\ngui_brightness(action=\"set\", value=\"50\")\n```", + "args": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "description": "get|set|up|down" + }, + "value": { + "type": "string", + "description": "Brightness percentage (for set action)" + } + } + } +} diff --git a/data/tools/gui_clipboard.meta b/data/tools/gui_clipboard.meta new file mode 100644 index 0000000..816a6fd --- /dev/null +++ b/data/tools/gui_clipboard.meta @@ -0,0 +1,25 @@ +{ + "description": "Read/write KDE clipboard via Klipper.", + "prompt": "## gui_clipboard\n\nRead/write KDE clipboard via Klipper.\n\n- Actions: `get`, `set`, `history`, `clear`\n\n```\ngui_clipboard(action=\"get\")\ngui_clipboard(action=\"set\", text=\"copied text\")\n```", + "args": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "description": "get|set|history|clear" + }, + "text": { + "type": "string", + "description": "Text to set (for set action)" + }, + "index": { + "type": "string", + "description": "History index (for history action, default: 0)" + } + } + }, + "readOnly": true +} diff --git a/data/tools/gui_files.meta b/data/tools/gui_files.meta new file mode 100644 index 0000000..ac11676 --- /dev/null +++ b/data/tools/gui_files.meta @@ -0,0 +1,29 @@ +{ + "description": "Search files using Baloo (KDE file indexer).", + "prompt": "## gui_files\n\nSearch files using Baloo (KDE file indexer).\n\n```\ngui_files(query=\"project report\", type=\"document\")\n```", + "args": { + "type": "object", + "required": [ + "query" + ], + "properties": { + "query": { + "type": "string", + "description": "Search query" + }, + "type": { + "type": "string", + "description": "Filter by type: document|image|video|audio|folder" + }, + "path": { + "type": "string", + "description": "Search directory path" + }, + "limit": { + "type": "string", + "description": "Max results (default: 50)" + } + } + }, + "readOnly": true +} diff --git a/data/tools/gui_input.meta b/data/tools/gui_input.meta new file mode 100644 index 0000000..c128a26 --- /dev/null +++ b/data/tools/gui_input.meta @@ -0,0 +1,48 @@ +{ + "description": "Synthetic keyboard/mouse input (ydotool/xdotool).", + "prompt": "## gui_input\n\nSynthetic keyboard/mouse input (ydotool/xdotool).\n\n- Actions: `key`, `type`, `click`, `move`, `scroll`\n\n```\ngui_input(action=\"key\", keys=\"ctrl+s\")\ngui_input(action=\"type\", text=\"hello world\")\ngui_input(action=\"click\", button=\"1\", x=\"100\", y=\"200\")\n```", + "args": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "description": "key|type|click|move|scroll" + }, + "keys": { + "type": "string", + "description": "Key combo (for key action, e.g. ctrl+s)" + }, + "text": { + "type": "string", + "description": "Text to type (for type action)" + }, + "button": { + "type": "string", + "description": "Mouse button: 1=left, 2=middle, 3=right (for click)" + }, + "x": { + "type": "string", + "description": "X coordinate (for click/move)" + }, + "y": { + "type": "string", + "description": "Y coordinate (for click/move)" + }, + "direction": { + "type": "string", + "description": "Scroll direction: up|down|left|right" + }, + "amount": { + "type": "string", + "description": "Scroll amount (default: 3)" + }, + "relative": { + "type": "boolean", + "description": "Use relative movement (for move)" + } + } + } +} diff --git a/data/tools/gui_media.meta b/data/tools/gui_media.meta new file mode 100644 index 0000000..9bb1774 --- /dev/null +++ b/data/tools/gui_media.meta @@ -0,0 +1,36 @@ +{ + "description": "Control MPRIS media players.", + "prompt": "## gui_media\n\nControl MPRIS media players.\n\n**Args**: `[action]` or `[action, target]`\n- Actions: `list`, `status`, `open`, `playlist`, `play`, `pause`, `stop`, `next`, `previous`\n\n```\ngui_media(action=\"status\")\ngui_media(action=\"open\", target=\"/path/to/song.mp3\")\n```", + "args": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "description": "list|status|open|playlist|play|pause|stop|next|previous" + }, + "target": { + "type": "string", + "description": "Target file, URI, or playlist description" + }, + "player": { + "type": "string", + "description": "Player name" + }, + "library": { + "type": "string", + "description": "Music library path" + }, + "count": { + "type": "integer", + "description": "Playlist track count (default: 15)" + }, + "output": { + "type": "string", + "description": "M3U8 output path" + } + } + } +} diff --git a/data/tools/gui_notify.meta b/data/tools/gui_notify.meta new file mode 100644 index 0000000..5ea871a --- /dev/null +++ b/data/tools/gui_notify.meta @@ -0,0 +1,36 @@ +{ + "description": "Show desktop notifications.", + "prompt": "## gui_notify\n\nShow desktop notifications.\n\n```\ngui_notify(summary=\"Build complete\", body=\"All tests passed\")\n```", + "args": { + "type": "object", + "required": [ + "summary" + ], + "properties": { + "summary": { + "type": "string", + "description": "Notification title" + }, + "body": { + "type": "string", + "description": "Notification body" + }, + "actions": { + "type": "string", + "description": "Comma-separated action labels" + }, + "timeout": { + "type": "integer", + "description": "Timeout in ms (default: 5000, 10000 with actions)" + }, + "icon": { + "type": "string", + "description": "Icon name (default: ollie)" + }, + "urgency": { + "type": "string", + "description": "low|normal|critical (default: normal)" + } + } + } +} diff --git a/data/tools/gui_screenshot.meta b/data/tools/gui_screenshot.meta new file mode 100644 index 0000000..ba9a87f --- /dev/null +++ b/data/tools/gui_screenshot.meta @@ -0,0 +1,33 @@ +{ + "description": "Capture screenshots via KWin (Wayland-safe).", + "prompt": "## gui_screenshot\n\nCapture screenshots via KWin (Wayland-safe).\n\n**Args**: `[mode]` or `[mode, \"--output=path\"]`\n- mode: `active`, `screen`, `area`, `window` (default: active)\n\n```\ngui_screenshot(mode=\"active\")\ngui_screenshot(mode=\"screen\", output=\"--output=/tmp/shot.png\")\n```", + "args": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "description": "active|screen|area|window (default: active)" + }, + "output": { + "type": "string", + "description": "Output file path" + }, + "screen": { + "type": "integer", + "description": "Screen index for screen mode" + }, + "title": { + "type": "string", + "description": "Window title substring for window mode" + }, + "include_cursor": { + "type": "boolean", + "description": "Include cursor in capture" + }, + "include_decoration": { + "type": "boolean", + "description": "Include window decoration (default: true)" + } + } + } +} diff --git a/data/tools/gui_shortcuts.meta b/data/tools/gui_shortcuts.meta new file mode 100644 index 0000000..d730722 --- /dev/null +++ b/data/tools/gui_shortcuts.meta @@ -0,0 +1,28 @@ +{ + "description": "Manage KDE global shortcuts.", + "prompt": "## gui_shortcuts\n\nManage KDE global shortcuts.\n\n**Args**: `[action, component, shortcut, keys]`\n- Actions: `list`, `trigger`, `get`, `set`\n\n```\ngui_shortcuts(action=\"list\")\ngui_shortcuts(action=\"trigger\", component=\"kwin\", shortcut=\"Window Close\")\n```", + "args": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "description": "list|trigger|get|set" + }, + "component": { + "type": "string", + "description": "Component name (e.g. kwin)" + }, + "shortcut": { + "type": "string", + "description": "Shortcut name" + }, + "keys": { + "type": "string", + "description": "Key binding (for set)" + } + } + } +} diff --git a/data/tools/gui_windows.meta b/data/tools/gui_windows.meta new file mode 100644 index 0000000..d30c413 --- /dev/null +++ b/data/tools/gui_windows.meta @@ -0,0 +1,40 @@ +{ + "description": "KWin window management via D-Bus.", + "prompt": "## gui_windows\n\nKWin window management via D-Bus.\n\n**Args**: `[action, ...]`\n- Actions: `list`, `focus`, `close`, `minimize`, `maximize`, `move`, `resize`, `desktops`\n\n```\ngui_windows(action=\"list\")\ngui_windows(action=\"focus\", title=\"Firefox\")\n```", + "args": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "description": "list|focus|close|minimize|maximize|move|resize|desktops|desktop" + }, + "title": { + "type": "string", + "description": "Window title substring" + }, + "x": { + "type": "string", + "description": "X position (for move)" + }, + "y": { + "type": "string", + "description": "Y position (for move)" + }, + "width": { + "type": "string", + "description": "Width (for resize)" + }, + "height": { + "type": "string", + "description": "Height (for resize)" + }, + "number": { + "type": "string", + "description": "Desktop number (for desktop)" + } + } + } +} diff --git a/data/tools/image_read.meta b/data/tools/image_read.meta new file mode 100644 index 0000000..1bbcc1a --- /dev/null +++ b/data/tools/image_read.meta @@ -0,0 +1,26 @@ +{ + "description": "Read an image file. Returns an image content block for the model.", + "prompt": "## image_read\n\nRead an image file. Returns an image content block for the model.\n\n```\nimage_read(path=\"/abs/path/image.png\")\n```", + "args": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "description": "Absolute path to image file" + }, + "region": { + "type": "string", + "description": "Region to capture: x,y,w,h" + }, + "scale": { + "type": "number", + "description": "Scale factor" + } + } + }, + "tier": "cold", + "readOnly": true +} diff --git a/data/tools/lsp_completion.meta b/data/tools/lsp_completion.meta new file mode 100644 index 0000000..59f3be6 --- /dev/null +++ b/data/tools/lsp_completion.meta @@ -0,0 +1,27 @@ +{ + "description": "Completion candidates at cursor position.", + "prompt": "## lsp_completion\n\nCompletion candidates at cursor position.\n\n**Args**: `[path, line, col]`\n\n```\nlsp_completion(path=\"/abs/path.go\", line=\"42\", col=\"10\")\n```\n\n**Returns**: up to 20 candidates.", + "args": { + "type": "object", + "required": [ + "path", + "line", + "col" + ], + "properties": { + "path": { + "type": "string", + "description": "Absolute file path" + }, + "line": { + "type": "string", + "description": "Line number (1-indexed)" + }, + "col": { + "type": "string", + "description": "Column number (1-indexed)" + } + } + }, + "readOnly": true +} diff --git a/data/tools/lsp_definition.meta b/data/tools/lsp_definition.meta new file mode 100644 index 0000000..47a9cff --- /dev/null +++ b/data/tools/lsp_definition.meta @@ -0,0 +1,27 @@ +{ + "description": "Go to definition of symbol at position.", + "prompt": "## lsp_definition\n\nGo to definition of symbol at position.\n\n**Args**: `[path, line, col]`\n\n```\nlsp_definition(path=\"/abs/path.go\", line=\"42\", col=\"10\")\n```\n\n**Returns**: `file:line:col` location(s).", + "args": { + "type": "object", + "required": [ + "path", + "line", + "col" + ], + "properties": { + "path": { + "type": "string", + "description": "Absolute file path" + }, + "line": { + "type": "string", + "description": "Line number (1-indexed)" + }, + "col": { + "type": "string", + "description": "Column number (1-indexed)" + } + } + }, + "readOnly": true +} diff --git a/data/tools/lsp_diagnostics.meta b/data/tools/lsp_diagnostics.meta new file mode 100644 index 0000000..81fd0fd --- /dev/null +++ b/data/tools/lsp_diagnostics.meta @@ -0,0 +1,17 @@ +{ + "description": "Errors and warnings for a file.", + "prompt": "## lsp_diagnostics\n\nErrors and warnings for a file.\n\n**Args**: `[path]`\n\n```\nlsp_diagnostics(path=\"/abs/path.go\")\n```", + "args": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "description": "Absolute file path" + } + } + }, + "readOnly": true +} diff --git a/data/tools/lsp_hover.meta b/data/tools/lsp_hover.meta new file mode 100644 index 0000000..0c0350f --- /dev/null +++ b/data/tools/lsp_hover.meta @@ -0,0 +1,27 @@ +{ + "description": "Type signature and documentation for symbol at position.", + "prompt": "## lsp_hover\n\nType signature and documentation for symbol at position.\n\n**Args**: `[path, line, col]`\n\n```\nlsp_hover(path=\"/abs/path.go\", line=\"42\", col=\"10\")\n```", + "args": { + "type": "object", + "required": [ + "path", + "line", + "col" + ], + "properties": { + "path": { + "type": "string", + "description": "Absolute file path" + }, + "line": { + "type": "string", + "description": "Line number (1-indexed)" + }, + "col": { + "type": "string", + "description": "Column number (1-indexed)" + } + } + }, + "readOnly": true +} diff --git a/data/tools/lsp_references.meta b/data/tools/lsp_references.meta new file mode 100644 index 0000000..47a9141 --- /dev/null +++ b/data/tools/lsp_references.meta @@ -0,0 +1,27 @@ +{ + "description": "Find all references to symbol at position. Capped at 50.", + "prompt": "## lsp_references\n\nFind all references to symbol at position. Capped at 50.\n\n**Args**: `[path, line, col]`\n\n```\nlsp_references(path=\"/abs/path.go\", line=\"42\", col=\"10\")\n```\n\n**Returns**: `file:line:col` per reference.\n\n**Prefer over `file_grep`** for semantic references — won't match comments, strings, or unrelated identifiers.", + "args": { + "type": "object", + "required": [ + "path", + "line", + "col" + ], + "properties": { + "path": { + "type": "string", + "description": "Absolute file path" + }, + "line": { + "type": "string", + "description": "Line number (1-indexed)" + }, + "col": { + "type": "string", + "description": "Column number (1-indexed)" + } + } + }, + "readOnly": true +} diff --git a/data/tools/lsp_rename.meta b/data/tools/lsp_rename.meta new file mode 100644 index 0000000..12c172c --- /dev/null +++ b/data/tools/lsp_rename.meta @@ -0,0 +1,31 @@ +{ + "description": "Rename symbol across workspace. Applies edits directly.", + "prompt": "## lsp_rename\n\nRename symbol across workspace. Applies edits directly.\n\n**Args**: `[path, line, col, new_name]`\n\n```\nlsp_rename(path=\"/abs/path.go\", line=\"42\", col=\"10\", new_name=\"NewName\")\n```\n\n**Prefer over manual find-and-replace** — understands scope, interfaces, cross-package references.", + "args": { + "type": "object", + "required": [ + "path", + "line", + "col", + "new_name" + ], + "properties": { + "path": { + "type": "string", + "description": "Absolute file path" + }, + "line": { + "type": "string", + "description": "Line number (1-indexed)" + }, + "col": { + "type": "string", + "description": "Column number (1-indexed)" + }, + "new_name": { + "type": "string", + "description": "New symbol name" + } + } + } +} diff --git a/data/tools/lsp_symbols.meta b/data/tools/lsp_symbols.meta new file mode 100644 index 0000000..e6a84ee --- /dev/null +++ b/data/tools/lsp_symbols.meta @@ -0,0 +1,21 @@ +{ + "description": "List symbols in a file, or search workspace-wide.", + "prompt": "## lsp_symbols\n\nList symbols in a file, or search workspace-wide.\n\n**Args**: `[path]` or `[query, \"--workspace\"]`\n\n```\nlsp_symbols(path=\"/abs/path.go\")\nlsp_symbols(path=\"MyFunc\", workspace=\"--workspace\")\n```", + "args": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "description": "Absolute file path, or search query if workspace=true" + }, + "workspace": { + "type": "boolean", + "description": "Search entire workspace instead of single file" + } + } + }, + "readOnly": true +} diff --git a/data/tools/memory_recall.meta b/data/tools/memory_recall.meta new file mode 100644 index 0000000..0bccdea --- /dev/null +++ b/data/tools/memory_recall.meta @@ -0,0 +1,18 @@ +{ + "description": "Search stored memories for relevant context.", + "prompt": "## memory_recall\n\nSearch stored memories for relevant context.\n\n**Trigger condition**: ALWAYS recall before exploring code or starting work on a task. Memory is a first-class source of context — check it before reading files or running commands.\n\n**Calling convention**:\n```\nmemory_recall(query=\"keyword\")\n```\n- `query`: single keyword or short term. Prefer single keywords over phrases.\n\n**Returns**: matching memory files.\n\n**Constraints**:\n- Recall at the start of a topic, not mid-task.\n- If recall returns nothing relevant, proceed without it.", + "args": { + "type": "object", + "required": [ + "query" + ], + "properties": { + "query": { + "type": "string", + "description": "Search keyword or phrase" + } + } + }, + "tier": "cold", + "readOnly": true +} diff --git a/data/tools/memory_remember.meta b/data/tools/memory_remember.meta new file mode 100644 index 0000000..a3c4245 --- /dev/null +++ b/data/tools/memory_remember.meta @@ -0,0 +1,26 @@ +{ + "description": "Persist a fact that would otherwise be lost when the session ends.", + "prompt": "## memory_remember\n\nPersist a fact that would otherwise be lost when the session ends.\n\n**Trigger condition**: the user states a preference, constraint, or decision affecting future work; you discover a non-obvious fact about the codebase, system, or environment; a debugging session surfaces a root cause worth keeping; the user explicitly asks you to remember something. Autonomously remember architectural patterns, service relationships, API behaviors, configuration quirks, and other structural knowledge as you encounter it.\n\n**Calling convention**:\n```\nmemory_remember(title=\"...\", tags=\"...\", body=\"...\")\n```\n- `title`: short noun phrase\n- `tags`: comma-separated tags; pick specific and reusable terms\n- `body`: the fact, written so it stands alone without session context\n\n**Returns**: confirmation.\n\n**Constraints**:\n- One memory per distinct fact.\n- Before writing, recall on the same topic to avoid duplicates.\n- Do not store stale facts. If a previously stored fact is wrong, update it with `file_edit`.\n- Never delete a memory without explicit user instruction.\n- **Act autonomously.** Do not ask the user whether to remember something.", + "args": { + "type": "object", + "required": [ + "title", + "tags", + "body" + ], + "properties": { + "title": { + "type": "string", + "description": "Short noun phrase" + }, + "tags": { + "type": "string", + "description": "Comma-separated tags" + }, + "body": { + "type": "string", + "description": "The fact to remember" + } + } + } +} diff --git a/data/tools/process_dismiss.meta b/data/tools/process_dismiss.meta new file mode 100644 index 0000000..c010a74 --- /dev/null +++ b/data/tools/process_dismiss.meta @@ -0,0 +1,16 @@ +{ + "description": "Dismiss a finished detached process, removing it from the list.", + "prompt": "## process_dismiss\n\nDismiss a finished detached process, removing it from the list.\n\n```\nprocess_dismiss(pid=\"12345\")\n```", + "args": { + "type": "object", + "required": [ + "pid" + ], + "properties": { + "pid": { + "type": "string", + "description": "Process PID" + } + } + } +} diff --git a/data/tools/process_list.meta b/data/tools/process_list.meta new file mode 100644 index 0000000..55ed765 --- /dev/null +++ b/data/tools/process_list.meta @@ -0,0 +1,9 @@ +{ + "description": "List background (detached) processes for this session.", + "prompt": "## process_list\n\nList background (detached) processes for this session.\n\n```\nprocess_list()\n```\n\n**Returns**: PID, command, start time, and status (running/exited) for each.", + "args": { + "type": "object", + "properties": {} + }, + "readOnly": true +} diff --git a/data/tools/process_output.meta b/data/tools/process_output.meta new file mode 100644 index 0000000..ba63aed --- /dev/null +++ b/data/tools/process_output.meta @@ -0,0 +1,17 @@ +{ + "description": "Read output from a detached background process (last 64KB ring buffer).", + "prompt": "## process_output\n\nRead output from a detached background process (last 64KB ring buffer).\n\n```\nprocess_output(pid=\"12345\")\n```", + "args": { + "type": "object", + "required": [ + "pid" + ], + "properties": { + "pid": { + "type": "string", + "description": "Process PID" + } + } + }, + "readOnly": true +} diff --git a/data/tools/process_signal.meta b/data/tools/process_signal.meta new file mode 100644 index 0000000..a4e7c41 --- /dev/null +++ b/data/tools/process_signal.meta @@ -0,0 +1,20 @@ +{ + "description": "Send a signal to a detached background process.", + "prompt": "## process_signal\n\nSend a signal to a detached background process.\n\n```\nprocess_signal(pid=\"12345\", signal=\"TERM\")\n```", + "args": { + "type": "object", + "required": [ + "pid" + ], + "properties": { + "pid": { + "type": "string", + "description": "Process PID" + }, + "signal": { + "type": "string", + "description": "Signal name: TERM (default) or KILL" + } + } + } +} diff --git a/data/tools/route.meta b/data/tools/route.meta new file mode 100644 index 0000000..59efae9 --- /dev/null +++ b/data/tools/route.meta @@ -0,0 +1,25 @@ +{ + "description": "Route a subtask to an appropriate model tier. Spawns a subagent that reports back.", + "prompt": "## route\n\nRoute a subtask to an appropriate model tier. Spawns a subagent that reports back.\n\n```\nroute(instruction=\"implement the login page\")\nroute(instruction=\"refactor auth module\", tier=\"expert\")\n```", + "args": { + "type": "object", + "required": [ + "instruction" + ], + "properties": { + "instruction": { + "type": "string", + "description": "Task description to route" + }, + "tier": { + "type": "string", + "description": "Model tier: worker (default) or expert" + }, + "backend": { + "type": "string", + "description": "Backend override" + } + } + }, + "readOnly": true +} diff --git a/data/tools/subagent_generate.meta b/data/tools/subagent_generate.meta new file mode 100644 index 0000000..a9b3ce9 --- /dev/null +++ b/data/tools/subagent_generate.meta @@ -0,0 +1,40 @@ +{ + "description": "Generate a JIT subagent config. No args prints the JSON template.", + "prompt": "## subagent_generate\n\nGenerate a JIT subagent config. No args prints the JSON template.\nWith JSON input, creates the config and prints the agent name.\n\n```\nsubagent_generate(role=\"backend dev\")\nsubagent_generate()\n```", + "args": { + "type": "object", + "properties": { + "role": { + "type": "string", + "description": "Who this agent is and what it does" + }, + "name": { + "type": "string", + "description": "Config name (default: jit-\u003chex\u003e)" + }, + "roster": { + "type": "string", + "description": "Name/task mapping of peers" + }, + "shared_task": { + "type": "string", + "description": "Tracking entry or issue ID" + }, + "protocol": { + "type": "string", + "description": "Coordination mechanics" + }, + "constraints": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of constraint strings" + }, + "prompt": { + "type": "string", + "description": "Legacy: complete identity as single string" + } + } + } +} diff --git a/data/tools/subagent_spawn.meta b/data/tools/subagent_spawn.meta new file mode 100644 index 0000000..5d1bdf5 --- /dev/null +++ b/data/tools/subagent_spawn.meta @@ -0,0 +1,36 @@ +{ + "description": "Spawn an independent subagent session with a prompt. Returns immediately.", + "prompt": "## subagent_spawn\n\nSpawn an independent subagent session with a prompt. Returns immediately.\n\n```\nsubagent_spawn(prompt=\"implement the login page\", name=\"worker\")\nsubagent_spawn(prompt=\"fix the tests in auth/\")\n```\n\n**Returns**: session ID, backend, model.", + "args": { + "type": "object", + "required": [ + "prompt" + ], + "properties": { + "prompt": { + "type": "string", + "description": "Task prompt for the subagent" + }, + "name": { + "type": "string", + "description": "Subagent name (optional)" + }, + "agent": { + "type": "string", + "description": "Agent config (optional)" + }, + "backend": { + "type": "string", + "description": "Backend (optional)" + }, + "model": { + "type": "string", + "description": "Model (optional)" + }, + "cwd": { + "type": "string", + "description": "Working directory (optional)" + } + } + } +} diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md index e8c9f86..5c2f840 100644 --- a/doc/ARCHITECTURE.md +++ b/doc/ARCHITECTURE.md @@ -260,7 +260,7 @@ The tool system has exactly one built-in server (`toolsrv.Server`) that exposes | `skill_load`/`skill_list`/`skill_active` | Dynamic skill registry | All other capabilities are **tool scripts** — executable files discovered from `OLLIE_TOOLS_PATH` (default: `~/.config/ollie/tools/`). ### Tool Scripts -Tool scripts are plain executables with a structured header comment declaring their name, description, parameters, and parallelism annotation: +Tool scripts are plain executables with a `.meta` sidecar JSON file declaring description, parameters, tier, and parallelism class: - `file_read`, `file_write`, `file_edit`, `file_glob`, `file_grep` — filesystem I/O - `lsp_definition`, `lsp_references`, `lsp_rename`, `lsp_symbols`, `lsp_diagnostics`, `lsp_hover`, `lsp_completion` — LSP bridge (Python) - `memory_remember`, `memory_recall` — persistent memory @@ -268,7 +268,7 @@ Tool scripts are plain executables with a structured header comment declaring th - `subagent_spawn`, `subagent_generate` — sub-agent lifecycle - `route` — orchestrator dispatch - `gui_*` — KDE desktop automation (screenshot, windows, clipboard, input, ...) -Scripts annotated `ollie:parallel read` can be fanned out concurrently by the agent loop. +Tools marked `"readOnly": true` in their `.meta` can be fanned out concurrently by the agent loop. ### Sandboxing Every code execution step is wrapped with [landrun](https://github.com/landlock-lsm/landrun) (Landlock LSM). Configuration is YAML-based: ```yaml diff --git a/doc/CORE.md b/doc/CORE.md index e3e7e32..5b3f54e 100644 --- a/doc/CORE.md +++ b/doc/CORE.md @@ -224,7 +224,7 @@ On the first error, the `turnError` hook fires. If it exits 0 (handled), the loo Tool calls are dispatched with parallelism awareness: -1. **Classify** each tool call via `ClassifyTool(name)` (reads `ollie:parallel` annotation) +1. **Classify** each tool call via `ClassifyTool(name)` (reads `readOnly` from `.meta` file) 2. **Batch** consecutive parallel-read-safe calls into a group 3. **Fan out** the batch concurrently with deduplication (identical calls share one execution) 4. **Serial** calls run one at a time @@ -301,7 +301,7 @@ Tool results are classified at execution time: Classification sources (in priority order): 1. Built-in table in `toolsrv/tier.go` (`defaultColdTools`) -2. `ollie:tier` annotation in the tool script header +2. `tier` field in the tool's `.meta` sidecar file 3. Default: hot ### Persistence @@ -427,13 +427,12 @@ Validation failures are rate-limited: 5 failures within 1 minute triggers a 5-mi ### Parallelism & Locking -Tool scripts declare their concurrency class via header annotations: +Tool scripts declare their concurrency class via their `.meta` sidecar file: -| Annotation | Lock Class | Behavior | +| `.meta` field | Lock Class | Behavior | |---|---|---| -| `ollie:parallel read` | Read | Shared lock (LOCK_SH); concurrent with other reads | -| `ollie:parallel write` | Write | Exclusive lock (LOCK_EX); serialized | -| (none) | Global | Serialized with everything | +| `"readOnly": true` | Read | Shared lock (LOCK_SH); concurrent with other reads | +| `"readOnly": false` (default) | Write | Exclusive lock (LOCK_EX); serialized | Lock files live in `/tmp/ollie/{sessionID}/` and are acquired via `flock(2)`. @@ -443,7 +442,7 @@ Built-in cold tools (results consumed immediately, don't need verbatim retention - `file_read`, `memory_recall`, `web_fetch`, `web_search` - `lsp_definition`, `lsp_references`, `lsp_hover` -Custom tools declare their tier via `ollie:tier cold|warm|hot` in the script header. +Custom tools declare their tier via `"tier": "cold|warm|hot"` in their `.meta` file. ### Elevation diff --git a/doc/USAGE.md b/doc/USAGE.md index 63b4bb6..a00212b 100644 --- a/doc/USAGE.md +++ b/doc/USAGE.md @@ -460,40 +460,32 @@ appropriate file. ## Tool discovery -Tools are executable scripts in `$OLLIE_TOOLS_PATH` (default: -`~/.config/ollie/tools/`). At session startup, all scripts are scanned and a -compact listing (name + one-line description) is injected into the system prompt. +Tools are executables in `$OLLIE_TOOLS_PATH` (default: +`~/.config/ollie/tools/`). At session startup, all `.meta` sidecar files are +scanned and a compact listing (name + one-line description) is injected into +the system prompt. -### Header format +### Metadata format -Each tool script embeds its documentation and metadata in comment headers: +Each tool has a `.meta` JSON sidecar file alongside the executable: -```python -#!/usr/bin/env python3 -# ollie:parallel read ← safe to run concurrently with other reads -# ollie:tier cold ← result tier (cold=cacheable, warm, hot) -# ollie:prompt -# ## my_tool -# -# Short description on first non-heading line (used in system prompt listing). -# -# **Args**: `[arg1, arg2]` -# -# ``` -# file_edit: args=["/path", "old", "new"] -# ``` -# -# **Constraints**: any notes for the model. -# ollie:end - -# ... script body ... +```json +{ + "description": "Short description (used in system prompt listing).", + "prompt": "## my_tool\n\nFull documentation shown when tool is loaded.\n\n**Args**: ...", + "args": {"type":"object","required":["path"],"properties":{"path":{"type":"string","description":"File path"}}}, + "tier": "cold", + "readOnly": true +} ``` -| Annotation | Purpose | +| Field | Purpose | |---|---| -| `ollie:prompt` / `ollie:end` | Delimits the documentation block | -| `ollie:parallel read` | Marks tool as safe for parallel execution | -| `ollie:tier cold\|warm\|hot` | Result cacheability tier | +| `description` | One-liner shown in tool listing | +| `prompt` | Full documentation injected when tool is loaded | +| `args` | JSON Schema for tool input parameters | +| `tier` | Result cacheability: `cold`, `warm`, or `hot` (default: hot) | +| `readOnly` | Safe for parallel execution with other read tools | ### Runtime access @@ -508,7 +500,7 @@ Agents query `/tools` when they need detailed usage for a specific tool. ### Adding a tool -Drop an executable script in `$OLLIE_TOOLS_PATH` with the header format above. +Drop an executable and its `.meta` file in `$OLLIE_TOOLS_PATH`. The next session created will pick it up automatically. No restart required. ## Alternative front-ends diff --git a/doc/WRITING_TOOLS.md b/doc/WRITING_TOOLS.md index e74fff1..61a35b6 100644 --- a/doc/WRITING_TOOLS.md +++ b/doc/WRITING_TOOLS.md @@ -1,39 +1,39 @@ # Writing Ollie Tools -Tools are executable scripts that extend ollie's capabilities. They are loaded lazily via `tool_load` and become first-class callable functions with JSON schemas. +Tools are executables that extend ollie's capabilities. They are loaded lazily via `tool_load` and become first-class callable functions with JSON schemas. ## Tool Discovery -Tools live in `$OLLIE_TOOLS_PATH` (default: `~/.config/ollie/tools/`). Any executable file in that directory with the `ollie:prompt` annotation is a tool. The tool name is the filename. - -Files without `ollie:prompt` (e.g., shared libraries in `_lib/`) are ignored by the registry. +Tools live in `$OLLIE_TOOLS_PATH` (default: `~/.config/ollie/tools/`). Any file with a corresponding `.meta` sidecar is a tool. The tool name is the filename (without `.meta`). ## Tool Metadata -Tools carry metadata in header comments. These are parsed by the registry and exposed to the agent: +Each tool has a `.meta` JSON sidecar file alongside the executable. This is the sole source of metadata — the registry reads only `.meta` files. -```sh -#!/usr/bin/env bash -# args_json: {"type":"object","required":["path"],"properties":{"path":{"type":"string","description":"File path"},"verbose":{"type":"boolean","description":"Enable verbose output"}}} -# ollie:prompt -# ## my_tool -# -# Does something useful. -# -# ``` -# call_tool: calls=[{tool: "my_tool", args: {"path": "/foo", "verbose": true}}] -# ``` -# ollie:end -# ollie:parallel read -# ollie:tier cold +```json +{ + "description": "Does something useful.", + "prompt": "## my_tool\n\nDoes something useful.\n\n**Args**: `path` (required), `verbose` (optional)\n\n```\nmy_tool(path=\"/foo\", verbose=true)\n```", + "args": { + "type": "object", + "required": ["path"], + "properties": { + "path": {"type": "string", "description": "File path"}, + "verbose": {"type": "boolean", "description": "Enable verbose output"} + } + }, + "tier": "cold", + "readOnly": true +} ``` -| Annotation | Purpose | -|---|---| -| `args_json: ` | JSON Schema for the tool's input (required for typed tools) | -| `ollie:prompt` ... `ollie:end` | Usage documentation shown to the agent | -| `ollie:parallel read` | Tool is safe to run concurrently with other read tools | -| `ollie:tier cold\|warm\|hot` | Context retention tier (default: hot) | +| Field | Type | Purpose | +|---|---|---| +| `description` | string | One-liner shown in the tool listing (system prompt) | +| `prompt` | string | Full documentation injected when the tool is loaded | +| `args` | JSON Schema | Input schema exposed to the model | +| `tier` | `"hot"\|"warm"\|"cold"` | Context retention tier (default: hot) | +| `readOnly` | bool | Safe for parallel execution with other read tools | ## Input Protocol @@ -81,15 +81,9 @@ The `_lib/args.sh` module provides: - `arg_json key` — get raw JSON value - `arg_raw` — get the full stdin JSON -### Schema declaration +### Go tools -Declare the input schema with `# args_json:` followed by a JSON Schema object on a single line: - -``` -# args_json: {"type":"object","required":["path","pattern"],"properties":{"path":{"type":"string","description":"Search directory"},"pattern":{"type":"string","description":"Regex pattern"},"case_insensitive":{"type":"boolean","description":"Ignore case"}}} -``` - -This schema is exposed to the model as the tool's `inputSchema`, telling it exactly what JSON object to construct. +Compiled Go binaries work the same way — read JSON from stdin, write result to stdout. No `_lib` dependency needed; use `encoding/json` directly. ## Sandboxing @@ -127,7 +121,7 @@ Tools receive these environment variables: ## Examples -See the tools in `$OLLIE_TOOLS_PATH` for real examples: +See the tools in `data/tools/` for real examples: - `file_read` — reads files with line numbers - `file_edit` — fuzzy text replacement - `file_grep` — ripgrep wrapper diff --git a/justfile b/justfile index ca92fbe..e667b0d 100644 --- a/justfile +++ b/justfile @@ -151,6 +151,8 @@ install-data: install -m755 data/tools/route {{cfg}}/tools/route install -m755 data/tools/subagent_generate {{cfg}}/tools/subagent_generate install -m755 data/tools/subagent_spawn {{cfg}}/tools/subagent_spawn + # Tool metadata (.meta sidecar files) + for f in data/tools/*.meta; do install -m644 "$$f" {{cfg}}/tools/"$$(basename $$f)"; done # Tools (library modules) install -m644 data/tools/_lib/__init__.py {{cfg}}/tools/_lib/__init__.py install -m644 data/tools/_lib/args.py {{cfg}}/tools/_lib/args.py diff --git a/toolsrv/discover.go b/toolsrv/discover.go index 63a13cc..898dd86 100644 --- a/toolsrv/discover.go +++ b/toolsrv/discover.go @@ -1,7 +1,7 @@ package toolsrv import ( - "fmt" + "encoding/json" "os" "path/filepath" "strings" @@ -22,55 +22,8 @@ func ToolsPath() string { return paths.CfgDir() + "/tools" } - -// ExtractPrompt parses the ollie:prompt ... ollie:end block from a tool -// script's header comments. Returns the prompt text with comment prefixes -// stripped, or "" if no prompt block is found. -func ExtractPrompt(script string) string { - var lines []string - var inPrompt bool - for _, line := range strings.Split(script, "\n") { - trimmed := strings.TrimSpace(line) - // Strip comment prefix (# or //) - bare := trimmed - if strings.HasPrefix(bare, "# ") { - bare = bare[2:] - } else if strings.HasPrefix(bare, "#") { - bare = bare[1:] - } else if strings.HasPrefix(bare, "// ") { - bare = bare[3:] - } else if strings.HasPrefix(bare, "//") { - bare = bare[2:] - } else if inPrompt { - // Non-comment line while in prompt block — end of header - break - } else { - // Non-comment line before prompt block found - if trimmed != "" && !strings.HasPrefix(trimmed, "#!") { - break - } - continue - } - - if strings.Contains(bare, "ollie:prompt") { - inPrompt = true - continue - } - if inPrompt && strings.Contains(bare, "ollie:end") { - break - } - if inPrompt { - lines = append(lines, bare) - } - } - if len(lines) == 0 { - return "" - } - return strings.Join(lines, "\n") -} - -// DiscoverTools scans the tools directory and returns metadata for all scripts, -// including their ollie:prompt blocks and short descriptions. +// DiscoverTools scans the tools directory for .meta files and returns +// metadata for all tools. func DiscoverTools() []ToolInfo { dir := ToolsPath() entries, err := os.ReadDir(dir) @@ -79,69 +32,45 @@ func DiscoverTools() []ToolInfo { } var infos []ToolInfo for _, e := range entries { - if e.IsDir() || e.Name() == "idx" || strings.HasPrefix(e.Name(), ".") { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".meta") { continue } + name := strings.TrimSuffix(e.Name(), ".meta") data, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { continue } - script := string(data) - prompt := ExtractPrompt(script) - desc := extractShortDescription(prompt) - infos = append(infos, ToolInfo{ - Name: e.Name(), - Description: desc, - Prompt: prompt, - }) + var m MetaFile + if err := json.Unmarshal(data, &m); err != nil { + continue + } + infos = append(infos, ToolInfoFromMeta(name, &m)) } return infos } -// extractShortDescription gets the first non-heading, non-empty line from a prompt. -func extractShortDescription(prompt string) string { - for _, line := range strings.Split(prompt, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - return line - } - return "" -} - -// ExtractShortDescription is the exported version of extractShortDescription. -func ExtractShortDescription(prompt string) string { - return extractShortDescription(prompt) -} - -// ToolPrompt returns the full ollie:prompt content for a named tool. -// Returns "" if the tool has no prompt or doesn't exist. +// ToolPrompt returns the full prompt content for a named tool. +// Returns "" if the tool has no .meta file or no prompt. func ToolPrompt(name string) string { if strings.Contains(name, "/") || strings.Contains(name, "..") { return "" } - path := filepath.Join(ToolsPath(), name) - data, err := os.ReadFile(path) - if err != nil { + m, err := LoadMetaFile(name) + if err != nil || m == nil { return "" } - return ExtractPrompt(string(data)) + return m.Prompt } // ReadTool reads a named tool script from the tools directory. func ReadTool(name string) (string, error) { if strings.Contains(name, "/") || strings.Contains(name, "..") { - return "", fmt.Errorf("invalid tool name") + return "", os.ErrNotExist } - path := filepath.Join(ToolsPath(), name) data, err := os.ReadFile(path) if err != nil { - if os.IsNotExist(err) { - return "", fmt.Errorf("tool not found: %s", name) - } - return "", fmt.Errorf("read tool %s: %w", name, err) + return "", err } return string(data), nil } diff --git a/toolsrv/meta.go b/toolsrv/meta.go new file mode 100644 index 0000000..16afbb5 --- /dev/null +++ b/toolsrv/meta.go @@ -0,0 +1,54 @@ +package toolsrv + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// MetaFile is the JSON structure of a .meta sidecar file. +type MetaFile struct { + Description string `json:"description"` + Prompt string `json:"prompt,omitempty"` + Args json.RawMessage `json:"args,omitempty"` + Tier string `json:"tier,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` +} + +// LoadMetaFile reads and parses a .meta sidecar file for the given tool name. +// Returns nil if the .meta file does not exist. +func LoadMetaFile(name string) (*MetaFile, error) { + path := filepath.Join(ToolsPath(), name+".meta") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var m MetaFile + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return &m, nil +} + +// ToolInfoFromMeta builds a ToolInfo from a MetaFile. +func ToolInfoFromMeta(name string, m *MetaFile) ToolInfo { + args := m.Args + if args == nil { + args = json.RawMessage(`{"type":"object","properties":{}}`) + } + tier := m.Tier + if tier == "" { + tier = "hot" + } + return ToolInfo{ + Name: name, + Description: m.Description, + InputSchema: args, + Prompt: m.Prompt, + Tier: tier, + ReadOnly: m.ReadOnly, + } +} diff --git a/toolsrv/schema.go b/toolsrv/schema.go index 4fd6a8c..129fd2d 100644 --- a/toolsrv/schema.go +++ b/toolsrv/schema.go @@ -2,83 +2,19 @@ package toolsrv import ( "encoding/json" - "strings" - - ) -type ToolMeta struct { - ReadOnly bool `json:"readOnly,omitempty"` - RequiresApproval bool `json:"requiresApproval,omitempty"` - NetworkAccess bool `json:"networkAccess,omitempty"` - PathScope string `json:"pathScope,omitempty"` - SandboxLevel string `json:"sandboxLevel,omitempty"` - Examples []string `json:"examples,omitempty"` - Tags []string `json:"tags,omitempty"` -} - -func ExtractArgsSchema(script string) json.RawMessage { - for _, line := range strings.Split(script, "\n") { - trimmed := strings.TrimSpace(line) - if strings.HasPrefix(trimmed, "# args_json:") { - schema := strings.TrimSpace(strings.TrimPrefix(trimmed, "# args_json:")) - if schema != "" { - return json.RawMessage(schema) - } - } - } - return nil -} - - -func ExtractTier(script string) string { - for _, line := range strings.Split(script, "\n") { - idx := strings.Index(line, "ollie:tier") - if idx < 0 { - continue - } - rest := strings.TrimSpace(line[idx+len("ollie:tier"):]) - switch { - case rest == "cold" || strings.HasPrefix(rest, "cold "): - return "cold" - case rest == "warm" || strings.HasPrefix(rest, "warm "): - return "warm" - case rest == "hot" || strings.HasPrefix(rest, "hot "): - return "hot" - } - } - return "" -} - -func ExtractMetadata(script string) ToolMeta { - var meta ToolMeta - for _, line := range strings.Split(script, "\n") { - trimmed := strings.TrimSpace(line) - if strings.Contains(trimmed, "ollie:parallel read") { - meta.ReadOnly = true - } - } - return meta -} - +// ParseToolInfo builds a ToolInfo from the .meta sidecar file for the named tool. +// Falls back to a minimal ToolInfo if no .meta exists. func ParseToolInfo(name, script string) ToolInfo { - prompt := ExtractPrompt(script) - desc := ExtractShortDescription(prompt) - argsSchema := ExtractArgsSchema(script) - if argsSchema == nil { - argsSchema = json.RawMessage(`{"type":"object","properties":{}}`) - } - meta := ExtractMetadata(script) - tier := ExtractTier(script) - if tier == "" { - tier = "hot" + m, err := LoadMetaFile(name) + if err == nil && m != nil { + return ToolInfoFromMeta(name, m) } return ToolInfo{ Name: name, - Description: desc, - InputSchema: argsSchema, - Prompt: prompt, - Tier: tier, - ReadOnly: meta.ReadOnly, + Description: name, + InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), + Tier: "hot", } } diff --git a/toolsrv/tier.go b/toolsrv/tier.go index 1b1fca8..8382e5c 100644 --- a/toolsrv/tier.go +++ b/toolsrv/tier.go @@ -2,22 +2,21 @@ package toolsrv import ( "encoding/json" - "strings" ) -// ResultTier implements tools.TierClassifier. Looks up the tool's cached tier -// from the registry; falls back to reading the script on disk. +// ResultTier implements TierClassifier. Looks up the tool's tier +// from the registry, then from its .meta file. func (e *Server) ResultTier(name string) string { if e.toolRegistry != nil && e.sessionID != "" { if info, ok := e.toolRegistry.Lookup(e.sessionID, name); ok && info.Tier != "" { return info.Tier } } - code, err := ReadTool(name) - if err != nil { - return "hot" + m, err := LoadMetaFile(name) + if err == nil && m != nil && m.Tier != "" { + return m.Tier } - return detectTier(code) + return "hot" } // ResultTierArgs classifies the tier using both the outer tool name and its @@ -32,44 +31,17 @@ func (e *Server) ResultTierArgs(name string, args json.RawMessage) string { } } -// IsParallelRead implements tools.ParallelClassifier. Returns true when the -// named tool script is ReadOnly (ollie:parallel read annotation). -// Checks the registry cache first; falls back to reading the script. +// IsParallelRead implements ParallelClassifier. Returns true when the +// named tool is marked readOnly in its .meta file. func (e *Server) IsParallelRead(name string) bool { if e.toolRegistry != nil && e.sessionID != "" { if info, ok := e.toolRegistry.Lookup(e.sessionID, name); ok { return info.ReadOnly } } - code, err := ReadTool(name) - if err != nil { - return false - } - for _, line := range strings.SplitN(code, "\n", 11) { - if strings.Contains(line, "ollie:parallel read") { - return true - } + m, err := LoadMetaFile(name) + if err == nil && m != nil { + return m.ReadOnly } return false } - -// detectTier scans the first 10 lines of a tool script for an -// "ollie:tier" annotation. Fallback when a tool isn't in the registry. -func detectTier(code string) string { - for _, line := range strings.SplitN(code, "\n", 11) { - idx := strings.Index(line, "ollie:tier") - if idx < 0 { - continue - } - rest := strings.TrimSpace(line[idx+len("ollie:tier"):]) - switch { - case rest == "cold" || strings.HasPrefix(rest, "cold "): - return "cold" - case rest == "warm" || strings.HasPrefix(rest, "warm "): - return "warm" - case rest == "hot" || strings.HasPrefix(rest, "hot "): - return "hot" - } - } - return "hot" -}