tool metadata: replace header comment parsing with .meta sidecar files
The tool registry now reads metadata exclusively from JSON .meta sidecar files alongside each tool executable. Header comment parsing (ollie:prompt, ollie:tier, ollie:parallel, args_json) is removed entirely. - Add toolsrv/meta.go: MetaFile struct, LoadMetaFile, ToolInfoFromMeta - Rewrite discover.go: scans .meta files only - Rewrite schema.go: ParseToolInfo reads .meta only - Rewrite tier.go: tier/readOnly from .meta only - Generate .meta for all 35 tools - Update all docs: README, AGENTS.md, ARCHITECTURE, CORE, USAGE, WRITING_TOOLS
This commit is contained in:
parent
942cad2dee
commit
69b2c2cc40
11
AGENTS.md
11
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/<name>.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 <name>` 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:
|
||||
|
|
|
|||
22
README.md
22
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
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
15
doc/CORE.md
15
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
|
||||
|
||||
|
|
|
|||
50
doc/USAGE.md
50
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
|
||||
|
|
|
|||
|
|
@ -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: <schema>` | 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
|
||||
|
|
|
|||
2
justfile
2
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue