Detailed changes
@@ -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
@@ -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"
+```
@@ -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=="],
+ }
+}
@@ -0,0 +1,2 @@
+[install]
+linker = "isolated"
@@ -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
@@ -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"
+ }
+}
@@ -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,
+ };
+}
@@ -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.`;
@@ -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.`;
@@ -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,
+ };
+}
@@ -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 }),
+ },
+ };
+ },
+ };
+};
@@ -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 },
+ };
+ },
+});
@@ -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 },
+ };
+ },
+});
@@ -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 },
+ };
+ },
+});
@@ -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";
@@ -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 },
+ };
+ },
+});
@@ -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: {},
+ };
+ },
+});
@@ -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 },
+ };
+ },
+});
@@ -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 } : {}),
+ },
+ });
+ });
+ });
+ },
+ };
+};
@@ -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";
@@ -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 } : {}),
+ },
+ };
+ },
+});
@@ -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;
+}
@@ -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 },
+ };
+ },
+});
@@ -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));
+ }
+ },
+ };
+}
@@ -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 },
+ };
+ },
+});
@@ -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();
+ }
+}
@@ -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();
+ }
+}
@@ -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();
@@ -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)}`);
+}
@@ -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: {},
+};
@@ -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;
+}
@@ -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>;
@@ -0,0 +1,3 @@
+declare module "kagi-ken" {
+ export function search(query: string, token: string): Promise<any>;
+}
@@ -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");
+ }
+}
@@ -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 };
+}
@@ -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"),
+ };
+}
@@ -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)}`);
+ }
+}
@@ -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"]
+}