chore: initial commit

Amolith created

Change summary

AGENTS.md                       |  92 ++++++++++
README.md                       | 104 ++++++++++++
bun.lock                        | 302 +++++++++++++++++++++++++++++++++++
bunfig.toml                     |   2 
config.example.toml             |  73 ++++++++
package.json                    |  30 +++
src/agent/model-resolver.ts     | 104 ++++++++++++
src/agent/prompts/repo.ts       |   5 
src/agent/prompts/web.ts        |   7 
src/agent/runner.ts             |  51 +++++
src/agent/tools/find.ts         |  88 ++++++++++
src/agent/tools/git/blame.ts    |  27 +++
src/agent/tools/git/checkout.ts |  27 +++
src/agent/tools/git/diff.ts     |  40 ++++
src/agent/tools/git/index.ts    |   6 
src/agent/tools/git/log.ts      |  59 ++++++
src/agent/tools/git/refs.ts     |  43 ++++
src/agent/tools/git/show.ts     |  27 +++
src/agent/tools/grep.ts         | 189 +++++++++++++++++++++
src/agent/tools/index.ts        |  26 +++
src/agent/tools/ls.ts           |  82 +++++++++
src/agent/tools/path-utils.ts   |  70 ++++++++
src/agent/tools/read.ts         |  83 +++++++++
src/agent/tools/web-fetch.ts    |  44 +++++
src/agent/tools/web-search.ts   |  26 +++
src/cli/commands/repo.ts        |  97 +++++++++++
src/cli/commands/web.ts         | 104 ++++++++++++
src/cli/index.ts                | 106 ++++++++++++
src/cli/output.ts               |  53 ++++++
src/config/defaults.ts          |  17 +
src/config/loader.ts            |  75 ++++++++
src/config/schema.ts            |  59 ++++++
src/types/kagi-ken.d.ts         |   3 
src/util/errors.ts              |  39 ++++
src/util/truncate.ts            | 210 ++++++++++++++++++++++++
src/workspace/content.ts        |  24 ++
src/workspace/manager.ts        |  29 +++
tsconfig.json                   |  19 ++
38 files changed, 2,442 insertions(+)

Detailed changes

AGENTS.md πŸ”—

@@ -0,0 +1,92 @@
+# Rumilo Agent Guide
+
+CLI that dispatches specialized AI research subagents for web research and git repository exploration.
+
+## Commands
+
+```bash
+bun run dev       # Run CLI via bun (development)
+bun run build     # Build to dist/
+bun run typecheck # TypeScript check (also: bun run lint)
+```
+
+No test suite currently exists (`test/` is empty).
+
+## Architecture
+
+```
+CLI entry (src/cli/index.ts)
+    β”œβ”€β”€ web command β†’ runWebCommand() β†’ runAgent() with web tools
+    └── repo command β†’ runRepoCommand() β†’ runAgent() with git tools
+```
+
+### Control Flow
+
+1. **CLI** parses args with custom parser (no library), dispatches to command handlers
+2. **Command handlers** (`src/cli/commands/`) load config, create workspace, configure tools, invoke `runAgent()`
+3. **Agent runner** (`src/agent/runner.ts`) wraps `@mariozechner/pi-agent` - creates `Agent`, subscribes to events, prompts, extracts final message
+4. **Tools** are created via factory functions that close over workspace path for sandboxing
+5. **Workspace** (`src/workspace/manager.ts`) is a temp directory (cleaned up unless `--no-cleanup`)
+
+### Two Agent Modes
+
+| Mode | Tools | External Services |
+|------|-------|-------------------|
+| `web` | `web_search`, `web_fetch`, `read`, `grep`, `ls`, `find` | Kagi (search), Tabstack (fetch→markdown) |
+| `repo` | `read`, `grep`, `ls`, `find`, `git_log`, `git_show`, `git_blame`, `git_diff`, `git_refs`, `git_checkout` | None (clones repo to workspace) |
+
+## Key Patterns
+
+### Tool Factory Pattern
+
+All tools follow the same signature:
+```typescript
+const createFooTool = (workspacePath: string): AgentTool => ({ ... })
+```
+
+Tools use `@sinclair/typebox` for parameter schemas. Execute functions return `{ content: [{type: "text", text}], details?: {...} }`.
+
+### Workspace Sandboxing
+
+Tools must constrain paths to workspace:
+- `ensureWorkspacePath()` in `src/agent/tools/index.ts` validates paths don't escape
+- `resolveToCwd()` / `resolveReadPath()` in `src/agent/tools/path-utils.ts` handle expansion and normalization
+
+### Config Cascade
+
+```
+defaultConfig β†’ XDG_CONFIG_HOME/rumilo/config.toml β†’ CLI flags
+```
+
+Config uses TOML, validated against TypeBox schema (`src/config/schema.ts`).
+
+### Model Resolution
+
+Model strings use `provider:model` format. `custom:name` prefix looks up custom model definitions from config's `[custom_models]` section. Built-in providers delegate to `@mariozechner/pi-ai`.
+
+### Error Handling
+
+Custom error classes in `src/util/errors.ts` extend `RumiloError` with error codes:
+- `ConfigError`, `FetchError`, `CloneError`, `WorkspaceError`, `ToolInputError`
+
+### Output Truncation
+
+`src/util/truncate.ts` handles large content:
+- `DEFAULT_MAX_LINES = 2000`
+- `DEFAULT_MAX_BYTES = 50KB`
+- `GREP_MAX_LINE_LENGTH = 500` chars
+
+## Gotchas
+
+1. **Grep requires ripgrep** - `createGrepTool` checks for `rg` at tool creation time and throws if missing
+2. **Web tools require credentials** - `KAGI_SESSION_TOKEN` and `TABSTACK_API_KEY` via env or config
+3. **Shallow clones by default** - repo mode uses `--depth 1 --filter=blob:limit=5m` unless `--full`
+4. **Pre-fetch injection threshold** - web command with `-u URL` injects content directly if ≀50KB, otherwise stores in workspace
+5. **No `.js` extension in imports** - source uses `.js` extensions for ESM compatibility even though files are `.ts`
+
+## Adding a New Tool
+
+1. Create `src/agent/tools/newtool.ts` following the factory pattern
+2. Export from `src/agent/tools/index.ts` if general-purpose
+3. Add to appropriate command's tool array in `src/cli/commands/{web,repo}.ts`
+4. Use `ToolInputError` for validation failures

README.md πŸ”—

@@ -0,0 +1,104 @@
+# rumilo
+
+Rumilo is a CLI that dispatches specialized research subagents. It supports two modes:
+
+- `web` for web research (search + fetch, stored in a sandboxed workspace)
+- `repo` for git repository exploration (clone to workspace, git-aware tools)
+
+## Requirements
+
+- Bun
+- Git (for repo mode)
+- Kagi session token
+- Tabstack API key
+
+## Configuration
+
+Rumilo reads configuration from `$XDG_CONFIG_HOME/rumilo/config.toml`.
+
+Example:
+
+```toml
+[defaults]
+model = "anthropic:claude-sonnet-4-20250514"
+cleanup = true
+
+[web]
+model = "anthropic:claude-sonnet-4-20250514"
+
+[repo]
+model = "anthropic:claude-sonnet-4-20250514"
+```
+
+### Custom Models
+
+You can define custom OpenAI-compatible endpoints like Ollama, vLLM, or self-hosted models in the `[custom_models]` section:
+
+```toml
+[custom_models.ollama]
+provider = "ollama"
+api = "openai-completions"
+base_url = "http://localhost:11434/v1"
+id = "ollama/llama3"
+name = "Llama 3 (Ollama)"
+reasoning = false
+input = ["text"]
+cost = { input = 0, output = 0 }
+context_window = 128000
+max_tokens = 4096
+```
+
+Use custom models with the `custom:` prefix:
+
+```bash
+rumilo web "query" --model custom:ollama
+rumilo repo -u <uri> "query" --model custom:ollama
+```
+
+#### Custom Model Fields
+
+- `provider`: Provider identifier (e.g., "ollama", "custom")
+- `api`: API type - typically "openai-completions"
+- `base_url`: API endpoint URL
+- `id`: Unique model identifier
+- `name`: Human-readable display name
+- `reasoning`: Whether the model supports thinking/reasoning
+- `input`: Input modalities - `["text"]` or `["text", "image"]`
+- `cost`: Cost per million tokens (can use 0 for local models)
+- `context_window`: Maximum context size in tokens
+- `max_tokens`: Maximum output tokens
+
+#### Compatibility Flags (Optional)
+
+Some OpenAI-compatible endpoints have quirks. Use the `compat` section to override:
+
+```toml
+[custom_models.mistral]
+provider = "mistral"
+api = "openai-completions"
+base_url = "https://api.mistral.ai/v1"
+# ... other fields ...
+
+[custom_models.mistral.compat]
+max_tokens_field = "max_tokens"
+requires_tool_result_name = true
+requires_thinking_as_text = true
+requires_mistral_tool_ids = true
+```
+
+See [pi-ai documentation](https://deepwiki.com/badlogic/pi-mono/2.6-custom-models-and-compatibility) for all compat flags.
+
+### Credentials
+
+Set credentials either via config or environment:
+
+- `KAGI_SESSION_TOKEN`: Kagi session token
+- `TABSTACK_API_KEY`: Tabstack API key
+
+## Usage
+
+```bash
+rumilo web "how does X work"
+rumilo web -u https://example.com/docs "explain the auth flow"
+rumilo repo -u https://github.com/org/repo "how is caching implemented"
+```

bun.lock πŸ”—

@@ -0,0 +1,302 @@
+{
+  "lockfileVersion": 1,
+  "configVersion": 1,
+  "workspaces": {
+    "": {
+      "name": "rumilo",
+      "dependencies": {
+        "@mariozechner/pi-agent": "^0.9.0",
+        "@mariozechner/pi-ai": "^0.6.1",
+        "@sinclair/typebox": "^0.32.14",
+        "@tabstack/sdk": "^2.1.0",
+        "kagi-ken": "github:czottmann/kagi-ken#1.2.0",
+        "simple-git": "^3.25.0",
+        "toml": "^3.0.0",
+      },
+      "devDependencies": {
+        "@types/node": "^22.13.4",
+        "bun-types": "^1.0.24",
+        "typescript": "^5.7.3",
+      },
+    },
+  },
+  "packages": {
+    "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.61.0", "", { "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-GnlOXrPxow0uoaVB3DGNh9EJBU1MyagCBCLpU+bwDVlj/oOPYIwoiasMWlykkfYcQOrDP2x/zHnRD0xN7PeZPw=="],
+
+    "@google/genai": ["@google/genai@1.40.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-fhIww8smT0QYRX78qWOiz/nIQhHMF5wXOrlXvj33HBrz3vKDBb+wibLcEmTA+L9dmPD4KmfNr7UF3LDQVTXNjA=="],
+
+    "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
+
+    "@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="],
+
+    "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="],
+
+    "@mariozechner/pi-agent": ["@mariozechner/pi-agent@0.9.0", "", { "dependencies": { "@mariozechner/pi-ai": "^0.9.0", "@mariozechner/pi-tui": "^0.9.0" } }, "sha512-VS53eCoyn3vSeqFGzWPV4BoN2ag+FjjFsx4ApLolxgAaLH2uQUEh1V8fHXeuG7j2Cjyr6ZiMY6I1HwX1JTs1ng=="],
+
+    "@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.6.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.61.0", "@google/genai": "^1.17.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "5.21.0", "partial-json": "^0.1.7", "zod-to-json-schema": "^3.24.6" } }, "sha512-sVuNRo7j2AL+dk2RQrjVa6+j5Hf+5wFssJoRs0EpSbaVlLveiEAXOYx8ajryirTvzzpAPzbOX4S/UoJiqDh1vQ=="],
+
+    "@mariozechner/pi-tui": ["@mariozechner/pi-tui@0.9.4", "", { "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "marked": "^15.0.12", "mime-types": "^3.0.1", "string-width": "^8.1.0" } }, "sha512-jEuqehUniAnJsaiwRIkhHO4APWGZJqoxWgayj02MhSSKUhwi9M+KiqvV8I0Mfgy+4YuZiqY0F9ld/mn1XWoj/w=="],
+
+    "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
+
+    "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
+
+    "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
+
+    "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
+
+    "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
+
+    "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
+
+    "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
+
+    "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
+
+    "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
+
+    "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
+
+    "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
+
+    "@sinclair/typebox": ["@sinclair/typebox@0.32.35", "", {}, "sha512-Ul3YyOTU++to8cgNkttakC0dWvpERr6RYoHO2W47DLbFvrwBDJUY31B1sImH6JZSYc4Kt4PyHtoPNu+vL2r2dA=="],
+
+    "@tabstack/sdk": ["@tabstack/sdk@2.1.0", "", {}, "sha512-gKqQDo+UaY2E5XjQWvFNmuQCuUuYxAbv+JBhfaP5EdWA0ho0fq2EOZ+pMVJ3ALzHVF50RqmKJlogaDwNq42OjA=="],
+
+    "@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="],
+
+    "@types/node": ["@types/node@22.19.9", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A=="],
+
+    "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
+    "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
+
+    "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
+
+    "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+
+    "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
+
+    "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+
+    "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
+
+    "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
+
+    "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
+
+    "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
+
+    "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
+
+    "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
+
+    "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
+
+    "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
+
+    "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
+
+    "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
+
+    "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
+
+    "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
+    "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
+
+    "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
+
+    "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
+
+    "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+    "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
+
+    "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
+
+    "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
+
+    "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
+
+    "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
+
+    "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
+
+    "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
+
+    "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
+
+    "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+
+    "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
+
+    "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+
+    "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
+
+    "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
+
+    "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
+
+    "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
+
+    "gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
+
+    "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
+
+    "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="],
+
+    "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
+
+    "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="],
+
+    "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
+
+    "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="],
+
+    "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
+
+    "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+
+    "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
+
+    "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
+
+    "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
+    "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
+
+    "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
+
+    "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+
+    "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
+
+    "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
+
+    "kagi-ken": ["kagi-ken@github:czottmann/kagi-ken#8f45ab5", { "dependencies": { "cheerio": "^1.1.2" } }, "czottmann-kagi-ken-8f45ab5"],
+
+    "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
+
+    "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+
+    "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
+
+    "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
+
+    "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
+
+    "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
+
+    "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="],
+
+    "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+    "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
+
+    "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
+
+    "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
+
+    "openai": ["openai@5.21.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-E9LuV51vgvwbahPJaZu2x4V6SWMq9g3X6Bj2/wnFiNfV7lmAxYVxPxcQNZqCWbAVMaEoers9HzIxpOp6Vvgn8w=="],
+
+    "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
+
+    "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+
+    "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
+
+    "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
+
+    "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="],
+
+    "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
+    "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
+
+    "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
+
+    "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
+
+    "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
+
+    "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+
+    "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+
+    "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+    "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+
+    "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
+
+    "simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="],
+
+    "string-width": ["string-width@8.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw=="],
+
+    "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
+    "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
+
+    "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+    "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="],
+
+    "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+    "undici": ["undici@7.20.0", "", {}, "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ=="],
+
+    "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
+
+    "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
+
+    "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
+
+    "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
+
+    "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+    "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
+
+    "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
+
+    "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
+
+    "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+
+    "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
+
+    "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
+
+    "@mariozechner/pi-agent/@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.9.4", "", { "dependencies": { "@anthropic-ai/sdk": "^0.61.0", "@google/genai": "^1.30.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "5.21.0", "partial-json": "^0.1.7", "zod-to-json-schema": "^3.24.6" } }, "sha512-xI2bnh0LgNBSszDqzXxM3aMPB23hhM5r5xtJaj1KNPVpDP17fTGPcpfO3/4xOIlDYGOvc+OC4778suGRYwwbsg=="],
+
+    "@mariozechner/pi-ai/@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
+
+    "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+
+    "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
+    "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+
+    "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+    "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+    "wrap-ansi/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
+
+    "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
+
+    "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+
+    "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+
+    "@mariozechner/pi-agent/@mariozechner/pi-ai/@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="],
+
+    "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+
+    "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+
+    "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+  }
+}

config.example.toml πŸ”—

@@ -0,0 +1,73 @@
+# Example custom models configuration for rumilo
+# Copy relevant sections to your $XDG_CONFIG_HOME/rumilo/config.toml
+
+[defaults]
+model = "anthropic:claude-sonnet-4-20250514"
+cleanup = true
+# kagi_session_token = "your-kagi-token"
+# tabstack_api_key = "your-tabstack-key"
+
+[web]
+model = "anthropic:claude-sonnet-4-20250514"
+# system_prompt_path = "~/.config/rumilo/web-prompt.txt"
+
+[repo]
+model = "anthropic:claude-sonnet-4-20250514"
+default_depth = 1
+blob_limit = "5m"
+# system_prompt_path = "~/.config/rumilo/repo-prompt.txt"
+
+# Custom models allow you to use any OpenAI-compatible endpoint
+# Reference them with: rumilo web "query" --model custom:ollama
+
+[custom_models.ollama]
+# Ollama running locally
+provider = "ollama"
+api = "openai-completions"
+base_url = "http://localhost:11434/v1"
+id = "ollama/llama3.2"
+name = "Llama 3.2 (Ollama)"
+reasoning = false
+input = ["text"]
+cost = { input = 0, output = 0 }
+context_window = 128000
+max_tokens = 4096
+
+[custom_models.vllm]
+# vLLM inference server
+provider = "vllm"
+api = "openai-completions"
+base_url = "http://localhost:8000/v1"
+id = "meta-llama/Llama-3.3-70B-Instruct"
+name = "Llama 3.3 70B (vLLM)"
+reasoning = false
+input = ["text"]
+cost = { input = 0, output = 0 }
+context_window = 131072
+max_tokens = 8192
+
+[custom_models.groq]
+# Groq (built-in to pi-ai, but shown here as example)
+provider = "groq"
+api = "openai-completions"
+base_url = "https://api.groq.com/openai/v1"
+id = "groq/llama-3.3-70b-versatile"
+name = "Llama 3.3 70B Versatile (Groq)"
+reasoning = false
+input = ["text"]
+cost = { input = 0.59, output = 0.79 }
+context_window = 131072
+max_tokens = 8192
+
+[custom_models.openrouter]
+# OpenRouter API (model aggregation)
+provider = "openrouter"
+api = "openai-completions"
+base_url = "https://openrouter.ai/api/v1"
+id = "openrouter/zai/glm-4.5v"
+name = "GLM-4.5V (OpenRouter)"
+reasoning = true
+input = ["text", "image"]
+cost = { input = 0.5, output = 1.5 }
+context_window = 128000
+max_tokens = 4096

package.json πŸ”—

@@ -0,0 +1,30 @@
+{
+  "name": "rumilo",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "bin": {
+    "rumilo": "./dist/cli/index.js"
+  },
+  "scripts": {
+    "dev": "bun src/cli/index.ts",
+    "build": "bun build src/cli/index.ts --outdir dist --target=node",
+    "start": "bun dist/cli/index.js",
+    "lint": "bun run --silent typecheck",
+    "typecheck": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@mariozechner/pi-ai": "^0.6.1",
+    "@mariozechner/pi-agent": "^0.9.0",
+    "@sinclair/typebox": "^0.32.14",
+    "@tabstack/sdk": "^2.1.0",
+    "kagi-ken": "github:czottmann/kagi-ken#1.2.0",
+    "simple-git": "^3.25.0",
+    "toml": "^3.0.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.13.4",
+    "bun-types": "^1.0.24",
+    "typescript": "^5.7.3"
+  }
+}

src/agent/model-resolver.ts πŸ”—

@@ -0,0 +1,104 @@
+import { getModel, type Model } from "@mariozechner/pi-ai";
+import {
+  type CustomModelConfig,
+  type RumiloConfig,
+} from "../config/schema.js";
+import { ConfigError } from "../util/errors.js";
+
+export function resolveModel(
+  modelString: string,
+  config: RumiloConfig,
+): Model<any> {
+  const [provider, modelName] = modelString.split(":");
+
+  if (!provider || !modelName) {
+    throw new ConfigError("Model must be in provider:model format");
+  }
+
+  // Handle custom models
+  if (provider === "custom") {
+    return resolveCustomModel(modelName, config);
+  }
+
+  // Handle built-in providers
+  return getModel(provider as any, modelName);
+}
+
+function resolveCustomModel(modelName: string, config: RumiloConfig): Model<any> {
+  if (!config.custom_models) {
+    throw new ConfigError(
+      `No custom models configured. Use 'custom:' prefix only with custom model definitions in config.`,
+    );
+  }
+
+  const customConfig = config.custom_models[modelName];
+  if (!customConfig) {
+    const available = Object.keys(config.custom_models).join(", ");
+    throw new ConfigError(
+      `Custom model '${modelName}' not found. Available custom models: ${available}`,
+    );
+  }
+
+  return buildCustomModel(customConfig);
+}
+
+function buildCustomModel(config: CustomModelConfig): Model<any> {
+  const api = config.api as any;
+
+  const cost: any = {
+    input: config.cost.input,
+    output: config.cost.output,
+  };
+
+  if (config.cost.cache_read !== undefined) {
+    cost.cacheRead = config.cost.cache_read;
+  }
+
+  if (config.cost.cache_write !== undefined) {
+    cost.cacheWrite = config.cost.cache_write;
+  }
+
+  const model: any = {
+    id: config.id,
+    name: config.name,
+    api,
+    provider: config.provider as any,
+    baseUrl: config.base_url,
+    reasoning: config.reasoning,
+    input: config.input,
+    cost,
+    contextWindow: config.context_window,
+    maxTokens: config.max_tokens,
+  };
+
+  if (config.headers) {
+    model.headers = config.headers;
+  }
+
+  if (config.compat) {
+    model.compat = convertCompatConfig(config.compat);
+  }
+
+  return model;
+}
+
+function convertCompatConfig(
+  compat: CustomModelConfig["compat"],
+): any {
+  if (!compat) {
+    throw new Error("Compat config is expected to be defined");
+  }
+
+  return {
+    supportsStore: compat.supports_store,
+    supportsDeveloperRole: compat.supports_developer_role,
+    supportsReasoningEffort: compat.supports_reasoning_effort,
+    supportsUsageInStreaming: compat.supports_usage_in_streaming,
+    maxTokensField: compat.max_tokens_field,
+    requiresToolResultName: compat.requires_tool_result_name,
+    requiresAssistantAfterToolResult: compat.requires_assistant_after_tool_result,
+    requiresThinkingAsText: compat.requires_thinking_as_text,
+    requiresMistralToolIds: compat.requires_mistral_tool_ids,
+    thinkingFormat: compat.thinking_format,
+  };
+}

src/agent/prompts/repo.ts πŸ”—

@@ -0,0 +1,5 @@
+export const REPO_SYSTEM_PROMPT = `You are a research assistant focused on answering the user's question by exploring a git repository in the workspace.
+
+Use read and grep to inspect files. Use git tools to explore history and refs. Prefer concrete file references and explain where information comes from.
+
+Be concise and answer directly.`;

src/agent/prompts/web.ts πŸ”—

@@ -0,0 +1,7 @@
+export const WEB_SYSTEM_PROMPT = `You are a research assistant focused on answering the user's question using web sources.
+
+You have tools to search the web, fetch URLs into markdown, and read or grep files in the workspace.
+
+Use web_search to find sources. Use web_fetch to retrieve a URL. When content is large, it may be stored in the workspace; use read and grep to explore it.
+
+Be concise, cite sources with URLs, and answer directly.`;

src/agent/runner.ts πŸ”—

@@ -0,0 +1,51 @@
+import { Agent, ProviderTransport, type AgentEvent } from "@mariozechner/pi-agent";
+import { type AgentTool } from "@mariozechner/pi-ai";
+import { ToolInputError } from "../util/errors.js";
+import type { RumiloConfig } from "../config/schema.js";
+import { resolveModel } from "./model-resolver.js";
+
+export interface AgentRunOptions {
+  model: string;
+  systemPrompt: string;
+  tools: AgentTool[];
+  onEvent?: (event: AgentEvent) => void;
+  config: RumiloConfig;
+}
+
+export interface AgentRunResult {
+  message: string;
+  usage?: unknown;
+}
+
+export async function runAgent(query: string, options: AgentRunOptions): Promise<AgentRunResult> {
+  const agent = new Agent({
+    initialState: {
+      systemPrompt: options.systemPrompt,
+      model: resolveModel(options.model, options.config),
+      tools: options.tools,
+    },
+    transport: new ProviderTransport(),
+  });
+
+  if (options.onEvent) {
+    agent.subscribe(options.onEvent);
+  }
+
+  await agent.prompt(query);
+
+  const last = agent.state.messages
+    .slice()
+    .reverse()
+    .find((msg) => msg.role === "assistant");
+
+  const text = last?.content
+    ?.filter((content) => content.type === "text")
+    .map((content) => content.text)
+    .join("")
+    .trim();
+
+  return {
+    message: text ?? "",
+    usage: (last as any)?.usage,
+  };
+}

src/agent/tools/find.ts πŸ”—

@@ -0,0 +1,88 @@
+import { spawnSync } from "node:child_process";
+import { relative } from "node:path";
+import { Type } from "@sinclair/typebox";
+import type { AgentTool } from "@mariozechner/pi-ai";
+import { resolveToCwd } from "./path-utils.js";
+import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../util/truncate.js";
+import { ToolInputError } from "../../util/errors.js";
+
+const DEFAULT_LIMIT = 1000;
+
+const FindSchema = Type.Object({
+  pattern: Type.String({ description: "Glob pattern to match files, e.g. '*.ts', '**/*.json'" }),
+  path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })),
+  limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })),
+});
+
+export const createFindTool = (workspacePath: string): AgentTool => {
+  const fdResult = spawnSync("which", ["fd"], { encoding: "utf-8" });
+  const fdPath = fdResult.stdout?.trim();
+  if (!fdPath) throw new ToolInputError("fd is not available");
+
+  return {
+    name: "find",
+    label: "Find Files",
+    description: `Search for files by glob pattern using fd. Returns up to ${DEFAULT_LIMIT} results and ${DEFAULT_MAX_BYTES / 1024} KB of output. Respects .gitignore.`,
+    parameters: FindSchema as any,
+    execute: async (_toolCallId: string, params: any) => {
+      const searchDir: string = params.path || ".";
+      const effectiveLimit = params.limit ?? DEFAULT_LIMIT;
+      const searchPath = resolveToCwd(searchDir, workspacePath);
+
+    const args = [
+      "--glob",
+      "--color=never",
+      "--hidden",
+      "--max-results",
+      String(effectiveLimit),
+      params.pattern,
+      searchPath,
+    ];
+
+    const result = spawnSync(fdPath, args, {
+      encoding: "utf-8",
+      maxBuffer: 10 * 1024 * 1024,
+    });
+
+    const rawOutput = result.stdout?.trim() ?? "";
+    if (!rawOutput) {
+      return {
+        content: [{ type: "text", text: "No files found matching pattern" }],
+        details: { pattern: params.pattern, path: searchDir, matches: 0 },
+      };
+    }
+
+    const lines = rawOutput.split("\n").map((line) => {
+      const isDir = line.endsWith("/");
+      const rel = relative(searchPath, line);
+      return isDir && !rel.endsWith("/") ? `${rel}/` : rel;
+    });
+
+    const relativized = lines.join("\n");
+    const truncated = truncateHead(relativized, { maxLines: Number.MAX_SAFE_INTEGER });
+
+    const notices: string[] = [];
+    if (lines.length >= effectiveLimit) {
+      notices.push(`Result limit reached (${effectiveLimit}). Narrow your pattern for complete results.`);
+    }
+    if (truncated.truncatedBy === "bytes") {
+      const droppedBytes = truncated.totalBytes - truncated.outputBytes;
+      notices.push(`Output truncated by ${formatSize(droppedBytes)} to fit ${formatSize(DEFAULT_MAX_BYTES)} limit.`);
+    }
+
+    const output = notices.length > 0
+      ? `${truncated.content}\n\n${notices.join("\n")}`
+      : truncated.content;
+
+    return {
+      content: [{ type: "text", text: output }],
+      details: {
+        pattern: params.pattern,
+        path: searchDir,
+        matches: lines.length,
+        ...(truncated.truncated && { truncatedBy: truncated.truncatedBy }),
+      },
+    };
+    },
+  };
+};

src/agent/tools/git/blame.ts πŸ”—

@@ -0,0 +1,27 @@
+import { Type } from "@sinclair/typebox";
+import type { AgentTool } from "@mariozechner/pi-ai";
+import simpleGit from "simple-git";
+import { ToolInputError } from "../../../util/errors.js";
+
+const BlameSchema = Type.Object({
+  path: Type.String({ description: "File path relative to repo root" }),
+});
+
+export const createGitBlameTool = (workspacePath: string): AgentTool => ({
+  name: "git_blame",
+  label: "Git Blame",
+  description: "Blame a file to see commit attribution.",
+  parameters: BlameSchema as any,
+  execute: async (_toolCallId: string, params: any) => {
+    if (!String(params.path ?? "").trim()) {
+      throw new ToolInputError("path must be a non-empty string");
+    }
+    const git = simpleGit(workspacePath);
+    const text = await git.raw(["blame", "--", params.path]);
+
+    return {
+      content: [{ type: "text", text }],
+      details: { path: params.path },
+    };
+  },
+});

src/agent/tools/git/checkout.ts πŸ”—

@@ -0,0 +1,27 @@
+import { Type } from "@sinclair/typebox";
+import type { AgentTool } from "@mariozechner/pi-ai";
+import simpleGit from "simple-git";
+import { ToolInputError } from "../../../util/errors.js";
+
+const CheckoutSchema = Type.Object({
+  ref: Type.String({ description: "Ref to checkout" }),
+});
+
+export const createGitCheckoutTool = (workspacePath: string): AgentTool => ({
+  name: "git_checkout",
+  label: "Git Checkout",
+  description: "Checkout a branch, tag, or commit.",
+  parameters: CheckoutSchema as any,
+  execute: async (_toolCallId: string, params: any) => {
+    if (!String(params.ref ?? "").trim()) {
+      throw new ToolInputError("ref must be a non-empty string");
+    }
+    const git = simpleGit(workspacePath);
+    await git.checkout(params.ref);
+
+    return {
+      content: [{ type: "text", text: `Checked out ${params.ref}` }],
+      details: { ref: params.ref },
+    };
+  },
+});

src/agent/tools/git/diff.ts πŸ”—

@@ -0,0 +1,40 @@
+import { Type } from "@sinclair/typebox";
+import type { AgentTool } from "@mariozechner/pi-ai";
+import simpleGit from "simple-git";
+import { ToolInputError } from "../../../util/errors.js";
+
+const DiffSchema = Type.Object({
+  ref: Type.Optional(Type.String({ description: "Base ref (optional)" })),
+  ref2: Type.Optional(Type.String({ description: "Compare ref (optional)" })),
+  path: Type.Optional(Type.String({ description: "Limit diff to path" })),
+});
+
+export const createGitDiffTool = (workspacePath: string): AgentTool => ({
+  name: "git_diff",
+  label: "Git Diff",
+  description: "Show diff between refs or working tree.",
+  parameters: DiffSchema as any,
+  execute: async (_toolCallId: string, params: any) => {
+    const git = simpleGit(workspacePath);
+    const args: string[] = [];
+
+    if (params.ref && !String(params.ref).trim()) {
+      throw new ToolInputError("ref must be a non-empty string");
+    }
+    if (params.ref2 && !String(params.ref2).trim()) {
+      throw new ToolInputError("ref2 must be a non-empty string");
+    }
+    if (params.path && !String(params.path).trim()) {
+      throw new ToolInputError("path must be a non-empty string");
+    }
+    if (params.ref) args.push(params.ref);
+    if (params.ref2) args.push(params.ref2);
+    if (params.path) args.push("--", params.path);
+
+    const text = await git.diff(args);
+    return {
+      content: [{ type: "text", text }],
+      details: { path: params.path ?? null },
+    };
+  },
+});

src/agent/tools/git/index.ts πŸ”—

@@ -0,0 +1,6 @@
+export { createGitLogTool } from "./log.js";
+export { createGitShowTool } from "./show.js";
+export { createGitBlameTool } from "./blame.js";
+export { createGitDiffTool } from "./diff.js";
+export { createGitRefsTool } from "./refs.js";
+export { createGitCheckoutTool } from "./checkout.js";

src/agent/tools/git/log.ts πŸ”—

@@ -0,0 +1,59 @@
+import { Type } from "@sinclair/typebox";
+import type { AgentTool } from "@mariozechner/pi-ai";
+import simpleGit from "simple-git";
+import { ToolInputError } from "../../../util/errors.js";
+
+const LogSchema = Type.Object({
+  path: Type.Optional(Type.String({ description: "Filter to commits touching this path" })),
+  author: Type.Optional(Type.String({ description: "Filter by author name/email" })),
+  since: Type.Optional(Type.String({ description: "Commits after this date (e.g., 2024-01-01)" })),
+  until: Type.Optional(Type.String({ description: "Commits before this date" })),
+  n: Type.Optional(Type.Number({ description: "Maximum number of commits (default: 20)" })),
+  oneline: Type.Optional(Type.Boolean({ description: "Compact one-line format (default: false)" })),
+});
+
+export const createGitLogTool = (workspacePath: string): AgentTool => ({
+  name: "git_log",
+  label: "Git Log",
+  description: "View commit history. Supports filtering by path, author, date range, and count.",
+  parameters: LogSchema as any,
+  execute: async (_toolCallId: string, params: any) => {
+    const git = simpleGit(workspacePath);
+    const options: string[] = [];
+
+    if (params.n !== undefined) {
+      if (typeof params.n !== "number" || Number.isNaN(params.n) || params.n <= 0) {
+        throw new ToolInputError("n must be a positive number");
+      }
+      options.push("-n", String(Math.floor(params.n)));
+    }
+    if (params.oneline) options.push("--oneline");
+    if (params.author && !String(params.author).trim()) {
+      throw new ToolInputError("author must be a non-empty string");
+    }
+    if (params.author) options.push(`--author=${params.author}`);
+    if (params.since && !String(params.since).trim()) {
+      throw new ToolInputError("since must be a non-empty string");
+    }
+    if (params.since) options.push(`--since=${params.since}`);
+    if (params.until && !String(params.until).trim()) {
+      throw new ToolInputError("until must be a non-empty string");
+    }
+    if (params.until) options.push(`--until=${params.until}`);
+
+    const result = await git.log(options.concat(params.path ? ["--", params.path] : []));
+
+    const text = result.all
+      .map((entry) =>
+        params.oneline
+          ? `${entry.hash} ${entry.message}`
+          : `${entry.hash} ${entry.date} ${entry.author_name} ${entry.message}`,
+      )
+      .join("\n");
+
+    return {
+      content: [{ type: "text", text }],
+      details: { count: result.all.length },
+    };
+  },
+});

src/agent/tools/git/refs.ts πŸ”—

@@ -0,0 +1,43 @@
+import { Type } from "@sinclair/typebox";
+import type { AgentTool } from "@mariozechner/pi-ai";
+import simpleGit from "simple-git";
+
+const RefsSchema = Type.Object({
+  type: Type.Union([
+    Type.Literal("branches"),
+    Type.Literal("tags"),
+    Type.Literal("remotes"),
+  ]),
+});
+
+export const createGitRefsTool = (workspacePath: string): AgentTool => ({
+  name: "git_refs",
+  label: "Git Refs",
+  description: "List branches or tags.",
+  parameters: RefsSchema as any,
+  execute: async (_toolCallId: string, params: any) => {
+    const git = simpleGit(workspacePath);
+
+    if (params.type === "tags") {
+      const tags = await git.tags();
+      return {
+        content: [{ type: "text", text: tags.all.join("\n") }],
+        details: { count: tags.all.length },
+      };
+    }
+
+    if (params.type === "remotes") {
+      const raw = await git.raw(["branch", "-r"]);
+      return {
+        content: [{ type: "text", text: raw }],
+        details: {},
+      };
+    }
+
+    const raw = await git.raw(["branch", "-a"]);
+    return {
+      content: [{ type: "text", text: raw }],
+      details: {},
+    };
+  },
+});

src/agent/tools/git/show.ts πŸ”—

@@ -0,0 +1,27 @@
+import { Type } from "@sinclair/typebox";
+import type { AgentTool } from "@mariozechner/pi-ai";
+import simpleGit from "simple-git";
+import { ToolInputError } from "../../../util/errors.js";
+
+const ShowSchema = Type.Object({
+  ref: Type.String({ description: "Commit hash or ref" }),
+});
+
+export const createGitShowTool = (workspacePath: string): AgentTool => ({
+  name: "git_show",
+  label: "Git Show",
+  description: "Show details for a commit or object.",
+  parameters: ShowSchema as any,
+  execute: async (_toolCallId: string, params: any) => {
+    if (!String(params.ref ?? "").trim()) {
+      throw new ToolInputError("ref must be a non-empty string");
+    }
+    const git = simpleGit(workspacePath);
+    const text = await git.show([params.ref]);
+
+    return {
+      content: [{ type: "text", text }],
+      details: { ref: params.ref },
+    };
+  },
+});

src/agent/tools/grep.ts πŸ”—

@@ -0,0 +1,189 @@
+import { spawn, spawnSync } from "node:child_process";
+import { createInterface } from "node:readline";
+import { readFileSync, statSync } from "node:fs";
+import { relative, basename } from "node:path";
+import { Type } from "@sinclair/typebox";
+import type { AgentTool } from "@mariozechner/pi-ai";
+import { resolveToCwd } from "./path-utils.js";
+import {
+  DEFAULT_MAX_BYTES,
+  formatSize,
+  GREP_MAX_LINE_LENGTH,
+  truncateHead,
+  truncateLine,
+} from "../../util/truncate.js";
+import { ToolInputError } from "../../util/errors.js";
+
+const DEFAULT_LIMIT = 100;
+
+const GrepSchema = Type.Object({
+  pattern: Type.String({ description: "Search pattern (regex or literal string)" }),
+  path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })),
+  glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts'" })),
+  ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
+  literal: Type.Optional(
+    Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }),
+  ),
+  context: Type.Optional(Type.Number({ description: "Lines of context around each match (default: 0)" })),
+  limit: Type.Optional(Type.Number({ description: "Maximum matches to return (default: 100)" })),
+});
+
+export const createGrepTool = (workspacePath: string): AgentTool => {
+  const rgResult = spawnSync("which", ["rg"], { encoding: "utf-8" });
+  if (rgResult.status !== 0) {
+    throw new ToolInputError("ripgrep (rg) is not available");
+  }
+  const rgPath = rgResult.stdout.trim();
+
+  return {
+    name: "grep",
+    label: "Grep",
+    description: `Search file contents for a pattern using ripgrep. Returns up to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Respects .gitignore.`,
+    parameters: GrepSchema as any,
+    execute: async (_toolCallId: string, params: any) => {
+      const searchDir: string | undefined = params.path;
+      const searchPath = resolveToCwd(searchDir || ".", workspacePath);
+      let isDirectory = false;
+      try {
+        isDirectory = statSync(searchPath).isDirectory();
+      } catch {
+        throw new ToolInputError(`Path does not exist or is not accessible: ${searchDir || "."}`);
+      }
+
+    const effectiveLimit: number = Math.max(1, Math.floor(params.limit ?? DEFAULT_LIMIT));
+    const contextLines: number = Math.max(0, Math.floor(params.context ?? 0));
+
+    const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"];
+    if (params.ignoreCase) args.push("--ignore-case");
+    if (params.literal) args.push("--fixed-strings");
+    if (params.glob) args.push("--glob", params.glob);
+    args.push(params.pattern, searchPath);
+
+    return new Promise((resolve, reject) => {
+      const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
+
+      const rl = createInterface({ input: child.stdout! });
+      const fileCache = new Map<string, string[]>();
+
+      interface MatchEntry {
+        filePath: string;
+        lineNumber: number;
+      }
+
+      const matches: MatchEntry[] = [];
+      let stderrData = "";
+      let limitReached = false;
+
+      child.stderr!.on("data", (chunk: Buffer) => {
+        stderrData += chunk.toString();
+      });
+
+      rl.on("line", (line: string) => {
+        if (matches.length >= effectiveLimit) {
+          if (!limitReached) {
+            limitReached = true;
+            child.kill();
+          }
+          return;
+        }
+
+        try {
+          const parsed = JSON.parse(line);
+          if (parsed.type === "match") {
+            const filePath: string = parsed.data.path.text;
+            const lineNumber: number = parsed.data.line_number;
+            matches.push({ filePath, lineNumber });
+          }
+        } catch {
+          // ignore malformed JSON lines
+        }
+      });
+
+      child.on("close", (code: number | null) => {
+        if (code !== 0 && code !== 1 && !limitReached) {
+          reject(new Error(`rg exited with code ${code}: ${stderrData.trim()}`));
+          return;
+        }
+
+        if (matches.length === 0) {
+          resolve({
+            content: [{ type: "text", text: "No matches found" }],
+            details: { matches: 0 },
+          });
+          return;
+        }
+
+        const outputLines: string[] = [];
+        let anyLineTruncated = false;
+
+        for (const match of matches) {
+          let lines: string[];
+          if (fileCache.has(match.filePath)) {
+            lines = fileCache.get(match.filePath)!;
+          } else {
+            try {
+              lines = readFileSync(match.filePath, "utf-8").split(/\r?\n/);
+              fileCache.set(match.filePath, lines);
+            } catch {
+              lines = [];
+            }
+          }
+
+          const relPath = isDirectory
+            ? relative(searchPath, match.filePath)
+            : basename(match.filePath);
+
+          const startLine = Math.max(0, match.lineNumber - 1 - contextLines);
+          const endLine = Math.min(lines.length, match.lineNumber + contextLines);
+
+          for (let i = startLine; i < endLine; i++) {
+            const lineContent = lines[i] ?? "";
+            const { text: truncatedContent, wasTruncated } = truncateLine(lineContent);
+            if (wasTruncated) anyLineTruncated = true;
+
+            const lineNum = i + 1;
+            if (lineNum === match.lineNumber) {
+              outputLines.push(`${relPath}:${lineNum}: ${truncatedContent}`);
+            } else {
+              outputLines.push(`${relPath}-${lineNum}- ${truncatedContent}`);
+            }
+          }
+        }
+
+        const rawOutput = outputLines.join("\n");
+        const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
+
+        const notices: string[] = [];
+        if (limitReached) {
+          notices.push(
+            `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
+          );
+        }
+        if (truncation.truncated) {
+          notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
+        }
+        if (anyLineTruncated) {
+          notices.push(
+            `Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`,
+          );
+        }
+
+        let output = truncation.content;
+        if (notices.length > 0) {
+          output += `\n\n[${notices.join(". ")}]`;
+        }
+
+        resolve({
+          content: [{ type: "text", text: output }],
+          details: {
+            matches: matches.length,
+            ...(limitReached ? { matchLimitReached: effectiveLimit } : {}),
+            ...(truncation.truncated ? { truncation } : {}),
+            ...(anyLineTruncated ? { linesTruncated: true } : {}),
+          },
+        });
+      });
+    });
+    },
+  };
+};

src/agent/tools/index.ts πŸ”—

@@ -0,0 +1,26 @@
+import { resolve, sep } from "node:path";
+import type { AgentTool } from "@mariozechner/pi-ai";
+import { ToolInputError } from "../../util/errors.js";
+
+export type ToolFactory = (workspacePath: string) => AgentTool<any>;
+
+export interface ToolBundle {
+  name: string;
+  tools: AgentTool<any>[];
+}
+
+export function ensureWorkspacePath(workspacePath: string, targetPath: string): string {
+  const resolved = resolve(workspacePath, targetPath);
+  const root = workspacePath.endsWith(sep) ? workspacePath : `${workspacePath}${sep}`;
+
+  if (resolved === workspacePath || resolved.startsWith(root)) {
+    return resolved;
+  }
+
+  throw new ToolInputError(`Path escapes workspace: ${targetPath}`);
+}
+
+export { createReadTool } from "./read.js";
+export { createGrepTool } from "./grep.js";
+export { createLsTool } from "./ls.js";
+export { createFindTool } from "./find.js";

src/agent/tools/ls.ts πŸ”—

@@ -0,0 +1,82 @@
+import { existsSync, readdirSync, statSync } from "node:fs";
+import { join } from "node:path";
+import { Type } from "@sinclair/typebox";
+import type { AgentTool } from "@mariozechner/pi-ai";
+import { resolveToCwd } from "./path-utils.js";
+import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../util/truncate.js";
+
+const DEFAULT_LIMIT = 500;
+
+const LsSchema = Type.Object({
+  path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })),
+  limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })),
+});
+
+export const createLsTool = (workspacePath: string): AgentTool => ({
+  name: "ls",
+  label: "List Directory",
+  description: `List directory contents. Returns up to ${DEFAULT_LIMIT} entries and ${DEFAULT_MAX_BYTES / 1024} KB of output.`,
+  parameters: LsSchema as any,
+  execute: async (_toolCallId: string, params: any) => {
+    const resolved = resolveToCwd(params.path || ".", workspacePath);
+
+    if (!existsSync(resolved)) {
+      throw new Error(`Path does not exist: ${params.path || "."}`);
+    }
+
+    const stats = statSync(resolved);
+    if (!stats.isDirectory()) {
+      throw new Error(`Not a directory: ${params.path || "."}`);
+    }
+
+    const entries = readdirSync(resolved);
+    entries.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
+
+    const effectiveLimit = params.limit ?? DEFAULT_LIMIT;
+    const limited = entries.slice(0, effectiveLimit);
+    const entryLimitReached = entries.length > effectiveLimit;
+
+    const lines = limited.map((entry) => {
+      const entryPath = join(resolved, entry);
+      try {
+        const entryStat = statSync(entryPath);
+        return entryStat.isDirectory() ? `${entry}/` : entry;
+      } catch {
+        return entry;
+      }
+    });
+
+    if (lines.length === 0) {
+      return {
+        content: [{ type: "text", text: "(empty directory)" }],
+        details: {},
+      };
+    }
+
+    const rawOutput = lines.join("\n");
+    const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
+
+    const notices: string[] = [];
+    if (entryLimitReached) {
+      notices.push(`Entry limit reached: showing ${effectiveLimit} of ${entries.length} entries.`);
+    }
+    if (truncation.truncated) {
+      notices.push(
+        `Output truncated: showing ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}.`,
+      );
+    }
+
+    let output = truncation.content;
+    if (notices.length > 0) {
+      output += `\n\n[${notices.join(" ")}]`;
+    }
+
+    return {
+      content: [{ type: "text", text: output }],
+      details: {
+        ...(truncation.truncated ? { truncation } : {}),
+        ...(entryLimitReached ? { entryLimitReached: true } : {}),
+      },
+    };
+  },
+});

src/agent/tools/path-utils.ts πŸ”—

@@ -0,0 +1,70 @@
+import { accessSync, constants } from "node:fs";
+import * as os from "node:os";
+import { isAbsolute, resolve as resolvePath } from "node:path";
+
+const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
+
+function normalizeAtPrefix(filePath: string): string {
+	return filePath.startsWith("@") ? filePath.slice(1) : filePath;
+}
+
+function fileExists(path: string): boolean {
+	try {
+		accessSync(path, constants.F_OK);
+		return true;
+	} catch {
+		return false;
+	}
+}
+
+function tryMacOSScreenshotPath(filePath: string): string {
+	return filePath.replace(/ (AM|PM)\./g, "\u202F$1.");
+}
+
+function tryNFDVariant(filePath: string): string {
+	return filePath.normalize("NFD");
+}
+
+function tryCurlyQuoteVariant(filePath: string): string {
+	return filePath.replace(/'/g, "\u2019");
+}
+
+export function expandPath(filePath: string): string {
+	let result = filePath.replace(UNICODE_SPACES, " ");
+	result = normalizeAtPrefix(result);
+
+	if (result === "~") {
+		return os.homedir();
+	}
+	if (result.startsWith("~/")) {
+		return resolvePath(os.homedir(), result.slice(2));
+	}
+
+	return result;
+}
+
+export function resolveToCwd(filePath: string, cwd: string): string {
+	const expanded = expandPath(filePath);
+	if (isAbsolute(expanded)) {
+		return expanded;
+	}
+	return resolvePath(cwd, expanded);
+}
+
+export function resolveReadPath(filePath: string, cwd: string): string {
+	const resolved = resolveToCwd(filePath, cwd);
+
+	if (fileExists(resolved)) {
+		return resolved;
+	}
+
+	const variants = [tryNFDVariant, tryMacOSScreenshotPath, tryCurlyQuoteVariant];
+	for (const variant of variants) {
+		const candidate = variant(resolved);
+		if (candidate !== resolved && fileExists(candidate)) {
+			return candidate;
+		}
+	}
+
+	return resolved;
+}

src/agent/tools/read.ts πŸ”—

@@ -0,0 +1,83 @@
+import { readFile, stat } from "node:fs/promises";
+import { Type } from "@sinclair/typebox";
+import type { AgentTool } from "@mariozechner/pi-ai";
+import { resolveReadPath } from "./path-utils.js";
+import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "../../util/truncate.js";
+import { ToolInputError } from "../../util/errors.js";
+
+const MAX_READ_BYTES = 5 * 1024 * 1024;
+
+const ReadSchema = Type.Object({
+	path: Type.String({ description: "Path relative to workspace root" }),
+	offset: Type.Optional(Type.Number({ description: "1-based starting line (default: 1)" })),
+	limit: Type.Optional(Type.Number({ description: "Maximum lines to return" })),
+});
+
+export const createReadTool = (workspacePath: string): AgentTool => ({
+	name: "read",
+	label: "Read File",
+	description: `Read a file's contents. Output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`,
+	parameters: ReadSchema as any,
+	execute: async (_toolCallId: string, params: any) => {
+		const absolutePath = resolveReadPath(params.path, workspacePath);
+		const fileStats = await stat(absolutePath);
+
+		if (fileStats.size > MAX_READ_BYTES) {
+			throw new ToolInputError(`File exceeds 5MB limit: ${params.path}`);
+		}
+
+		const raw = await readFile(absolutePath, "utf8");
+		const allLines = raw.split("\n");
+		const totalFileLines = allLines.length;
+
+		const startLine = params.offset ? Math.max(0, params.offset - 1) : 0;
+		const startLineDisplay = startLine + 1;
+
+		if (startLine >= allLines.length) {
+			throw new Error(`Offset ${params.offset} is beyond end of file (${allLines.length} lines total)`);
+		}
+
+		let selectedContent: string;
+		let userLimitedLines: number | undefined;
+		if (params.limit !== undefined) {
+			const endLine = Math.min(startLine + params.limit, allLines.length);
+			selectedContent = allLines.slice(startLine, endLine).join("\n");
+			userLimitedLines = endLine - startLine;
+		} else {
+			selectedContent = allLines.slice(startLine).join("\n");
+		}
+
+		const truncation = truncateHead(selectedContent);
+
+		let output: string;
+
+		if (truncation.firstLineExceedsLimit) {
+			const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine] ?? "", "utf-8"));
+			output = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use a local file viewer to extract that single line by number.]`;
+		} else if (truncation.truncated) {
+			const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
+			const nextOffset = endLineDisplay + 1;
+
+			output = truncation.content;
+
+			if (truncation.truncatedBy === "lines") {
+				output += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
+			} else {
+				output += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
+			}
+		} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
+			const remaining = allLines.length - (startLine + userLimitedLines);
+			const nextOffset = startLine + userLimitedLines + 1;
+
+			output = truncation.content;
+			output += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
+		} else {
+			output = truncation.content;
+		}
+
+		return {
+			content: [{ type: "text", text: output }],
+			details: { truncation },
+		};
+	},
+});

src/agent/tools/web-fetch.ts πŸ”—

@@ -0,0 +1,44 @@
+import { Type } from "@sinclair/typebox";
+import type { AgentTool } from "@mariozechner/pi-ai";
+import Tabstack from "@tabstack/sdk";
+import { FetchError, ToolInputError } from "../../util/errors.js";
+
+export interface WebFetchResult {
+  content: string;
+  url: string;
+}
+
+const FetchSchema = Type.Object({
+  url: Type.String({ description: "URL to fetch" }),
+  nocache: Type.Optional(Type.Boolean({ description: "Force fresh fetch" })),
+});
+
+export function createWebFetchTool(apiKey: string): AgentTool {
+  return {
+    name: "web_fetch",
+    label: "Web Fetch",
+    description: "Fetch a URL and return markdown using Tabstack.",
+    parameters: FetchSchema as any,
+    execute: async (_toolCallId: string, params: any) => {
+      if (!apiKey) {
+        throw new ToolInputError("Missing Tabstack API key");
+      }
+
+      const client = new Tabstack({ apiKey });
+
+      try {
+        const result = await client.extract.markdown({
+          url: params.url,
+          nocache: params.nocache ?? false,
+        });
+
+        return {
+          content: [{ type: "text", text: result.content ?? "" }],
+          details: { url: params.url, length: result.content?.length ?? 0 },
+        };
+      } catch (error: any) {
+        throw new FetchError(params.url, error?.message ?? String(error));
+      }
+    },
+  };
+}

src/agent/tools/web-search.ts πŸ”—

@@ -0,0 +1,26 @@
+import { Type } from "@sinclair/typebox";
+import type { AgentTool } from "@mariozechner/pi-ai";
+import { search } from "kagi-ken";
+import { ToolInputError } from "../../util/errors.js";
+
+const SearchSchema = Type.Object({
+  query: Type.String({ description: "Search query" }),
+});
+
+export const createWebSearchTool = (sessionToken: string): AgentTool => ({
+  name: "web_search",
+  label: "Web Search",
+  description: "Search the web using Kagi (session token required).",
+  parameters: SearchSchema as any,
+  execute: async (_toolCallId: string, params: any) => {
+    if (!sessionToken) {
+      throw new ToolInputError("Missing Kagi session token");
+    }
+
+    const result = await search(params.query, sessionToken);
+    return {
+      content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+      details: { query: params.query, resultCount: result?.data?.length ?? 0 },
+    };
+  },
+});

src/cli/commands/repo.ts πŸ”—

@@ -0,0 +1,97 @@
+import { readFile } from "node:fs/promises";
+import { applyConfigOverrides, loadConfig } from "../../config/loader.js";
+import { createWorkspace } from "../../workspace/manager.js";
+import { createGrepTool } from "../../agent/tools/grep.js";
+import { createReadTool } from "../../agent/tools/read.js";
+import { createLsTool } from "../../agent/tools/ls.js";
+import { createFindTool } from "../../agent/tools/find.js";
+import { createGitLogTool } from "../../agent/tools/git/log.js";
+import { createGitShowTool } from "../../agent/tools/git/show.js";
+import { createGitBlameTool } from "../../agent/tools/git/blame.js";
+import { createGitDiffTool } from "../../agent/tools/git/diff.js";
+import { createGitRefsTool } from "../../agent/tools/git/refs.js";
+import { createGitCheckoutTool } from "../../agent/tools/git/checkout.js";
+import { runAgent } from "../../agent/runner.js";
+import { REPO_SYSTEM_PROMPT } from "../../agent/prompts/repo.js";
+import { createEventLogger, printUsageSummary } from "../output.js";
+import { CloneError } from "../../util/errors.js";
+import simpleGit from "simple-git";
+
+export interface RepoCommandOptions {
+  query: string;
+  uri: string;
+  ref?: string;
+  full: boolean;
+  model?: string;
+  verbose: boolean;
+  cleanup: boolean;
+}
+
+export async function runRepoCommand(options: RepoCommandOptions): Promise<void> {
+  const { config } = await loadConfig();
+  const overrides = applyConfigOverrides(config, {
+    defaults: { model: options.model ?? config.defaults.model, cleanup: options.cleanup },
+    repo: { model: options.model ?? config.repo.model },
+  });
+
+  const workspace = await createWorkspace({ cleanup: overrides.defaults.cleanup });
+  const logger = createEventLogger({ verbose: options.verbose });
+
+  let systemPrompt = REPO_SYSTEM_PROMPT;
+  const promptPath = overrides.repo.system_prompt_path;
+  if (promptPath) {
+    const home = process.env["HOME"] ?? "";
+    systemPrompt = await readFile(promptPath.replace(/^~\//, `${home}/`), "utf8");
+  }
+
+  const git = simpleGit();
+  const cloneArgs: string[] = [];
+  if (!options.full) {
+    const depth = overrides.repo.default_depth ?? 1;
+    const blobLimit = overrides.repo.blob_limit ?? "5m";
+    cloneArgs.push("--depth", String(depth), `--filter=blob:limit=${blobLimit}`);
+  }
+
+  try {
+    await git.clone(options.uri, workspace.path, cloneArgs);
+  } catch (error: any) {
+    await workspace.cleanup();
+    if (!overrides.defaults.cleanup) {
+      console.error(`Workspace preserved at ${workspace.path}`);
+    }
+    throw new CloneError(options.uri, error?.message ?? String(error));
+  }
+
+  const repoGit = simpleGit(workspace.path);
+  if (options.ref) {
+    await repoGit.checkout(options.ref);
+  }
+
+  const tools = [
+    createReadTool(workspace.path),
+    createGrepTool(workspace.path),
+    createLsTool(workspace.path),
+    createFindTool(workspace.path),
+    createGitLogTool(workspace.path),
+    createGitShowTool(workspace.path),
+    createGitBlameTool(workspace.path),
+    createGitDiffTool(workspace.path),
+    createGitRefsTool(workspace.path),
+    createGitCheckoutTool(workspace.path),
+  ];
+
+  try {
+    const result = await runAgent(options.query, {
+      model: overrides.repo.model ?? overrides.defaults.model,
+      systemPrompt,
+      tools,
+      onEvent: logger,
+      config: overrides,
+    });
+
+    process.stdout.write(result.message + "\n");
+    printUsageSummary(result.usage as any);
+  } finally {
+    await workspace.cleanup();
+  }
+}

src/cli/commands/web.ts πŸ”—

@@ -0,0 +1,104 @@
+import { readFile } from "node:fs/promises";
+import { basename } from "node:path";
+import { applyConfigOverrides, loadConfig } from "../../config/loader.js";
+import { createWorkspace } from "../../workspace/manager.js";
+import { writeWorkspaceFile } from "../../workspace/content.js";
+import { createGrepTool } from "../../agent/tools/grep.js";
+import { createReadTool } from "../../agent/tools/read.js";
+import { createLsTool } from "../../agent/tools/ls.js";
+import { createFindTool } from "../../agent/tools/find.js";
+import { createWebFetchTool } from "../../agent/tools/web-fetch.js";
+import { createWebSearchTool } from "../../agent/tools/web-search.js";
+import { runAgent } from "../../agent/runner.js";
+import { WEB_SYSTEM_PROMPT } from "../../agent/prompts/web.js";
+import { createEventLogger, printUsageSummary } from "../output.js";
+import { FetchError, ToolInputError } from "../../util/errors.js";
+
+const INJECT_THRESHOLD = 50 * 1024;
+
+export interface WebCommandOptions {
+  query: string;
+  url?: string;
+  model?: string;
+  verbose: boolean;
+  cleanup: boolean;
+}
+
+export async function runWebCommand(options: WebCommandOptions): Promise<void> {
+  const { config } = await loadConfig();
+  const overrides = applyConfigOverrides(config, {
+    defaults: { model: options.model ?? config.defaults.model, cleanup: options.cleanup },
+    web: { model: options.model ?? config.web.model },
+  });
+
+  const workspace = await createWorkspace({ cleanup: overrides.defaults.cleanup });
+  const logger = createEventLogger({ verbose: options.verbose });
+
+  const kagiSession =
+    overrides.web.kagi_session_token ?? overrides.defaults.kagi_session_token ?? process.env["KAGI_SESSION_TOKEN"];
+  const tabstackKey =
+    overrides.web.tabstack_api_key ??
+    overrides.defaults.tabstack_api_key ??
+    process.env["TABSTACK_API_KEY"];
+
+  if (!kagiSession) {
+    throw new ToolInputError("Missing Kagi session token (set KAGI_SESSION_TOKEN or config)");
+  }
+  if (!tabstackKey) {
+    throw new ToolInputError("Missing Tabstack API key (set TABSTACK_API_KEY or config)");
+  }
+
+  let systemPrompt = WEB_SYSTEM_PROMPT;
+  const promptPath = overrides.web.system_prompt_path;
+  if (promptPath) {
+    const home = process.env["HOME"] ?? "";
+    systemPrompt = await readFile(promptPath.replace(/^~\//, `${home}/`), "utf8");
+  }
+
+  const tools = [
+    createWebSearchTool(kagiSession),
+    createWebFetchTool(tabstackKey),
+    createReadTool(workspace.path),
+    createGrepTool(workspace.path),
+    createLsTool(workspace.path),
+    createFindTool(workspace.path),
+  ];
+
+  let seededContext = "";
+  if (options.url) {
+    const fetchTool = createWebFetchTool(tabstackKey);
+    try {
+      const result = await fetchTool.execute("prefetch", { url: options.url, nocache: false });
+      const text = result.content
+        .map((block) => (block.type === "text" ? block.text ?? "" : ""))
+        .join("");
+      if (text.length <= INJECT_THRESHOLD) {
+        seededContext = text;
+      } else {
+        const filename = `web/${basename(new URL(options.url).pathname) || "index"}.md`;
+        await writeWorkspaceFile(workspace.path, filename, text);
+        seededContext = `Fetched content stored at ${filename}`;
+      }
+    } catch (error: any) {
+      await workspace.cleanup();
+      throw new FetchError(options.url, error?.message ?? String(error));
+    }
+  }
+
+  const query = seededContext ? `${options.query}\n\n${seededContext}` : options.query;
+
+  try {
+    const result = await runAgent(query, {
+      model: overrides.web.model ?? overrides.defaults.model,
+      systemPrompt,
+      tools,
+      onEvent: logger,
+      config: overrides,
+    });
+
+    process.stdout.write(result.message + "\n");
+    printUsageSummary(result.usage as any);
+  } finally {
+    await workspace.cleanup();
+  }
+}

src/cli/index.ts πŸ”—

@@ -0,0 +1,106 @@
+#!/usr/bin/env bun
+import { runWebCommand } from "./commands/web.js";
+import { runRepoCommand } from "./commands/repo.js";
+import { RumiloError } from "../util/errors.js";
+
+interface ParsedArgs {
+  command?: string;
+  options: Record<string, string | boolean>;
+  positional: string[];
+}
+
+function parseArgs(args: string[]): ParsedArgs {
+  const [, , command, ...rest] = args;
+  const options: Record<string, string | boolean> = {};
+  const positional: string[] = [];
+
+  for (let i = 0; i < rest.length; i += 1) {
+    const arg = rest[i];
+    if (!arg) continue;
+
+    if (arg.startsWith("--")) {
+      const [key, value] = arg.slice(2).split("=");
+      if (!key) continue;
+      if (value !== undefined) {
+        options[key] = value;
+      } else if (rest[i + 1] && !rest[i + 1]?.startsWith("-")) {
+        options[key] = rest[i + 1] as string;
+        i += 1;
+      } else {
+        options[key] = true;
+      }
+    } else if (arg.startsWith("-")) {
+      const short = arg.slice(1);
+      if (short === "u" && rest[i + 1]) {
+        options["uri"] = rest[i + 1] as string;
+        i += 1;
+      } else if (short === "f") {
+        options["full"] = true;
+      } else {
+        options[short] = true;
+      }
+    } else {
+      positional.push(arg);
+    }
+  }
+
+  return { command, options, positional };
+}
+
+async function main() {
+  const { command, options, positional } = parseArgs(process.argv);
+
+  if (!command || command === "help") {
+    console.log("rumilo web <query> [-u URL] [--model <provider:model>] [--verbose] [--no-cleanup]");
+    console.log("rumilo repo -u <uri> <query> [--ref <ref>] [--full] [--model <provider:model>] [--verbose] [--no-cleanup]");
+    process.exit(0);
+  }
+
+  try {
+    if (command === "web") {
+      const query = positional.join(" ");
+      if (!query) {
+        throw new RumiloError("Missing query", "CLI_ERROR");
+      }
+
+      await runWebCommand({
+        query,
+        url: options["uri"] ? String(options["uri"]) : undefined,
+        model: options["model"] ? String(options["model"]) : undefined,
+        verbose: Boolean(options["verbose"]),
+        cleanup: !options["no-cleanup"],
+      });
+      return;
+    }
+
+    if (command === "repo") {
+      const query = positional.join(" ");
+      const uri = options["uri"] ? String(options["uri"]) : undefined;
+      if (!uri) {
+        throw new RumiloError("Missing repo URI", "CLI_ERROR");
+      }
+      if (!query) {
+        throw new RumiloError("Missing query", "CLI_ERROR");
+      }
+
+      await runRepoCommand({
+        query,
+        uri,
+        ref: options["ref"] ? String(options["ref"]) : undefined,
+        full: Boolean(options["full"]),
+        model: options["model"] ? String(options["model"]) : undefined,
+        verbose: Boolean(options["verbose"]),
+        cleanup: !options["no-cleanup"],
+      });
+      return;
+    }
+
+    throw new RumiloError(`Unknown command: ${command}`, "CLI_ERROR");
+  } catch (error: any) {
+    const message = error instanceof Error ? error.message : String(error);
+    console.error(message);
+    process.exitCode = 1;
+  }
+}
+
+main();

src/cli/output.ts πŸ”—

@@ -0,0 +1,53 @@
+import type { AgentEvent } from "@mariozechner/pi-agent";
+
+const MAX_OUTPUT_LINES = 20;
+
+export interface OutputOptions {
+  verbose: boolean;
+}
+
+export function formatToolOutput(output: string): string {
+  const lines = output.split(/\r?\n/);
+  if (lines.length <= MAX_OUTPUT_LINES) {
+    return output;
+  }
+
+  const truncated = lines.slice(0, MAX_OUTPUT_LINES).join("\n");
+  return `${truncated}\n(+${lines.length - MAX_OUTPUT_LINES} more lines)`;
+}
+
+export function createEventLogger(options: OutputOptions) {
+  return (event: AgentEvent) => {
+    if (!options.verbose) return;
+
+    if (event.type === "tool_execution_start") {
+      console.error(`\n[tool] ${event.toolName}`);
+      console.error(JSON.stringify(event.args, null, 2));
+    }
+
+    if (event.type === "tool_execution_end") {
+      if (event.isError) {
+        console.error("[tool error]");
+        console.error(String(event.result));
+        return;
+      }
+
+      const text = (event.result?.content ?? [])
+        .filter((content: { type: string }) => content.type === "text")
+        .map((content: { text?: string }) => content.text ?? "")
+        .join("");
+
+      if (text) {
+        console.error(formatToolOutput(text));
+      }
+    }
+  };
+}
+
+export function printUsageSummary(usage: { cost?: { total?: number }; totalTokens?: number; output?: number; input?: number } | undefined) {
+  if (!usage) return;
+
+  const cost = usage.cost?.total ?? 0;
+  const tokens = usage.totalTokens ?? (usage.output ?? 0) + (usage.input ?? 0);
+  console.error(`\nusage: ${tokens} tokens, cost $${cost.toFixed(4)}`);
+}

src/config/defaults.ts πŸ”—

@@ -0,0 +1,17 @@
+import type { RumiloConfig } from "./schema.js";
+
+export const defaultConfig: RumiloConfig = {
+  defaults: {
+    model: "anthropic:claude-sonnet-4-20250514",
+    cleanup: true,
+  },
+  web: {
+    model: "anthropic:claude-sonnet-4-20250514",
+  },
+  repo: {
+    model: "anthropic:claude-sonnet-4-20250514",
+    default_depth: 1,
+    blob_limit: "5m",
+  },
+  custom_models: {},
+};

src/config/loader.ts πŸ”—

@@ -0,0 +1,75 @@
+import { readFile } from "node:fs/promises";
+import { resolve } from "node:path";
+import { defaultConfig } from "./defaults.js";
+import type { RumiloConfig } from "./schema.js";
+import { ConfigError } from "../util/errors.js";
+import toml from "toml";
+
+export interface LoadedConfig {
+  config: RumiloConfig;
+  path?: string;
+}
+
+function mergeConfig(base: RumiloConfig, override: Partial<RumiloConfig>): RumiloConfig {
+  return {
+    defaults: {
+      ...base.defaults,
+      ...override.defaults,
+    },
+    web: {
+      ...base.web,
+      ...override.web,
+    },
+    repo: {
+      ...base.repo,
+      ...override.repo,
+    },
+    custom_models: {
+      ...base.custom_models,
+      ...override.custom_models,
+    },
+  };
+}
+
+function validateConfig(config: RumiloConfig): void {
+  if (!config.defaults.model) {
+    throw new ConfigError("defaults.model is required");
+  }
+  if (typeof config.defaults.cleanup !== "boolean") {
+    throw new ConfigError("defaults.cleanup must be a boolean");
+  }
+}
+
+export async function loadConfig(): Promise<LoadedConfig> {
+  const base = structuredClone(defaultConfig);
+  const configHome = process.env["XDG_CONFIG_HOME"] || `${process.env["HOME"] ?? ""}/.config`;
+  const configPath = resolve(configHome, "rumilo", "config.toml");
+
+  try {
+    const raw = await readFile(configPath, "utf8");
+    const parsed = toml.parse(raw) as Partial<RumiloConfig>;
+    const merged = mergeConfig(base, parsed);
+    validateConfig(merged);
+    return { config: merged, path: configPath };
+  } catch (error: any) {
+    if (error?.code === "ENOENT") {
+      validateConfig(base);
+      return { config: base };
+    }
+
+    if (error instanceof Error) {
+      throw new ConfigError(error.message);
+    }
+
+    throw new ConfigError(String(error));
+  }
+}
+
+export function applyConfigOverrides(
+  config: RumiloConfig,
+  overrides: Partial<RumiloConfig>,
+): RumiloConfig {
+  const merged = mergeConfig(config, overrides);
+  validateConfig(merged);
+  return merged;
+}

src/config/schema.ts πŸ”—

@@ -0,0 +1,59 @@
+import { Type, type Static } from "@sinclair/typebox";
+
+const CustomModelSchema = Type.Object({
+  provider: Type.String(),
+  api: Type.String(),
+  base_url: Type.String(),
+  id: Type.String(),
+  name: Type.String(),
+  reasoning: Type.Boolean(),
+  input: Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")])),
+  cost: Type.Object({
+    input: Type.Number(),
+    output: Type.Number(),
+    cache_read: Type.Optional(Type.Number()),
+    cache_write: Type.Optional(Type.Number()),
+  }),
+  context_window: Type.Number(),
+  max_tokens: Type.Number(),
+  headers: Type.Optional(Type.Record(Type.String(), Type.String())),
+  compat: Type.Optional(
+    Type.Object({
+      supports_store: Type.Optional(Type.Boolean()),
+      supports_developer_role: Type.Optional(Type.Boolean()),
+      supports_reasoning_effort: Type.Optional(Type.Boolean()),
+      supports_usage_in_streaming: Type.Optional(Type.Boolean()),
+      max_tokens_field: Type.Optional(Type.Union([Type.Literal("max_tokens"), Type.Literal("max_completion_tokens")])),
+      requires_tool_result_name: Type.Optional(Type.Boolean()),
+      requires_assistant_after_tool_result: Type.Optional(Type.Boolean()),
+      requires_thinking_as_text: Type.Optional(Type.Boolean()),
+      requires_mistral_tool_ids: Type.Optional(Type.Boolean()),
+      thinking_format: Type.Optional(Type.Union([Type.Literal("openai"), Type.Literal("zai")])),
+    })
+  ),
+});
+
+const ConfigSchema = Type.Object({
+  defaults: Type.Object({
+    model: Type.String(),
+    cleanup: Type.Boolean(),
+    kagi_session_token: Type.Optional(Type.String()),
+    tabstack_api_key: Type.Optional(Type.String()),
+  }),
+  web: Type.Object({
+    model: Type.Optional(Type.String()),
+    system_prompt_path: Type.Optional(Type.String()),
+    kagi_session_token: Type.Optional(Type.String()),
+    tabstack_api_key: Type.Optional(Type.String()),
+  }),
+  repo: Type.Object({
+    model: Type.Optional(Type.String()),
+    system_prompt_path: Type.Optional(Type.String()),
+    default_depth: Type.Optional(Type.Number({ minimum: 1 })),
+    blob_limit: Type.Optional(Type.String()),
+  }),
+  custom_models: Type.Optional(Type.Record(Type.String(), CustomModelSchema)),
+});
+
+export type RumiloConfig = Static<typeof ConfigSchema>;
+export type CustomModelConfig = Static<typeof CustomModelSchema>;

src/util/errors.ts πŸ”—

@@ -0,0 +1,39 @@
+export class RumiloError extends Error {
+  readonly code: string;
+
+  constructor(message: string, code: string) {
+    super(message);
+    this.name = this.constructor.name;
+    this.code = code;
+  }
+}
+
+export class ConfigError extends RumiloError {
+  constructor(message: string) {
+    super(message, "CONFIG_ERROR");
+  }
+}
+
+export class FetchError extends RumiloError {
+  constructor(url: string, reason: string) {
+    super(`Failed to fetch ${url}: ${reason}`, "FETCH_ERROR");
+  }
+}
+
+export class CloneError extends RumiloError {
+  constructor(uri: string, reason: string) {
+    super(`Failed to clone ${uri}: ${reason}`, "CLONE_ERROR");
+  }
+}
+
+export class WorkspaceError extends RumiloError {
+  constructor(message: string) {
+    super(message, "WORKSPACE_ERROR");
+  }
+}
+
+export class ToolInputError extends RumiloError {
+  constructor(message: string) {
+    super(message, "TOOL_INPUT_ERROR");
+  }
+}

src/util/truncate.ts πŸ”—

@@ -0,0 +1,210 @@
+export const DEFAULT_MAX_LINES = 2000;
+export const DEFAULT_MAX_BYTES = 50 * 1024;
+export const GREP_MAX_LINE_LENGTH = 500;
+
+export interface TruncationResult {
+  content: string;
+  truncated: boolean;
+  truncatedBy: "lines" | "bytes" | null;
+  totalLines: number;
+  totalBytes: number;
+  outputLines: number;
+  outputBytes: number;
+  lastLinePartial: boolean;
+  firstLineExceedsLimit: boolean;
+  maxLines: number;
+  maxBytes: number;
+}
+
+export interface TruncationOptions {
+  maxLines?: number;
+  maxBytes?: number;
+}
+
+export function formatSize(bytes: number): string {
+  if (bytes < 1024) return `${bytes}B`;
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
+  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
+}
+
+export function truncateHead(
+  content: string,
+  options: TruncationOptions = {},
+): TruncationResult {
+  const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
+  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
+  const totalBytes = Buffer.byteLength(content, "utf-8");
+  const lines = content.split("\n");
+  const totalLines = lines.length;
+
+  if (totalLines <= maxLines && totalBytes <= maxBytes) {
+    return {
+      content,
+      truncated: false,
+      truncatedBy: null,
+      totalLines,
+      totalBytes,
+      outputLines: totalLines,
+      outputBytes: totalBytes,
+      lastLinePartial: false,
+      firstLineExceedsLimit: false,
+      maxLines,
+      maxBytes,
+    };
+  }
+
+  const firstLineBytes = Buffer.byteLength(lines[0]!, "utf-8");
+  if (firstLineBytes > maxBytes) {
+    return {
+      content: "",
+      truncated: true,
+      truncatedBy: "bytes",
+      totalLines,
+      totalBytes,
+      outputLines: 0,
+      outputBytes: 0,
+      lastLinePartial: false,
+      firstLineExceedsLimit: true,
+      maxLines,
+      maxBytes,
+    };
+  }
+
+  const outputLinesArr: string[] = [];
+  let outputBytesCount = 0;
+  let truncatedBy: "lines" | "bytes" = "lines";
+
+  for (let i = 0; i < lines.length && i < maxLines; i++) {
+    const line = lines[i]!;
+    const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0);
+    if (outputBytesCount + lineBytes > maxBytes) {
+      truncatedBy = "bytes";
+      break;
+    }
+    outputLinesArr.push(line);
+    outputBytesCount += lineBytes;
+  }
+
+  if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
+    truncatedBy = "lines";
+  }
+
+  const outputContent = outputLinesArr.join("\n");
+  const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
+
+  return {
+    content: outputContent,
+    truncated: true,
+    truncatedBy,
+    totalLines,
+    totalBytes,
+    outputLines: outputLinesArr.length,
+    outputBytes: finalOutputBytes,
+    lastLinePartial: false,
+    firstLineExceedsLimit: false,
+    maxLines,
+    maxBytes,
+  };
+}
+
+function truncateStringToBytesFromEnd(
+  str: string,
+  maxBytes: number,
+): string {
+  const buf = Buffer.from(str, "utf-8");
+  if (buf.length <= maxBytes) return str;
+  let end = buf.length;
+  let start = end - maxBytes;
+  // Walk forward to a valid UTF-8 boundary
+  while (start < end && (buf[start]! & 0xc0) === 0x80) {
+    start++;
+  }
+  return buf.subarray(start, end).toString("utf-8");
+}
+
+export function truncateTail(
+  content: string,
+  options: TruncationOptions = {},
+): TruncationResult {
+  const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
+  const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
+  const totalBytes = Buffer.byteLength(content, "utf-8");
+  const lines = content.split("\n");
+  const totalLines = lines.length;
+
+  if (totalLines <= maxLines && totalBytes <= maxBytes) {
+    return {
+      content,
+      truncated: false,
+      truncatedBy: null,
+      totalLines,
+      totalBytes,
+      outputLines: totalLines,
+      outputBytes: totalBytes,
+      lastLinePartial: false,
+      firstLineExceedsLimit: false,
+      maxLines,
+      maxBytes,
+    };
+  }
+
+  const outputLinesArr: string[] = [];
+  let outputBytesCount = 0;
+  let truncatedBy: "lines" | "bytes" = "lines";
+
+  for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) {
+    const line = lines[i]!;
+    const lineBytes =
+      Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0);
+    if (outputBytesCount + lineBytes > maxBytes) {
+      truncatedBy = "bytes";
+      break;
+    }
+    outputLinesArr.unshift(line);
+    outputBytesCount += lineBytes;
+  }
+
+  if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) {
+    truncatedBy = "lines";
+  }
+
+  let lastLinePartial = false;
+
+  // If we couldn't fit any lines (last line alone exceeds byte limit),
+  // take partial content from the end.
+  if (outputLinesArr.length === 0) {
+    const lastLine = lines[lines.length - 1]!;
+    const partial = truncateStringToBytesFromEnd(lastLine, maxBytes);
+    outputLinesArr.push(partial);
+    outputBytesCount = Buffer.byteLength(partial, "utf-8");
+    truncatedBy = "bytes";
+    lastLinePartial = true;
+  }
+
+  const outputContent = outputLinesArr.join("\n");
+  const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8");
+
+  return {
+    content: outputContent,
+    truncated: true,
+    truncatedBy,
+    totalLines,
+    totalBytes,
+    outputLines: outputLinesArr.length,
+    outputBytes: finalOutputBytes,
+    lastLinePartial,
+    firstLineExceedsLimit: false,
+    maxLines,
+    maxBytes,
+  };
+}
+
+export function truncateLine(
+  line: string,
+  maxChars: number = GREP_MAX_LINE_LENGTH,
+): { text: string; wasTruncated: boolean } {
+  if (line.length <= maxChars) {
+    return { text: line, wasTruncated: false };
+  }
+  return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true };
+}

src/workspace/content.ts πŸ”—

@@ -0,0 +1,24 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+
+export interface WorkspaceContent {
+  filePath: string;
+  content: string;
+  bytes: number;
+}
+
+export async function writeWorkspaceFile(
+  workspacePath: string,
+  relativePath: string,
+  content: string,
+): Promise<WorkspaceContent> {
+  const filePath = join(workspacePath, relativePath);
+  await mkdir(dirname(filePath), { recursive: true });
+  await writeFile(filePath, content, "utf8");
+
+  return {
+    filePath,
+    content,
+    bytes: Buffer.byteLength(content, "utf8"),
+  };
+}

src/workspace/manager.ts πŸ”—

@@ -0,0 +1,29 @@
+import { mkdtemp, rm, chmod } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { WorkspaceError } from "../util/errors.js";
+
+export interface Workspace {
+  path: string;
+  cleanup: () => Promise<void>;
+}
+
+export interface WorkspaceOptions {
+  cleanup: boolean;
+}
+
+export async function createWorkspace(options: WorkspaceOptions): Promise<Workspace> {
+  try {
+    const root = await mkdtemp(join(tmpdir(), "rumilo-"));
+    await chmod(root, 0o700);
+
+    const cleanup = async () => {
+      if (!options.cleanup) return;
+      await rm(root, { recursive: true, force: true });
+    };
+
+    return { path: root, cleanup };
+  } catch (error) {
+    throw new WorkspaceError(`Failed to create workspace: ${String(error)}`);
+  }
+}

tsconfig.json πŸ”—

@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "strict": true,
+    "noUncheckedIndexedAccess": true,
+    "noImplicitOverride": true,
+    "noPropertyAccessFromIndexSignature": true,
+    "resolveJsonModule": true,
+    "allowJs": false,
+    "types": ["bun-types"],
+    "outDir": "dist",
+    "rootDir": "src",
+    "skipLibCheck": true
+  },
+  "include": ["src", "test"],
+  "exclude": ["dist"]
+}