From 3f1527375cd7bae2a9c9aa5fe0bd25580135507c Mon Sep 17 00:00:00 2001 From: kim Date: Mon, 6 Jul 2026 17:53:27 +0900 Subject: [PATCH] Add web search/fetch, sub-agents, MCP support, git tools, session persistence, and context compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substantially expands locode's tool loop toward feature parity with Claude Code: - web_search / web_fetch: DuckDuckGo-backed search and URL fetching (HTML stripped to text) - git_status / git_commit: structured git tools with real diff/status/commit-list previews before any mutating operation is confirmed - agent tool: sub-agents run an isolated, headless tool loop sharing the parent's permissions; bounded by an explicit depth guard (no nested sub-agents) and a 120s hard timeout, independent of the toolset already excluding `agent` for sub-agent sessions - MCP client support (stdio + streamable HTTP transports) via the official SDK: locode connects to servers configured in `.mcp.json` or via `locode mcp add`, and exposes their tools alongside the built-in ones, namespaced as `mcp____` - Session persistence: conversations auto-save after every turn; `--continue`/`--resume`, `locode sessions list/rm`, and an interactive resume picker - Context compaction: tracks real token usage via `stream_options.include_usage` (falling back to a char-based estimate), auto-detects the model's context window (Ollama/LM Studio native APIs, cached, else a configurable default), and auto-summarizes the conversation at 85% usage or on demand via /compact - Status bar and welcome banner: added a context-usage indicator and fixed several dim-by-default colors that were hard to read Session/loop architecture changed to support all of this: Session now carries its own toolset (tools + registry + OpenAI schemas) built from local + MCP tools, so runTurn no longer depends on a fixed global tool list — the same mechanism that lets sub-agents inherit MCP tools while excluding `agent` itself. Co-Authored-By: Claude Sonnet 5 --- README.md | 46 +- package-lock.json | 1055 ++++++++++++++++++++++++++++- package.json | 1 + src/agent/loop.ts | 168 ++++- src/agent/session.ts | 91 ++- src/backend/contextWindow.ts | 74 ++ src/backend/contextWindowCache.ts | 45 ++ src/cli.ts | 167 ++++- src/config/config.ts | 11 +- src/config/defaults.ts | 4 + src/config/store.ts | 2 + src/mcp/client.ts | 39 ++ src/mcp/config.ts | 51 ++ src/mcp/manager.ts | 54 ++ src/mcp/toolAdapter.ts | 43 ++ src/mcp/types.ts | 20 + src/persistence/sessionStore.ts | 93 +++ src/toolcalling/nativeAdapter.ts | 2 +- src/tools/agentTool.ts | 30 + src/tools/git.ts | 150 ++++ src/tools/index.ts | 18 +- src/tools/toolset.ts | 15 + src/tools/types.ts | 12 + src/tools/webFetch.ts | 54 ++ src/tools/webSearch.ts | 71 ++ src/ui/ink/App.tsx | 262 ++++++- src/ui/ink/HistoryItemView.tsx | 65 +- src/ui/ink/SessionSelect.tsx | 43 ++ src/ui/ink/StatusBar.tsx | 37 +- src/ui/ink/index.tsx | 14 +- src/ui/ink/types.ts | 22 +- src/ui/toolSummary.ts | 22 + src/utils/html.ts | 39 ++ src/utils/tokens.ts | 8 + 34 files changed, 2751 insertions(+), 77 deletions(-) create mode 100644 src/backend/contextWindow.ts create mode 100644 src/backend/contextWindowCache.ts create mode 100644 src/mcp/client.ts create mode 100644 src/mcp/config.ts create mode 100644 src/mcp/manager.ts create mode 100644 src/mcp/toolAdapter.ts create mode 100644 src/mcp/types.ts create mode 100644 src/persistence/sessionStore.ts create mode 100644 src/tools/agentTool.ts create mode 100644 src/tools/git.ts create mode 100644 src/tools/toolset.ts create mode 100644 src/tools/webFetch.ts create mode 100644 src/tools/webSearch.ts create mode 100644 src/ui/ink/SessionSelect.tsx create mode 100644 src/utils/html.ts create mode 100644 src/utils/tokens.ts diff --git a/README.md b/README.md index 612dd2d..493ef48 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,48 @@ List models available from the configured backend: locode models ``` +Every conversation is auto-saved as you go. Resume later: + +```sh +locode --continue # resume the most recent conversation +locode --resume # pick from a list of saved conversations +locode --resume # resume a specific one +locode sessions list # see saved conversations (id, model, title) without starting the UI +locode sessions rm # delete a saved conversation +``` + +locode can also use tools from external [MCP](https://modelcontextprotocol.io) servers: + +```sh +locode mcp add my-server --command npx --arg -y --arg @some/mcp-server # stdio server +locode mcp add my-remote --url https://example.com/mcp # remote (streamable HTTP) server +locode mcp list # see configured servers (user-level + project .mcp.json) +locode mcp remove +``` + +Project-level servers can also be checked into a repo via a `.mcp.json` file in its root: + +```json +{ + "mcpServers": { + "my-server": { "command": "npx", "args": ["-y", "@some/mcp-server"] } + } +} +``` + +locode connects to every configured server on startup; their tools show up alongside the built-in ones, namespaced as `mcp____`. + ## How it works locode is a full-screen terminal app built with [Ink](https://github.com/vadimdemedes/ink) (the same React-for-CLI framework Claude Code itself is built with) — it needs a real interactive terminal (piped/redirected input isn't supported). It takes over the terminal's alternate screen buffer (like `vim`/`htop`) — your prior scrollback is restored when you exit. The input box is always pinned to the last row of the window; the conversation fills the space above it and old messages scroll off the top as new ones arrive. Press Ctrl+C or type `/exit` to quit. - **Backends**: `--backend ollama` (default) or `--backend lmstudio`, or `--base-url ` for anything else that speaks the same API. -- **Tools**: `read_file`, `list_files`, `grep` run automatically. `write_file`, `edit_file`, and `bash` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running. +- **Tools**: `read_file`, `list_files`, `grep`, `web_search`, `web_fetch`, `git_status` run automatically. `write_file`, `edit_file`, `bash`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running. +- **Git**: `git_status` covers read-only inspection (`status`, `diff`, `log`, `show`, `branches`) and runs automatically. `git_commit` covers `add`, `commit`, `create_branch`, `checkout`, and `push` — each shows the actual diff/status/commits it's about to affect before you confirm (e.g. a commit's preview is the staged diff plus the message, a push's preview is the list of commits it would send). +- **Sub-agents**: the `agent` tool lets the model delegate a self-contained task to a fresh, isolated tool loop (same tools, minus `agent` itself — no nested sub-agents) and get back only the final answer, keeping the main conversation's context focused. It shows up as a single `⏺ Agent(description)` / `⎿ Sub-agent finished (...)` line — the sub-agent's own intermediate steps aren't displayed. Any mutating tool calls it makes still go through the same permission prompts as the main conversation. +- **MCP servers**: locode connects to any [MCP](https://modelcontextprotocol.io) servers configured via `locode mcp add` or a project's `.mcp.json` (stdio and remote/streamable-HTTP transports), and adds their tools to every session, namespaced as `mcp____`. A server's tool is treated as mutating (confirmation required) unless it declares itself read-only via the MCP `readOnlyHint` annotation. One misconfigured server doesn't block the others — check `/mcp` for per-server connection status. - **Tool-calling mode**: on connect, locode probes whether the model reliably uses native OpenAI-style function calling. If not, it switches to a prompt-based fallback mode where the model is instructed to emit tool calls as fenced ` ```tool_call ``` ` JSON blocks, which locode parses itself. The result is cached per backend+model so future sessions skip the probe. Override with `--tool-mode native|fallback|auto` or the in-session `/mode` command. +- **Context tracking & compaction**: the status bar shows `ctx NN%` — context window usage, from real `usage.prompt_tokens` when the backend reports it (requested via `stream_options.include_usage`), or a `~`-prefixed char-based estimate otherwise. The window size itself is auto-detected (Ollama's `/api/show`, then LM Studio's `/api/v0/models`) and cached per backend+model; falls back to a configurable default (`locode config set contextWindow `, or `$LOCODE_CONTEXT_WINDOW`) if neither responds. At 85% usage, locode automatically asks the model to summarize the conversation and replaces the history with that summary (a notice tells you when this happens) — or trigger it yourself anytime with `/compact`. Note: even models with genuine native tool-calling support occasionally emit a tool call as plain text instead of a real structured call — this is model sampling variance, not a bug. If a turn seems to "describe" a tool call instead of running it, just ask again or try `/mode fallback`. @@ -53,6 +88,9 @@ Note: even models with genuine native tool-calling support occasionally emit a t /status show current model, backend, tool-call mode, and cwd /tools list available tools /permissions list mutating tools allowed for the rest of this session +/sessions list saved conversations you can resume with --resume +/mcp show connected MCP servers and their tool counts +/compact summarize the conversation now to free up context /clear clear conversation history /help show this help /exit, /quit exit @@ -65,6 +103,7 @@ Config precedence: CLI flags > env vars (`LOCODE_BACKEND`, `LOCODE_MODEL`, `LOCO ```sh locode config set backend ollama locode config set model qwen3-coder:30b +locode config set contextWindow 32768 # fallback size when auto-detection fails locode config get locode config path ``` @@ -74,6 +113,9 @@ locode config path - Requires a real interactive terminal (TTY) — you can't pipe input into it or run it from a non-interactive script. - Native tool-calling reliability varies by model and is non-deterministic even for capable models (see above). - No sandboxing beyond the confirmation prompts — mutating tools operate on the real filesystem/shell with the permissions of the user running `locode`. Only approve commands you understand. -- No session save/resume yet — each `locode` run starts a fresh conversation. +- Session resume replays prior user/assistant text so you can see it, but it doesn't re-display prior tool-call/tool-result lines from before the resume (the model still has that history — it's just not re-rendered). - No in-app scrollback — once a message scrolls off the top of the window it's gone until you resize the terminal taller (the conversation itself is still intact and sent to the model; this only affects what you can visually re-read). - Windows shell quoting for the `bash` tool has only had light testing; behavior may differ from Unix shells for complex quoting. +- `git_commit` covers add/commit/create_branch/checkout/push only — no reset, stash, merge, rebase, or branch deletion. Use `bash` for anything beyond that. +- MCP tool results only support text content — image/audio/resource content blocks are shown as a placeholder note rather than rendered. Remote (HTTP) MCP servers support static headers (e.g. a bearer token) but not OAuth flows. +- Compaction (`/compact` or automatic at 85%) replaces history with a model-generated prose summary — it costs one extra model call and loses tool-call/tool-result detail (the model's own account of what happened survives; the raw record doesn't). The 85% threshold isn't currently configurable. diff --git a/package-lock.json b/package-lock.json index 05dac60..f047960 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "locode", "version": "0.1.0", "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@vscode/ripgrep": "^1.18.0", "commander": "^13.0.0", "diff": "^9.0.0", @@ -532,6 +533,18 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -571,6 +584,46 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1355,6 +1408,19 @@ "win32" ] }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", @@ -1368,6 +1434,39 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -1438,6 +1537,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1466,6 +1602,15 @@ "esbuild": ">=0.18" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1476,6 +1621,35 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1745,6 +1919,28 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -1754,6 +1950,41 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1779,7 +2010,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1803,6 +2033,15 @@ "node": ">=6" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/diff": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", @@ -1812,6 +2051,26 @@ "node": ">=0.3.1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -1824,6 +2083,15 @@ "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/env-paths": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-4.0.0.tgz", @@ -1851,6 +2119,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1858,6 +2144,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-toolkit": { "version": "1.49.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", @@ -1919,6 +2217,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", @@ -1938,6 +2242,36 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/execa/-/execa-9.6.1.tgz", @@ -1974,6 +2308,73 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -1990,6 +2391,22 @@ "node": ">=8.6.0" } }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2044,6 +2461,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -2056,6 +2494,24 @@ "rollup": "^4.34.8" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2071,6 +2527,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2092,6 +2557,43 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", @@ -2120,6 +2622,18 @@ "node": ">= 6" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2129,6 +2643,30 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -2138,6 +2676,35 @@ "node": "*" } }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/human-signals": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", @@ -2147,6 +2714,22 @@ "node": ">=18.18.0" } }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -2159,6 +2742,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ink": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.0.tgz", @@ -2393,6 +2982,24 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2459,6 +3066,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-safe-filename": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-safe-filename/-/is-safe-filename-0.1.1.tgz", @@ -2501,6 +3114,15 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -2518,6 +3140,18 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2598,6 +3232,36 @@ "marked": ">=1 <16" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2632,6 +3296,31 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -2658,7 +3347,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -2691,6 +3379,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-emoji": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", @@ -2743,6 +3440,39 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/openai": { "version": "6.45.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.45.0.tgz", @@ -2806,6 +3536,15 @@ "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", "license": "MIT" }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/patch-console": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", @@ -2824,6 +3563,16 @@ "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2871,6 +3620,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -2970,6 +3728,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2990,6 +3777,34 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -3037,6 +3852,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -3102,6 +3926,22 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -3125,12 +3965,69 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3152,6 +4049,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3265,6 +4234,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -3519,6 +4497,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -4104,6 +5091,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -4153,6 +5171,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -4408,6 +5444,12 @@ "node": ">=8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -4526,6 +5568,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index d04c9a5..def1321 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "prepublishOnly": "npm run build" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@vscode/ripgrep": "^1.18.0", "commander": "^13.0.0", "diff": "^9.0.0", diff --git a/src/agent/loop.ts b/src/agent/loop.ts index 5d1cbfd..e79eb68 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -1,19 +1,89 @@ +import { randomUUID } from "node:crypto"; import type { ChatCompletionChunk } from "openai/resources/chat/completions"; +import type { CompletionUsage } from "openai/resources/completions"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; import type { AgentEventHandler } from "./events.js"; -import { TOOL_REGISTRY, TOOLS } from "../tools/index.js"; +import { buildToolSet, type ToolSet } from "../tools/toolset.js"; +import type { SubAgentTask } from "../tools/types.js"; import { FALLBACK_RETRY_NUDGE } from "../toolcalling/fallbackPrompt.js"; import { parseFallbackToolCalls } from "../toolcalling/fallbackParser.js"; -import { resolveToolCall, toOpenAITools } from "../toolcalling/nativeAdapter.js"; +import { resolveToolCall } from "../toolcalling/nativeAdapter.js"; import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js"; import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js"; +import { estimateTokens } from "../utils/tokens.js"; +import { buildSystemPrompt } from "./systemPrompt.js"; import type { Session } from "./session.js"; export class AgentError extends Error {} -const openaiTools = toOpenAITools(TOOLS); const MAX_MALFORMED_RETRIES = 2; +// Sub-agent safety limits. The toolset already excludes `agent` for sub-agents (so a model can't +// spawn nested sub-agents through normal tool use), but these are independent, explicit backstops +// that hold even if that filtering were ever bypassed or a future change loosened it. +const MAX_SUBAGENT_DEPTH = 1; +const SUBAGENT_TIMEOUT_MS = 120_000; + +/** Fraction of the context window at which the caller should auto-compact (see shouldAutoCompact). */ +export const AUTO_COMPACT_THRESHOLD = 0.85; + +function updateContextTracking(session: Session, promptTokens: number | null | undefined): void { + if (typeof promptTokens === "number") { + session.lastContextTokens = promptTokens; + session.lastContextTokensIsEstimate = false; + } else { + session.lastContextTokens = estimateTokens(session.messages); + session.lastContextTokensIsEstimate = true; + } +} + +export function contextUsageRatio(session: Session): number { + return session.lastContextTokens / session.contextWindow; +} + +export function shouldAutoCompact(session: Session): boolean { + return contextUsageRatio(session) >= AUTO_COMPACT_THRESHOLD; +} + +/** + * Replaces the conversation history with a model-generated summary of everything so far, to free + * up context. Runs as a plain (non-tool-calling) request so the model just produces prose, not + * more tool calls. The new history is just [system, a synthetic assistant "recap"] — framing the + * summary as an assistant turn keeps proper role alternation with whatever real user message + * follows next. + */ +export async function compactSession(session: Session): Promise { + const requestMessages: ChatCompletionMessageParam[] = [ + ...session.messages, + { + role: "user", + content: + "Summarize this entire conversation so far, concisely but completely: the user's goals, key decisions " + + "made, files created/changed and why, current task state, and anything still outstanding. Write it as " + + "background context for continuing the conversation — plain prose, no meta-commentary about summarizing.", + }, + ]; + + const res = await session.client.chat.completions.create({ + model: session.model, + messages: requestMessages, + stream: false, + max_tokens: 1024, + }); + const summary = res.choices[0]?.message?.content; + if (!summary) { + throw new AgentError("Compaction failed: the model returned no summary."); + } + + session.messages = [ + { role: "system", content: buildSystemPrompt(session.toolset.tools, session.mode) }, + { role: "assistant", content: `[Earlier conversation compacted to save context]\n\n${summary}` }, + ]; + // res.usage describes the old (now-discarded) prompt, not the new shorter history — estimate fresh. + session.lastContextTokens = estimateTokens(session.messages); + session.lastContextTokensIsEstimate = true; +} + /** Accumulates streaming tool-call deltas into complete tool calls. */ interface AccumulatedToolCall { id: string; @@ -35,7 +105,7 @@ async function gateAndRun( const { tool, args } = resolved; emit({ type: "tool_call", label: formatCallLabel(tool.name, args) }); - const ctx = { cwd: session.cwd }; + const ctx = { cwd: session.cwd, runSubAgent: (task: SubAgentTask) => runSubAgentTurn(session, task) }; if (tool.mutating && !session.permissions.isAutoApproved(tool.name)) { const preview = tool.preview ? await tool.preview(args, ctx) : undefined; @@ -55,6 +125,64 @@ async function gateAndRun( return result; } +/** + * Runs a sub-agent as a fresh, isolated turn loop that shares the parent's client/model/cwd/ + * permissions, but starts with no conversation history beyond the delegated task. Runs headless — + * its own streaming text and tool calls aren't surfaced to the UI, only its final text is (via the + * `agent` tool's result) — so mutating tool calls it makes still go through the shared + * PermissionManager/confirm and will still prompt the user, they just aren't shown mid-flight. + * + * Bounded by two independent safety limits (see MAX_SUBAGENT_DEPTH/SUBAGENT_TIMEOUT_MS above): + * an explicit depth check (on top of the toolset already excluding `agent` for sub-agents) and a + * hard wall-clock timeout, so a misbehaving model can't hang the parent turn indefinitely. + */ +async function runSubAgentTurn(parent: Session, task: SubAgentTask): Promise { + if (parent.subAgentDepth >= MAX_SUBAGENT_DEPTH) { + throw new Error(`Sub-agents cannot spawn further sub-agents (max depth ${MAX_SUBAGENT_DEPTH}).`); + } + + // Derived from the parent's own toolset (so MCP tools carry over) minus `agent` itself — + // sub-agents can't spawn further sub-agents, keeping recursion bounded to one level. + const subToolset = buildToolSet(parent.toolset.tools.filter((t) => t.name !== "agent")); + const subMessages: ChatCompletionMessageParam[] = [ + { + role: "system", + content: `${buildSystemPrompt(subToolset.tools, parent.mode)}\n\nYou are a sub-agent handling one focused task delegated by another assistant. Only the final text you return will be seen — not your intermediate tool calls — so make your answer complete and self-contained.`, + }, + ]; + const subSession: Session = { + id: randomUUID(), + createdAt: new Date().toISOString(), + client: parent.client, + model: parent.model, + cwd: parent.cwd, + mode: parent.mode, + messages: subMessages, + maxIterations: parent.maxIterations, + permissions: parent.permissions, + confirm: parent.confirm, + toolset: subToolset, + subAgentDepth: parent.subAgentDepth + 1, + contextWindow: parent.contextWindow, + contextWindowIsEstimate: parent.contextWindowIsEstimate, + lastContextTokens: estimateTokens(subMessages), + lastContextTokensIsEstimate: true, + }; + + let timeoutId: ReturnType; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`Sub-agent timed out after ${SUBAGENT_TIMEOUT_MS / 1000}s.`)), + SUBAGENT_TIMEOUT_MS, + ); + }); + try { + return await Promise.race([runTurn(subSession, task.prompt, () => {}, subToolset), timeout]); + } finally { + clearTimeout(timeoutId!); + } +} + /** * Process a completed (non-streaming) response — handles both native tool_calls * and plain text, shared by the streaming path (after accumulation) and the @@ -64,6 +192,7 @@ async function handleCompletedMessage( message: any, session: Session, emit: AgentEventHandler, + toolset: ToolSet, ): Promise<{ text: string; hadToolCalls: boolean; malformed?: boolean }> { // Native tool calls if (session.mode === "native" && message.tool_calls?.length) { @@ -74,7 +203,7 @@ async function handleCompletedMessage( } as ChatCompletionMessageParam); for (const call of message.tool_calls) { - const resolved = resolveToolCall(call as any, TOOL_REGISTRY); + const resolved = resolveToolCall(call as any, toolset.registry); const label = call.type === "function" ? `${call.function.name}(${call.function.arguments})` : call.type; const result = await gateAndRun(resolved, label, session, emit); session.messages.push({ @@ -96,7 +225,7 @@ async function handleCompletedMessage( if (parsed.calls.length) { session.messages.push({ role: "assistant", content: text }); for (const call of parsed.calls) { - const resolved = resolveToolInvocation(call.name, call.arguments, TOOL_REGISTRY); + const resolved = resolveToolInvocation(call.name, call.arguments, toolset.registry); const label = `${call.name}(${JSON.stringify(call.arguments)})`; const result = await gateAndRun(resolved, label, session, emit); session.messages.push({ @@ -116,7 +245,12 @@ async function handleCompletedMessage( return { text, hadToolCalls: false }; } -export async function runTurn(session: Session, userInput: string, emit: AgentEventHandler): Promise { +export async function runTurn( + session: Session, + userInput: string, + emit: AgentEventHandler, + toolset: ToolSet = session.toolset, +): Promise { session.messages.push({ role: "user", content: userInput }); let malformedRetries = 0; @@ -126,16 +260,23 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv let finishReason: string | null = null; const accumulatedToolCalls: AccumulatedToolCall[] = []; + let usage: CompletionUsage | undefined; + try { const stream = await session.client.chat.completions.create({ model: session.model, messages: session.messages, - tools: session.mode === "native" ? openaiTools : undefined, + tools: session.mode === "native" ? toolset.openaiTools : undefined, stream: true, + stream_options: { include_usage: true }, max_tokens: 4096, }); for await (const chunk of stream) { + // The usage-carrying final chunk has an empty `choices` array per spec — read it before + // the early-continue below would otherwise skip straight past it. + if (chunk.usage) usage = chunk.usage; + const choice = chunk.choices[0]; if (!choice) continue; @@ -177,6 +318,8 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv throw streamErr; } + updateContextTracking(session, usage?.prompt_tokens); + // --- Handle native tool calls from streaming --- if (session.mode === "native" && accumulatedToolCalls.length > 0) { // Validate accumulated arguments — if any fail to parse (Ollama streaming bug), @@ -196,12 +339,13 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv const res = await session.client.chat.completions.create({ model: session.model, messages: session.messages, - tools: openaiTools, + tools: toolset.openaiTools, stream: false, max_tokens: 4096, }); const message = res.choices[0]?.message; if (!message) throw new AgentError("Empty response from model."); + updateContextTracking(session, res.usage?.prompt_tokens); // If we streamed partial text, we need to tell the UI the text is done // (it may have been partially displayed). The non-streaming response @@ -210,7 +354,7 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv // The partial stream text is superseded by the non-streaming response } - const result = await handleCompletedMessage(message, session, emit); + const result = await handleCompletedMessage(message, session, emit, toolset); if (result.hadToolCalls) continue; // Non-tool-call text from the retry @@ -235,7 +379,7 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv for (const tc of accumulatedToolCalls) { const resolved = resolveToolCall( { id: tc.id, type: "function", function: { name: tc.name, arguments: tc.arguments } } as any, - TOOL_REGISTRY, + toolset.registry, ); const label = `${tc.name}(${tc.arguments})`; const result = await gateAndRun(resolved, label, session, emit); @@ -258,7 +402,7 @@ export async function runTurn(session: Session, userInput: string, emit: AgentEv emit({ type: "text_done", fullText }); session.messages.push({ role: "assistant", content: fullText }); for (const call of parsed.calls) { - const resolved = resolveToolInvocation(call.name, call.arguments, TOOL_REGISTRY); + const resolved = resolveToolInvocation(call.name, call.arguments, toolset.registry); const label = `${call.name}(${JSON.stringify(call.arguments)})`; const result = await gateAndRun(resolved, label, session, emit); session.messages.push({ diff --git a/src/agent/session.ts b/src/agent/session.ts index 36901e1..ba7abab 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -1,12 +1,20 @@ +import { randomUUID } from "node:crypto"; import type OpenAI from "openai"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; import type { ToolCallMode } from "../backend/capabilityProbe.js"; +import { DEFAULT_CONTEXT_WINDOW } from "../config/defaults.js"; +import type { SessionRecord } from "../persistence/sessionStore.js"; import { PermissionManager } from "../permissions/permissionManager.js"; import type { ConfirmFn } from "../permissions/types.js"; import { TOOLS } from "../tools/index.js"; +import { buildToolSet, type ToolSet } from "../tools/toolset.js"; +import type { ToolDef } from "../tools/types.js"; +import { estimateTokens } from "../utils/tokens.js"; import { buildSystemPrompt } from "./systemPrompt.js"; export interface Session { + id: string; + createdAt: string; client: OpenAI; model: string; cwd: string; @@ -15,6 +23,21 @@ export interface Session { maxIterations: number; permissions: PermissionManager; confirm: ConfirmFn; + /** Local tools plus any dynamically-discovered ones (currently: MCP) available for this session. */ + toolset: ToolSet; + /** 0 for a normal session; incremented for each level of sub-agent nesting (capped at + * MAX_SUBAGENT_DEPTH in agent/loop.ts, independent of the toolset already excluding `agent`). */ + subAgentDepth: number; + /** The model's context window in tokens — auto-detected where possible (backend/contextWindow.ts), + * otherwise a configured/hardcoded fallback (see contextWindowIsEstimate). */ + contextWindow: number; + /** True when `contextWindow` is a fallback rather than an actual value read from the backend. */ + contextWindowIsEstimate: boolean; + /** Tokens consumed by the last request's prompt (system + history), i.e. current context usage. + * From real `usage.prompt_tokens` when the backend reports it, else a char-based estimate. */ + lastContextTokens: number; + /** True when `lastContextTokens` is a char-based estimate rather than real usage stats. */ + lastContextTokensIsEstimate: boolean; } export function createSession( @@ -23,24 +46,88 @@ export function createSession( cwd: string, confirm: ConfirmFn, mode: ToolCallMode, + tools: ToolDef[] = TOOLS, + contextWindow: number = DEFAULT_CONTEXT_WINDOW, + contextWindowIsEstimate: boolean = true, ): Session { + const toolset = buildToolSet(tools); + const messages: ChatCompletionMessageParam[] = [{ role: "system", content: buildSystemPrompt(toolset.tools, mode) }]; return { + id: randomUUID(), + createdAt: new Date().toISOString(), client, model, cwd, mode, - messages: [{ role: "system", content: buildSystemPrompt(TOOLS, mode) }], + messages, maxIterations: 8, permissions: new PermissionManager(), confirm, + toolset, + subAgentDepth: 0, + contextWindow, + contextWindowIsEstimate, + lastContextTokens: estimateTokens(messages), + lastContextTokensIsEstimate: true, + }; +} + +/** Rebuilds the system prompt from the current tool list rather than trusting the saved one, + * so a resumed session always reflects whatever tools locode has today (including MCP tools). */ +export function createSessionFromRecord( + client: OpenAI, + record: SessionRecord, + cwd: string, + confirm: ConfirmFn, + tools: ToolDef[] = TOOLS, + contextWindow: number = DEFAULT_CONTEXT_WINDOW, + contextWindowIsEstimate: boolean = true, +): Session { + const toolset = buildToolSet(tools); + const messages: ChatCompletionMessageParam[] = [ + { role: "system", content: buildSystemPrompt(toolset.tools, record.mode) }, + ...record.messages, + ]; + return { + id: record.id, + createdAt: record.createdAt, + client, + model: record.model, + cwd, + mode: record.mode, + messages, + maxIterations: 8, + permissions: new PermissionManager(), + confirm, + toolset, + subAgentDepth: 0, + contextWindow, + contextWindowIsEstimate, + lastContextTokens: estimateTokens(messages), + lastContextTokensIsEstimate: true, + }; +} + +export function toSessionRecord(session: Session, baseURL: string): SessionRecord { + return { + id: session.id, + createdAt: session.createdAt, + updatedAt: new Date().toISOString(), + cwd: session.cwd, + baseURL, + model: session.model, + mode: session.mode, + messages: session.messages.slice(1), }; } export function resetSession(session: Session): void { session.messages = [session.messages[0] as ChatCompletionMessageParam]; + session.lastContextTokens = estimateTokens(session.messages); + session.lastContextTokensIsEstimate = true; } export function setMode(session: Session, mode: ToolCallMode): void { session.mode = mode; - session.messages[0] = { role: "system", content: buildSystemPrompt(TOOLS, mode) }; + session.messages[0] = { role: "system", content: buildSystemPrompt(session.toolset.tools, mode) }; } diff --git a/src/backend/contextWindow.ts b/src/backend/contextWindow.ts new file mode 100644 index 0000000..39725bb --- /dev/null +++ b/src/backend/contextWindow.ts @@ -0,0 +1,74 @@ +import { resolveContextWindowDefault } from "../config/config.js"; +import { getCachedContextWindow, setCachedContextWindow } from "./contextWindowCache.js"; + +function stripV1(baseURL: string): string { + return baseURL.replace(/\/v1\/?$/, ""); +} + +/** Ollama's native (non-OpenAI-compatible) endpoint exposes the model's real context length, + * nested under an architecture-prefixed key (e.g. "llama.context_length") that we have to look + * up dynamically via the architecture name it also reports. */ +async function detectOllamaContextWindow(baseURL: string, model: string): Promise { + try { + const res = await fetch(`${stripV1(baseURL)}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + }); + if (!res.ok) return null; + const data = (await res.json()) as { model_info?: Record }; + const info = data.model_info; + if (!info) return null; + const architecture = info["general.architecture"]; + if (typeof architecture !== "string") return null; + const contextLength = info[`${architecture}.context_length`]; + return typeof contextLength === "number" ? contextLength : null; + } catch { + return null; + } +} + +/** LM Studio's native REST API (distinct from its OpenAI-compatible one) reports the context + * length a loaded model was actually started with, or its maximum otherwise. */ +async function detectLmStudioContextWindow(baseURL: string, model: string): Promise { + try { + const res = await fetch(`${stripV1(baseURL)}/api/v0/models`); + if (!res.ok) return null; + const data = (await res.json()) as { data?: Array> }; + const match = data.data?.find((m) => m.id === model); + const length = match?.loaded_context_length ?? match?.max_context_length; + return typeof length === "number" ? length : null; + } catch { + return null; + } +} + +/** Best-effort: tries each backend's own way of reporting context length in turn, since we can't + * assume which one is actually running behind an OpenAI-compatible baseURL. Returns null (rather + * than guessing) if neither responds usefully — callers should fall back to a configured default. */ +export async function detectContextWindow(baseURL: string, model: string): Promise { + const ollama = await detectOllamaContextWindow(baseURL, model); + if (ollama !== null) return ollama; + return detectLmStudioContextWindow(baseURL, model); +} + +export interface ResolvedContextWindow { + value: number; + /** false only when this came from an actual backend lookup, not the configured/hardcoded default. */ + isEstimate: boolean; +} + +/** Cache-then-detect-then-default resolution, so repeat sessions with the same backend+model skip + * the network round-trip. */ +export async function resolveContextWindow(baseURL: string, model: string): Promise { + const cached = getCachedContextWindow(baseURL, model); + if (cached !== undefined) return { value: cached, isEstimate: false }; + + const detected = await detectContextWindow(baseURL, model); + if (detected !== null) { + setCachedContextWindow(baseURL, model, detected); + return { value: detected, isEstimate: false }; + } + + return { value: resolveContextWindowDefault(), isEstimate: true }; +} diff --git a/src/backend/contextWindowCache.ts b/src/backend/contextWindowCache.ts new file mode 100644 index 0000000..e292601 --- /dev/null +++ b/src/backend/contextWindowCache.ts @@ -0,0 +1,45 @@ +import envPaths from "env-paths"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const paths = envPaths("locode", { suffix: "" }); +const cacheFile = path.join(paths.config, "context-windows.json"); + +type Cache = Record; + +function keyFor(baseURL: string, model: string): string { + return `${baseURL}::${model}`; +} + +let memoryCache: Cache | null = null; + +function load(): Cache { + if (memoryCache) return memoryCache; + if (!existsSync(cacheFile)) { + memoryCache = {}; + return memoryCache; + } + try { + memoryCache = JSON.parse(readFileSync(cacheFile, "utf-8")) as Cache; + return memoryCache; + } catch { + memoryCache = {}; + return memoryCache; + } +} + +function save(cache: Cache): void { + memoryCache = cache; + mkdirSync(paths.config, { recursive: true }); + writeFileSync(cacheFile, JSON.stringify(cache, null, 2)); +} + +export function getCachedContextWindow(baseURL: string, model: string): number | undefined { + return load()[keyFor(baseURL, model)]; +} + +export function setCachedContextWindow(baseURL: string, model: string, contextWindow: number): void { + const cache = load(); + cache[keyFor(baseURL, model)] = contextWindow; + save(cache); +} diff --git a/src/cli.ts b/src/cli.ts index 7db5f38..77118e7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,6 +2,9 @@ import { Command } from "commander"; import { makeClient } from "./backend/client.js"; import { ConfigError, resolveBackendConfig, resolveModel } from "./config/config.js"; import { configFilePath, loadStoredConfig, saveStoredConfig, type StoredConfig } from "./config/store.js"; +import { loadMergedServers, removeUserServer, saveUserServer, userMcpFilePath } from "./mcp/config.js"; +import { isHttpServerConfig } from "./mcp/types.js"; +import { deleteSession, listSessions, mostRecentSessionId, sessionsDir } from "./persistence/sessionStore.js"; import { runInkApp } from "./ui/ink/index.js"; const program = new Command(); @@ -24,6 +27,8 @@ addBackendOptions(program) "native | fallback | auto — auto defaults to native, probing only on /model and /backend", "auto", ) + .option("-c, --continue", "resume the most recently saved conversation") + .option("-r, --resume [id]", "resume a saved conversation by id, or pick interactively if no id is given") .action(async (opts) => { try { const { baseURL } = resolveBackendConfig(opts); @@ -31,7 +36,32 @@ addBackendOptions(program) const model: string | undefined = opts.model; const suggestedModel = model ? undefined : resolveModel(undefined); const toolModeOverride = opts.toolMode === "native" || opts.toolMode === "fallback" ? opts.toolMode : undefined; - await runInkApp({ baseURL, model, suggestedModel, cwd: process.cwd(), toolModeOverride }); + + let resumeSessionId: string | undefined; + let interactiveResume = false; + if (opts.continue) { + resumeSessionId = mostRecentSessionId(); + if (!resumeSessionId) { + console.error("No saved sessions found."); + process.exit(1); + } + } else if (opts.resume !== undefined) { + if (typeof opts.resume === "string" && opts.resume.length > 0) { + resumeSessionId = opts.resume; + } else { + interactiveResume = true; + } + } + + await runInkApp({ + baseURL, + model, + suggestedModel, + cwd: process.cwd(), + toolModeOverride, + resumeSessionId, + interactiveResume, + }); } catch (err) { if (err instanceof ConfigError) { console.error(err.message); @@ -78,14 +108,23 @@ configCmd configCmd .command("set ") - .description("Persist a config value (backend, model, baseUrl)") + .description("Persist a config value (backend, model, baseUrl, contextWindow)") .action((key: string, value: string) => { - if (key !== "backend" && key !== "model" && key !== "baseUrl") { - console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl`); + if (key !== "backend" && key !== "model" && key !== "baseUrl" && key !== "contextWindow") { + console.error(`Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow`); process.exit(1); } const stored = loadStoredConfig(); - stored[key] = value; + if (key === "contextWindow") { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) { + console.error(`contextWindow must be a positive number, got "${value}".`); + process.exit(1); + } + stored.contextWindow = n; + } else { + stored[key] = value; + } saveStoredConfig(stored); console.log(`Set ${key} = ${value}`); }); @@ -97,4 +136,122 @@ configCmd console.log(configFilePath()); }); +const sessionsCmd = program.command("sessions").description("Manage saved conversations"); + +sessionsCmd + .command("list") + .description("List saved conversations, most recent first") + .action(() => { + const sessions = listSessions(); + if (sessions.length === 0) { + console.log("No saved sessions found."); + return; + } + for (const s of sessions) { + console.log(`${s.id} [${s.updatedAt}] (${s.model}, ${s.messageCount} msgs) ${s.title}`); + } + }); + +sessionsCmd + .command("rm ") + .description("Delete a saved conversation") + .action((id: string) => { + if (deleteSession(id)) { + console.log(`Deleted session ${id}.`); + } else { + console.error(`No saved session found with id "${id}".`); + process.exit(1); + } + }); + +sessionsCmd + .command("path") + .description("Print the directory where sessions are stored") + .action(() => { + console.log(sessionsDir()); + }); + +function collect(value: string, previous: string[]): string[] { + previous.push(value); + return previous; +} + +function parseKeyValueList(pairs: string[], separator: string): Record { + const result: Record = {}; + for (const pair of pairs) { + const idx = pair.indexOf(separator); + if (idx === -1) { + console.error(`Expected "KEY${separator}VALUE", got "${pair}".`); + process.exit(1); + } + result[pair.slice(0, idx)] = pair.slice(idx + 1); + } + return result; +} + +const mcpCmd = program + .command("mcp") + .description("Manage MCP servers (add/remove apply to your user-level config; project .mcp.json is read too)"); + +mcpCmd + .command("add ") + .description("Add an MCP server: stdio (--command) or remote (--url)") + .option("--command ", "command to spawn for a stdio server") + .option("--arg ", "argument to pass to the command (repeatable)", collect, [] as string[]) + .option("--env ", "environment variable for the spawned command (repeatable)", collect, [] as string[]) + .option("--url ", "URL for a remote (streamable HTTP) server") + .option("--header ", "HTTP header for a remote server (repeatable)", collect, [] as string[]) + .action((name: string, opts) => { + if (opts.command && opts.url) { + console.error("Specify either --command (stdio) or --url (remote), not both."); + process.exit(1); + } + if (opts.url) { + saveUserServer(name, { url: opts.url, headers: parseKeyValueList(opts.header, ":") }); + } else if (opts.command) { + saveUserServer(name, { command: opts.command, args: opts.arg, env: parseKeyValueList(opts.env, "=") }); + } else { + console.error("Specify --command for a stdio server or --url for a remote server."); + process.exit(1); + } + console.log(`Added MCP server "${name}".`); + }); + +mcpCmd + .command("remove ") + .description("Remove a user-level MCP server") + .action((name: string) => { + if (removeUserServer(name)) { + console.log(`Removed MCP server "${name}".`); + } else { + console.error(`No user-level MCP server found with name "${name}".`); + process.exit(1); + } + }); + +mcpCmd + .command("list") + .description("List configured MCP servers (user-level + project .mcp.json)") + .action(() => { + const servers = loadMergedServers(process.cwd()); + const names = Object.keys(servers); + if (names.length === 0) { + console.log("No MCP servers configured."); + return; + } + for (const name of names) { + const cfg = servers[name]!; + console.log( + isHttpServerConfig(cfg) ? `${name} [http] ${cfg.url}` : `${name} [stdio] ${cfg.command} ${(cfg.args ?? []).join(" ")}`, + ); + } + }); + +mcpCmd + .command("path") + .description("Print the path to the user-level MCP config file") + .action(() => { + console.log(userMcpFilePath()); + }); + program.parseAsync(); diff --git a/src/config/config.ts b/src/config/config.ts index 1b2f9ba..283f76f 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1,4 +1,4 @@ -import { KNOWN_BACKENDS, type BackendName } from "./defaults.js"; +import { DEFAULT_CONTEXT_WINDOW, KNOWN_BACKENDS, type BackendName } from "./defaults.js"; import { loadStoredConfig } from "./store.js"; export class ConfigError extends Error {} @@ -32,3 +32,12 @@ export function resolveModel(cliModel?: string): string | undefined { const stored = loadStoredConfig(); return cliModel ?? process.env.LOCODE_MODEL ?? stored.model; } + +/** The fallback context window size to use when it can't be auto-detected from the backend. */ +export function resolveContextWindowDefault(): number { + const stored = loadStoredConfig(); + const envValue = Number(process.env.LOCODE_CONTEXT_WINDOW); + if (Number.isFinite(envValue) && envValue > 0) return envValue; + if (typeof stored.contextWindow === "number" && stored.contextWindow > 0) return stored.contextWindow; + return DEFAULT_CONTEXT_WINDOW; +} diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 441abfe..91a442d 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -7,3 +7,7 @@ export const KNOWN_BACKENDS = { } as const; export type BackendName = keyof typeof KNOWN_BACKENDS; + +/** Used when the context window can't be auto-detected from the backend (see backend/contextWindow.ts) + * and the user hasn't configured one — a conservative size common among smaller local models. */ +export const DEFAULT_CONTEXT_WINDOW = 8192; diff --git a/src/config/store.ts b/src/config/store.ts index 5a10d88..60456d5 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -6,6 +6,8 @@ export interface StoredConfig { backend?: string; model?: string; baseUrl?: string; + /** Fallback context window size (in tokens) used when it can't be auto-detected from the backend. */ + contextWindow?: number; } const paths = envPaths("locode", { suffix: "" }); diff --git a/src/mcp/client.ts b/src/mcp/client.ts new file mode 100644 index 0000000..836c175 --- /dev/null +++ b/src/mcp/client.ts @@ -0,0 +1,39 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { isHttpServerConfig, type McpServerConfig } from "./types.js"; + +export interface McpToolInfo { + name: string; + description?: string; + inputSchema: Record; + annotations?: { readOnlyHint?: boolean }; +} + +export interface ConnectedMcpServer { + name: string; + client: Client; + transport: Transport; + tools: McpToolInfo[]; +} + +export async function connectMcpServer(name: string, config: McpServerConfig): Promise { + const transport: Transport = isHttpServerConfig(config) + ? new StreamableHTTPClientTransport(new URL(config.url), { + requestInit: config.headers ? { headers: config.headers } : undefined, + }) + : new StdioClientTransport({ + command: config.command, + args: config.args, + env: config.env, + // Default is "inherit", which would leak the child's stderr straight into the terminal + // and corrupt Ink's alternate-screen UI. Pipe it instead so it's just discarded. + stderr: "pipe", + }); + + const client = new Client({ name: "locode", version: "0.1.0" }); + await client.connect(transport); + const { tools } = await client.listTools(); + return { name, client, transport, tools: tools as McpToolInfo[] }; +} diff --git a/src/mcp/config.ts b/src/mcp/config.ts new file mode 100644 index 0000000..7afe51f --- /dev/null +++ b/src/mcp/config.ts @@ -0,0 +1,51 @@ +import envPaths from "env-paths"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import type { McpServerConfig, McpServersFile } from "./types.js"; + +const paths = envPaths("locode", { suffix: "" }); +const userFile = path.join(paths.config, "mcp.json"); + +export function userMcpFilePath(): string { + return userFile; +} + +function readServersFile(file: string): Record { + if (!existsSync(file)) return {}; + try { + const parsed = JSON.parse(readFileSync(file, "utf-8")) as McpServersFile; + return parsed.mcpServers ?? {}; + } catch { + return {}; + } +} + +/** User-level servers (`locode mcp add`), available in every project. */ +export function loadUserServers(): Record { + return readServersFile(userFile); +} + +/** Project-level `.mcp.json` in the given directory — meant to be hand-edited/checked into a repo. */ +export function loadProjectServers(cwd: string): Record { + return readServersFile(path.join(cwd, ".mcp.json")); +} + +/** User-level servers plus project-level ones, with project entries winning on name collisions. */ +export function loadMergedServers(cwd: string): Record { + return { ...loadUserServers(), ...loadProjectServers(cwd) }; +} + +export function saveUserServer(name: string, config: McpServerConfig): void { + const servers = loadUserServers(); + servers[name] = config; + mkdirSync(paths.config, { recursive: true }); + writeFileSync(userFile, JSON.stringify({ mcpServers: servers }, null, 2)); +} + +export function removeUserServer(name: string): boolean { + const servers = loadUserServers(); + if (!(name in servers)) return false; + delete servers[name]; + writeFileSync(userFile, JSON.stringify({ mcpServers: servers }, null, 2)); + return true; +} diff --git a/src/mcp/manager.ts b/src/mcp/manager.ts new file mode 100644 index 0000000..7e1a758 --- /dev/null +++ b/src/mcp/manager.ts @@ -0,0 +1,54 @@ +import type { ToolDef } from "../tools/types.js"; +import { connectMcpServer, type ConnectedMcpServer } from "./client.js"; +import { loadMergedServers } from "./config.js"; +import { mcpToolToToolDef } from "./toolAdapter.js"; + +export interface McpServerStatus { + name: string; + status: "connected" | "error"; + toolCount: number; + error?: string; +} + +let connections: ConnectedMcpServer[] = []; +let statuses: McpServerStatus[] = []; + +/** Connects to every configured MCP server in parallel (one bad server doesn't block the rest) + * and returns the flattened, ready-to-use tools from whichever servers connected successfully. */ +export async function connectConfiguredMcpServers(cwd: string): Promise { + const servers = loadMergedServers(cwd); + const entries = Object.entries(servers); + if (entries.length === 0) return []; + + const results = await Promise.allSettled(entries.map(([name, config]) => connectMcpServer(name, config))); + + const allTools: ToolDef[] = []; + const nextStatuses: McpServerStatus[] = []; + const nextConnections: ConnectedMcpServer[] = []; + + results.forEach((result, i) => { + const name = entries[i]![0]; + if (result.status === "fulfilled") { + const server = result.value; + nextConnections.push(server); + const toolDefs = server.tools.map((t) => mcpToolToToolDef(name, server.client, t)); + allTools.push(...toolDefs); + nextStatuses.push({ name, status: "connected", toolCount: toolDefs.length }); + } else { + nextStatuses.push({ name, status: "error", toolCount: 0, error: (result.reason as Error).message }); + } + }); + + statuses = nextStatuses; + connections = nextConnections; + return allTools; +} + +export function getMcpStatuses(): McpServerStatus[] { + return statuses; +} + +export async function disconnectAllMcpServers(): Promise { + await Promise.allSettled(connections.map((c) => c.transport.close())); + connections = []; +} diff --git a/src/mcp/toolAdapter.ts b/src/mcp/toolAdapter.ts new file mode 100644 index 0000000..4c865de --- /dev/null +++ b/src/mcp/toolAdapter.ts @@ -0,0 +1,43 @@ +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { z } from "zod"; +import type { ToolDef } from "../tools/types.js"; +import type { McpToolInfo } from "./client.js"; + +function sanitize(id: string): string { + return id.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +/** MCP tool names are namespaced as `mcp____` (matching the convention Claude Code + * itself uses) so tools from different servers can't collide with each other or with local tools. */ +export function mcpToolName(serverName: string, toolName: string): string { + return `mcp__${sanitize(serverName)}__${sanitize(toolName)}`; +} + +const argsSchema = z.record(z.string(), z.unknown()); + +export function mcpToolToToolDef(serverName: string, client: Client, mcpTool: McpToolInfo): ToolDef> { + return { + name: mcpToolName(serverName, mcpTool.name), + description: `[MCP: ${serverName}] ${mcpTool.description ?? mcpTool.name}`, + schema: argsSchema, + rawInputSchema: mcpTool.inputSchema, + // MCP tools can do anything server-side; only trust the spec's readOnlyHint to skip confirmation. + mutating: mcpTool.annotations?.readOnlyHint !== true, + preview: async (args) => "```json\n" + JSON.stringify(args, null, 2) + "\n```", + handler: async (args) => { + const result = await client.callTool({ name: mcpTool.name, arguments: args }); + const blocks = Array.isArray(result.content) ? result.content : []; + const text = blocks + .map((block) => + block && typeof block === "object" && "type" in block && block.type === "text" + ? (block as { text: string }).text + : `[unsupported MCP content type: ${(block as { type?: string })?.type ?? "unknown"}]`, + ) + .join("\n"); + if (result.isError) { + return { error: text || "MCP tool call failed." }; + } + return { content: text }; + }, + }; +} diff --git a/src/mcp/types.ts b/src/mcp/types.ts new file mode 100644 index 0000000..0535b70 --- /dev/null +++ b/src/mcp/types.ts @@ -0,0 +1,20 @@ +export interface McpStdioServerConfig { + command: string; + args?: string[]; + env?: Record; +} + +export interface McpHttpServerConfig { + url: string; + headers?: Record; +} + +export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig; + +export interface McpServersFile { + mcpServers: Record; +} + +export function isHttpServerConfig(cfg: McpServerConfig): cfg is McpHttpServerConfig { + return "url" in cfg; +} diff --git a/src/persistence/sessionStore.ts b/src/persistence/sessionStore.ts new file mode 100644 index 0000000..b903077 --- /dev/null +++ b/src/persistence/sessionStore.ts @@ -0,0 +1,93 @@ +import envPaths from "env-paths"; +import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; +import type { ToolCallMode } from "../backend/capabilityProbe.js"; + +/** A saved conversation. `messages` excludes the system prompt — it's rebuilt fresh from the + * current tool list on load, so a saved session always reflects whatever tools locode has today. */ +export interface SessionRecord { + id: string; + createdAt: string; + updatedAt: string; + cwd: string; + baseURL: string; + model: string; + mode: ToolCallMode; + messages: ChatCompletionMessageParam[]; +} + +export interface SessionSummary { + id: string; + updatedAt: string; + title: string; + model: string; + baseURL: string; + messageCount: number; +} + +const paths = envPaths("locode", { suffix: "" }); +const dir = path.join(paths.data, "sessions"); + +export function sessionsDir(): string { + return dir; +} + +function filePath(id: string): string { + return path.join(dir, `${id}.json`); +} + +export function deriveTitle(messages: ChatCompletionMessageParam[]): string { + const first = messages.find((m) => m.role === "user"); + const text = first && typeof first.content === "string" ? first.content.trim() : ""; + if (!text) return "(empty conversation)"; + return text.length > 60 ? `${text.slice(0, 60)}...` : text; +} + +export function saveSession(record: SessionRecord): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(filePath(record.id), JSON.stringify(record, null, 2)); +} + +export function loadSession(id: string): SessionRecord | undefined { + const file = filePath(id); + if (!existsSync(file)) return undefined; + try { + return JSON.parse(readFileSync(file, "utf-8")) as SessionRecord; + } catch { + return undefined; + } +} + +export function listSessions(): SessionSummary[] { + if (!existsSync(dir)) return []; + const summaries: SessionSummary[] = []; + for (const entry of readdirSync(dir)) { + if (!entry.endsWith(".json")) continue; + try { + const record = JSON.parse(readFileSync(path.join(dir, entry), "utf-8")) as SessionRecord; + summaries.push({ + id: record.id, + updatedAt: record.updatedAt, + title: deriveTitle(record.messages), + model: record.model, + baseURL: record.baseURL, + messageCount: record.messages.length, + }); + } catch { + // Skip corrupt/partial session files + } + } + return summaries.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} + +export function mostRecentSessionId(): string | undefined { + return listSessions()[0]?.id; +} + +export function deleteSession(id: string): boolean { + const file = filePath(id); + if (!existsSync(file)) return false; + unlinkSync(file); + return true; +} diff --git a/src/toolcalling/nativeAdapter.ts b/src/toolcalling/nativeAdapter.ts index 0fda404..1faff92 100644 --- a/src/toolcalling/nativeAdapter.ts +++ b/src/toolcalling/nativeAdapter.ts @@ -9,7 +9,7 @@ export function toOpenAITools(tools: ToolDef[]): ChatCompletionTool[] { function: { name: t.name, description: t.description, - parameters: z.toJSONSchema(t.schema) as Record, + parameters: t.rawInputSchema ?? (z.toJSONSchema(t.schema) as Record), }, })); } diff --git a/src/tools/agentTool.ts b/src/tools/agentTool.ts new file mode 100644 index 0000000..8c7a18a --- /dev/null +++ b/src/tools/agentTool.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; +import type { ToolDef } from "./types.js"; + +const schema = z.object({ + description: z.string().describe("A short (3-6 word) label for what this sub-agent is doing, shown in the UI."), + prompt: z + .string() + .describe( + "The full task for the sub-agent to carry out. Be specific and self-contained — the sub-agent starts with no " + + "memory of this conversation beyond what you put here, and it cannot ask you follow-up questions.", + ), +}); + +export const agentTool: ToolDef> = { + name: "agent", + description: + "Delegate a self-contained task to a fresh sub-agent with its own tool loop (read_file, list_files, grep, " + + "web_search, web_fetch, write_file, edit_file, bash — but not another agent). Use for multi-step research or " + + "exploration you want to run in isolation, getting back only the final answer so your own context stays " + + "focused. Only the sub-agent's final text is returned to you, not its intermediate steps.", + schema, + mutating: false, + handler: async (args, ctx) => { + if (!ctx.runSubAgent) { + throw new Error("Sub-agents are not available in this context."); + } + const result = await ctx.runSubAgent(args); + return { description: args.description, result }; + }, +}; diff --git a/src/tools/git.ts b/src/tools/git.ts new file mode 100644 index 0000000..688e1c2 --- /dev/null +++ b/src/tools/git.ts @@ -0,0 +1,150 @@ +import { execa } from "execa"; +import { z } from "zod"; +import { truncate } from "../utils/truncate.js"; +import type { ToolDef } from "./types.js"; + +async function tryGit(args: string[], cwd: string): Promise { + try { + const result = await execa("git", args, { cwd, reject: false }); + return result.exitCode === 0 ? result.stdout : ""; + } catch { + return ""; + } +} + +// --- git_status: read-only inspection, auto-runs like grep/read_file --- + +const statusSchema = z.object({ + operation: z.enum(["status", "diff", "log", "show", "branches"]), + paths: z.array(z.string()).optional().describe("Limit `status`/`diff` to these paths, relative to the working directory."), + staged: z.boolean().optional().describe("For `diff`: show staged changes (--cached) instead of the working tree diff."), + ref: z.string().optional().describe("For `show`: the commit/ref to inspect (default HEAD). For `log`: start from this ref."), + maxCount: z.number().int().min(1).max(200).optional().describe("For `log`: maximum number of commits to return (default 20)."), +}); + +export const gitStatusTool: ToolDef> = { + name: "git_status", + description: + "Inspect the git repository at the working directory: `status` (working tree state), `diff` (unstaged or, with staged:true, " + + "staged changes), `log` (recent commits), `show` (a specific commit/ref, default HEAD), or `branches` (local branches with " + + "upstream tracking info). Read-only — runs automatically without confirmation.", + schema: statusSchema, + mutating: false, + handler: async ({ operation, paths, staged, ref, maxCount }, ctx) => { + const pathArgs = paths?.length ? ["--", ...paths] : []; + const args: string[] = (() => { + switch (operation) { + case "status": + return ["status", "--short", "--branch", ...pathArgs]; + case "diff": + return ["diff", ...(staged ? ["--cached"] : []), ...pathArgs]; + case "log": + return ["log", `--max-count=${maxCount ?? 20}`, "--oneline", "--decorate", ...(ref ? [ref] : [])]; + case "show": + return ["show", ref ?? "HEAD"]; + case "branches": + return ["branch", "--list", "-vv"]; + } + })(); + + const result = await execa("git", args, { cwd: ctx.cwd, reject: false }); + if (result.exitCode !== 0) { + return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) }; + } + return { output: truncate(result.stdout) }; + }, +}; + +// --- git_commit: mutating operations, gated behind a confirmation prompt --- + +const commitSchema = z.object({ + operation: z.enum(["add", "commit", "create_branch", "checkout", "push"]), + paths: z.array(z.string()).optional().describe("For `add`: paths to stage, relative to the working directory. Omit to stage all changes."), + message: z.string().optional().describe("Commit message. Required for `commit`."), + branchName: z.string().optional().describe("Branch name. Required for `create_branch`/`checkout`."), + createIfMissing: z.boolean().optional().describe("For `checkout`: create the branch if it doesn't exist yet (like `checkout -b`)."), + remote: z.string().optional().describe("For `push`: remote name (default \"origin\")."), + setUpstream: z.boolean().optional().describe("For `push`: set upstream tracking — needed the first time a new branch is pushed."), +}); + +export const gitCommitTool: ToolDef> = { + name: "git_commit", + description: + "Make changes to the git repository at the working directory: `add` (stage paths, or all changes if omitted), `commit` " + + "(requires `message`; commit the currently staged changes), `create_branch` (requires `branchName`; branch at current HEAD " + + "without switching), `checkout` (requires `branchName`; switch branches, or create-and-switch with createIfMissing), or " + + "`push` (push the current branch, optionally with setUpstream for a new branch's first push). Each use requires the user's " + + "confirmation, which shows the actual diff/status/commits affected.", + schema: commitSchema, + mutating: true, + preview: async ({ operation, paths, message, branchName, createIfMissing, remote, setUpstream }, ctx) => { + switch (operation) { + case "add": { + const target = paths?.length ? paths.join(", ") : "all changes"; + const status = await tryGit(["status", "--short", ...(paths?.length ? ["--", ...paths] : [])], ctx.cwd); + return status ? `Stage ${target}:\n\n${status}` : `Stage ${target} (no changes detected).`; + } + case "commit": { + const staged = await tryGit(["diff", "--cached"], ctx.cwd); + if (!staged) return "Nothing is staged — this commit will fail. Use the `add` operation first."; + return `Commit message: "${message ?? "(none provided — this will fail)"}"\n\n${staged}`; + } + case "create_branch": + return `Create new branch "${branchName}" pointing at current HEAD (does not switch to it).`; + case "checkout": { + const status = await tryGit(["status", "--short"], ctx.cwd); + const action = createIfMissing ? `Create and switch to new branch "${branchName}"` : `Switch to branch "${branchName}"`; + return status ? `${action}.\n\nUncommitted changes that will carry over:\n${status}` : `${action}. Working tree is clean.`; + } + case "push": { + const remoteName = remote ?? "origin"; + const branch = (await tryGit(["rev-parse", "--abbrev-ref", "HEAD"], ctx.cwd)).trim() || "HEAD"; + // `@{u}..` only resolves once upstream tracking exists — on a branch's first push + // (exactly when setUpstream is used) it doesn't, so fall back to recent commits on HEAD. + const ahead = + (await tryGit(["log", "--oneline", "@{u}.."], ctx.cwd)) || (await tryGit(["log", "--oneline", "-n", "10"], ctx.cwd)); + const upstreamNote = setUpstream ? " (setting upstream tracking)" : ""; + return ahead + ? `Push "${branch}" to "${remoteName}"${upstreamNote} — commits to push:\n\n${ahead}` + : `Push "${branch}" to "${remoteName}"${upstreamNote}.`; + } + } + }, + handler: async ({ operation, paths, message, branchName, createIfMissing, remote, setUpstream }, ctx) => { + const args: string[] = (() => { + switch (operation) { + case "add": + return ["add", ...(paths?.length ? paths : ["-A"])]; + case "commit": + if (!message) throw new Error("`message` is required for the commit operation."); + return ["commit", "-m", message]; + case "create_branch": + if (!branchName) throw new Error("`branchName` is required for the create_branch operation."); + return ["branch", branchName]; + case "checkout": + if (!branchName) throw new Error("`branchName` is required for the checkout operation."); + return createIfMissing ? ["checkout", "-b", branchName] : ["checkout", branchName]; + case "push": + return ["push", ...(setUpstream ? ["-u"] : []), remote ?? "origin", "HEAD"]; + } + })(); + + const result = await execa("git", args, { cwd: ctx.cwd, reject: false }); + if (result.exitCode !== 0) { + return { error: truncate(result.stderr || result.stdout || `git ${operation} failed (exit ${result.exitCode})`) }; + } + // `git push` writes its useful progress/summary output to stderr even on success. + const output = truncate(result.stdout || result.stderr); + + switch (operation) { + case "add": + return { staged: paths?.length ? paths : ["(all changes)"] }; + case "create_branch": + return { created: branchName }; + case "checkout": + return { switchedTo: branchName }; + default: + return { output }; + } + }, +}; diff --git a/src/tools/index.ts b/src/tools/index.ts index 9b23d06..c207fbc 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,11 +1,27 @@ +import { agentTool } from "./agentTool.js"; import { bashTool } from "./bash.js"; import { editFileTool } from "./editFile.js"; +import { gitCommitTool, gitStatusTool } from "./git.js"; import { grepTool } from "./grep.js"; import { listFilesTool } from "./listFiles.js"; import { readFileTool } from "./readFile.js"; +import { webFetchTool } from "./webFetch.js"; +import { webSearchTool } from "./webSearch.js"; import { writeFileTool } from "./writeFile.js"; import type { ToolDef } from "./types.js"; -export const TOOLS: ToolDef[] = [readFileTool, listFilesTool, grepTool, writeFileTool, editFileTool, bashTool]; +export const TOOLS: ToolDef[] = [ + readFileTool, + listFilesTool, + grepTool, + webSearchTool, + webFetchTool, + gitStatusTool, + writeFileTool, + editFileTool, + bashTool, + gitCommitTool, + agentTool, +]; export const TOOL_REGISTRY: Map = new Map(TOOLS.map((t) => [t.name, t])); diff --git a/src/tools/toolset.ts b/src/tools/toolset.ts new file mode 100644 index 0000000..1b51fa7 --- /dev/null +++ b/src/tools/toolset.ts @@ -0,0 +1,15 @@ +import type { ChatCompletionTool } from "openai/resources/chat/completions"; +import { toOpenAITools } from "../toolcalling/nativeAdapter.js"; +import type { ToolDef } from "./types.js"; + +/** A tool list bundled with its lookup registry and pre-built OpenAI schemas — built once per + * session (whose tools may include dynamically-discovered MCP tools) rather than per-call. */ +export interface ToolSet { + tools: ToolDef[]; + registry: Map; + openaiTools: ChatCompletionTool[]; +} + +export function buildToolSet(tools: ToolDef[]): ToolSet { + return { tools, registry: new Map(tools.map((t) => [t.name, t])), openaiTools: toOpenAITools(tools) }; +} diff --git a/src/tools/types.ts b/src/tools/types.ts index 90f4786..3a7c287 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -1,13 +1,25 @@ import type { z } from "zod"; +export interface SubAgentTask { + /** Short (3-6 word) label shown in the UI while the sub-agent runs. */ + description: string; + /** The full, self-contained task — the sub-agent has no memory of the parent conversation. */ + prompt: string; +} + export interface ToolContext { cwd: string; + /** Only present when running inside a session capable of spawning sub-agents (used by the `agent` tool). */ + runSubAgent?: (task: SubAgentTask) => Promise; } export interface ToolDef { name: string; description: string; schema: z.ZodType; + /** If set, used verbatim as the JSON Schema shown to the model instead of one derived from `schema` — + * needed for tools (e.g. MCP-provided ones) whose real parameter schema isn't expressible as a Zod type. */ + rawInputSchema?: Record; mutating: boolean; /** For mutating tools: human-readable preview (e.g. a diff) shown before the user confirms. */ preview?: (args: T, ctx: ToolContext) => Promise; diff --git a/src/tools/webFetch.ts b/src/tools/webFetch.ts new file mode 100644 index 0000000..32a8d34 --- /dev/null +++ b/src/tools/webFetch.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import { truncate } from "../utils/truncate.js"; +import { htmlToText, USER_AGENT, withFetchTimeout } from "../utils/html.js"; +import type { ToolDef } from "./types.js"; + +const schema = z.object({ + url: z.string().describe("URL to fetch. Must include the scheme, e.g. 'https://example.com'."), +}); + +const MAX_CHARS = 20_000; + +export const webFetchTool: ToolDef> = { + name: "web_fetch", + description: + "Fetch a URL and return its content as readable text (HTML is stripped of tags/scripts/styles). Use to read a specific page, e.g. one found via web_search or linked from documentation.", + schema, + mutating: false, + handler: async ({ url }) => { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid URL: ${url}`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`Unsupported URL scheme: ${parsed.protocol}`); + } + + const { signal, clear } = withFetchTimeout(15_000); + try { + const response = await fetch(parsed, { + headers: { "User-Agent": USER_AGENT }, + signal, + redirect: "follow", + }); + if (!response.ok) { + throw new Error(`Fetch failed with status ${response.status}`); + } + const contentType = response.headers.get("content-type") ?? ""; + const body = await response.text(); + const content = contentType.includes("html") ? htmlToText(body) : body; + + return { + url: parsed.toString(), + finalUrl: response.url, + status: response.status, + contentType, + content: truncate(content, MAX_CHARS), + }; + } finally { + clear(); + } + }, +}; diff --git a/src/tools/webSearch.ts b/src/tools/webSearch.ts new file mode 100644 index 0000000..09a6060 --- /dev/null +++ b/src/tools/webSearch.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; +import { stripTags, USER_AGENT, withFetchTimeout } from "../utils/html.js"; +import type { ToolDef } from "./types.js"; + +const schema = z.object({ + query: z.string().describe("Search query."), + max_results: z.number().int().min(1).max(20).optional(), +}); + +interface SearchResult { + title: string; + url: string; + snippet: string; +} + +function resolveResultUrl(href: string): string { + const absolute = href.startsWith("//") ? `https:${href}` : href; + try { + const url = new URL(absolute); + const uddg = url.searchParams.get("uddg"); + return uddg ? uddg : absolute; + } catch { + return absolute; + } +} + +function parseResults(html: string, limit: number): SearchResult[] { + const results: SearchResult[] = []; + const resultRegex = + /([\s\S]*?)<\/a>[\s\S]*?]*>([\s\S]*?)<\/a>/g; + + let match: RegExpExecArray | null; + while ((match = resultRegex.exec(html)) && results.length < limit) { + const [, href, titleHtml, snippetHtml] = match; + if (!href || !titleHtml || !snippetHtml) continue; + results.push({ + title: stripTags(titleHtml), + url: resolveResultUrl(href), + snippet: stripTags(snippetHtml), + }); + } + return results; +} + +export const webSearchTool: ToolDef> = { + name: "web_search", + description: + "Search the web via DuckDuckGo and return matching results (title, url, snippet). Use for information not available in the local codebase, e.g. current docs, library APIs, or general knowledge.", + schema, + mutating: false, + handler: async ({ query, max_results }) => { + const limit = max_results ?? 5; + const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`; + + const { signal, clear } = withFetchTimeout(10_000); + try { + const response = await fetch(url, { + headers: { "User-Agent": USER_AGENT }, + signal, + }); + if (!response.ok) { + throw new Error(`Search request failed with status ${response.status}`); + } + const html = await response.text(); + const results = parseResults(html, limit); + return { query, results }; + } finally { + clear(); + } + }, +}; diff --git a/src/ui/ink/App.tsx b/src/ui/ink/App.tsx index 081c739..73efed7 100644 --- a/src/ui/ink/App.tsx +++ b/src/ui/ink/App.tsx @@ -1,17 +1,37 @@ import { Box, Static, useApp } from "ink"; import { useCallback, useEffect, useRef, useState } from "react"; -import { AgentError, runTurn } from "../../agent/loop.js"; -import { createSession, resetSession, setMode, type Session } from "../../agent/session.js"; +import { AgentError, compactSession, contextUsageRatio, runTurn, shouldAutoCompact } from "../../agent/loop.js"; +import { + createSession, + createSessionFromRecord, + resetSession, + setMode, + toSessionRecord, + type Session, +} from "../../agent/session.js"; import { makeClient } from "../../backend/client.js"; import type { ToolCallMode } from "../../backend/capabilityProbe.js"; import { setCachedMode } from "../../backend/capabilityCache.js"; +import { resolveContextWindow } from "../../backend/contextWindow.js"; import { resolveToolCallMode } from "../../backend/resolveMode.js"; import { KNOWN_BACKENDS, type BackendName } from "../../config/defaults.js"; +import { getMcpStatuses } from "../../mcp/manager.js"; import type { PermissionDecision, PermissionMode } from "../../permissions/types.js"; +import { + deriveTitle, + listSessions, + loadSession, + saveSession, + type SessionRecord, + type SessionSummary, +} from "../../persistence/sessionStore.js"; +import { TOOLS } from "../../tools/index.js"; +import type { ToolDef } from "../../tools/types.js"; import { ChatInput } from "./ChatInput.js"; import { HistoryItemView } from "./HistoryItemView.js"; import { ModelSelect } from "./ModelSelect.js"; import { PermissionPrompt } from "./PermissionPrompt.js"; +import { SessionSelect } from "./SessionSelect.js"; import { StatusBar } from "./StatusBar.js"; import { ThinkingIndicator } from "./ThinkingIndicator.js"; import { nextId, type HistoryItem, type NewHistoryItem } from "./types.js"; @@ -22,6 +42,9 @@ export interface AppProps { toolModeOverride?: ToolCallMode; initialModel?: string; suggestedModel?: string; + resumeSessionId?: string; + interactiveResume?: boolean; + mcpToolsPromise: Promise; } interface PendingPermission { @@ -31,7 +54,7 @@ interface PendingPermission { resolve: (decision: PermissionDecision) => void; } -type Phase = "connecting" | "loading-models" | "model-select" | "input"; +type Phase = "starting" | "connecting" | "loading-models" | "model-select" | "session-select" | "input"; export function App({ baseURL: initialBaseURL, @@ -39,16 +62,22 @@ export function App({ toolModeOverride, initialModel, suggestedModel, + resumeSessionId, + interactiveResume, + mcpToolsPromise, }: AppProps) { const { exit } = useApp(); const [staticItems, setStaticItems] = useState([]); - const [phase, setPhase] = useState(initialModel ? "connecting" : "loading-models"); + const [phase, setPhase] = useState( + resumeSessionId ? "connecting" : interactiveResume ? "starting" : initialModel ? "connecting" : "loading-models", + ); const [inputValue, setInputValue] = useState(""); const [permission, setPermission] = useState(null); const [streamingText, setStreamingText] = useState(null); const [isThinking, setIsThinking] = useState(false); const [permMode, setPermMode] = useState("default"); const [modelList, setModelList] = useState([]); + const [sessionList, setSessionList] = useState([]); const baseURLRef = useRef(initialBaseURL); const sessionRef = useRef(null); @@ -69,10 +98,17 @@ export function App({ setStaticItems((h) => [...h, { id: nextId(), ...partial } as HistoryItem]); }, []); - // Fetch models on mount when no --model is specified - useEffect(() => { - if (initialModel) return; // skip model listing if --model was provided + const persistCurrentSession = useCallback(() => { + const session = sessionRef.current; + if (!session) return; + try { + saveSession(toSessionRecord(session, baseURLRef.current)); + } catch { + // Best-effort — a failed save shouldn't crash the UI. + } + }, []); + const fetchModelsForPicker = useCallback(() => { const client = makeClient({ baseURL: baseURLRef.current, model: "" }); client.models .list() @@ -90,8 +126,7 @@ export function App({ push({ kind: "notice", text: `Failed to list models: ${(err as Error).message}`, isError: true }); setPhase("input"); }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [push]); const initSession = useCallback( async (model: string) => { @@ -105,7 +140,17 @@ export function App({ new Promise((resolve) => { setPermission({ ...opts, resolve }); }); - sessionRef.current = createSession(client, model, cwd, confirmFn, mode); + const [mcpTools, contextWindow] = await Promise.all([mcpToolsPromise, resolveContextWindow(baseURLRef.current, model)]); + sessionRef.current = createSession( + client, + model, + cwd, + confirmFn, + mode, + [...TOOLS, ...mcpTools], + contextWindow.value, + contextWindow.isEstimate, + ); push({ kind: "banner", cwd, model, backend: baseURLRef.current }); setPhase("input"); } catch (err) { @@ -121,13 +166,92 @@ export function App({ } } }, - [cwd, toolModeOverride, modelList, exit, push], + [cwd, toolModeOverride, modelList, exit, push, mcpToolsPromise], ); + const initSessionFromRecord = useCallback( + async (record: SessionRecord) => { + setPhase("connecting"); + try { + baseURLRef.current = record.baseURL; + const client = makeClient({ baseURL: record.baseURL, model: record.model }); + const confirmFn = (opts: { toolName: string; args: unknown; preview?: string }) => + new Promise((resolve) => { + setPermission({ ...opts, resolve }); + }); + const [mcpTools, contextWindow] = await Promise.all([ + mcpToolsPromise, + resolveContextWindow(record.baseURL, record.model), + ]); + sessionRef.current = createSessionFromRecord( + client, + record, + cwd, + confirmFn, + [...TOOLS, ...mcpTools], + contextWindow.value, + contextWindow.isEstimate, + ); + push({ + kind: "banner", + cwd, + model: record.model, + backend: record.baseURL, + resumedTitle: deriveTitle(record.messages), + }); + + // Replay the saved user/assistant turns so the transcript isn't blank — tool + // call/result lines aren't replayed since we don't persist their display labels. + const replayed: HistoryItem[] = []; + for (const m of record.messages) { + if (m.role === "user" && typeof m.content === "string" && !m.content.startsWith("```tool_result")) { + replayed.push({ id: nextId(), kind: "user", text: m.content }); + } else if (m.role === "assistant" && typeof m.content === "string" && m.content) { + replayed.push({ id: nextId(), kind: "assistant", text: m.content }); + } + } + setStaticItems((prev) => [...prev, ...replayed]); + + setPhase("input"); + } catch (err) { + push({ kind: "notice", text: `Failed to resume session: ${(err as Error).message}`, isError: true }); + fetchModelsForPicker(); + } + }, + [cwd, push, fetchModelsForPicker, mcpToolsPromise], + ); + + // Decide the startup path once on mount: resume a specific session, show a resume + // picker, jump straight to a given model, or fall back to the model picker. useEffect(() => { + if (resumeSessionId) { + const record = loadSession(resumeSessionId); + if (record) { + void initSessionFromRecord(record); + } else { + push({ kind: "notice", text: `No saved session found with id "${resumeSessionId}".`, isError: true }); + if (initialModel) void initSession(initialModel); + else fetchModelsForPicker(); + } + return; + } + if (interactiveResume) { + const list = listSessions(); + if (list.length === 0) { + push({ kind: "notice", text: "No saved sessions found — starting a new conversation." }); + if (initialModel) void initSession(initialModel); + else fetchModelsForPicker(); + } else { + setSessionList(list); + setPhase("session-select"); + } + return; + } if (initialModel) { void initSession(initialModel); + return; } + fetchModelsForPicker(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -135,6 +259,21 @@ export function App({ await initSession(model); } + function handleSessionSelect(id: string | null) { + if (id === null) { + if (initialModel) void initSession(initialModel); + else fetchModelsForPicker(); + return; + } + const record = loadSession(id); + if (record) { + void initSessionFromRecord(record); + } else { + push({ kind: "notice", text: `No saved session found with id "${id}".`, isError: true }); + fetchModelsForPicker(); + } + } + async function switchModel(name: string) { const session = sessionRef.current; if (!session) return; @@ -142,9 +281,15 @@ export function App({ session.model = name; setIsThinking(true); try { - const newMode = await resolveToolCallMode(session.client, baseURLRef.current, name); + const [newMode, newContextWindow] = await Promise.all([ + resolveToolCallMode(session.client, baseURLRef.current, name), + resolveContextWindow(baseURLRef.current, name), + ]); setMode(session, newMode); + session.contextWindow = newContextWindow.value; + session.contextWindowIsEstimate = newContextWindow.isEstimate; push({ kind: "notice", text: `Switched model to "${name}" (tool-call mode: ${newMode}).` }); + persistCurrentSession(); } catch (err) { session.model = previousModel; push({ kind: "notice", text: `Failed to switch model: ${(err as Error).message}`, isError: true }); @@ -162,12 +307,18 @@ export function App({ session.client = makeClient({ baseURL: baseURLRef.current, model: session.model }); setIsThinking(true); try { - const newMode = await resolveToolCallMode(session.client, baseURLRef.current, session.model); + const [newMode, newContextWindow] = await Promise.all([ + resolveToolCallMode(session.client, baseURLRef.current, session.model), + resolveContextWindow(baseURLRef.current, session.model), + ]); setMode(session, newMode); + session.contextWindow = newContextWindow.value; + session.contextWindowIsEstimate = newContextWindow.isEstimate; push({ kind: "notice", text: `Switched backend to "${name}" (${baseURLRef.current}, tool-call mode: ${newMode}).`, }); + persistCurrentSession(); } catch (err) { baseURLRef.current = previousBaseURL; session.client = previousClient; @@ -198,20 +349,53 @@ export function App({ if (trimmed === "/clear") { resetSession(session); push({ kind: "notice", text: "Conversation history cleared." }); + persistCurrentSession(); return; } if (trimmed === "/status") { - push({ kind: "status", model: session.model, baseURL: baseURLRef.current, mode: session.mode, cwd }); + push({ + kind: "status", + model: session.model, + baseURL: baseURLRef.current, + mode: session.mode, + cwd, + sessionId: session.id, + contextTokens: session.lastContextTokens, + contextWindow: session.contextWindow, + contextTokensIsEstimate: session.lastContextTokensIsEstimate, + contextWindowIsEstimate: session.contextWindowIsEstimate, + }); + return; + } + if (trimmed === "/compact") { + setIsThinking(true); + try { + await compactSession(session); + push({ kind: "notice", text: "Conversation compacted to save context." }); + persistCurrentSession(); + } catch (err) { + push({ kind: "notice", text: `Compaction failed: ${(err as Error).message}`, isError: true }); + } finally { + setIsThinking(false); + } return; } if (trimmed === "/tools") { - push({ kind: "tools" }); + push({ kind: "tools", tools: session.toolset.tools }); return; } if (trimmed === "/permissions") { push({ kind: "permissions", allowed: session.permissions.listAllowed() }); return; } + if (trimmed === "/sessions") { + push({ kind: "sessions", sessions: listSessions() }); + return; + } + if (trimmed === "/mcp") { + push({ kind: "mcp", statuses: getMcpStatuses() }); + return; + } if (trimmed.startsWith("/model")) { const name = trimmed.slice("/model".length).trim(); if (!name) { @@ -232,21 +416,6 @@ export function App({ } return; } - if (trimmed === "/perm") { - // Cycle: default → auto-edit → auto-accept → default - const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"]; - const currentIdx = modes.indexOf(permMode); - const nextMode = modes[(currentIdx + 1) % modes.length]; - setPermMode(nextMode); - session.permissions.setMode(nextMode); - const labels: Record = { - default: "default (ask before mutating tools)", - "auto-edit": "auto-edit (file edits auto-approved, bash still asks)", - "auto-accept": "auto-accept (all tools auto-approved ⚠)", - }; - push({ kind: "notice", text: `Permission mode: ${labels[nextMode]}` }); - return; - } if (trimmed.startsWith("/perm")) { const name = trimmed.slice("/perm".length).trim(); const validModes: Record = { default: "default", "auto-edit": "auto-edit", "auto-accept": "auto-accept" }; @@ -254,7 +423,7 @@ export function App({ // Cycle const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"]; const currentIdx = modes.indexOf(permMode); - const nextMode = modes[(currentIdx + 1) % modes.length]; + const nextMode = modes[(currentIdx + 1) % modes.length]!; setPermMode(nextMode); session.permissions.setMode(nextMode); const labels: Record = { @@ -283,6 +452,7 @@ export function App({ setMode(session, name); setCachedMode(baseURLRef.current, session.model, name); push({ kind: "notice", text: `Forced tool-call mode to "${name}" (cached for this model).` }); + persistCurrentSession(); } return; } @@ -325,6 +495,25 @@ export function App({ }); // text_done already added the assistant message to staticItems // No fallback needed — the streaming loop always emits text_done + void text; + persistCurrentSession(); + + if (shouldAutoCompact(session)) { + const percentBefore = Math.round(contextUsageRatio(session) * 100); + setIsThinking(true); + try { + await compactSession(session); + push({ + kind: "notice", + text: `Context was getting full (${percentBefore}%) — auto-compacted the conversation.`, + }); + persistCurrentSession(); + } catch (err) { + push({ kind: "notice", text: `Auto-compact failed: ${(err as Error).message}`, isError: true }); + } finally { + setIsThinking(false); + } + } } catch (err) { if (streamRafRef.current !== null) { clearTimeout(streamRafRef.current); @@ -354,7 +543,7 @@ export function App({ if (!session) return; const modes: PermissionMode[] = ["default", "auto-edit", "auto-accept"]; const currentIdx = modes.indexOf(permMode); - const nextMode = modes[(currentIdx + 1) % modes.length]; + const nextMode = modes[(currentIdx + 1) % modes.length]!; setPermMode(nextMode); session.permissions.setMode(nextMode); const labels: Record = { @@ -385,10 +574,14 @@ export function App({ preview={permission.preview} onSelect={handlePermissionSelect} /> + ) : phase === "starting" ? ( + ) : phase === "connecting" ? ( ) : phase === "loading-models" ? ( + ) : phase === "session-select" ? ( + ) : phase === "model-select" ? ( ) : ( @@ -400,8 +593,11 @@ export function App({ mode={sessionRef.current.mode} permMode={permMode} cwd={cwd} + contextTokens={sessionRef.current.lastContextTokens} + contextWindow={sessionRef.current.contextWindow} + contextIsEstimate={sessionRef.current.contextWindowIsEstimate || sessionRef.current.lastContextTokensIsEstimate} /> )} ); -} \ No newline at end of file +} diff --git a/src/ui/ink/HistoryItemView.tsx b/src/ui/ink/HistoryItemView.tsx index d828e95..56dfc68 100644 --- a/src/ui/ink/HistoryItemView.tsx +++ b/src/ui/ink/HistoryItemView.tsx @@ -1,5 +1,4 @@ import { Box, Text } from "ink"; -import { TOOLS } from "../../tools/index.js"; import { renderMarkdown } from "../render.js"; import { ACCENT_HEX } from "../theme.js"; import type { HistoryItem } from "./types.js"; @@ -13,6 +12,9 @@ const HELP_LINES = [ " /status show current model, backend, tool-call mode, and cwd", " /tools list available tools", " /permissions list mutating tools allowed for the rest of this session", + " /sessions list saved conversations you can resume with --resume", + " /mcp show connected MCP servers and their tool counts", + " /compact summarize the conversation now to free up context", " /clear clear conversation history", " /help show this help", " /exit, /quit exit", @@ -32,22 +34,28 @@ export function HistoryItemView({ item }: { item: HistoryItem }) { {item.model ? ( - model: + model: {item.model} ) : null} {item.backend ? ( - backend: + backend: {item.backend} ) : null} - cwd: + cwd: {item.cwd} + {item.resumedTitle ? ( + + resumed: + {item.resumedTitle} + + ) : null} - /help for commands · /perm to change permission mode · Shift+Tab to cycle modes + /help for commands · /perm to change permission mode · Shift+Tab to cycle modes ); @@ -72,6 +80,17 @@ export function HistoryItemView({ item }: { item: HistoryItem }) { cwd: {item.cwd} + + session: + {item.sessionId} + + + context: + {item.contextTokens.toLocaleString()} + {item.contextTokensIsEstimate ? " (est.)" : ""} / {item.contextWindow.toLocaleString()} + {item.contextWindowIsEstimate ? " (default)" : ""} tokens ( + {Math.round((item.contextTokens / item.contextWindow) * 100)}%) + ); @@ -123,7 +142,7 @@ export function HistoryItemView({ item }: { item: HistoryItem }) { return ( Available tools: - {TOOLS.map((t) => ( + {item.tools.map((t) => ( {" "} {t.name} @@ -141,5 +160,39 @@ export function HistoryItemView({ item }: { item: HistoryItem }) { : "No mutating tools have been allowed for the rest of this session yet."} ); + + case "sessions": + return ( + + Saved conversations (resume with `locode --resume <id>`): + {item.sessions.length === 0 ? ( + (none yet) + ) : ( + item.sessions.map((s) => ( + + {" "} + {s.id} {s.title} ({s.model}, {s.messageCount} msgs) + + )) + )} + + ); + + case "mcp": + return ( + + MCP servers: + {item.statuses.length === 0 ? ( + (none configured — see .mcp.json or `locode mcp add`) + ) : ( + item.statuses.map((s) => ( + + {" "} + {s.name}: {s.status === "connected" ? `connected, ${s.toolCount} tool(s)` : `error — ${s.error}`} + + )) + )} + + ); } } diff --git a/src/ui/ink/SessionSelect.tsx b/src/ui/ink/SessionSelect.tsx new file mode 100644 index 0000000..7ddc5c0 --- /dev/null +++ b/src/ui/ink/SessionSelect.tsx @@ -0,0 +1,43 @@ +import { Box, Text } from "ink"; +import SelectInput from "ink-select-input"; +import type { SessionSummary } from "../../persistence/sessionStore.js"; +import { ACCENT_HEX } from "../theme.js"; + +interface Props { + sessions: SessionSummary[]; + onSelect: (id: string | null) => void; +} + +function relativeTime(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + const minutes = Math.round(ms / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.round(hours / 24)}d ago`; +} + +export function SessionSelect({ sessions, onSelect }: Props) { + const items = [ + { label: "Start a new conversation", value: "__new__" }, + ...sessions.map((s) => ({ + label: `${s.title} (${s.model}, ${s.messageCount} msgs, ${relativeTime(s.updatedAt)})`, + value: s.id, + })), + ]; + + return ( + + + + ✻ locode + + + Resume a conversation, or start fresh: + + onSelect(item.value === "__new__" ? null : item.value)} /> + + + ); +} diff --git a/src/ui/ink/StatusBar.tsx b/src/ui/ink/StatusBar.tsx index b79e8b9..57db704 100644 --- a/src/ui/ink/StatusBar.tsx +++ b/src/ui/ink/StatusBar.tsx @@ -9,7 +9,7 @@ const MODE_LABELS: Record = { }; const MODE_COLORS: Record = { - default: "gray", + default: "yellow", "auto-edit": "yellow", "auto-accept": "red", }; @@ -19,24 +19,47 @@ interface Props { mode: string; permMode: PermissionMode; cwd: string; + contextTokens: number; + contextWindow: number; + contextIsEstimate: boolean; } -export function StatusBar({ model, mode, permMode, cwd }: Props) { +// Always a legible, visible color — never dimmed, since this is one of the most-referenced fields. +function contextColor(percent: number): string { + if (percent >= 85) return "red"; + if (percent >= 60) return "yellow"; + return "green"; +} + +function formatTokenCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}K`; + return String(n); +} + +export function StatusBar({ model, mode, permMode, cwd, contextTokens, contextWindow, contextIsEstimate }: Props) { // Show just the last segment of cwd for brevity const shortCwd = cwd.split(/[/\\]/).pop() ?? cwd; + const percent = Math.round((contextTokens / contextWindow) * 100); + const approx = contextIsEstimate ? "~" : ""; return ( - {model} + {model} · - {mode} + {mode} · - {shortCwd} + {shortCwd} · {MODE_LABELS[permMode]} + · + + ctx {approx} + {formatTokenCount(contextTokens)}/{formatTokenCount(contextWindow)} ({percent}%) + - /perm · /help · /exit + /compact · /perm · /help · /exit ); -} \ No newline at end of file +} diff --git a/src/ui/ink/index.tsx b/src/ui/ink/index.tsx index 8e0139e..6ee90e0 100644 --- a/src/ui/ink/index.tsx +++ b/src/ui/ink/index.tsx @@ -1,6 +1,8 @@ import { render } from "ink"; import { makeClient } from "../../backend/client.js"; import type { ToolCallMode } from "../../backend/capabilityProbe.js"; +import { connectConfiguredMcpServers, disconnectAllMcpServers } from "../../mcp/manager.js"; +import type { ToolDef } from "../../tools/types.js"; import { App } from "./App.js"; export interface RunInkAppOptions { @@ -9,6 +11,8 @@ export interface RunInkAppOptions { suggestedModel?: string; cwd: string; toolModeOverride?: ToolCallMode; + resumeSessionId?: string; + interactiveResume?: boolean; } export async function runInkApp(opts: RunInkAppOptions): Promise { @@ -19,6 +23,10 @@ export async function runInkApp(opts: RunInkAppOptions): Promise { process.exit(1); } + // Kick off MCP server connections immediately so they overlap with model listing/picking — + // by the time a session is actually created, this is usually already settled. + const mcpToolsPromise: Promise = connectConfiguredMcpServers(opts.cwd).catch(() => []); + // Start the UI immediately — no blocking network calls before rendering. // Model listing happens inside the App component so the user sees the UI right away. const instance = render( @@ -28,7 +36,11 @@ export async function runInkApp(opts: RunInkAppOptions): Promise { toolModeOverride={opts.toolModeOverride} initialModel={opts.model} suggestedModel={opts.suggestedModel} + resumeSessionId={opts.resumeSessionId} + interactiveResume={opts.interactiveResume} + mcpToolsPromise={mcpToolsPromise} />, ); await instance.waitUntilExit(); -} \ No newline at end of file + await disconnectAllMcpServers(); +} diff --git a/src/ui/ink/types.ts b/src/ui/ink/types.ts index 0291c8c..ff11f4b 100644 --- a/src/ui/ink/types.ts +++ b/src/ui/ink/types.ts @@ -1,6 +1,18 @@ export type HistoryItem = - | { id: string; kind: "banner"; cwd: string; model?: string; backend?: string } - | { id: string; kind: "status"; model: string; baseURL: string; mode: string; cwd: string } + | { id: string; kind: "banner"; cwd: string; model?: string; backend?: string; resumedTitle?: string } + | { + id: string; + kind: "status"; + model: string; + baseURL: string; + mode: string; + cwd: string; + sessionId: string; + contextTokens: number; + contextWindow: number; + contextTokensIsEstimate: boolean; + contextWindowIsEstimate: boolean; + } | { id: string; kind: "user"; text: string } | { id: string; kind: "assistant"; text: string } | { id: string; kind: "streaming_text"; text: string } @@ -8,8 +20,10 @@ export type HistoryItem = | { id: string; kind: "tool_result"; summary: string; isError: boolean } | { id: string; kind: "notice"; text: string; isError?: boolean } | { id: string; kind: "help" } - | { id: string; kind: "tools" } - | { id: string; kind: "permissions"; allowed: string[] }; + | { id: string; kind: "tools"; tools: import("../../tools/types.js").ToolDef[] } + | { id: string; kind: "permissions"; allowed: string[] } + | { id: string; kind: "sessions"; sessions: import("../../persistence/sessionStore.js").SessionSummary[] } + | { id: string; kind: "mcp"; statuses: import("../../mcp/manager.js").McpServerStatus[] }; /** Plain Omit collapses to keys common to every member; this distributes over each branch instead. */ export type NewHistoryItem = HistoryItem extends infer T diff --git a/src/ui/toolSummary.ts b/src/ui/toolSummary.ts index f0254d7..7b8331c 100644 --- a/src/ui/toolSummary.ts +++ b/src/ui/toolSummary.ts @@ -7,6 +7,15 @@ export function formatCallLabel(name: string, args: unknown): string { return `List(${a.pattern ?? ""})`; case "grep": return `Grep(${a.pattern ?? ""})`; + case "web_search": + return `WebSearch(${a.query ?? ""})`; + case "web_fetch": + return `WebFetch(${a.url ?? ""})`; + case "agent": + return `Agent(${a.description ?? ""})`; + case "git_status": + case "git_commit": + return `Git(${a.operation ?? ""})`; case "write_file": return `Write(${a.path ?? ""})`; case "edit_file": @@ -33,6 +42,19 @@ export function summarizeToolResult(toolName: string, result: unknown): string { return Array.isArray(r.matches) ? `Found ${r.matches.length} file(s)` : "Listed files"; case "grep": return Array.isArray(r.matches) ? `Found ${r.matches.length} match(es)` : "Searched"; + case "web_search": + return Array.isArray(r.results) ? `Found ${r.results.length} result(s)` : "Searched web"; + case "web_fetch": + return typeof r.status === "number" ? `Fetched (HTTP ${r.status})` : "Fetched page"; + case "agent": + return typeof r.result === "string" ? `Sub-agent finished (${r.result.length} chars)` : "Sub-agent finished"; + case "git_status": + return typeof r.output === "string" ? `${r.output.split("\n").filter(Boolean).length} line(s)` : "Ran git query"; + case "git_commit": + if (Array.isArray(r.staged)) return `Staged ${r.staged.length} path(s)`; + if (typeof r.created === "string") return `Created branch "${r.created}"`; + if (typeof r.switchedTo === "string") return `Switched to "${r.switchedTo}"`; + return "Done"; case "write_file": return typeof r.bytesWritten === "number" ? `Wrote ${r.bytesWritten} bytes` : "Wrote file"; case "edit_file": diff --git a/src/utils/html.ts b/src/utils/html.ts new file mode 100644 index 0000000..c0aed88 --- /dev/null +++ b/src/utils/html.ts @@ -0,0 +1,39 @@ +export const USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36"; + +export function decodeEntities(text: string): string { + return text + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/ /g, " "); +} + +export function stripTags(html: string): string { + return decodeEntities(html.replace(/<[^>]+>/g, "")).trim(); +} + +/** Strips script/style/head content and tags from an HTML document, collapsing it to readable text. */ +export function htmlToText(html: string): string { + const withoutNonContent = html + .replace(//gi, "") + .replace(//gi, "") + .replace(//gi, "") + .replace(//g, ""); + const withBreaks = withoutNonContent + .replace(/<\/(p|div|li|h[1-6]|tr|section|article|br)>/gi, "\n") + .replace(//gi, "\n"); + return stripTags(withBreaks) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .join("\n"); +} + +export function withFetchTimeout(ms: number): { signal: AbortSignal; clear: () => void } { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ms); + return { signal: controller.signal, clear: () => clearTimeout(timeout) }; +} diff --git a/src/utils/tokens.ts b/src/utils/tokens.ts new file mode 100644 index 0000000..63fa1b8 --- /dev/null +++ b/src/utils/tokens.ts @@ -0,0 +1,8 @@ +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; + +/** Rough token estimate (~4 chars/token) used when the backend doesn't report real usage stats + * (via `stream_options: { include_usage: true }`) or before any turn has run yet. */ +export function estimateTokens(messages: ChatCompletionMessageParam[]): number { + const chars = JSON.stringify(messages).length; + return Math.ceil(chars / 4); +}