commit 1aa893ae2e2d47d8d7710cba50c40eaa2a018670 Author: Amolith Date: Fri Feb 6 17:53:42 2026 -0700 chore: initial commit diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..921d5e41097a64de6fa0952d4221057275e7f677 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ +# Rumilo Agent Guide + +CLI that dispatches specialized AI research subagents for web research and git repository exploration. + +## Commands + +```bash +bun run dev # Run CLI via bun (development) +bun run build # Build to dist/ +bun run typecheck # TypeScript check (also: bun run lint) +``` + +No test suite currently exists (`test/` is empty). + +## Architecture + +``` +CLI entry (src/cli/index.ts) + ├── web command → runWebCommand() → runAgent() with web tools + └── repo command → runRepoCommand() → runAgent() with git tools +``` + +### Control Flow + +1. **CLI** parses args with custom parser (no library), dispatches to command handlers +2. **Command handlers** (`src/cli/commands/`) load config, create workspace, configure tools, invoke `runAgent()` +3. **Agent runner** (`src/agent/runner.ts`) wraps `@mariozechner/pi-agent` - creates `Agent`, subscribes to events, prompts, extracts final message +4. **Tools** are created via factory functions that close over workspace path for sandboxing +5. **Workspace** (`src/workspace/manager.ts`) is a temp directory (cleaned up unless `--no-cleanup`) + +### Two Agent Modes + +| Mode | Tools | External Services | +|------|-------|-------------------| +| `web` | `web_search`, `web_fetch`, `read`, `grep`, `ls`, `find` | Kagi (search), Tabstack (fetch→markdown) | +| `repo` | `read`, `grep`, `ls`, `find`, `git_log`, `git_show`, `git_blame`, `git_diff`, `git_refs`, `git_checkout` | None (clones repo to workspace) | + +## Key Patterns + +### Tool Factory Pattern + +All tools follow the same signature: +```typescript +const createFooTool = (workspacePath: string): AgentTool => ({ ... }) +``` + +Tools use `@sinclair/typebox` for parameter schemas. Execute functions return `{ content: [{type: "text", text}], details?: {...} }`. + +### Workspace Sandboxing + +Tools must constrain paths to workspace: +- `ensureWorkspacePath()` in `src/agent/tools/index.ts` validates paths don't escape +- `resolveToCwd()` / `resolveReadPath()` in `src/agent/tools/path-utils.ts` handle expansion and normalization + +### Config Cascade + +``` +defaultConfig → XDG_CONFIG_HOME/rumilo/config.toml → CLI flags +``` + +Config uses TOML, validated against TypeBox schema (`src/config/schema.ts`). + +### Model Resolution + +Model strings use `provider:model` format. `custom:name` prefix looks up custom model definitions from config's `[custom_models]` section. Built-in providers delegate to `@mariozechner/pi-ai`. + +### Error Handling + +Custom error classes in `src/util/errors.ts` extend `RumiloError` with error codes: +- `ConfigError`, `FetchError`, `CloneError`, `WorkspaceError`, `ToolInputError` + +### Output Truncation + +`src/util/truncate.ts` handles large content: +- `DEFAULT_MAX_LINES = 2000` +- `DEFAULT_MAX_BYTES = 50KB` +- `GREP_MAX_LINE_LENGTH = 500` chars + +## Gotchas + +1. **Grep requires ripgrep** - `createGrepTool` checks for `rg` at tool creation time and throws if missing +2. **Web tools require credentials** - `KAGI_SESSION_TOKEN` and `TABSTACK_API_KEY` via env or config +3. **Shallow clones by default** - repo mode uses `--depth 1 --filter=blob:limit=5m` unless `--full` +4. **Pre-fetch injection threshold** - web command with `-u URL` injects content directly if ≤50KB, otherwise stores in workspace +5. **No `.js` extension in imports** - source uses `.js` extensions for ESM compatibility even though files are `.ts` + +## Adding a New Tool + +1. Create `src/agent/tools/newtool.ts` following the factory pattern +2. Export from `src/agent/tools/index.ts` if general-purpose +3. Add to appropriate command's tool array in `src/cli/commands/{web,repo}.ts` +4. Use `ToolInputError` for validation failures diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..69f41b969c7ad215dc6124cadb7a58a14e638191 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# rumilo + +Rumilo is a CLI that dispatches specialized research subagents. It supports two modes: + +- `web` for web research (search + fetch, stored in a sandboxed workspace) +- `repo` for git repository exploration (clone to workspace, git-aware tools) + +## Requirements + +- Bun +- Git (for repo mode) +- Kagi session token +- Tabstack API key + +## Configuration + +Rumilo reads configuration from `$XDG_CONFIG_HOME/rumilo/config.toml`. + +Example: + +```toml +[defaults] +model = "anthropic:claude-sonnet-4-20250514" +cleanup = true + +[web] +model = "anthropic:claude-sonnet-4-20250514" + +[repo] +model = "anthropic:claude-sonnet-4-20250514" +``` + +### Custom Models + +You can define custom OpenAI-compatible endpoints like Ollama, vLLM, or self-hosted models in the `[custom_models]` section: + +```toml +[custom_models.ollama] +provider = "ollama" +api = "openai-completions" +base_url = "http://localhost:11434/v1" +id = "ollama/llama3" +name = "Llama 3 (Ollama)" +reasoning = false +input = ["text"] +cost = { input = 0, output = 0 } +context_window = 128000 +max_tokens = 4096 +``` + +Use custom models with the `custom:` prefix: + +```bash +rumilo web "query" --model custom:ollama +rumilo repo -u "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" +``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000000000000000000000000000000000000..4d2f606d7d0c777e44de01c18102c9ba29988b1a --- /dev/null +++ b/bun.lock @@ -0,0 +1,302 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "rumilo", + "dependencies": { + "@mariozechner/pi-agent": "^0.9.0", + "@mariozechner/pi-ai": "^0.6.1", + "@sinclair/typebox": "^0.32.14", + "@tabstack/sdk": "^2.1.0", + "kagi-ken": "github:czottmann/kagi-ken#1.2.0", + "simple-git": "^3.25.0", + "toml": "^3.0.0", + }, + "devDependencies": { + "@types/node": "^22.13.4", + "bun-types": "^1.0.24", + "typescript": "^5.7.3", + }, + }, + }, + "packages": { + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.61.0", "", { "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-GnlOXrPxow0uoaVB3DGNh9EJBU1MyagCBCLpU+bwDVlj/oOPYIwoiasMWlykkfYcQOrDP2x/zHnRD0xN7PeZPw=="], + + "@google/genai": ["@google/genai@1.40.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-fhIww8smT0QYRX78qWOiz/nIQhHMF5wXOrlXvj33HBrz3vKDBb+wibLcEmTA+L9dmPD4KmfNr7UF3LDQVTXNjA=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@kwsites/file-exists": ["@kwsites/file-exists@1.1.1", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw=="], + + "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], + + "@mariozechner/pi-agent": ["@mariozechner/pi-agent@0.9.0", "", { "dependencies": { "@mariozechner/pi-ai": "^0.9.0", "@mariozechner/pi-tui": "^0.9.0" } }, "sha512-VS53eCoyn3vSeqFGzWPV4BoN2ag+FjjFsx4ApLolxgAaLH2uQUEh1V8fHXeuG7j2Cjyr6ZiMY6I1HwX1JTs1ng=="], + + "@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.6.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.61.0", "@google/genai": "^1.17.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "5.21.0", "partial-json": "^0.1.7", "zod-to-json-schema": "^3.24.6" } }, "sha512-sVuNRo7j2AL+dk2RQrjVa6+j5Hf+5wFssJoRs0EpSbaVlLveiEAXOYx8ajryirTvzzpAPzbOX4S/UoJiqDh1vQ=="], + + "@mariozechner/pi-tui": ["@mariozechner/pi-tui@0.9.4", "", { "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", "marked": "^15.0.12", "mime-types": "^3.0.1", "string-width": "^8.1.0" } }, "sha512-jEuqehUniAnJsaiwRIkhHO4APWGZJqoxWgayj02MhSSKUhwi9M+KiqvV8I0Mfgy+4YuZiqY0F9ld/mn1XWoj/w=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.32.35", "", {}, "sha512-Ul3YyOTU++to8cgNkttakC0dWvpERr6RYoHO2W47DLbFvrwBDJUY31B1sImH6JZSYc4Kt4PyHtoPNu+vL2r2dA=="], + + "@tabstack/sdk": ["@tabstack/sdk@2.1.0", "", {}, "sha512-gKqQDo+UaY2E5XjQWvFNmuQCuUuYxAbv+JBhfaP5EdWA0ho0fq2EOZ+pMVJ3ALzHVF50RqmKJlogaDwNq42OjA=="], + + "@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], + + "@types/node": ["@types/node@22.19.9", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-PD03/U8g1F9T9MI+1OBisaIARhSzeidsUjQaf51fOxrfjeiKN9bLVO06lHuHYjxdnqLWJijJHfqXPSJri2EM2A=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "kagi-ken": ["kagi-ken@github:czottmann/kagi-ken#8f45ab5", { "dependencies": { "cheerio": "^1.1.2" } }, "czottmann-kagi-ken-8f45ab5"], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "openai": ["openai@5.21.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-E9LuV51vgvwbahPJaZu2x4V6SWMq9g3X6Bj2/wnFiNfV7lmAxYVxPxcQNZqCWbAVMaEoers9HzIxpOp6Vvgn8w=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], + + "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + + "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="], + + "string-width": ["string-width@8.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.20.0", "", {}, "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@mariozechner/pi-agent/@mariozechner/pi-ai": ["@mariozechner/pi-ai@0.9.4", "", { "dependencies": { "@anthropic-ai/sdk": "^0.61.0", "@google/genai": "^1.30.0", "@sinclair/typebox": "^0.34.41", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "chalk": "^5.6.2", "openai": "5.21.0", "partial-json": "^0.1.7", "zod-to-json-schema": "^3.24.6" } }, "sha512-xI2bnh0LgNBSszDqzXxM3aMPB23hhM5r5xtJaj1KNPVpDP17fTGPcpfO3/4xOIlDYGOvc+OC4778suGRYwwbsg=="], + + "@mariozechner/pi-ai/@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], + + "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@mariozechner/pi-agent/@mariozechner/pi-ai/@sinclair/typebox": ["@sinclair/typebox@0.34.48", "", {}, "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000000000000000000000000000000000000..641e878fd332ca4428d98984c425ae667ab6e811 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install] +linker = "isolated" diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000000000000000000000000000000000000..9d34564ac28735fd3fa4f1166a4031241e491536 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,73 @@ +# Example custom models configuration for rumilo +# Copy relevant sections to your $XDG_CONFIG_HOME/rumilo/config.toml + +[defaults] +model = "anthropic:claude-sonnet-4-20250514" +cleanup = true +# kagi_session_token = "your-kagi-token" +# tabstack_api_key = "your-tabstack-key" + +[web] +model = "anthropic:claude-sonnet-4-20250514" +# system_prompt_path = "~/.config/rumilo/web-prompt.txt" + +[repo] +model = "anthropic:claude-sonnet-4-20250514" +default_depth = 1 +blob_limit = "5m" +# system_prompt_path = "~/.config/rumilo/repo-prompt.txt" + +# Custom models allow you to use any OpenAI-compatible endpoint +# Reference them with: rumilo web "query" --model custom:ollama + +[custom_models.ollama] +# Ollama running locally +provider = "ollama" +api = "openai-completions" +base_url = "http://localhost:11434/v1" +id = "ollama/llama3.2" +name = "Llama 3.2 (Ollama)" +reasoning = false +input = ["text"] +cost = { input = 0, output = 0 } +context_window = 128000 +max_tokens = 4096 + +[custom_models.vllm] +# vLLM inference server +provider = "vllm" +api = "openai-completions" +base_url = "http://localhost:8000/v1" +id = "meta-llama/Llama-3.3-70B-Instruct" +name = "Llama 3.3 70B (vLLM)" +reasoning = false +input = ["text"] +cost = { input = 0, output = 0 } +context_window = 131072 +max_tokens = 8192 + +[custom_models.groq] +# Groq (built-in to pi-ai, but shown here as example) +provider = "groq" +api = "openai-completions" +base_url = "https://api.groq.com/openai/v1" +id = "groq/llama-3.3-70b-versatile" +name = "Llama 3.3 70B Versatile (Groq)" +reasoning = false +input = ["text"] +cost = { input = 0.59, output = 0.79 } +context_window = 131072 +max_tokens = 8192 + +[custom_models.openrouter] +# OpenRouter API (model aggregation) +provider = "openrouter" +api = "openai-completions" +base_url = "https://openrouter.ai/api/v1" +id = "openrouter/zai/glm-4.5v" +name = "GLM-4.5V (OpenRouter)" +reasoning = true +input = ["text", "image"] +cost = { input = 0.5, output = 1.5 } +context_window = 128000 +max_tokens = 4096 diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..1bc72bb965a0910193bc9dc59cf1aa88556b9b20 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "rumilo", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "rumilo": "./dist/cli/index.js" + }, + "scripts": { + "dev": "bun src/cli/index.ts", + "build": "bun build src/cli/index.ts --outdir dist --target=node", + "start": "bun dist/cli/index.js", + "lint": "bun run --silent typecheck", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@mariozechner/pi-ai": "^0.6.1", + "@mariozechner/pi-agent": "^0.9.0", + "@sinclair/typebox": "^0.32.14", + "@tabstack/sdk": "^2.1.0", + "kagi-ken": "github:czottmann/kagi-ken#1.2.0", + "simple-git": "^3.25.0", + "toml": "^3.0.0" + }, + "devDependencies": { + "@types/node": "^22.13.4", + "bun-types": "^1.0.24", + "typescript": "^5.7.3" + } +} diff --git a/src/agent/model-resolver.ts b/src/agent/model-resolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..556b9cc4dc96a6d4963f9925f4bf410682989bb2 --- /dev/null +++ b/src/agent/model-resolver.ts @@ -0,0 +1,104 @@ +import { getModel, type Model } from "@mariozechner/pi-ai"; +import { + type CustomModelConfig, + type RumiloConfig, +} from "../config/schema.js"; +import { ConfigError } from "../util/errors.js"; + +export function resolveModel( + modelString: string, + config: RumiloConfig, +): Model { + 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 { + 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 { + 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, + }; +} diff --git a/src/agent/prompts/repo.ts b/src/agent/prompts/repo.ts new file mode 100644 index 0000000000000000000000000000000000000000..0c423d5d87997c2dff128d0417f857489dbb4e76 --- /dev/null +++ b/src/agent/prompts/repo.ts @@ -0,0 +1,5 @@ +export const REPO_SYSTEM_PROMPT = `You are a research assistant focused on answering the user's question by exploring a git repository in the workspace. + +Use read and grep to inspect files. Use git tools to explore history and refs. Prefer concrete file references and explain where information comes from. + +Be concise and answer directly.`; diff --git a/src/agent/prompts/web.ts b/src/agent/prompts/web.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c0cc9981adde2fa35253ff4356bedc24f424353 --- /dev/null +++ b/src/agent/prompts/web.ts @@ -0,0 +1,7 @@ +export const WEB_SYSTEM_PROMPT = `You are a research assistant focused on answering the user's question using web sources. + +You have tools to search the web, fetch URLs into markdown, and read or grep files in the workspace. + +Use web_search to find sources. Use web_fetch to retrieve a URL. When content is large, it may be stored in the workspace; use read and grep to explore it. + +Be concise, cite sources with URLs, and answer directly.`; diff --git a/src/agent/runner.ts b/src/agent/runner.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ed58733cd0fd8e2c635b492c3427d19cb47bfe7 --- /dev/null +++ b/src/agent/runner.ts @@ -0,0 +1,51 @@ +import { Agent, ProviderTransport, type AgentEvent } from "@mariozechner/pi-agent"; +import { type AgentTool } from "@mariozechner/pi-ai"; +import { ToolInputError } from "../util/errors.js"; +import type { RumiloConfig } from "../config/schema.js"; +import { resolveModel } from "./model-resolver.js"; + +export interface AgentRunOptions { + model: string; + systemPrompt: string; + tools: AgentTool[]; + onEvent?: (event: AgentEvent) => void; + config: RumiloConfig; +} + +export interface AgentRunResult { + message: string; + usage?: unknown; +} + +export async function runAgent(query: string, options: AgentRunOptions): Promise { + 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, + }; +} diff --git a/src/agent/tools/find.ts b/src/agent/tools/find.ts new file mode 100644 index 0000000000000000000000000000000000000000..d67dd063f47c45bcfc913664c080a6c2d0170e7b --- /dev/null +++ b/src/agent/tools/find.ts @@ -0,0 +1,88 @@ +import { spawnSync } from "node:child_process"; +import { relative } from "node:path"; +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import { resolveToCwd } from "./path-utils.js"; +import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../util/truncate.js"; +import { ToolInputError } from "../../util/errors.js"; + +const DEFAULT_LIMIT = 1000; + +const FindSchema = Type.Object({ + pattern: Type.String({ description: "Glob pattern to match files, e.g. '*.ts', '**/*.json'" }), + path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })), + limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })), +}); + +export const createFindTool = (workspacePath: string): AgentTool => { + const fdResult = spawnSync("which", ["fd"], { encoding: "utf-8" }); + const fdPath = fdResult.stdout?.trim(); + if (!fdPath) throw new ToolInputError("fd is not available"); + + return { + name: "find", + label: "Find Files", + description: `Search for files by glob pattern using fd. Returns up to ${DEFAULT_LIMIT} results and ${DEFAULT_MAX_BYTES / 1024} KB of output. Respects .gitignore.`, + parameters: FindSchema as any, + execute: async (_toolCallId: string, params: any) => { + const searchDir: string = params.path || "."; + const effectiveLimit = params.limit ?? DEFAULT_LIMIT; + const searchPath = resolveToCwd(searchDir, workspacePath); + + const args = [ + "--glob", + "--color=never", + "--hidden", + "--max-results", + String(effectiveLimit), + params.pattern, + searchPath, + ]; + + const result = spawnSync(fdPath, args, { + encoding: "utf-8", + maxBuffer: 10 * 1024 * 1024, + }); + + const rawOutput = result.stdout?.trim() ?? ""; + if (!rawOutput) { + return { + content: [{ type: "text", text: "No files found matching pattern" }], + details: { pattern: params.pattern, path: searchDir, matches: 0 }, + }; + } + + const lines = rawOutput.split("\n").map((line) => { + const isDir = line.endsWith("/"); + const rel = relative(searchPath, line); + return isDir && !rel.endsWith("/") ? `${rel}/` : rel; + }); + + const relativized = lines.join("\n"); + const truncated = truncateHead(relativized, { maxLines: Number.MAX_SAFE_INTEGER }); + + const notices: string[] = []; + if (lines.length >= effectiveLimit) { + notices.push(`Result limit reached (${effectiveLimit}). Narrow your pattern for complete results.`); + } + if (truncated.truncatedBy === "bytes") { + const droppedBytes = truncated.totalBytes - truncated.outputBytes; + notices.push(`Output truncated by ${formatSize(droppedBytes)} to fit ${formatSize(DEFAULT_MAX_BYTES)} limit.`); + } + + const output = notices.length > 0 + ? `${truncated.content}\n\n${notices.join("\n")}` + : truncated.content; + + return { + content: [{ type: "text", text: output }], + details: { + pattern: params.pattern, + path: searchDir, + matches: lines.length, + ...(truncated.truncated && { truncatedBy: truncated.truncatedBy }), + }, + }; + }, + }; +}; diff --git a/src/agent/tools/git/blame.ts b/src/agent/tools/git/blame.ts new file mode 100644 index 0000000000000000000000000000000000000000..8cfbc6b3ba46d2876f3fd5c7978133fca485049b --- /dev/null +++ b/src/agent/tools/git/blame.ts @@ -0,0 +1,27 @@ +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import simpleGit from "simple-git"; +import { ToolInputError } from "../../../util/errors.js"; + +const BlameSchema = Type.Object({ + path: Type.String({ description: "File path relative to repo root" }), +}); + +export const createGitBlameTool = (workspacePath: string): AgentTool => ({ + name: "git_blame", + label: "Git Blame", + description: "Blame a file to see commit attribution.", + parameters: BlameSchema as any, + execute: async (_toolCallId: string, params: any) => { + if (!String(params.path ?? "").trim()) { + throw new ToolInputError("path must be a non-empty string"); + } + const git = simpleGit(workspacePath); + const text = await git.raw(["blame", "--", params.path]); + + return { + content: [{ type: "text", text }], + details: { path: params.path }, + }; + }, +}); diff --git a/src/agent/tools/git/checkout.ts b/src/agent/tools/git/checkout.ts new file mode 100644 index 0000000000000000000000000000000000000000..da3ee6895ca2b17380388d3299c99156af0e3660 --- /dev/null +++ b/src/agent/tools/git/checkout.ts @@ -0,0 +1,27 @@ +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import simpleGit from "simple-git"; +import { ToolInputError } from "../../../util/errors.js"; + +const CheckoutSchema = Type.Object({ + ref: Type.String({ description: "Ref to checkout" }), +}); + +export const createGitCheckoutTool = (workspacePath: string): AgentTool => ({ + name: "git_checkout", + label: "Git Checkout", + description: "Checkout a branch, tag, or commit.", + parameters: CheckoutSchema as any, + execute: async (_toolCallId: string, params: any) => { + if (!String(params.ref ?? "").trim()) { + throw new ToolInputError("ref must be a non-empty string"); + } + const git = simpleGit(workspacePath); + await git.checkout(params.ref); + + return { + content: [{ type: "text", text: `Checked out ${params.ref}` }], + details: { ref: params.ref }, + }; + }, +}); diff --git a/src/agent/tools/git/diff.ts b/src/agent/tools/git/diff.ts new file mode 100644 index 0000000000000000000000000000000000000000..56773c2fa75c861a08eeff9ddc1647253c2e61a1 --- /dev/null +++ b/src/agent/tools/git/diff.ts @@ -0,0 +1,40 @@ +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import simpleGit from "simple-git"; +import { ToolInputError } from "../../../util/errors.js"; + +const DiffSchema = Type.Object({ + ref: Type.Optional(Type.String({ description: "Base ref (optional)" })), + ref2: Type.Optional(Type.String({ description: "Compare ref (optional)" })), + path: Type.Optional(Type.String({ description: "Limit diff to path" })), +}); + +export const createGitDiffTool = (workspacePath: string): AgentTool => ({ + name: "git_diff", + label: "Git Diff", + description: "Show diff between refs or working tree.", + parameters: DiffSchema as any, + execute: async (_toolCallId: string, params: any) => { + const git = simpleGit(workspacePath); + const args: string[] = []; + + if (params.ref && !String(params.ref).trim()) { + throw new ToolInputError("ref must be a non-empty string"); + } + if (params.ref2 && !String(params.ref2).trim()) { + throw new ToolInputError("ref2 must be a non-empty string"); + } + if (params.path && !String(params.path).trim()) { + throw new ToolInputError("path must be a non-empty string"); + } + if (params.ref) args.push(params.ref); + if (params.ref2) args.push(params.ref2); + if (params.path) args.push("--", params.path); + + const text = await git.diff(args); + return { + content: [{ type: "text", text }], + details: { path: params.path ?? null }, + }; + }, +}); diff --git a/src/agent/tools/git/index.ts b/src/agent/tools/git/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..632bf4816bf8b8f2c603619d054b3eec6321744e --- /dev/null +++ b/src/agent/tools/git/index.ts @@ -0,0 +1,6 @@ +export { createGitLogTool } from "./log.js"; +export { createGitShowTool } from "./show.js"; +export { createGitBlameTool } from "./blame.js"; +export { createGitDiffTool } from "./diff.js"; +export { createGitRefsTool } from "./refs.js"; +export { createGitCheckoutTool } from "./checkout.js"; diff --git a/src/agent/tools/git/log.ts b/src/agent/tools/git/log.ts new file mode 100644 index 0000000000000000000000000000000000000000..56fe195dbcf134901d4c302c27184cdbe0e46443 --- /dev/null +++ b/src/agent/tools/git/log.ts @@ -0,0 +1,59 @@ +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import simpleGit from "simple-git"; +import { ToolInputError } from "../../../util/errors.js"; + +const LogSchema = Type.Object({ + path: Type.Optional(Type.String({ description: "Filter to commits touching this path" })), + author: Type.Optional(Type.String({ description: "Filter by author name/email" })), + since: Type.Optional(Type.String({ description: "Commits after this date (e.g., 2024-01-01)" })), + until: Type.Optional(Type.String({ description: "Commits before this date" })), + n: Type.Optional(Type.Number({ description: "Maximum number of commits (default: 20)" })), + oneline: Type.Optional(Type.Boolean({ description: "Compact one-line format (default: false)" })), +}); + +export const createGitLogTool = (workspacePath: string): AgentTool => ({ + name: "git_log", + label: "Git Log", + description: "View commit history. Supports filtering by path, author, date range, and count.", + parameters: LogSchema as any, + execute: async (_toolCallId: string, params: any) => { + const git = simpleGit(workspacePath); + const options: string[] = []; + + if (params.n !== undefined) { + if (typeof params.n !== "number" || Number.isNaN(params.n) || params.n <= 0) { + throw new ToolInputError("n must be a positive number"); + } + options.push("-n", String(Math.floor(params.n))); + } + if (params.oneline) options.push("--oneline"); + if (params.author && !String(params.author).trim()) { + throw new ToolInputError("author must be a non-empty string"); + } + if (params.author) options.push(`--author=${params.author}`); + if (params.since && !String(params.since).trim()) { + throw new ToolInputError("since must be a non-empty string"); + } + if (params.since) options.push(`--since=${params.since}`); + if (params.until && !String(params.until).trim()) { + throw new ToolInputError("until must be a non-empty string"); + } + if (params.until) options.push(`--until=${params.until}`); + + const result = await git.log(options.concat(params.path ? ["--", params.path] : [])); + + const text = result.all + .map((entry) => + params.oneline + ? `${entry.hash} ${entry.message}` + : `${entry.hash} ${entry.date} ${entry.author_name} ${entry.message}`, + ) + .join("\n"); + + return { + content: [{ type: "text", text }], + details: { count: result.all.length }, + }; + }, +}); diff --git a/src/agent/tools/git/refs.ts b/src/agent/tools/git/refs.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c87551a4e1fe2f1e7e56a6f5b21b0382e8ccbb1 --- /dev/null +++ b/src/agent/tools/git/refs.ts @@ -0,0 +1,43 @@ +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import simpleGit from "simple-git"; + +const RefsSchema = Type.Object({ + type: Type.Union([ + Type.Literal("branches"), + Type.Literal("tags"), + Type.Literal("remotes"), + ]), +}); + +export const createGitRefsTool = (workspacePath: string): AgentTool => ({ + name: "git_refs", + label: "Git Refs", + description: "List branches or tags.", + parameters: RefsSchema as any, + execute: async (_toolCallId: string, params: any) => { + const git = simpleGit(workspacePath); + + if (params.type === "tags") { + const tags = await git.tags(); + return { + content: [{ type: "text", text: tags.all.join("\n") }], + details: { count: tags.all.length }, + }; + } + + if (params.type === "remotes") { + const raw = await git.raw(["branch", "-r"]); + return { + content: [{ type: "text", text: raw }], + details: {}, + }; + } + + const raw = await git.raw(["branch", "-a"]); + return { + content: [{ type: "text", text: raw }], + details: {}, + }; + }, +}); diff --git a/src/agent/tools/git/show.ts b/src/agent/tools/git/show.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a60b0a6a63f2aea84db3c762b37f393bfc6bc29 --- /dev/null +++ b/src/agent/tools/git/show.ts @@ -0,0 +1,27 @@ +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import simpleGit from "simple-git"; +import { ToolInputError } from "../../../util/errors.js"; + +const ShowSchema = Type.Object({ + ref: Type.String({ description: "Commit hash or ref" }), +}); + +export const createGitShowTool = (workspacePath: string): AgentTool => ({ + name: "git_show", + label: "Git Show", + description: "Show details for a commit or object.", + parameters: ShowSchema as any, + execute: async (_toolCallId: string, params: any) => { + if (!String(params.ref ?? "").trim()) { + throw new ToolInputError("ref must be a non-empty string"); + } + const git = simpleGit(workspacePath); + const text = await git.show([params.ref]); + + return { + content: [{ type: "text", text }], + details: { ref: params.ref }, + }; + }, +}); diff --git a/src/agent/tools/grep.ts b/src/agent/tools/grep.ts new file mode 100644 index 0000000000000000000000000000000000000000..a27a3a438a228999afbee3fbd82c72cd6d5b7eb4 --- /dev/null +++ b/src/agent/tools/grep.ts @@ -0,0 +1,189 @@ +import { spawn, spawnSync } from "node:child_process"; +import { createInterface } from "node:readline"; +import { readFileSync, statSync } from "node:fs"; +import { relative, basename } from "node:path"; +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import { resolveToCwd } from "./path-utils.js"; +import { + DEFAULT_MAX_BYTES, + formatSize, + GREP_MAX_LINE_LENGTH, + truncateHead, + truncateLine, +} from "../../util/truncate.js"; +import { ToolInputError } from "../../util/errors.js"; + +const DEFAULT_LIMIT = 100; + +const GrepSchema = Type.Object({ + pattern: Type.String({ description: "Search pattern (regex or literal string)" }), + path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })), + glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts'" })), + ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })), + literal: Type.Optional( + Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }), + ), + context: Type.Optional(Type.Number({ description: "Lines of context around each match (default: 0)" })), + limit: Type.Optional(Type.Number({ description: "Maximum matches to return (default: 100)" })), +}); + +export const createGrepTool = (workspacePath: string): AgentTool => { + const rgResult = spawnSync("which", ["rg"], { encoding: "utf-8" }); + if (rgResult.status !== 0) { + throw new ToolInputError("ripgrep (rg) is not available"); + } + const rgPath = rgResult.stdout.trim(); + + return { + name: "grep", + label: "Grep", + description: `Search file contents for a pattern using ripgrep. Returns up to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Respects .gitignore.`, + parameters: GrepSchema as any, + execute: async (_toolCallId: string, params: any) => { + const searchDir: string | undefined = params.path; + const searchPath = resolveToCwd(searchDir || ".", workspacePath); + let isDirectory = false; + try { + isDirectory = statSync(searchPath).isDirectory(); + } catch { + throw new ToolInputError(`Path does not exist or is not accessible: ${searchDir || "."}`); + } + + const effectiveLimit: number = Math.max(1, Math.floor(params.limit ?? DEFAULT_LIMIT)); + const contextLines: number = Math.max(0, Math.floor(params.context ?? 0)); + + const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"]; + if (params.ignoreCase) args.push("--ignore-case"); + if (params.literal) args.push("--fixed-strings"); + if (params.glob) args.push("--glob", params.glob); + args.push(params.pattern, searchPath); + + return new Promise((resolve, reject) => { + const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] }); + + const rl = createInterface({ input: child.stdout! }); + const fileCache = new Map(); + + 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 } : {}), + }, + }); + }); + }); + }, + }; +}; diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e803d0ea06cccbedcdd62b4497e9757475cd16d --- /dev/null +++ b/src/agent/tools/index.ts @@ -0,0 +1,26 @@ +import { resolve, sep } from "node:path"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import { ToolInputError } from "../../util/errors.js"; + +export type ToolFactory = (workspacePath: string) => AgentTool; + +export interface ToolBundle { + name: string; + tools: AgentTool[]; +} + +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"; diff --git a/src/agent/tools/ls.ts b/src/agent/tools/ls.ts new file mode 100644 index 0000000000000000000000000000000000000000..4546a1516c6be1e8073530c8678dec9c7dbc7cba --- /dev/null +++ b/src/agent/tools/ls.ts @@ -0,0 +1,82 @@ +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import { resolveToCwd } from "./path-utils.js"; +import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../util/truncate.js"; + +const DEFAULT_LIMIT = 500; + +const LsSchema = Type.Object({ + path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })), + limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })), +}); + +export const createLsTool = (workspacePath: string): AgentTool => ({ + name: "ls", + label: "List Directory", + description: `List directory contents. Returns up to ${DEFAULT_LIMIT} entries and ${DEFAULT_MAX_BYTES / 1024} KB of output.`, + parameters: LsSchema as any, + execute: async (_toolCallId: string, params: any) => { + const resolved = resolveToCwd(params.path || ".", workspacePath); + + if (!existsSync(resolved)) { + throw new Error(`Path does not exist: ${params.path || "."}`); + } + + const stats = statSync(resolved); + if (!stats.isDirectory()) { + throw new Error(`Not a directory: ${params.path || "."}`); + } + + const entries = readdirSync(resolved); + entries.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + + const effectiveLimit = params.limit ?? DEFAULT_LIMIT; + const limited = entries.slice(0, effectiveLimit); + const entryLimitReached = entries.length > effectiveLimit; + + const lines = limited.map((entry) => { + const entryPath = join(resolved, entry); + try { + const entryStat = statSync(entryPath); + return entryStat.isDirectory() ? `${entry}/` : entry; + } catch { + return entry; + } + }); + + if (lines.length === 0) { + return { + content: [{ type: "text", text: "(empty directory)" }], + details: {}, + }; + } + + const rawOutput = lines.join("\n"); + const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); + + const notices: string[] = []; + if (entryLimitReached) { + notices.push(`Entry limit reached: showing ${effectiveLimit} of ${entries.length} entries.`); + } + if (truncation.truncated) { + notices.push( + `Output truncated: showing ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}.`, + ); + } + + let output = truncation.content; + if (notices.length > 0) { + output += `\n\n[${notices.join(" ")}]`; + } + + return { + content: [{ type: "text", text: output }], + details: { + ...(truncation.truncated ? { truncation } : {}), + ...(entryLimitReached ? { entryLimitReached: true } : {}), + }, + }; + }, +}); diff --git a/src/agent/tools/path-utils.ts b/src/agent/tools/path-utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4412f8269ccfa71b41e50d65d365e1b631c232e --- /dev/null +++ b/src/agent/tools/path-utils.ts @@ -0,0 +1,70 @@ +import { accessSync, constants } from "node:fs"; +import * as os from "node:os"; +import { isAbsolute, resolve as resolvePath } from "node:path"; + +const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; + +function normalizeAtPrefix(filePath: string): string { + return filePath.startsWith("@") ? filePath.slice(1) : filePath; +} + +function fileExists(path: string): boolean { + try { + accessSync(path, constants.F_OK); + return true; + } catch { + return false; + } +} + +function tryMacOSScreenshotPath(filePath: string): string { + return filePath.replace(/ (AM|PM)\./g, "\u202F$1."); +} + +function tryNFDVariant(filePath: string): string { + return filePath.normalize("NFD"); +} + +function tryCurlyQuoteVariant(filePath: string): string { + return filePath.replace(/'/g, "\u2019"); +} + +export function expandPath(filePath: string): string { + let result = filePath.replace(UNICODE_SPACES, " "); + result = normalizeAtPrefix(result); + + if (result === "~") { + return os.homedir(); + } + if (result.startsWith("~/")) { + return resolvePath(os.homedir(), result.slice(2)); + } + + return result; +} + +export function resolveToCwd(filePath: string, cwd: string): string { + const expanded = expandPath(filePath); + if (isAbsolute(expanded)) { + return expanded; + } + return resolvePath(cwd, expanded); +} + +export function resolveReadPath(filePath: string, cwd: string): string { + const resolved = resolveToCwd(filePath, cwd); + + if (fileExists(resolved)) { + return resolved; + } + + const variants = [tryNFDVariant, tryMacOSScreenshotPath, tryCurlyQuoteVariant]; + for (const variant of variants) { + const candidate = variant(resolved); + if (candidate !== resolved && fileExists(candidate)) { + return candidate; + } + } + + return resolved; +} diff --git a/src/agent/tools/read.ts b/src/agent/tools/read.ts new file mode 100644 index 0000000000000000000000000000000000000000..0630a23457c1d4cbb2364d58dba77427be7550a0 --- /dev/null +++ b/src/agent/tools/read.ts @@ -0,0 +1,83 @@ +import { readFile, stat } from "node:fs/promises"; +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import { resolveReadPath } from "./path-utils.js"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "../../util/truncate.js"; +import { ToolInputError } from "../../util/errors.js"; + +const MAX_READ_BYTES = 5 * 1024 * 1024; + +const ReadSchema = Type.Object({ + path: Type.String({ description: "Path relative to workspace root" }), + offset: Type.Optional(Type.Number({ description: "1-based starting line (default: 1)" })), + limit: Type.Optional(Type.Number({ description: "Maximum lines to return" })), +}); + +export const createReadTool = (workspacePath: string): AgentTool => ({ + name: "read", + label: "Read File", + description: `Read a file's contents. Output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`, + parameters: ReadSchema as any, + execute: async (_toolCallId: string, params: any) => { + const absolutePath = resolveReadPath(params.path, workspacePath); + const fileStats = await stat(absolutePath); + + if (fileStats.size > MAX_READ_BYTES) { + throw new ToolInputError(`File exceeds 5MB limit: ${params.path}`); + } + + const raw = await readFile(absolutePath, "utf8"); + const allLines = raw.split("\n"); + const totalFileLines = allLines.length; + + const startLine = params.offset ? Math.max(0, params.offset - 1) : 0; + const startLineDisplay = startLine + 1; + + if (startLine >= allLines.length) { + throw new Error(`Offset ${params.offset} is beyond end of file (${allLines.length} lines total)`); + } + + let selectedContent: string; + let userLimitedLines: number | undefined; + if (params.limit !== undefined) { + const endLine = Math.min(startLine + params.limit, allLines.length); + selectedContent = allLines.slice(startLine, endLine).join("\n"); + userLimitedLines = endLine - startLine; + } else { + selectedContent = allLines.slice(startLine).join("\n"); + } + + const truncation = truncateHead(selectedContent); + + let output: string; + + if (truncation.firstLineExceedsLimit) { + const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine] ?? "", "utf-8")); + output = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use a local file viewer to extract that single line by number.]`; + } else if (truncation.truncated) { + const endLineDisplay = startLineDisplay + truncation.outputLines - 1; + const nextOffset = endLineDisplay + 1; + + output = truncation.content; + + if (truncation.truncatedBy === "lines") { + output += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`; + } else { + output += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`; + } + } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) { + const remaining = allLines.length - (startLine + userLimitedLines); + const nextOffset = startLine + userLimitedLines + 1; + + output = truncation.content; + output += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`; + } else { + output = truncation.content; + } + + return { + content: [{ type: "text", text: output }], + details: { truncation }, + }; + }, +}); diff --git a/src/agent/tools/web-fetch.ts b/src/agent/tools/web-fetch.ts new file mode 100644 index 0000000000000000000000000000000000000000..141cbe0d691699ed91567b5f2023ac005f0e8a68 --- /dev/null +++ b/src/agent/tools/web-fetch.ts @@ -0,0 +1,44 @@ +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import Tabstack from "@tabstack/sdk"; +import { FetchError, ToolInputError } from "../../util/errors.js"; + +export interface WebFetchResult { + content: string; + url: string; +} + +const FetchSchema = Type.Object({ + url: Type.String({ description: "URL to fetch" }), + nocache: Type.Optional(Type.Boolean({ description: "Force fresh fetch" })), +}); + +export function createWebFetchTool(apiKey: string): AgentTool { + return { + name: "web_fetch", + label: "Web Fetch", + description: "Fetch a URL and return markdown using Tabstack.", + parameters: FetchSchema as any, + execute: async (_toolCallId: string, params: any) => { + if (!apiKey) { + throw new ToolInputError("Missing Tabstack API key"); + } + + const client = new Tabstack({ apiKey }); + + try { + const result = await client.extract.markdown({ + url: params.url, + nocache: params.nocache ?? false, + }); + + return { + content: [{ type: "text", text: result.content ?? "" }], + details: { url: params.url, length: result.content?.length ?? 0 }, + }; + } catch (error: any) { + throw new FetchError(params.url, error?.message ?? String(error)); + } + }, + }; +} diff --git a/src/agent/tools/web-search.ts b/src/agent/tools/web-search.ts new file mode 100644 index 0000000000000000000000000000000000000000..77dd047f2da47ee1a0c3ea231bfc620180b42b20 --- /dev/null +++ b/src/agent/tools/web-search.ts @@ -0,0 +1,26 @@ +import { Type } from "@sinclair/typebox"; +import type { AgentTool } from "@mariozechner/pi-ai"; +import { search } from "kagi-ken"; +import { ToolInputError } from "../../util/errors.js"; + +const SearchSchema = Type.Object({ + query: Type.String({ description: "Search query" }), +}); + +export const createWebSearchTool = (sessionToken: string): AgentTool => ({ + name: "web_search", + label: "Web Search", + description: "Search the web using Kagi (session token required).", + parameters: SearchSchema as any, + execute: async (_toolCallId: string, params: any) => { + if (!sessionToken) { + throw new ToolInputError("Missing Kagi session token"); + } + + const result = await search(params.query, sessionToken); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + details: { query: params.query, resultCount: result?.data?.length ?? 0 }, + }; + }, +}); diff --git a/src/cli/commands/repo.ts b/src/cli/commands/repo.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e9915c79e200b28c4946b2420e1d6728a072b49 --- /dev/null +++ b/src/cli/commands/repo.ts @@ -0,0 +1,97 @@ +import { readFile } from "node:fs/promises"; +import { applyConfigOverrides, loadConfig } from "../../config/loader.js"; +import { createWorkspace } from "../../workspace/manager.js"; +import { createGrepTool } from "../../agent/tools/grep.js"; +import { createReadTool } from "../../agent/tools/read.js"; +import { createLsTool } from "../../agent/tools/ls.js"; +import { createFindTool } from "../../agent/tools/find.js"; +import { createGitLogTool } from "../../agent/tools/git/log.js"; +import { createGitShowTool } from "../../agent/tools/git/show.js"; +import { createGitBlameTool } from "../../agent/tools/git/blame.js"; +import { createGitDiffTool } from "../../agent/tools/git/diff.js"; +import { createGitRefsTool } from "../../agent/tools/git/refs.js"; +import { createGitCheckoutTool } from "../../agent/tools/git/checkout.js"; +import { runAgent } from "../../agent/runner.js"; +import { REPO_SYSTEM_PROMPT } from "../../agent/prompts/repo.js"; +import { createEventLogger, printUsageSummary } from "../output.js"; +import { CloneError } from "../../util/errors.js"; +import simpleGit from "simple-git"; + +export interface RepoCommandOptions { + query: string; + uri: string; + ref?: string; + full: boolean; + model?: string; + verbose: boolean; + cleanup: boolean; +} + +export async function runRepoCommand(options: RepoCommandOptions): Promise { + 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(); + } +} diff --git a/src/cli/commands/web.ts b/src/cli/commands/web.ts new file mode 100644 index 0000000000000000000000000000000000000000..13abe98ba9c5c453c7b6df922592517819589e53 --- /dev/null +++ b/src/cli/commands/web.ts @@ -0,0 +1,104 @@ +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; +import { applyConfigOverrides, loadConfig } from "../../config/loader.js"; +import { createWorkspace } from "../../workspace/manager.js"; +import { writeWorkspaceFile } from "../../workspace/content.js"; +import { createGrepTool } from "../../agent/tools/grep.js"; +import { createReadTool } from "../../agent/tools/read.js"; +import { createLsTool } from "../../agent/tools/ls.js"; +import { createFindTool } from "../../agent/tools/find.js"; +import { createWebFetchTool } from "../../agent/tools/web-fetch.js"; +import { createWebSearchTool } from "../../agent/tools/web-search.js"; +import { runAgent } from "../../agent/runner.js"; +import { WEB_SYSTEM_PROMPT } from "../../agent/prompts/web.js"; +import { createEventLogger, printUsageSummary } from "../output.js"; +import { FetchError, ToolInputError } from "../../util/errors.js"; + +const INJECT_THRESHOLD = 50 * 1024; + +export interface WebCommandOptions { + query: string; + url?: string; + model?: string; + verbose: boolean; + cleanup: boolean; +} + +export async function runWebCommand(options: WebCommandOptions): Promise { + 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(); + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100755 index 0000000000000000000000000000000000000000..72ea1eea2dcd4ef5b96332a5d422de7613417ba1 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,106 @@ +#!/usr/bin/env bun +import { runWebCommand } from "./commands/web.js"; +import { runRepoCommand } from "./commands/repo.js"; +import { RumiloError } from "../util/errors.js"; + +interface ParsedArgs { + command?: string; + options: Record; + positional: string[]; +} + +function parseArgs(args: string[]): ParsedArgs { + const [, , command, ...rest] = args; + const options: Record = {}; + 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 [-u URL] [--model ] [--verbose] [--no-cleanup]"); + console.log("rumilo repo -u [--ref ] [--full] [--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(); diff --git a/src/cli/output.ts b/src/cli/output.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ecdad5880ac39c35e51b5ee57f93fb7b5a6b354 --- /dev/null +++ b/src/cli/output.ts @@ -0,0 +1,53 @@ +import type { AgentEvent } from "@mariozechner/pi-agent"; + +const MAX_OUTPUT_LINES = 20; + +export interface OutputOptions { + verbose: boolean; +} + +export function formatToolOutput(output: string): string { + const lines = output.split(/\r?\n/); + if (lines.length <= MAX_OUTPUT_LINES) { + return output; + } + + const truncated = lines.slice(0, MAX_OUTPUT_LINES).join("\n"); + return `${truncated}\n(+${lines.length - MAX_OUTPUT_LINES} more lines)`; +} + +export function createEventLogger(options: OutputOptions) { + return (event: AgentEvent) => { + if (!options.verbose) return; + + if (event.type === "tool_execution_start") { + console.error(`\n[tool] ${event.toolName}`); + console.error(JSON.stringify(event.args, null, 2)); + } + + if (event.type === "tool_execution_end") { + if (event.isError) { + console.error("[tool error]"); + console.error(String(event.result)); + return; + } + + const text = (event.result?.content ?? []) + .filter((content: { type: string }) => content.type === "text") + .map((content: { text?: string }) => content.text ?? "") + .join(""); + + if (text) { + console.error(formatToolOutput(text)); + } + } + }; +} + +export function printUsageSummary(usage: { cost?: { total?: number }; totalTokens?: number; output?: number; input?: number } | undefined) { + if (!usage) return; + + const cost = usage.cost?.total ?? 0; + const tokens = usage.totalTokens ?? (usage.output ?? 0) + (usage.input ?? 0); + console.error(`\nusage: ${tokens} tokens, cost $${cost.toFixed(4)}`); +} diff --git a/src/config/defaults.ts b/src/config/defaults.ts new file mode 100644 index 0000000000000000000000000000000000000000..a53948c402f7d5432f79ed04b6a3300e1ed16c46 --- /dev/null +++ b/src/config/defaults.ts @@ -0,0 +1,17 @@ +import type { RumiloConfig } from "./schema.js"; + +export const defaultConfig: RumiloConfig = { + defaults: { + model: "anthropic:claude-sonnet-4-20250514", + cleanup: true, + }, + web: { + model: "anthropic:claude-sonnet-4-20250514", + }, + repo: { + model: "anthropic:claude-sonnet-4-20250514", + default_depth: 1, + blob_limit: "5m", + }, + custom_models: {}, +}; diff --git a/src/config/loader.ts b/src/config/loader.ts new file mode 100644 index 0000000000000000000000000000000000000000..140c3a1fafd8092f5f72fa95c313f404a1da1f58 --- /dev/null +++ b/src/config/loader.ts @@ -0,0 +1,75 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { defaultConfig } from "./defaults.js"; +import type { RumiloConfig } from "./schema.js"; +import { ConfigError } from "../util/errors.js"; +import toml from "toml"; + +export interface LoadedConfig { + config: RumiloConfig; + path?: string; +} + +function mergeConfig(base: RumiloConfig, override: Partial): RumiloConfig { + 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 { + 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; + 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 { + const merged = mergeConfig(config, overrides); + validateConfig(merged); + return merged; +} diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..574cb854d957fcc64a6c095cdb20e0644eb09d83 --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,59 @@ +import { Type, type Static } from "@sinclair/typebox"; + +const CustomModelSchema = Type.Object({ + provider: Type.String(), + api: Type.String(), + base_url: Type.String(), + id: Type.String(), + name: Type.String(), + reasoning: Type.Boolean(), + input: Type.Array(Type.Union([Type.Literal("text"), Type.Literal("image")])), + cost: Type.Object({ + input: Type.Number(), + output: Type.Number(), + cache_read: Type.Optional(Type.Number()), + cache_write: Type.Optional(Type.Number()), + }), + context_window: Type.Number(), + max_tokens: Type.Number(), + headers: Type.Optional(Type.Record(Type.String(), Type.String())), + compat: Type.Optional( + Type.Object({ + supports_store: Type.Optional(Type.Boolean()), + supports_developer_role: Type.Optional(Type.Boolean()), + supports_reasoning_effort: Type.Optional(Type.Boolean()), + supports_usage_in_streaming: Type.Optional(Type.Boolean()), + max_tokens_field: Type.Optional(Type.Union([Type.Literal("max_tokens"), Type.Literal("max_completion_tokens")])), + requires_tool_result_name: Type.Optional(Type.Boolean()), + requires_assistant_after_tool_result: Type.Optional(Type.Boolean()), + requires_thinking_as_text: Type.Optional(Type.Boolean()), + requires_mistral_tool_ids: Type.Optional(Type.Boolean()), + thinking_format: Type.Optional(Type.Union([Type.Literal("openai"), Type.Literal("zai")])), + }) + ), +}); + +const ConfigSchema = Type.Object({ + defaults: Type.Object({ + model: Type.String(), + cleanup: Type.Boolean(), + kagi_session_token: Type.Optional(Type.String()), + tabstack_api_key: Type.Optional(Type.String()), + }), + web: Type.Object({ + model: Type.Optional(Type.String()), + system_prompt_path: Type.Optional(Type.String()), + kagi_session_token: Type.Optional(Type.String()), + tabstack_api_key: Type.Optional(Type.String()), + }), + repo: Type.Object({ + model: Type.Optional(Type.String()), + system_prompt_path: Type.Optional(Type.String()), + default_depth: Type.Optional(Type.Number({ minimum: 1 })), + blob_limit: Type.Optional(Type.String()), + }), + custom_models: Type.Optional(Type.Record(Type.String(), CustomModelSchema)), +}); + +export type RumiloConfig = Static; +export type CustomModelConfig = Static; diff --git a/src/types/kagi-ken.d.ts b/src/types/kagi-ken.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..a46d863cfc776a91a11345f78ef8cf39287004b0 --- /dev/null +++ b/src/types/kagi-ken.d.ts @@ -0,0 +1,3 @@ +declare module "kagi-ken" { + export function search(query: string, token: string): Promise; +} diff --git a/src/util/errors.ts b/src/util/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e6fb9483098d9194de3028431ba79334dd7623a --- /dev/null +++ b/src/util/errors.ts @@ -0,0 +1,39 @@ +export class RumiloError extends Error { + readonly code: string; + + constructor(message: string, code: string) { + super(message); + this.name = this.constructor.name; + this.code = code; + } +} + +export class ConfigError extends RumiloError { + constructor(message: string) { + super(message, "CONFIG_ERROR"); + } +} + +export class FetchError extends RumiloError { + constructor(url: string, reason: string) { + super(`Failed to fetch ${url}: ${reason}`, "FETCH_ERROR"); + } +} + +export class CloneError extends RumiloError { + constructor(uri: string, reason: string) { + super(`Failed to clone ${uri}: ${reason}`, "CLONE_ERROR"); + } +} + +export class WorkspaceError extends RumiloError { + constructor(message: string) { + super(message, "WORKSPACE_ERROR"); + } +} + +export class ToolInputError extends RumiloError { + constructor(message: string) { + super(message, "TOOL_INPUT_ERROR"); + } +} diff --git a/src/util/truncate.ts b/src/util/truncate.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5136f376f6f012af3d0a17b502eac0e947e3ea7 --- /dev/null +++ b/src/util/truncate.ts @@ -0,0 +1,210 @@ +export const DEFAULT_MAX_LINES = 2000; +export const DEFAULT_MAX_BYTES = 50 * 1024; +export const GREP_MAX_LINE_LENGTH = 500; + +export interface TruncationResult { + content: string; + truncated: boolean; + truncatedBy: "lines" | "bytes" | null; + totalLines: number; + totalBytes: number; + outputLines: number; + outputBytes: number; + lastLinePartial: boolean; + firstLineExceedsLimit: boolean; + maxLines: number; + maxBytes: number; +} + +export interface TruncationOptions { + maxLines?: number; + maxBytes?: number; +} + +export function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes}B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +export function truncateHead( + content: string, + options: TruncationOptions = {}, +): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const totalBytes = Buffer.byteLength(content, "utf-8"); + const lines = content.split("\n"); + const totalLines = lines.length; + + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + const firstLineBytes = Buffer.byteLength(lines[0]!, "utf-8"); + if (firstLineBytes > maxBytes) { + return { + content: "", + truncated: true, + truncatedBy: "bytes", + totalLines, + totalBytes, + outputLines: 0, + outputBytes: 0, + lastLinePartial: false, + firstLineExceedsLimit: true, + maxLines, + maxBytes, + }; + } + + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + + for (let i = 0; i < lines.length && i < maxLines; i++) { + const line = lines[i]!; + const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + break; + } + outputLinesArr.push(line); + outputBytesCount += lineBytes; + } + + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8"); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +function truncateStringToBytesFromEnd( + str: string, + maxBytes: number, +): string { + const buf = Buffer.from(str, "utf-8"); + if (buf.length <= maxBytes) return str; + let end = buf.length; + let start = end - maxBytes; + // Walk forward to a valid UTF-8 boundary + while (start < end && (buf[start]! & 0xc0) === 0x80) { + start++; + } + return buf.subarray(start, end).toString("utf-8"); +} + +export function truncateTail( + content: string, + options: TruncationOptions = {}, +): TruncationResult { + const maxLines = options.maxLines ?? DEFAULT_MAX_LINES; + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const totalBytes = Buffer.byteLength(content, "utf-8"); + const lines = content.split("\n"); + const totalLines = lines.length; + + if (totalLines <= maxLines && totalBytes <= maxBytes) { + return { + content, + truncated: false, + truncatedBy: null, + totalLines, + totalBytes, + outputLines: totalLines, + outputBytes: totalBytes, + lastLinePartial: false, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; + } + + const outputLinesArr: string[] = []; + let outputBytesCount = 0; + let truncatedBy: "lines" | "bytes" = "lines"; + + for (let i = lines.length - 1; i >= 0 && outputLinesArr.length < maxLines; i--) { + const line = lines[i]!; + const lineBytes = + Buffer.byteLength(line, "utf-8") + (outputLinesArr.length > 0 ? 1 : 0); + if (outputBytesCount + lineBytes > maxBytes) { + truncatedBy = "bytes"; + break; + } + outputLinesArr.unshift(line); + outputBytesCount += lineBytes; + } + + if (outputLinesArr.length >= maxLines && outputBytesCount <= maxBytes) { + truncatedBy = "lines"; + } + + let lastLinePartial = false; + + // If we couldn't fit any lines (last line alone exceeds byte limit), + // take partial content from the end. + if (outputLinesArr.length === 0) { + const lastLine = lines[lines.length - 1]!; + const partial = truncateStringToBytesFromEnd(lastLine, maxBytes); + outputLinesArr.push(partial); + outputBytesCount = Buffer.byteLength(partial, "utf-8"); + truncatedBy = "bytes"; + lastLinePartial = true; + } + + const outputContent = outputLinesArr.join("\n"); + const finalOutputBytes = Buffer.byteLength(outputContent, "utf-8"); + + return { + content: outputContent, + truncated: true, + truncatedBy, + totalLines, + totalBytes, + outputLines: outputLinesArr.length, + outputBytes: finalOutputBytes, + lastLinePartial, + firstLineExceedsLimit: false, + maxLines, + maxBytes, + }; +} + +export function truncateLine( + line: string, + maxChars: number = GREP_MAX_LINE_LENGTH, +): { text: string; wasTruncated: boolean } { + if (line.length <= maxChars) { + return { text: line, wasTruncated: false }; + } + return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true }; +} diff --git a/src/workspace/content.ts b/src/workspace/content.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c30a22eec9948b8a5af004540997967ce96fac8 --- /dev/null +++ b/src/workspace/content.ts @@ -0,0 +1,24 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +export interface WorkspaceContent { + filePath: string; + content: string; + bytes: number; +} + +export async function writeWorkspaceFile( + workspacePath: string, + relativePath: string, + content: string, +): Promise { + const filePath = join(workspacePath, relativePath); + await mkdir(dirname(filePath), { recursive: true }); + await writeFile(filePath, content, "utf8"); + + return { + filePath, + content, + bytes: Buffer.byteLength(content, "utf8"), + }; +} diff --git a/src/workspace/manager.ts b/src/workspace/manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..75ea639f57c5f9458e125c5da1158701e1250186 --- /dev/null +++ b/src/workspace/manager.ts @@ -0,0 +1,29 @@ +import { mkdtemp, rm, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { WorkspaceError } from "../util/errors.js"; + +export interface Workspace { + path: string; + cleanup: () => Promise; +} + +export interface WorkspaceOptions { + cleanup: boolean; +} + +export async function createWorkspace(options: WorkspaceOptions): Promise { + 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)}`); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..ac242056f8e59db0f2be9fab23d4177c8f92e6c4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "resolveJsonModule": true, + "allowJs": false, + "types": ["bun-types"], + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true + }, + "include": ["src", "test"], + "exclude": ["dist"] +}