From 560c9670a8fe9df445e10827e69a1d53e8a99716 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 2 Jan 2026 17:54:42 +0100 Subject: [PATCH] feat: added MCP support and plugin system for external tool integration - Added MCP (Model Context Protocol) support with stdio and HTTP transports for external tool integration. - Implemented plugin system with install, enable, configure, and doctor commands for extensibility. - Added 22 Exa MCP tools for web search, LinkedIn, company research, and websets operations. - Introduced LSP tool with rust-analyzer support for code intelligence (diagnostics, hover, references, code actions). - Added task tool for delegating work to specialized subagents with parallel execution support. - Implemented web_search and web_fetch tools with Anthropic and Perplexity provider support. --- .gitignore | 2 + AGENTS.md | 4 - bun.lock | 38 +- packages/agent/README.md | 5 +- packages/agent/src/agent-loop.ts | 27 +- packages/agent/src/agent.ts | 9 + packages/agent/src/types.ts | 32 +- packages/coding-agent/docs/custom-tools.md | 73 +- .../examples/custom-tools/hello/index.ts | 9 +- .../examples/custom-tools/question/index.ts | 15 +- .../examples/custom-tools/subagent/index.ts | 75 +- .../examples/custom-tools/todo/index.ts | 21 +- packages/coding-agent/package.json | 2 +- packages/coding-agent/src/cli/plugin-cli.ts | 650 +++++ packages/coding-agent/src/commands/init.md | 20 + .../src/core/custom-tools/loader.ts | 129 +- .../src/core/custom-tools/types.ts | 4 + .../src/core/custom-tools/wrapper.ts | 4 +- .../coding-agent/src/core/hooks/loader.ts | 60 +- .../src/core/hooks/tool-wrapper.ts | 5 +- packages/coding-agent/src/core/hooks/types.ts | 5 + packages/coding-agent/src/core/index.ts | 15 + packages/coding-agent/src/core/mcp/client.ts | 158 ++ packages/coding-agent/src/core/mcp/config.ts | 154 ++ packages/coding-agent/src/core/mcp/index.ts | 45 + packages/coding-agent/src/core/mcp/loader.ts | 70 + packages/coding-agent/src/core/mcp/manager.ts | 181 ++ .../coding-agent/src/core/mcp/tool-bridge.ts | 148 ++ .../src/core/mcp/transports/http.ts | 316 +++ .../src/core/mcp/transports/index.ts | 6 + .../src/core/mcp/transports/stdio.ts | 252 ++ packages/coding-agent/src/core/mcp/types.ts | 220 ++ .../coding-agent/src/core/plugins/doctor.ts | 59 + .../coding-agent/src/core/plugins/index.ts | 38 + .../src/core/plugins/installer.ts | 189 ++ .../coding-agent/src/core/plugins/loader.ts | 338 +++ .../coding-agent/src/core/plugins/manager.ts | 672 +++++ .../coding-agent/src/core/plugins/parser.ts | 105 + .../coding-agent/src/core/plugins/paths.ts | 32 + .../coding-agent/src/core/plugins/types.ts | 190 ++ packages/coding-agent/src/core/sdk.ts | 95 +- .../coding-agent/src/core/settings-manager.ts | 29 + .../coding-agent/src/core/slash-commands.ts | 48 +- .../coding-agent/src/core/system-prompt.ts | 8 + packages/coding-agent/src/core/tools/ask.ts | 211 ++ packages/coding-agent/src/core/tools/ast.ts | 271 ++ .../coding-agent/src/core/tools/context.ts | 32 + .../src/core/tools/exa/company.ts | 59 + .../coding-agent/src/core/tools/exa/index.ts | 64 + .../src/core/tools/exa/linkedin.ts | 59 + .../coding-agent/src/core/tools/exa/logger.ts | 56 + .../src/core/tools/exa/mcp-client.ts | 367 +++ .../coding-agent/src/core/tools/exa/render.ts | 187 ++ .../src/core/tools/exa/researcher.ts | 90 + .../coding-agent/src/core/tools/exa/search.ts | 337 +++ .../coding-agent/src/core/tools/exa/types.ts | 168 ++ .../src/core/tools/exa/websets.ts | 248 ++ packages/coding-agent/src/core/tools/find.ts | 76 +- packages/coding-agent/src/core/tools/grep.ts | 246 +- packages/coding-agent/src/core/tools/index.ts | 131 +- .../coding-agent/src/core/tools/lsp/client.ts | 605 +++++ .../coding-agent/src/core/tools/lsp/config.ts | 147 ++ .../coding-agent/src/core/tools/lsp/edits.ts | 101 + .../coding-agent/src/core/tools/lsp/index.ts | 746 ++++++ .../coding-agent/src/core/tools/lsp/render.ts | 441 ++++ .../src/core/tools/lsp/rust-analyzer.ts | 145 ++ .../coding-agent/src/core/tools/lsp/types.ts | 436 ++++ .../coding-agent/src/core/tools/lsp/utils.ts | 486 ++++ .../coding-agent/src/core/tools/notebook.ts | 229 ++ packages/coding-agent/src/core/tools/read.ts | 52 +- .../coding-agent/src/core/tools/renderers.ts | 717 ++++++ .../coding-agent/src/core/tools/replace.ts | 297 +++ .../src/core/tools/task/agents.ts | 153 ++ .../src/core/tools/task/artifacts.ts | 114 + .../core/tools/task/bundled-agents/browser.md | 71 + .../core/tools/task/bundled-agents/explore.md | 82 + .../core/tools/task/bundled-agents/plan.md | 54 + .../tools/task/bundled-agents/reviewer.md | 59 + .../core/tools/task/bundled-agents/task.md | 53 + .../task/bundled-commands/architect-plan.md | 10 + .../bundled-commands/implement-with-critic.md | 11 + .../tools/task/bundled-commands/implement.md | 11 + .../src/core/tools/task/commands.ts | 213 ++ .../src/core/tools/task/discovery.ts | 216 ++ .../src/core/tools/task/executor.ts | 367 +++ .../coding-agent/src/core/tools/task/index.ts | 396 +++ .../src/core/tools/task/model-resolver.ts | 115 + .../src/core/tools/task/parallel.ts | 38 + .../src/core/tools/task/render.ts | 232 ++ .../coding-agent/src/core/tools/task/types.ts | 121 + .../coding-agent/src/core/tools/web-fetch.ts | 2258 +++++++++++++++++ .../src/core/tools/web-search/auth.ts | 193 ++ .../src/core/tools/web-search/index.ts | 219 ++ .../tools/web-search/providers/anthropic.ts | 198 ++ .../tools/web-search/providers/perplexity.ts | 195 ++ .../src/core/tools/web-search/render.ts | 170 ++ .../src/core/tools/web-search/types.ts | 180 ++ packages/coding-agent/src/index.ts | 17 +- packages/coding-agent/src/main.ts | 20 +- .../interactive/components/custom-editor.ts | 9 + .../interactive/components/plugin-settings.ts | 477 ++++ .../components/settings-selector.ts | 14 + .../interactive/components/tool-execution.ts | 43 +- .../src/modes/interactive/interactive-mode.ts | 56 +- .../coding-agent/src/modes/rpc/rpc-client.ts | 17 +- packages/coding-agent/src/utils/clipboard.ts | 218 ++ .../coding-agent/src/utils/tools-manager.ts | 57 +- packages/tui/src/components/editor.ts | 16 + packages/tui/src/index.ts | 1 + packages/tui/src/keys.ts | 13 + 110 files changed, 17880 insertions(+), 368 deletions(-) create mode 100644 packages/coding-agent/src/cli/plugin-cli.ts create mode 100644 packages/coding-agent/src/commands/init.md create mode 100644 packages/coding-agent/src/core/mcp/client.ts create mode 100644 packages/coding-agent/src/core/mcp/config.ts create mode 100644 packages/coding-agent/src/core/mcp/index.ts create mode 100644 packages/coding-agent/src/core/mcp/loader.ts create mode 100644 packages/coding-agent/src/core/mcp/manager.ts create mode 100644 packages/coding-agent/src/core/mcp/tool-bridge.ts create mode 100644 packages/coding-agent/src/core/mcp/transports/http.ts create mode 100644 packages/coding-agent/src/core/mcp/transports/index.ts create mode 100644 packages/coding-agent/src/core/mcp/transports/stdio.ts create mode 100644 packages/coding-agent/src/core/mcp/types.ts create mode 100644 packages/coding-agent/src/core/plugins/doctor.ts create mode 100644 packages/coding-agent/src/core/plugins/index.ts create mode 100644 packages/coding-agent/src/core/plugins/installer.ts create mode 100644 packages/coding-agent/src/core/plugins/loader.ts create mode 100644 packages/coding-agent/src/core/plugins/manager.ts create mode 100644 packages/coding-agent/src/core/plugins/parser.ts create mode 100644 packages/coding-agent/src/core/plugins/paths.ts create mode 100644 packages/coding-agent/src/core/plugins/types.ts create mode 100644 packages/coding-agent/src/core/tools/ask.ts create mode 100644 packages/coding-agent/src/core/tools/ast.ts create mode 100644 packages/coding-agent/src/core/tools/context.ts create mode 100644 packages/coding-agent/src/core/tools/exa/company.ts create mode 100644 packages/coding-agent/src/core/tools/exa/index.ts create mode 100644 packages/coding-agent/src/core/tools/exa/linkedin.ts create mode 100644 packages/coding-agent/src/core/tools/exa/logger.ts create mode 100644 packages/coding-agent/src/core/tools/exa/mcp-client.ts create mode 100644 packages/coding-agent/src/core/tools/exa/render.ts create mode 100644 packages/coding-agent/src/core/tools/exa/researcher.ts create mode 100644 packages/coding-agent/src/core/tools/exa/search.ts create mode 100644 packages/coding-agent/src/core/tools/exa/types.ts create mode 100644 packages/coding-agent/src/core/tools/exa/websets.ts create mode 100644 packages/coding-agent/src/core/tools/lsp/client.ts create mode 100644 packages/coding-agent/src/core/tools/lsp/config.ts create mode 100644 packages/coding-agent/src/core/tools/lsp/edits.ts create mode 100644 packages/coding-agent/src/core/tools/lsp/index.ts create mode 100644 packages/coding-agent/src/core/tools/lsp/render.ts create mode 100644 packages/coding-agent/src/core/tools/lsp/rust-analyzer.ts create mode 100644 packages/coding-agent/src/core/tools/lsp/types.ts create mode 100644 packages/coding-agent/src/core/tools/lsp/utils.ts create mode 100644 packages/coding-agent/src/core/tools/notebook.ts create mode 100644 packages/coding-agent/src/core/tools/renderers.ts create mode 100644 packages/coding-agent/src/core/tools/replace.ts create mode 100644 packages/coding-agent/src/core/tools/task/agents.ts create mode 100644 packages/coding-agent/src/core/tools/task/artifacts.ts create mode 100644 packages/coding-agent/src/core/tools/task/bundled-agents/browser.md create mode 100644 packages/coding-agent/src/core/tools/task/bundled-agents/explore.md create mode 100644 packages/coding-agent/src/core/tools/task/bundled-agents/plan.md create mode 100644 packages/coding-agent/src/core/tools/task/bundled-agents/reviewer.md create mode 100644 packages/coding-agent/src/core/tools/task/bundled-agents/task.md create mode 100644 packages/coding-agent/src/core/tools/task/bundled-commands/architect-plan.md create mode 100644 packages/coding-agent/src/core/tools/task/bundled-commands/implement-with-critic.md create mode 100644 packages/coding-agent/src/core/tools/task/bundled-commands/implement.md create mode 100644 packages/coding-agent/src/core/tools/task/commands.ts create mode 100644 packages/coding-agent/src/core/tools/task/discovery.ts create mode 100644 packages/coding-agent/src/core/tools/task/executor.ts create mode 100644 packages/coding-agent/src/core/tools/task/index.ts create mode 100644 packages/coding-agent/src/core/tools/task/model-resolver.ts create mode 100644 packages/coding-agent/src/core/tools/task/parallel.ts create mode 100644 packages/coding-agent/src/core/tools/task/render.ts create mode 100644 packages/coding-agent/src/core/tools/task/types.ts create mode 100644 packages/coding-agent/src/core/tools/web-fetch.ts create mode 100644 packages/coding-agent/src/core/tools/web-search/auth.ts create mode 100644 packages/coding-agent/src/core/tools/web-search/index.ts create mode 100644 packages/coding-agent/src/core/tools/web-search/providers/anthropic.ts create mode 100644 packages/coding-agent/src/core/tools/web-search/providers/perplexity.ts create mode 100644 packages/coding-agent/src/core/tools/web-search/render.ts create mode 100644 packages/coding-agent/src/core/tools/web-search/types.ts create mode 100644 packages/coding-agent/src/modes/interactive/components/plugin-settings.ts diff --git a/.gitignore b/.gitignore index c0a2bf893..f7c9b6354 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,5 @@ syntax.jsonl out.jsonl pi-*.html out.html + +*.kate-swp diff --git a/AGENTS.md b/AGENTS.md index bc6a60891..79246aaba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -345,7 +345,3 @@ Use these sections under `## [Unreleased]`: ``` The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. - -### Tool Usage - -**CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads). diff --git a/bun.lock b/bun.lock index 54ebddfa0..e6eef5818 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.31.1", + "version": "1.337.0", "dependencies": { "@mariozechner/pi-ai": "workspace:*", "@mariozechner/pi-tui": "workspace:*", @@ -31,7 +31,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.31.1", + "version": "1.337.0", "bin": { "pi-ai": "./src/cli.ts", }, @@ -55,7 +55,7 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.31.1", + "version": "1.337.0", "bin": { "pi": "src/cli.ts", }, @@ -69,9 +69,9 @@ "file-type": "^21.1.1", "glob": "^11.0.3", "highlight.js": "^11.11.1", - "jiti": "^2.6.1", "marked": "^15.0.12", "minimatch": "^10.1.1", + "node-html-parser": "^6.1.13", "strip-ansi": "^7.1.2", }, "devDependencies": { @@ -82,7 +82,7 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.31.1", + "version": "1.337.0", "bin": { "mom": "src/main.ts", }, @@ -105,7 +105,7 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.31.1", + "version": "1.337.0", "bin": { "pi-pods": "dist/cli.js", }, @@ -116,7 +116,7 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.31.1", + "version": "1.337.0", "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", @@ -131,7 +131,7 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.31.1", + "version": "1.337.0", "dependencies": { "@lmstudio/sdk": "^1.5.0", "@mariozechner/pi-agent-core": "workspace:*", @@ -514,6 +514,8 @@ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -562,6 +564,10 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -580,6 +586,14 @@ "docx-preview": ["docx-preview@0.3.7", "", { "dependencies": { "jszip": ">=3.0.0" } }, "sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg=="], + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], @@ -592,6 +606,8 @@ "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], @@ -678,6 +694,8 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], "html-parse-string": ["html-parse-string@0.0.9", "", {}, "sha512-wyGnsOolHbNrcb8N6bdJF4EHyzd3zVGCb9/mBxeNjAYBDOZqD7YkqLBz7kXtdgHwNnV8lN/BpSDpsI1zm8Sd8g=="], @@ -812,6 +830,10 @@ "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "node-html-parser": ["node-html-parser@6.1.13", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="], diff --git a/packages/agent/README.md b/packages/agent/README.md index 44206fb0a..ac66192a3 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -152,6 +152,9 @@ const agent = new Agent({ // Dynamic API key resolution (for expiring OAuth tokens) getApiKey: async (provider) => refreshToken(), + + // Tool execution context (late-bound UI/session access) + getToolContext: () => ({ /* app-defined */ }), }); ``` @@ -282,7 +285,7 @@ const readFileTool: AgentTool = { parameters: Type.Object({ path: Type.String({ description: "File path" }), }), - execute: async (toolCallId, params, signal, onUpdate) => { + execute: async (toolCallId, params, signal, onUpdate, context) => { const content = await fs.readFile(params.path, "utf-8"); // Optional: stream progress diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index ee63b89f1..3ac56c528 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -155,6 +155,7 @@ async function runLoop( signal, stream, config.getQueuedMessages, + config.getToolContext, ); toolResults.push(...toolExecution.toolResults); queuedAfterTools = toolExecution.queuedMessages ?? null; @@ -280,6 +281,7 @@ async function executeToolCalls( signal: AbortSignal | undefined, stream: EventStream, getQueuedMessages?: AgentLoopConfig["getQueuedMessages"], + getToolContext?: AgentLoopConfig["getToolContext"], ): Promise<{ toolResults: ToolResultMessage[]; queuedMessages?: AgentMessage[] }> { const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall"); const results: ToolResultMessage[] = []; @@ -303,16 +305,23 @@ async function executeToolCalls( if (!tool) throw new Error(`Tool ${toolCall.name} not found`); const validatedArgs = validateToolArguments(tool, toolCall); + const toolContext = getToolContext?.(); - result = await tool.execute(toolCall.id, validatedArgs, signal, (partialResult) => { - stream.push({ - type: "tool_execution_update", - toolCallId: toolCall.id, - toolName: toolCall.name, - args: toolCall.arguments, - partialResult, - }); - }); + result = await tool.execute( + toolCall.id, + validatedArgs, + signal, + (partialResult) => { + stream.push({ + type: "tool_execution_update", + toolCallId: toolCall.id, + toolName: toolCall.name, + args: toolCall.arguments, + partialResult, + }); + }, + toolContext, + ); } catch (e) { result = { content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }], diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 078b707ea..b70cd656d 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -20,6 +20,7 @@ import type { AgentMessage, AgentState, AgentTool, + AgentToolContext, StreamFn, ThinkingLevel, } from "./types.js"; @@ -61,6 +62,11 @@ export interface AgentOptions { * Useful for expiring tokens (e.g., GitHub Copilot OAuth). */ getApiKey?: (provider: string) => Promise | string | undefined; + + /** + * Provides tool execution context, resolved per tool call. + */ + getToolContext?: () => AgentToolContext | undefined; } export class Agent { @@ -84,6 +90,7 @@ export class Agent { private queueMode: "all" | "one-at-a-time"; public streamFn: StreamFn; public getApiKey?: (provider: string) => Promise | string | undefined; + private getToolContext?: () => AgentToolContext | undefined; private runningPrompt?: Promise; private resolveRunningPrompt?: () => void; @@ -94,6 +101,7 @@ export class Agent { this.queueMode = opts.queueMode || "one-at-a-time"; this.streamFn = opts.streamFn || streamSimple; this.getApiKey = opts.getApiKey; + this.getToolContext = opts.getToolContext; } get state(): AgentState { @@ -247,6 +255,7 @@ export class Agent { convertToLlm: this.convertToLlm, transformContext: this.transformContext, getApiKey: this.getApiKey, + getToolContext: this.getToolContext, getQueuedMessages: async () => { if (this.queueMode === "one-at-a-time") { if (this.messageQueue.length > 0) { diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index e8af618ed..e7c1f0167 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -81,6 +81,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions { * If messages are returned, they're added to the context before the next LLM call. */ getQueuedMessages?: () => Promise; + + /** + * Provides tool execution context, resolved per tool call. + * Use for late-bound UI or session state access. + */ + getToolContext?: () => AgentToolContext | undefined; } /** @@ -139,8 +145,25 @@ export interface AgentToolResult { // Callback for streaming tool execution updates export type AgentToolUpdateCallback = (partialResult: AgentToolResult) => void; +/** Options passed to renderResult */ +export interface RenderResultOptions { + /** Whether the result view is expanded */ + expanded: boolean; + /** Whether this is a partial/streaming result */ + isPartial: boolean; +} + +/** + * Context passed to tool execution. + * Apps can extend via declaration merging. + */ +export interface AgentToolContext { + // Empty by default - apps extend via declaration merging +} + // AgentTool extends Tool but adds the execute function -export interface AgentTool extends Tool { +export interface AgentTool + extends Tool { // A human-readable label for the tool to be displayed in UI label: string; execute: ( @@ -148,7 +171,14 @@ export interface AgentTool, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, + context?: AgentToolContext, ) => Promise>; + + /** Optional custom rendering for tool call display (returns UI component) */ + renderCall?: (args: Static, theme: TTheme) => unknown; + + /** Optional custom rendering for tool result display (returns UI component) */ + renderResult?: (result: AgentToolResult, options: RenderResultOptions, theme: TTheme) => unknown; } // AgentContext is like Context but uses AgentTool diff --git a/packages/coding-agent/docs/custom-tools.md b/packages/coding-agent/docs/custom-tools.md index 61061ef62..e847880ab 100644 --- a/packages/coding-agent/docs/custom-tools.md +++ b/packages/coding-agent/docs/custom-tools.md @@ -33,15 +33,14 @@ See [examples/custom-tools/](../examples/custom-tools/) for working examples. Create a file `~/.pi/agent/tools/hello/index.ts`: ```typescript -import { Type } from "@sinclair/typebox"; import type { CustomToolFactory } from "@mariozechner/pi-coding-agent"; const factory: CustomToolFactory = (pi) => ({ name: "hello", label: "Hello", description: "A simple greeting tool", - parameters: Type.Object({ - name: Type.String({ description: "Name to greet" }), + parameters: pi.typebox.Type.Object({ + name: pi.typebox.Type.String({ description: "Name to greet" }), }), async execute(toolCallId, params, onUpdate, ctx, signal) { @@ -86,23 +85,22 @@ Tools must be in a subdirectory with an `index.ts` entry point: ## Available Imports -Custom tools can import from these packages (automatically resolved by pi): +Custom tools can import from these packages: -| Package | Purpose | -|---------|---------| -| `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) | -| `@mariozechner/pi-coding-agent` | Types (`CustomToolFactory`, `CustomTool`, `CustomToolContext`, etc.) | -| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | -| `@mariozechner/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) | +| Package | Purpose | Import Method | +|---------|---------|---------------| +| `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) | Via `pi.typebox.*` (injected) | +| `@mariozechner/pi-coding-agent` | Types and utilities | Via `pi.pi.*` (injected) or direct import for types | +| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | Via `pi.pi.*` (re-exported through coding-agent) | +| `@mariozechner/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) | Via `pi.pi.*` (re-exported through coding-agent) | Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available. +**Important:** Use `pi.typebox.Type.*` instead of importing from `@sinclair/typebox` directly. Dependencies are injected via the `CustomToolAPI` to avoid import resolution issues. + ## Tool Definition ```typescript -import { Type } from "@sinclair/typebox"; -import { StringEnum } from "@mariozechner/pi-ai"; -import { Text } from "@mariozechner/pi-tui"; import type { CustomTool, CustomToolContext, @@ -110,15 +108,21 @@ import type { CustomToolSessionEvent, } from "@mariozechner/pi-coding-agent"; -const factory: CustomToolFactory = (pi) => ({ - name: "my_tool", - label: "My Tool", - description: "What this tool does (be specific for LLM)", - parameters: Type.Object({ - // Use StringEnum for string enums (Google API compatible) - action: StringEnum(["list", "add", "remove"] as const), - text: Type.Optional(Type.String()), - }), +const factory: CustomToolFactory = (pi) => { + // Destructure injected dependencies + const { Type } = pi.typebox; + const { StringEnum } = pi.pi; + const { Text } = pi.pi; + + return { + name: "my_tool", + label: "My Tool", + description: "What this tool does (be specific for LLM)", + parameters: Type.Object({ + // Use StringEnum for string enums (Google API compatible) + action: StringEnum(["list", "add", "remove"] as const), + text: Type.Optional(Type.String()), + }), async execute(toolCallId, params, onUpdate, ctx, signal) { // signal - AbortSignal for cancellation @@ -139,15 +143,16 @@ const factory: CustomToolFactory = (pi) => ({ // Reconstruct state from ctx.sessionManager.getBranch() }, - // Optional: Custom rendering - renderCall(args, theme) { /* return Component */ }, - renderResult(result, options, theme) { /* return Component */ }, -}); + // Optional: Custom rendering + renderCall(args, theme) { /* return Component */ }, + renderResult(result, options, theme) { /* return Component */ }, + }; +}; export default factory; ``` -**Important:** Use `StringEnum` from `@mariozechner/pi-ai` instead of `Type.Union`/`Type.Literal` for string enums. The latter doesn't work with Google's API. +**Important:** Use `StringEnum` from `pi.pi` instead of `Type.Union`/`Type.Literal` for string enums. The latter doesn't work with Google's API. ## CustomToolAPI Object @@ -159,6 +164,8 @@ interface CustomToolAPI { exec(command: string, args: string[], options?: ExecOptions): Promise; ui: ToolUIContext; hasUI: boolean; // false in --print or --mode rpc + typebox: typeof import("@sinclair/typebox"); // Injected @sinclair/typebox + pi: typeof import("@mariozechner/pi-coding-agent"); // Injected pi-coding-agent exports } interface ToolUIContext { @@ -304,20 +311,22 @@ interface MyToolDetails { } const factory: CustomToolFactory = (pi) => { + const { Type } = pi.typebox; + // In-memory state let items: string[] = []; // Reconstruct state from session entries const reconstructState = (event: CustomToolSessionEvent, ctx: CustomToolContext) => { if (event.reason === "shutdown") return; - + items = []; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type !== "message") continue; const msg = entry.message; if (msg.role !== "toolResult") continue; if (msg.toolName !== "my_tool") continue; - + const details = msg.details as MyToolDetails | undefined; if (details) { items = details.items; @@ -330,13 +339,13 @@ const factory: CustomToolFactory = (pi) => { label: "My Tool", description: "...", parameters: Type.Object({ ... }), - + onSession: reconstructState, - + async execute(toolCallId, params, onUpdate, ctx, signal) { // Modify items... items.push("new item"); - + return { content: [{ type: "text", text: "Added item" }], // Store current state in details for reconstruction diff --git a/packages/coding-agent/examples/custom-tools/hello/index.ts b/packages/coding-agent/examples/custom-tools/hello/index.ts index e72e7f05d..19cf9e655 100644 --- a/packages/coding-agent/examples/custom-tools/hello/index.ts +++ b/packages/coding-agent/examples/custom-tools/hello/index.ts @@ -1,16 +1,15 @@ import type { CustomToolFactory } from "@mariozechner/pi-coding-agent"; -import { Type } from "@sinclair/typebox"; -const factory: CustomToolFactory = (_pi) => ({ +const factory: CustomToolFactory = (pi) => ({ name: "hello", label: "Hello", description: "A simple greeting tool", - parameters: Type.Object({ - name: Type.String({ description: "Name to greet" }), + parameters: pi.typebox.Type.Object({ + name: pi.typebox.Type.String({ description: "Name to greet" }), }), async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { - const { name } = params as { name: string }; + const { name } = params; return { content: [{ type: "text", text: `Hello, ${name}!` }], details: { greeted: name }, diff --git a/packages/coding-agent/examples/custom-tools/question/index.ts b/packages/coding-agent/examples/custom-tools/question/index.ts index e75e8c451..2ccbb53d1 100644 --- a/packages/coding-agent/examples/custom-tools/question/index.ts +++ b/packages/coding-agent/examples/custom-tools/question/index.ts @@ -3,8 +3,6 @@ */ import type { CustomTool, CustomToolFactory } from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; -import { Type } from "@sinclair/typebox"; interface QuestionDetails { question: string; @@ -12,12 +10,15 @@ interface QuestionDetails { answer: string | null; } -const QuestionParams = Type.Object({ - question: Type.String({ description: "The question to ask the user" }), - options: Type.Array(Type.String(), { description: "Options for the user to choose from" }), -}); - const factory: CustomToolFactory = (pi) => { + const { Type } = pi.typebox; + const { Text } = pi.pi; + + const QuestionParams = Type.Object({ + question: Type.String({ description: "The question to ask the user" }), + options: Type.Array(Type.String(), { description: "Options for the user to choose from" }), + }); + const tool: CustomTool = { name: "question", label: "Question", diff --git a/packages/coding-agent/examples/custom-tools/subagent/index.ts b/packages/coding-agent/examples/custom-tools/subagent/index.ts index 3c6b56c66..fd75bd78a 100644 --- a/packages/coding-agent/examples/custom-tools/subagent/index.ts +++ b/packages/coding-agent/examples/custom-tools/subagent/index.ts @@ -17,15 +17,7 @@ import * as os from "node:os"; import * as path from "node:path"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { Message } from "@mariozechner/pi-ai"; -import { StringEnum } from "@mariozechner/pi-ai"; -import { - type CustomTool, - type CustomToolAPI, - type CustomToolFactory, - getMarkdownTheme, -} from "@mariozechner/pi-coding-agent"; -import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; -import { Type } from "@sinclair/typebox"; +import type { CustomTool, CustomToolAPI, CustomToolFactory } from "@mariozechner/pi-coding-agent"; import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList } from "./agents.js"; const MAX_PARALLEL_TASKS = 8; @@ -401,36 +393,39 @@ async function runSingleAgent( } } -const TaskItem = Type.Object({ - agent: Type.String({ description: "Name of the agent to invoke" }), - task: Type.String({ description: "Task to delegate to the agent" }), - cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })), -}); - -const ChainItem = Type.Object({ - agent: Type.String({ description: "Name of the agent to invoke" }), - task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }), - cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })), -}); - -const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, { - description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.', - default: "user", -}); - -const SubagentParams = Type.Object({ - agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })), - task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })), - tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })), - chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })), - agentScope: Type.Optional(AgentScopeSchema), - confirmProjectAgents: Type.Optional( - Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }), - ), - cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })), -}); - const factory: CustomToolFactory = (pi) => { + const { Type } = pi.typebox; + const { StringEnum, Container, Markdown, Spacer, Text, getMarkdownTheme } = pi.pi; + + const TaskItem = Type.Object({ + agent: Type.String({ description: "Name of the agent to invoke" }), + task: Type.String({ description: "Task to delegate to the agent" }), + cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })), + }); + + const ChainItem = Type.Object({ + agent: Type.String({ description: "Name of the agent to invoke" }), + task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }), + cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })), + }); + + const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, { + description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.', + default: "user", + }); + + const SubagentParams = Type.Object({ + agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })), + task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })), + tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })), + chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })), + agentScope: Type.Optional(AgentScopeSchema), + confirmProjectAgents: Type.Optional( + Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }), + ), + cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })), + }); + const tool: CustomTool = { name: "subagent", label: "Subagent", @@ -454,7 +449,7 @@ const factory: CustomToolFactory = (pi) => { parameters: SubagentParams, async execute(_toolCallId, params, onUpdate, _ctx, signal) { - const agentScope: AgentScope = params.agentScope ?? "user"; + const agentScope = (params.agentScope ?? "user") as AgentScope; const discovery = discoverAgents(pi.cwd, agentScope); const agents = discovery.agents; const confirmProjectAgents = params.confirmProjectAgents ?? true; @@ -683,7 +678,7 @@ const factory: CustomToolFactory = (pi) => { }, renderCall(args, theme) { - const scope: AgentScope = args.agentScope ?? "user"; + const scope = (args.agentScope ?? "user") as AgentScope; if (args.chain && args.chain.length > 0) { let text = theme.fg("toolTitle", theme.bold("subagent ")) + diff --git a/packages/coding-agent/examples/custom-tools/todo/index.ts b/packages/coding-agent/examples/custom-tools/todo/index.ts index a20bf3de9..8e414a7fa 100644 --- a/packages/coding-agent/examples/custom-tools/todo/index.ts +++ b/packages/coding-agent/examples/custom-tools/todo/index.ts @@ -8,15 +8,12 @@ * The onSession callback reconstructs state by scanning past tool results. */ -import { StringEnum } from "@mariozechner/pi-ai"; import type { CustomTool, CustomToolContext, CustomToolFactory, CustomToolSessionEvent, } from "@mariozechner/pi-coding-agent"; -import { Text } from "@mariozechner/pi-tui"; -import { Type } from "@sinclair/typebox"; interface Todo { id: number; @@ -32,14 +29,16 @@ interface TodoDetails { error?: string; } -// Define schema separately for proper type inference -const TodoParams = Type.Object({ - action: StringEnum(["list", "add", "toggle", "clear"] as const), - text: Type.Optional(Type.String({ description: "Todo text (for add)" })), - id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })), -}); +const factory: CustomToolFactory = (pi) => { + const { Type } = pi.typebox; + const { StringEnum, Text } = pi.pi; -const factory: CustomToolFactory = (_pi) => { + // Define schema separately for proper type inference + const TodoParams = Type.Object({ + action: StringEnum(["list", "add", "toggle", "clear"] as const), + text: Type.Optional(Type.String({ description: "Todo text (for add)" })), + id: Type.Optional(Type.Number({ description: "Todo ID (for toggle)" })), + }); // In-memory state (reconstructed from session on load) let todos: Todo[] = []; let nextId = 1; @@ -148,7 +147,7 @@ const factory: CustomToolFactory = (_pi) => { }, renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", args.action); + let text = theme.fg("toolTitle", theme.bold("todo ")) + theme.fg("muted", String(args.action)); if (args.text) text += ` ${theme.fg("dim", `"${args.text}"`)}`; if (args.id !== undefined) text += ` ${theme.fg("accent", `#${args.id}`)}`; return new Text(text, 0, 0); diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 70cd3cd64..2dfcec1b8 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -42,12 +42,12 @@ "@mariozechner/pi-ai": "workspace:*", "@mariozechner/pi-tui": "workspace:*", "chalk": "^5.5.0", + "node-html-parser": "^6.1.13", "cli-highlight": "^2.1.11", "diff": "^8.0.2", "file-type": "^21.1.1", "glob": "^11.0.3", "highlight.js": "^11.11.1", - "jiti": "^2.6.1", "marked": "^15.0.12", "minimatch": "^10.1.1", "strip-ansi": "^7.1.2" diff --git a/packages/coding-agent/src/cli/plugin-cli.ts b/packages/coding-agent/src/cli/plugin-cli.ts new file mode 100644 index 000000000..b647c2b7e --- /dev/null +++ b/packages/coding-agent/src/cli/plugin-cli.ts @@ -0,0 +1,650 @@ +/** + * Plugin CLI command handlers. + * + * Handles `pi plugin ` subcommands for plugin lifecycle management. + */ + +import chalk from "chalk"; +import { PluginManager, parseSettingValue, validateSetting } from "../core/plugins/index.js"; + +// ============================================================================= +// Types +// ============================================================================= + +export type PluginAction = + | "install" + | "uninstall" + | "list" + | "link" + | "doctor" + | "features" + | "config" + | "enable" + | "disable"; + +export interface PluginCommandArgs { + action: PluginAction; + args: string[]; + flags: { + json?: boolean; + fix?: boolean; + force?: boolean; + dryRun?: boolean; + local?: boolean; + enable?: string; + disable?: string; + set?: string; + }; +} + +// ============================================================================= +// Argument Parser +// ============================================================================= + +const VALID_ACTIONS: PluginAction[] = [ + "install", + "uninstall", + "list", + "link", + "doctor", + "features", + "config", + "enable", + "disable", +]; + +/** + * Parse plugin subcommand arguments. + * Returns undefined if not a plugin command. + */ +export function parsePluginArgs(args: string[]): PluginCommandArgs | undefined { + if (args.length === 0 || args[0] !== "plugin") { + return undefined; + } + + if (args.length < 2) { + return { action: "list", args: [], flags: {} }; + } + + const action = args[1]; + if (!VALID_ACTIONS.includes(action as PluginAction)) { + console.error(chalk.red(`Unknown plugin command: ${action}`)); + console.error(`Valid commands: ${VALID_ACTIONS.join(", ")}`); + process.exit(1); + } + + const result: PluginCommandArgs = { + action: action as PluginAction, + args: [], + flags: {}, + }; + + // Parse remaining arguments + for (let i = 2; i < args.length; i++) { + const arg = args[i]; + if (arg === "--json") { + result.flags.json = true; + } else if (arg === "--fix") { + result.flags.fix = true; + } else if (arg === "--force") { + result.flags.force = true; + } else if (arg === "--dry-run") { + result.flags.dryRun = true; + } else if (arg === "-l" || arg === "--local") { + result.flags.local = true; + } else if (arg === "--enable" && i + 1 < args.length) { + result.flags.enable = args[++i]; + } else if (arg === "--disable" && i + 1 < args.length) { + result.flags.disable = args[++i]; + } else if (arg === "--set" && i + 1 < args.length) { + result.flags.set = args[++i]; + } else if (!arg.startsWith("-")) { + result.args.push(arg); + } + } + + return result; +} + +// ============================================================================= +// Command Handlers +// ============================================================================= + +/** + * Run a plugin command. + */ +export async function runPluginCommand(cmd: PluginCommandArgs): Promise { + const manager = new PluginManager(); + + switch (cmd.action) { + case "install": + await handleInstall(manager, cmd.args, cmd.flags); + break; + case "uninstall": + await handleUninstall(manager, cmd.args, cmd.flags); + break; + case "list": + await handleList(manager, cmd.flags); + break; + case "link": + await handleLink(manager, cmd.args, cmd.flags); + break; + case "doctor": + await handleDoctor(manager, cmd.flags); + break; + case "features": + await handleFeatures(manager, cmd.args, cmd.flags); + break; + case "config": + await handleConfig(manager, cmd.args, cmd.flags); + break; + case "enable": + await handleEnable(manager, cmd.args, cmd.flags); + break; + case "disable": + await handleDisable(manager, cmd.args, cmd.flags); + break; + } +} + +async function handleInstall( + manager: PluginManager, + packages: string[], + flags: { json?: boolean; force?: boolean; dryRun?: boolean }, +): Promise { + if (packages.length === 0) { + console.error(chalk.red("Usage: pi plugin install [features] ...")); + console.error(chalk.dim("Examples:")); + console.error(chalk.dim(" pi plugin install @oh-my-pi/exa")); + console.error(chalk.dim(" pi plugin install @oh-my-pi/exa[search,websets]")); + console.error(chalk.dim(" pi plugin install @oh-my-pi/exa[*] # all features")); + console.error(chalk.dim(" pi plugin install @oh-my-pi/exa[] # no optional features")); + process.exit(1); + } + + for (const spec of packages) { + try { + const result = await manager.install(spec, { force: flags.force, dryRun: flags.dryRun }); + + if (flags.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + if (flags.dryRun) { + console.log(chalk.dim(`[dry-run] Would install ${spec}`)); + } else { + console.log(chalk.green(`✓ Installed ${result.name}@${result.version}`)); + if (result.enabledFeatures && result.enabledFeatures.length > 0) { + console.log(chalk.dim(` Features: ${result.enabledFeatures.join(", ")}`)); + } + if (result.manifest.description) { + console.log(chalk.dim(` ${result.manifest.description}`)); + } + } + } + } catch (err) { + console.error(chalk.red(`✗ Failed to install ${spec}: ${err}`)); + process.exit(1); + } + } +} + +async function handleUninstall(manager: PluginManager, packages: string[], flags: { json?: boolean }): Promise { + if (packages.length === 0) { + console.error(chalk.red("Usage: pi plugin uninstall ...")); + process.exit(1); + } + + for (const name of packages) { + try { + await manager.uninstall(name); + + if (flags.json) { + console.log(JSON.stringify({ uninstalled: name })); + } else { + console.log(chalk.green(`✓ Uninstalled ${name}`)); + } + } catch (err) { + console.error(chalk.red(`✗ Failed to uninstall ${name}: ${err}`)); + process.exit(1); + } + } +} + +async function handleList(manager: PluginManager, flags: { json?: boolean }): Promise { + const plugins = await manager.list(); + + if (flags.json) { + console.log(JSON.stringify(plugins, null, 2)); + return; + } + + if (plugins.length === 0) { + console.log(chalk.dim("No plugins installed")); + console.log(chalk.dim("\nInstall plugins with: pi plugin install ")); + return; + } + + console.log(chalk.bold("Installed Plugins:\n")); + for (const plugin of plugins) { + const status = plugin.enabled ? chalk.green("●") : chalk.dim("○"); + const nameVersion = `${plugin.name}@${plugin.version}`; + console.log(`${status} ${nameVersion}`); + + if (plugin.manifest.description) { + console.log(chalk.dim(` ${plugin.manifest.description}`)); + } + + if (plugin.enabledFeatures && plugin.enabledFeatures.length > 0) { + console.log(chalk.dim(` Features: ${plugin.enabledFeatures.join(", ")}`)); + } + + // Show available features if manifest has them + if (plugin.manifest.features) { + const availableFeatures = Object.keys(plugin.manifest.features); + if (availableFeatures.length > 0) { + const enabledSet = new Set(plugin.enabledFeatures ?? []); + const featureDisplay = availableFeatures + .map((f) => (enabledSet.has(f) ? chalk.green(f) : chalk.dim(f))) + .join(", "); + console.log(chalk.dim(` Available: [${featureDisplay}]`)); + } + } + } +} + +async function handleLink(manager: PluginManager, paths: string[], flags: { json?: boolean }): Promise { + if (paths.length === 0) { + console.error(chalk.red("Usage: pi plugin link ")); + process.exit(1); + } + + try { + const result = await manager.link(paths[0]); + + if (flags.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(chalk.green(`✓ Linked ${result.name} from ${paths[0]}`)); + } + } catch (err) { + console.error(chalk.red(`✗ Failed to link: ${err}`)); + process.exit(1); + } +} + +async function handleDoctor(manager: PluginManager, flags: { json?: boolean; fix?: boolean }): Promise { + const checks = await manager.doctor({ fix: flags.fix }); + + if (flags.json) { + console.log(JSON.stringify(checks, null, 2)); + return; + } + + console.log(chalk.bold("Plugin Health Check\n")); + + for (const check of checks) { + const icon = + check.status === "ok" ? chalk.green("✓") : check.status === "warning" ? chalk.yellow("!") : chalk.red("✗"); + console.log(`${icon} ${check.name}: ${check.message}`); + if (check.fixed) { + console.log(chalk.dim(` → Fixed`)); + } + } + + const errors = checks.filter((c) => c.status === "error" && !c.fixed).length; + const warnings = checks.filter((c) => c.status === "warning" && !c.fixed).length; + const ok = checks.filter((c) => c.status === "ok").length; + const fixed = checks.filter((c) => c.fixed).length; + + console.log(""); + console.log(`Summary: ${ok} ok, ${warnings} warnings, ${errors} errors${fixed > 0 ? `, ${fixed} fixed` : ""}`); + + if (errors > 0) { + if (!flags.fix) { + console.log(chalk.dim("\nRun with --fix to attempt automatic repair")); + } + process.exit(1); + } +} + +async function handleFeatures( + manager: PluginManager, + args: string[], + flags: { json?: boolean; enable?: string; disable?: string; set?: string }, +): Promise { + if (args.length === 0) { + console.error(chalk.red("Usage: pi plugin features [--enable f1,f2] [--disable f1] [--set f1,f2]")); + process.exit(1); + } + + const pluginName = args[0]; + const plugins = await manager.list(); + const plugin = plugins.find((p) => p.name === pluginName); + + if (!plugin) { + console.error(chalk.red(`Plugin "${pluginName}" not found`)); + process.exit(1); + } + + // Handle modifications + if (flags.enable || flags.disable || flags.set) { + let currentFeatures = new Set(manager.getEnabledFeatures(pluginName) ?? []); + + if (flags.set) { + // --set replaces all features + currentFeatures = new Set( + flags.set + .split(",") + .map((f) => f.trim()) + .filter(Boolean), + ); + } else { + if (flags.enable) { + for (const f of flags.enable + .split(",") + .map((f) => f.trim()) + .filter(Boolean)) { + currentFeatures.add(f); + } + } + if (flags.disable) { + for (const f of flags.disable + .split(",") + .map((f) => f.trim()) + .filter(Boolean)) { + currentFeatures.delete(f); + } + } + } + + await manager.setEnabledFeatures(pluginName, [...currentFeatures]); + console.log(chalk.green(`✓ Updated features for ${pluginName}`)); + } + + // Display current state + const updatedFeatures = manager.getEnabledFeatures(pluginName); + + if (flags.json) { + console.log( + JSON.stringify( + { + plugin: pluginName, + enabledFeatures: updatedFeatures, + availableFeatures: plugin.manifest.features ? Object.keys(plugin.manifest.features) : [], + }, + null, + 2, + ), + ); + return; + } + + console.log(chalk.bold(`Features for ${pluginName}:\n`)); + + if (!plugin.manifest.features || Object.keys(plugin.manifest.features).length === 0) { + console.log(chalk.dim(" No optional features available")); + return; + } + + const enabledSet = new Set(updatedFeatures ?? []); + for (const [name, feat] of Object.entries(plugin.manifest.features)) { + const enabled = enabledSet.has(name); + const icon = enabled ? chalk.green("●") : chalk.dim("○"); + const defaultLabel = feat.default ? chalk.dim(" (default)") : ""; + console.log(`${icon} ${name}${defaultLabel}`); + if (feat.description) { + console.log(chalk.dim(` ${feat.description}`)); + } + } +} + +async function handleConfig( + manager: PluginManager, + args: string[], + flags: { json?: boolean; local?: boolean }, +): Promise { + if (args.length === 0) { + console.error(chalk.red("Usage: pi plugin config [key] [value]")); + process.exit(1); + } + + const [subcommand, pluginName, key, ...valueArgs] = args; + + // Special case: validate doesn't need a plugin name + if (subcommand === "validate") { + await handleConfigValidate(manager, flags); + return; + } + + if (!pluginName) { + console.error(chalk.red("Plugin name required")); + process.exit(1); + } + + const plugins = await manager.list(); + const plugin = plugins.find((p) => p.name === pluginName); + + if (!plugin) { + console.error(chalk.red(`Plugin "${pluginName}" not found`)); + process.exit(1); + } + + switch (subcommand) { + case "list": { + const settings = manager.getPluginSettings(pluginName); + const schema = plugin.manifest.settings || {}; + + if (flags.json) { + console.log(JSON.stringify({ settings, schema }, null, 2)); + return; + } + + console.log(chalk.bold(`Settings for ${pluginName}:\n`)); + + if (Object.keys(schema).length === 0) { + console.log(chalk.dim(" No settings defined")); + return; + } + + for (const [k, s] of Object.entries(schema)) { + const value = settings[k] ?? s.default; + const displayValue = s.secret && value ? "********" : String(value ?? chalk.dim("(not set)")); + console.log(` ${k}: ${displayValue}`); + if (s.description) { + console.log(chalk.dim(` ${s.description}`)); + } + if (s.env) { + console.log(chalk.dim(` env: ${s.env}`)); + } + } + break; + } + + case "get": { + if (!key) { + console.error(chalk.red("Key required")); + process.exit(1); + } + + const settings = manager.getPluginSettings(pluginName); + const schema = plugin.manifest.settings?.[key]; + const value = settings[key] ?? schema?.default; + + if (flags.json) { + console.log(JSON.stringify({ [key]: value })); + } else { + const displayValue = schema?.secret && value ? "********" : String(value ?? "(not set)"); + console.log(displayValue); + } + break; + } + + case "set": { + if (!key) { + console.error(chalk.red("Key required")); + process.exit(1); + } + + const valueStr = valueArgs.join(" "); + const schema = plugin.manifest.settings?.[key]; + + // Parse value according to type + let value: unknown = valueStr; + if (schema) { + value = parseSettingValue(valueStr, schema); + + // Validate + const validation = validateSetting(value, schema); + if (!validation.valid) { + console.error(chalk.red(validation.error!)); + process.exit(1); + } + } + + manager.setPluginSetting(pluginName, key, value); + console.log(chalk.green(`✓ Set ${key}`)); + break; + } + + case "delete": { + if (!key) { + console.error(chalk.red("Key required")); + process.exit(1); + } + + manager.deletePluginSetting(pluginName, key); + console.log(chalk.green(`✓ Deleted ${key}`)); + break; + } + + default: + console.error(chalk.red(`Unknown config subcommand: ${subcommand}`)); + console.error(chalk.dim("Valid subcommands: list, get, set, delete, validate")); + process.exit(1); + } +} + +async function handleConfigValidate(manager: PluginManager, flags: { json?: boolean }): Promise { + const plugins = await manager.list(); + const results: Array<{ plugin: string; key: string; error: string }> = []; + + for (const plugin of plugins) { + const settings = manager.getPluginSettings(plugin.name); + const schema = plugin.manifest.settings || {}; + + for (const [key, s] of Object.entries(schema)) { + const value = settings[key]; + if (value !== undefined) { + const validation = validateSetting(value, s); + if (!validation.valid) { + results.push({ plugin: plugin.name, key, error: validation.error! }); + } + } + } + } + + if (flags.json) { + console.log(JSON.stringify({ valid: results.length === 0, errors: results }, null, 2)); + return; + } + + if (results.length === 0) { + console.log(chalk.green("✓ All settings valid")); + } else { + for (const { plugin, key, error } of results) { + console.log(chalk.red(`✗ ${plugin}.${key}: ${error}`)); + } + process.exit(1); + } +} + +async function handleEnable(manager: PluginManager, plugins: string[], flags: { json?: boolean }): Promise { + if (plugins.length === 0) { + console.error(chalk.red("Usage: pi plugin enable ...")); + process.exit(1); + } + + for (const name of plugins) { + try { + await manager.setEnabled(name, true); + + if (flags.json) { + console.log(JSON.stringify({ enabled: name })); + } else { + console.log(chalk.green(`✓ Enabled ${name}`)); + } + } catch (err) { + console.error(chalk.red(`✗ Failed to enable ${name}: ${err}`)); + process.exit(1); + } + } +} + +async function handleDisable(manager: PluginManager, plugins: string[], flags: { json?: boolean }): Promise { + if (plugins.length === 0) { + console.error(chalk.red("Usage: pi plugin disable ...")); + process.exit(1); + } + + for (const name of plugins) { + try { + await manager.setEnabled(name, false); + + if (flags.json) { + console.log(JSON.stringify({ disabled: name })); + } else { + console.log(chalk.green(`✓ Disabled ${name}`)); + } + } catch (err) { + console.error(chalk.red(`✗ Failed to disable ${name}: ${err}`)); + process.exit(1); + } + } +} + +// ============================================================================= +// Help +// ============================================================================= + +export function printPluginHelp(): void { + console.log(`${chalk.bold("pi plugin")} - Plugin lifecycle management + +${chalk.bold("Commands:")} + install [features] Install plugins from npm + uninstall Remove plugins + list Show installed plugins + link Link local plugin for development + doctor Check plugin health + features View/modify enabled features + config [key] [val] Manage plugin settings + enable Enable a disabled plugin + disable Disable plugin without uninstalling + +${chalk.bold("Feature Syntax:")} + pkg Install with default features + pkg[feat1,feat2] Install with specific features + pkg[*] Install with all features + pkg[] Install with no optional features + +${chalk.bold("Config Subcommands:")} + config list List all settings + config get Get a setting value + config set Set a setting value + config delete Delete a setting + config validate Validate all plugin settings + +${chalk.bold("Options:")} + --json Output as JSON + --fix Attempt automatic fixes (doctor) + --force Overwrite without prompting (install) + --dry-run Preview changes without applying (install) + -l, --local Use project-local overrides + +${chalk.bold("Examples:")} + pi plugin install @oh-my-pi/exa[search] + pi plugin list --json + pi plugin features my-plugin --enable search,web + pi plugin config set my-plugin apiKey sk-xxx + pi plugin doctor --fix +`); +} diff --git a/packages/coding-agent/src/commands/init.md b/packages/coding-agent/src/commands/init.md new file mode 100644 index 000000000..d8d511132 --- /dev/null +++ b/packages/coding-agent/src/commands/init.md @@ -0,0 +1,20 @@ +--- +name: init +description: Generate AGENTS.md documentation for the current codebase +--- + +Analyze this codebase and generate an AGENTS.md file that documents: + +1. **Project Overview**: Brief description of what this project does +2. **Key Directories**: Main source directories and their purposes +3. **Development Commands**: How to build, test, lint the project +4. **Code Conventions**: Formatting, naming, patterns used +5. **Important Files**: Entry points, config files, key modules + +Guidelines: +- Be concise and practical +- Focus on what an AI assistant needs to know to help with this codebase +- Include file paths where relevant +- Don't include information that's obvious from the code structure + +After analysis, write the AGENTS.md file to the project root. diff --git a/packages/coding-agent/src/core/custom-tools/loader.ts b/packages/coding-agent/src/core/custom-tools/loader.ts index be936c030..1458b6ea4 100644 --- a/packages/coding-agent/src/core/custom-tools/loader.ts +++ b/packages/coding-agent/src/core/custom-tools/loader.ts @@ -1,53 +1,23 @@ /** - * Custom tool loader - loads TypeScript tool modules using jiti. + * Custom tool loader - loads TypeScript tool modules using native Bun import. * - * For Bun compiled binaries, custom tools that import from @mariozechner/* packages - * are not supported because Bun's plugin system doesn't intercept imports from - * external files loaded at runtime. Users should use the npm-installed version - * for custom tools that depend on pi packages. + * Dependencies (@sinclair/typebox and pi-coding-agent) are injected via the CustomToolAPI + * to avoid import resolution issues with custom tools loaded from user directories. */ import * as fs from "node:fs"; -import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; -import { createJiti } from "jiti"; -import { getAgentDir, isBunBinary } from "../../config.js"; +import * as typebox from "@sinclair/typebox"; +import { getAgentDir } from "../../config.js"; +import * as piCodingAgent from "../../index.js"; import { theme } from "../../modes/interactive/theme/theme.js"; import type { ExecOptions } from "../exec.js"; import { execCommand } from "../exec.js"; import type { HookUIContext } from "../hooks/types.js"; +import { getAllPluginToolPaths } from "../plugins/loader.js"; import type { CustomToolAPI, CustomToolFactory, CustomToolsLoadResult, LoadedCustomTool } from "./types.js"; -// Create require function to resolve module paths at runtime -const require = createRequire(import.meta.url); - -// Lazily computed aliases - resolved at runtime to handle global installs -let _aliases: Record | null = null; -function getAliases(): Record { - if (_aliases) return _aliases; - - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const packageIndex = path.resolve(__dirname, "../..", "index.js"); - - // For typebox, we need the package root directory (not the entry file) - // because jiti's alias is prefix-based: imports like "@sinclair/typebox/compiler" - // get the alias prepended. If we alias to the entry file (.../build/cjs/index.js), - // then "@sinclair/typebox/compiler" becomes ".../build/cjs/index.js/compiler" (invalid). - // By aliasing to the package root, it becomes ".../typebox/compiler" which resolves correctly. - const typeboxEntry = require.resolve("@sinclair/typebox"); - const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, ""); - - _aliases = { - "@mariozechner/pi-coding-agent": packageIndex, - "@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"), - "@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"), - "@sinclair/typebox": typeboxRoot, - }; - return _aliases; -} - const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g; function normalizeUnicodeSpaces(str: string): string { @@ -103,19 +73,16 @@ function createNoOpUIContext(): HookUIContext { } /** - * Load a tool in Bun binary mode. - * - * Since Bun plugins don't work for dynamically loaded external files, - * custom tools that import from @mariozechner/* packages won't work. - * Tools that only use standard npm packages (installed in the tool's directory) - * may still work. + * Load a single tool module using native Bun import. */ -async function loadToolWithBun( - resolvedPath: string, +async function loadTool( + toolPath: string, + cwd: string, sharedApi: CustomToolAPI, ): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> { + const resolvedPath = resolveToolPath(toolPath, cwd); + try { - // Try to import directly - will work for tools without @mariozechner/* imports const module = await import(resolvedPath); const factory = (module.default ?? module) as CustomToolFactory; @@ -126,68 +93,6 @@ async function loadToolWithBun( const toolResult = await factory(sharedApi); const toolsArray = Array.isArray(toolResult) ? toolResult : [toolResult]; - const loadedTools: LoadedCustomTool[] = toolsArray.map((tool) => ({ - path: resolvedPath, - resolvedPath, - tool, - })); - - return { tools: loadedTools, error: null }; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - - // Check if it's a module resolution error for our packages - if (message.includes("Cannot find module") && message.includes("@mariozechner/")) { - return { - tools: null, - error: - `${message}\n` + - "Note: Custom tools importing from @mariozechner/* packages are not supported in the standalone binary.\n" + - "Please install pi via npm: npm install -g @mariozechner/pi-coding-agent", - }; - } - - return { tools: null, error: `Failed to load tool: ${message}` }; - } -} - -/** - * Load a single tool module using jiti (or Bun.build for compiled binaries). - */ -async function loadTool( - toolPath: string, - cwd: string, - sharedApi: CustomToolAPI, -): Promise<{ tools: LoadedCustomTool[] | null; error: string | null }> { - const resolvedPath = resolveToolPath(toolPath, cwd); - - // Use Bun.build for compiled binaries since jiti can't resolve bundled modules - if (isBunBinary) { - return loadToolWithBun(resolvedPath, sharedApi); - } - - try { - // Create jiti instance for TypeScript/ESM loading - // Use aliases to resolve package imports since tools are loaded from user directories - // (e.g. ~/.pi/agent/tools) but import from packages installed with pi-coding-agent - const jiti = createJiti(import.meta.url, { - alias: getAliases(), - }); - - // Import the module - const module = await jiti.import(resolvedPath, { default: true }); - const factory = module as CustomToolFactory; - - if (typeof factory !== "function") { - return { tools: null, error: "Tool must export a default function" }; - } - - // Call factory with shared API - const result = await factory(sharedApi); - - // Handle single tool or array of tools - const toolsArray = Array.isArray(result) ? result : [result]; - const loadedTools: LoadedCustomTool[] = toolsArray.map((tool) => ({ path: toolPath, resolvedPath, @@ -223,6 +128,8 @@ export async function loadCustomTools( execCommand(command, args, options?.cwd ?? cwd, options), ui: createNoOpUIContext(), hasUI: false, + typebox, + pi: piCodingAgent, }; for (const toolPath of paths) { @@ -294,6 +201,7 @@ function discoverToolsInDir(dir: string): string[] { * Discover and load tools from standard locations: * 1. agentDir/tools/*.ts (global) * 2. cwd/.pi/tools/*.ts (project-local) + * 3. Installed plugins (~/.pi/plugins/node_modules/*) * * Plus any explicitly configured paths from settings or CLI. * @@ -330,7 +238,10 @@ export async function discoverAndLoadCustomTools( const localToolsDir = path.join(cwd, ".pi", "tools"); addPaths(discoverToolsInDir(localToolsDir)); - // 3. Explicitly configured paths (can override/add) + // 3. Plugin tools: ~/.pi/plugins/node_modules/*/ + addPaths(getAllPluginToolPaths(cwd)); + + // 4. Explicitly configured paths (can override/add) addPaths(configuredPaths.map((p) => resolveToolPath(p, cwd))); return loadCustomTools(allPaths, cwd, builtInToolNames); diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts index 59d800f9e..ead82bf1b 100644 --- a/packages/coding-agent/src/core/custom-tools/types.ts +++ b/packages/coding-agent/src/core/custom-tools/types.ts @@ -34,6 +34,10 @@ export interface CustomToolAPI { ui: CustomToolUIContext; /** Whether UI is available (false in print/RPC mode) */ hasUI: boolean; + /** Injected @sinclair/typebox module */ + typebox: typeof import("@sinclair/typebox"); + /** Injected pi-coding-agent exports */ + pi: typeof import("../../index.js"); } /** diff --git a/packages/coding-agent/src/core/custom-tools/wrapper.ts b/packages/coding-agent/src/core/custom-tools/wrapper.ts index b24ee028e..3faa1e1a5 100644 --- a/packages/coding-agent/src/core/custom-tools/wrapper.ts +++ b/packages/coding-agent/src/core/custom-tools/wrapper.ts @@ -15,8 +15,8 @@ export function wrapCustomTool(tool: CustomTool, getContext: () => CustomToolCon label: tool.label, description: tool.description, parameters: tool.parameters, - execute: (toolCallId, params, signal, onUpdate) => - tool.execute(toolCallId, params, onUpdate, getContext(), signal), + execute: (toolCallId, params, signal, onUpdate, context) => + tool.execute(toolCallId, params, onUpdate, context ?? getContext(), signal), }; } diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index f876aed9a..a2d3e45ec 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -1,48 +1,19 @@ /** - * Hook loader - loads TypeScript hook modules using jiti. + * Hook loader - loads TypeScript hook modules using native Bun import. */ import * as fs from "node:fs"; -import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; -import { fileURLToPath } from "node:url"; -import { createJiti } from "jiti"; +import * as typebox from "@sinclair/typebox"; import { getAgentDir } from "../../config.js"; +import * as piCodingAgent from "../../index.js"; import type { HookMessage } from "../messages.js"; +import { getAllPluginHookPaths } from "../plugins/loader.js"; import type { SessionManager } from "../session-manager.js"; import { execCommand } from "./runner.js"; import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types.js"; -// Create require function to resolve module paths at runtime -const require = createRequire(import.meta.url); - -// Lazily computed aliases - resolved at runtime to handle global installs -let _aliases: Record | null = null; -function getAliases(): Record { - if (_aliases) return _aliases; - - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const packageIndex = path.resolve(__dirname, "../..", "index.js"); - - // For typebox, we need the package root directory (not the entry file) - // because jiti's alias is prefix-based: imports like "@sinclair/typebox/compiler" - // get the alias prepended. If we alias to the entry file (.../build/cjs/index.js), - // then "@sinclair/typebox/compiler" becomes ".../build/cjs/index.js/compiler" (invalid). - // By aliasing to the package root, it becomes ".../typebox/compiler" which resolves correctly. - const typeboxEntry = require.resolve("@sinclair/typebox"); - const typeboxRoot = typeboxEntry.replace(/\/build\/cjs\/index\.js$/, ""); - - _aliases = { - "@mariozechner/pi-coding-agent": packageIndex, - "@mariozechner/pi-coding-agent/hooks": path.resolve(__dirname, "index.js"), - "@mariozechner/pi-tui": require.resolve("@mariozechner/pi-tui"), - "@mariozechner/pi-ai": require.resolve("@mariozechner/pi-ai"), - "@sinclair/typebox": typeboxRoot, - }; - return _aliases; -} - /** * Generic handler function type. */ @@ -192,6 +163,8 @@ function createHookAPI( exec(command: string, args: string[], options?: ExecOptions) { return execCommand(command, args, options?.cwd ?? cwd, options); }, + typebox, + pi: piCodingAgent, } as HookAPI; return { @@ -208,22 +181,15 @@ function createHookAPI( } /** - * Load a single hook module using jiti. + * Load a single hook module using native Bun import. */ async function loadHook(hookPath: string, cwd: string): Promise<{ hook: LoadedHook | null; error: string | null }> { const resolvedPath = resolveHookPath(hookPath, cwd); try { - // Create jiti instance for TypeScript/ESM loading - // Use aliases to resolve package imports since hooks are loaded from user directories - // (e.g. ~/.pi/agent/hooks) but import from packages installed with pi-coding-agent - const jiti = createJiti(import.meta.url, { - alias: getAliases(), - }); - - // Import the module - const module = await jiti.import(resolvedPath, { default: true }); - const factory = module as HookFactory; + // Import the module using native Bun import + const module = await import(resolvedPath); + const factory = module.default as HookFactory; if (typeof factory !== "function") { return { hook: null, error: "Hook must export a default function" }; @@ -305,6 +271,7 @@ function discoverHooksInDir(dir: string): string[] { * Discover and load hooks from standard locations: * 1. agentDir/hooks/*.ts (global) * 2. cwd/.pi/hooks/*.ts (project-local) + * 3. Installed plugins (~/.pi/plugins/node_modules/*) * * Plus any explicitly configured paths from settings. */ @@ -335,7 +302,10 @@ export async function discoverAndLoadHooks( const localHooksDir = path.join(cwd, ".pi", "hooks"); addPaths(discoverHooksInDir(localHooksDir)); - // 3. Explicitly configured paths (can override/add) + // 3. Plugin hooks: ~/.pi/plugins/node_modules/*/ + addPaths(getAllPluginHookPaths(cwd)); + + // 4. Explicitly configured paths (can override/add) addPaths(configuredPaths.map((p) => resolveHookPath(p, cwd))); return loadHooks(allPaths, cwd); diff --git a/packages/coding-agent/src/core/hooks/tool-wrapper.ts b/packages/coding-agent/src/core/hooks/tool-wrapper.ts index 28c718f0b..632b9123e 100644 --- a/packages/coding-agent/src/core/hooks/tool-wrapper.ts +++ b/packages/coding-agent/src/core/hooks/tool-wrapper.ts @@ -2,7 +2,7 @@ * Tool wrapper - wraps tools with hook callbacks for interception. */ -import type { AgentTool, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core"; +import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core"; import type { HookRunner } from "./runner.js"; import type { ToolCallEventResult, ToolResultEventResult } from "./types.js"; @@ -20,6 +20,7 @@ export function wrapToolWithHooks(tool: AgentTool, hookRunner: HookRu params: Record, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback, + context?: AgentToolContext, ) => { // Emit tool_call event - hooks can block execution // If hook errors/times out, block by default (fail-safe) @@ -47,7 +48,7 @@ export function wrapToolWithHooks(tool: AgentTool, hookRunner: HookRu // Execute the actual tool, forwarding onUpdate for progress streaming try { - const result = await tool.execute(toolCallId, params, signal, onUpdate); + const result = await tool.execute(toolCallId, params, signal, onUpdate, context); // Emit tool_result event - hooks can modify the result if (hookRunner.hasHandlers("tool_result")) { diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index e2317d2d7..a96bbf8a9 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -746,6 +746,11 @@ export interface HookAPI { * Supports timeout and abort signal. */ exec(command: string, args: string[], options?: ExecOptions): Promise; + + /** Injected @sinclair/typebox module */ + typebox: typeof import("@sinclair/typebox"); + /** Injected pi-coding-agent exports */ + pi: typeof import("../../index.js"); } /** diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 4b15f6fef..29a3ad5a9 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -35,3 +35,18 @@ export { type HookUIContext, loadHooks, } from "./hooks/index.js"; +export { + createMCPManager, + discoverAndLoadMCPTools, + expandEnvVars, + loadAllMCPConfigs, + type MCPConfigFile, + type MCPLoadResult, + MCPManager, + type MCPServerConfig, + type MCPServerConnection, + type MCPToolDefinition, + type MCPToolDetails, + type MCPToolsLoadResult, + type MCPTransport, +} from "./mcp/index.js"; diff --git a/packages/coding-agent/src/core/mcp/client.ts b/packages/coding-agent/src/core/mcp/client.ts new file mode 100644 index 000000000..b1a46ce2b --- /dev/null +++ b/packages/coding-agent/src/core/mcp/client.ts @@ -0,0 +1,158 @@ +/** + * MCP Client. + * + * Handles connection initialization, tool listing, and tool calling. + */ + +import { createHttpTransport } from "./transports/http.js"; +import { createStdioTransport } from "./transports/stdio.js"; +import type { + MCPHttpServerConfig, + MCPInitializeParams, + MCPInitializeResult, + MCPServerCapabilities, + MCPServerConfig, + MCPServerConnection, + MCPSseServerConfig, + MCPStdioServerConfig, + MCPToolCallParams, + MCPToolCallResult, + MCPToolDefinition, + MCPToolsListResult, + MCPTransport, +} from "./types.js"; + +/** MCP protocol version we support */ +const PROTOCOL_VERSION = "2025-03-26"; + +/** Client info sent during initialization */ +const CLIENT_INFO = { + name: "pi-coding-agent", + version: "1.0.0", +}; + +/** + * Create a transport for the given server config. + */ +async function createTransport(config: MCPServerConfig): Promise { + const serverType = config.type ?? "stdio"; + + switch (serverType) { + case "stdio": + return createStdioTransport(config as MCPStdioServerConfig); + case "http": + case "sse": + return createHttpTransport(config as MCPHttpServerConfig | MCPSseServerConfig); + default: + throw new Error(`Unknown server type: ${serverType}`); + } +} + +/** + * Initialize connection with MCP server. + */ +async function initializeConnection(transport: MCPTransport): Promise { + const params: MCPInitializeParams = { + protocolVersion: PROTOCOL_VERSION, + capabilities: { + roots: { listChanged: false }, + }, + clientInfo: CLIENT_INFO, + }; + + const result = await transport.request( + "initialize", + params as unknown as Record, + ); + + // Send initialized notification + await transport.notify("notifications/initialized"); + + return result; +} + +/** + * Connect to an MCP server. + */ +export async function connectToServer(name: string, config: MCPServerConfig): Promise { + const transport = await createTransport(config); + + try { + const initResult = await initializeConnection(transport); + + return { + name, + config, + transport, + serverInfo: initResult.serverInfo, + capabilities: initResult.capabilities, + }; + } catch (error) { + await transport.close(); + throw error; + } +} + +/** + * List tools from a connected server. + */ +export async function listTools(connection: MCPServerConnection): Promise { + // Check if server supports tools + if (!connection.capabilities.tools) { + return []; + } + + // Return cached tools if available + if (connection.tools) { + return connection.tools; + } + + const allTools: MCPToolDefinition[] = []; + let cursor: string | undefined; + + do { + const params: Record = {}; + if (cursor) { + params.cursor = cursor; + } + + const result = await connection.transport.request("tools/list", params); + allTools.push(...result.tools); + cursor = result.nextCursor; + } while (cursor); + + // Cache tools + connection.tools = allTools; + + return allTools; +} + +/** + * Call a tool on a connected server. + */ +export async function callTool( + connection: MCPServerConnection, + toolName: string, + args: Record = {}, +): Promise { + const params: MCPToolCallParams = { + name: toolName, + arguments: args, + }; + + return connection.transport.request("tools/call", params as unknown as Record); +} + +/** + * Disconnect from a server. + */ +export async function disconnectServer(connection: MCPServerConnection): Promise { + await connection.transport.close(); +} + +/** + * Check if a server supports tools. + */ +export function serverSupportsTools(capabilities: MCPServerCapabilities): boolean { + return capabilities.tools !== undefined; +} diff --git a/packages/coding-agent/src/core/mcp/config.ts b/packages/coding-agent/src/core/mcp/config.ts new file mode 100644 index 000000000..bcea14710 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/config.ts @@ -0,0 +1,154 @@ +/** + * MCP configuration loader. + * + * Loads .mcp.json files from project root with environment variable expansion. + * Supports ${VAR} and ${VAR:-default} syntax. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { MCPConfigFile, MCPServerConfig } from "./types.js"; + +/** Environment variable expansion pattern: ${VAR} or ${VAR:-default} */ +const ENV_VAR_PATTERN = /\$\{([^}:]+)(?::-([^}]*))?\}/g; + +/** + * Expand environment variables in a string. + * Supports ${VAR} and ${VAR:-default} syntax. + */ +export function expandEnvVars(value: string, extraEnv?: Record): string { + return value.replace(ENV_VAR_PATTERN, (_, varName: string, defaultValue?: string) => { + const envValue = extraEnv?.[varName] ?? process.env[varName]; + if (envValue !== undefined) { + return envValue; + } + if (defaultValue !== undefined) { + return defaultValue; + } + // If no value and no default, leave the placeholder (will likely cause an error later) + return `\${${varName}}`; + }); +} + +/** + * Recursively expand environment variables in an object. + */ +function expandEnvVarsInObject(obj: T, extraEnv?: Record): T { + if (typeof obj === "string") { + return expandEnvVars(obj, extraEnv) as T; + } + if (Array.isArray(obj)) { + return obj.map((item) => expandEnvVarsInObject(item, extraEnv)) as T; + } + if (obj !== null && typeof obj === "object") { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + result[key] = expandEnvVarsInObject(value, extraEnv); + } + return result as T; + } + return obj; +} + +/** + * Load and parse an .mcp.json file. + * Returns null if file doesn't exist or is invalid. + */ +export function loadMCPConfigFile(filePath: string, extraEnv?: Record): MCPConfigFile | null { + if (!existsSync(filePath)) { + return null; + } + + try { + const content = readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(content) as MCPConfigFile; + + // Expand environment variables in server configs + if (parsed.mcpServers) { + parsed.mcpServers = expandEnvVarsInObject(parsed.mcpServers, extraEnv); + } + + return parsed; + } catch (error) { + console.error(`Warning: Failed to parse ${filePath}: ${error}`); + return null; + } +} + +/** + * Configuration locations (in order of priority, later overrides earlier). + */ +export interface MCPConfigLocations { + /** User-level config: ~/.pi/mcp.json or ~/.claude.json */ + user?: string; + /** Project-level config: /.mcp.json */ + project?: string; +} + +/** + * Get standard MCP config file paths. + */ +export function getMCPConfigPaths(cwd: string): MCPConfigLocations { + const home = homedir(); + return { + // User-level: ~/.pi/mcp.json (our standard) + user: join(home, ".pi", "mcp.json"), + // Project-level: .mcp.json at project root + project: join(cwd, ".mcp.json"), + }; +} + +/** + * Merge MCP configs from multiple sources. + * Later sources override earlier ones for servers with same name. + */ +export function mergeMCPConfigs(...configs: (MCPConfigFile | null)[]): Record { + const result: Record = {}; + + for (const config of configs) { + if (config?.mcpServers) { + Object.assign(result, config.mcpServers); + } + } + + return result; +} + +/** + * Load all MCP server configs from standard locations. + * Returns merged config with project overriding user. + */ +export function loadAllMCPConfigs(cwd: string, extraEnv?: Record): Record { + const paths = getMCPConfigPaths(cwd); + + const userConfig = paths.user ? loadMCPConfigFile(paths.user, extraEnv) : null; + const projectConfig = paths.project ? loadMCPConfigFile(paths.project, extraEnv) : null; + + return mergeMCPConfigs(userConfig, projectConfig); +} + +/** + * Validate server config has required fields. + */ +export function validateServerConfig(name: string, config: MCPServerConfig): string[] { + const errors: string[] = []; + + const serverType = config.type ?? "stdio"; + + if (serverType === "stdio") { + const stdioConfig = config as { command?: string }; + if (!stdioConfig.command) { + errors.push(`Server "${name}": stdio server requires "command" field`); + } + } else if (serverType === "http" || serverType === "sse") { + const httpConfig = config as { url?: string }; + if (!httpConfig.url) { + errors.push(`Server "${name}": ${serverType} server requires "url" field`); + } + } else { + errors.push(`Server "${name}": unknown server type "${serverType}"`); + } + + return errors; +} diff --git a/packages/coding-agent/src/core/mcp/index.ts b/packages/coding-agent/src/core/mcp/index.ts new file mode 100644 index 000000000..91e8c4f79 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/index.ts @@ -0,0 +1,45 @@ +/** + * MCP (Model Context Protocol) support. + * + * Provides per-project .mcp.json configuration for connecting to + * MCP servers via stdio or HTTP transports. + */ + +// Client +export { callTool, connectToServer, disconnectServer, listTools, serverSupportsTools } from "./client.js"; + +// Config +export { + expandEnvVars, + getMCPConfigPaths, + loadAllMCPConfigs, + loadMCPConfigFile, + mergeMCPConfigs, + validateServerConfig, +} from "./config.js"; +// Loader (for SDK integration) +export type { MCPToolsLoadResult } from "./loader.js"; +export { discoverAndLoadMCPTools } from "./loader.js"; +// Manager +export type { MCPLoadResult } from "./manager.js"; +export { createMCPManager, MCPManager } from "./manager.js"; +// Tool bridge +export type { MCPToolDetails } from "./tool-bridge.js"; +export { createMCPTool, createMCPToolName, createMCPTools, parseMCPToolName } from "./tool-bridge.js"; +// Transports +export { createHttpTransport, HttpTransport } from "./transports/http.js"; +export { createStdioTransport, StdioTransport } from "./transports/stdio.js"; +// Types +export type { + MCPConfigFile, + MCPContent, + MCPHttpServerConfig, + MCPServerCapabilities, + MCPServerConfig, + MCPServerConnection, + MCPSseServerConfig, + MCPStdioServerConfig, + MCPToolDefinition, + MCPToolWithServer, + MCPTransport, +} from "./types.js"; diff --git a/packages/coding-agent/src/core/mcp/loader.ts b/packages/coding-agent/src/core/mcp/loader.ts new file mode 100644 index 000000000..2bed67f8a --- /dev/null +++ b/packages/coding-agent/src/core/mcp/loader.ts @@ -0,0 +1,70 @@ +/** + * MCP tools loader. + * + * Integrates MCP tool discovery with the custom tools system. + */ + +import type { TSchema } from "@sinclair/typebox"; +import type { LoadedCustomTool } from "../custom-tools/types.js"; +import { createMCPManager, type MCPLoadResult, MCPManager } from "./manager.js"; +import type { MCPToolDetails } from "./tool-bridge.js"; + +/** Result from loading MCP tools */ +export interface MCPToolsLoadResult { + /** MCP manager (for lifecycle management) */ + manager: MCPManager; + /** Loaded tools as LoadedCustomTool format */ + tools: LoadedCustomTool[]; + /** Errors keyed by server name */ + errors: Array<{ path: string; error: string }>; + /** Connected server names */ + connectedServers: string[]; +} + +/** + * Discover and load MCP tools from .mcp.json files. + * + * @param cwd Working directory (project root) + * @param extraEnv Additional environment variables for expansion + * @returns MCP tools in LoadedCustomTool format for integration + */ +export async function discoverAndLoadMCPTools( + cwd: string, + extraEnv?: Record, +): Promise { + const manager = new MCPManager(cwd); + + let result: MCPLoadResult; + try { + result = await manager.discoverAndConnect(extraEnv); + } catch (error) { + // If discovery fails entirely, return empty result + const message = error instanceof Error ? error.message : String(error); + return { + manager, + tools: [], + errors: [{ path: ".mcp.json", error: message }], + connectedServers: [], + }; + } + + // Convert MCP tools to LoadedCustomTool format + const loadedTools: LoadedCustomTool[] = result.tools.map((tool) => ({ + path: `mcp:${tool.name}`, + resolvedPath: `mcp:${tool.name}`, + tool: tool as any, // MCPToolDetails is compatible with CustomTool + })); + + // Convert error map to array format + const errors: Array<{ path: string; error: string }> = []; + for (const [serverName, errorMsg] of result.errors) { + errors.push({ path: `mcp:${serverName}`, error: errorMsg }); + } + + return { + manager, + tools: loadedTools, + errors, + connectedServers: result.connectedServers, + }; +} diff --git a/packages/coding-agent/src/core/mcp/manager.ts b/packages/coding-agent/src/core/mcp/manager.ts new file mode 100644 index 000000000..f8dce8231 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/manager.ts @@ -0,0 +1,181 @@ +/** + * MCP Server Manager. + * + * Discovers, connects to, and manages MCP servers. + * Handles tool loading and lifecycle. + */ + +import type { TSchema } from "@sinclair/typebox"; +import type { CustomTool } from "../custom-tools/types.js"; +import { connectToServer, disconnectServer, listTools } from "./client.js"; +import { loadAllMCPConfigs, validateServerConfig } from "./config.js"; +import type { MCPToolDetails } from "./tool-bridge.js"; +import { createMCPTools } from "./tool-bridge.js"; +import type { MCPServerConfig, MCPServerConnection } from "./types.js"; + +/** Result of loading MCP tools */ +export interface MCPLoadResult { + /** Loaded tools as CustomTool instances */ + tools: CustomTool[]; + /** Connection errors by server name */ + errors: Map; + /** Connected server names */ + connectedServers: string[]; +} + +/** + * MCP Server Manager. + * + * Manages connections to MCP servers and provides tools to the agent. + */ +export class MCPManager { + private connections = new Map(); + private tools: CustomTool[] = []; + + constructor(private cwd: string) {} + + /** + * Discover and connect to all MCP servers from .mcp.json files. + * Returns tools and any connection errors. + */ + async discoverAndConnect(extraEnv?: Record): Promise { + const configs = loadAllMCPConfigs(this.cwd, extraEnv); + return this.connectServers(configs); + } + + /** + * Connect to specific MCP servers. + */ + async connectServers(configs: Record): Promise { + const errors = new Map(); + const connectedServers: string[] = []; + const allTools: CustomTool[] = []; + + for (const [name, config] of Object.entries(configs)) { + // Skip if already connected + if (this.connections.has(name)) { + connectedServers.push(name); + continue; + } + + // Validate config + const validationErrors = validateServerConfig(name, config); + if (validationErrors.length > 0) { + errors.set(name, validationErrors.join("; ")); + continue; + } + + try { + const connection = await connectToServer(name, config); + this.connections.set(name, connection); + connectedServers.push(name); + + // Load tools from this server + const serverTools = await listTools(connection); + const customTools = createMCPTools(connection, serverTools); + allTools.push(...customTools); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + errors.set(name, message); + } + } + + // Update cached tools + this.tools = allTools; + + return { + tools: allTools, + errors, + connectedServers, + }; + } + + /** + * Get all loaded tools. + */ + getTools(): CustomTool[] { + return this.tools; + } + + /** + * Get a specific connection. + */ + getConnection(name: string): MCPServerConnection | undefined { + return this.connections.get(name); + } + + /** + * Get all connected server names. + */ + getConnectedServers(): string[] { + return Array.from(this.connections.keys()); + } + + /** + * Disconnect from a specific server. + */ + async disconnectServer(name: string): Promise { + const connection = this.connections.get(name); + if (!connection) return; + + await disconnectServer(connection); + this.connections.delete(name); + + // Remove tools from this server + this.tools = this.tools.filter((t) => !t.name.startsWith(`mcp_${name}_`)); + } + + /** + * Disconnect from all servers. + */ + async disconnectAll(): Promise { + const promises = Array.from(this.connections.values()).map((conn) => disconnectServer(conn)); + await Promise.allSettled(promises); + + this.connections.clear(); + this.tools = []; + } + + /** + * Refresh tools from a specific server. + */ + async refreshServerTools(name: string): Promise { + const connection = this.connections.get(name); + if (!connection) return; + + // Clear cached tools + connection.tools = undefined; + + // Reload tools + const serverTools = await listTools(connection); + const customTools = createMCPTools(connection, serverTools); + + // Replace tools from this server + this.tools = this.tools.filter((t) => !t.name.startsWith(`mcp_${name}_`)); + this.tools.push(...customTools); + } + + /** + * Refresh tools from all servers. + */ + async refreshAllTools(): Promise { + const promises = Array.from(this.connections.keys()).map((name) => this.refreshServerTools(name)); + await Promise.allSettled(promises); + } +} + +/** + * Create an MCP manager and discover servers. + * Convenience function for quick setup. + */ +export async function createMCPManager( + cwd: string, + extraEnv?: Record, +): Promise<{ + manager: MCPManager; + result: MCPLoadResult; +}> { + const manager = new MCPManager(cwd); + const result = await manager.discoverAndConnect(extraEnv); + return { manager, result }; +} diff --git a/packages/coding-agent/src/core/mcp/tool-bridge.ts b/packages/coding-agent/src/core/mcp/tool-bridge.ts new file mode 100644 index 000000000..f63b2f9c4 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/tool-bridge.ts @@ -0,0 +1,148 @@ +/** + * MCP to CustomTool bridge. + * + * Converts MCP tool definitions to CustomTool format for the agent. + */ + +import type { TSchema } from "@sinclair/typebox"; +import type { CustomTool, CustomToolResult } from "../custom-tools/types.js"; +import { callTool } from "./client.js"; +import type { MCPContent, MCPServerConnection, MCPToolDefinition, MCPToolWithServer } from "./types.js"; + +/** Details included in MCP tool results for rendering */ +export interface MCPToolDetails { + /** Server name */ + serverName: string; + /** Original MCP tool name */ + mcpToolName: string; + /** Whether the call resulted in an error */ + isError?: boolean; + /** Raw content from MCP response */ + rawContent?: MCPContent[]; +} + +/** + * Convert JSON Schema from MCP to TypeBox-compatible schema. + * MCP uses standard JSON Schema, TypeBox uses a compatible subset. + */ +function convertSchema(mcpSchema: MCPToolDefinition["inputSchema"]): TSchema { + // MCP schemas are JSON Schema objects, TypeBox can use them directly + // as long as we ensure the structure is correct + return mcpSchema as unknown as TSchema; +} + +/** + * Format MCP content for LLM consumption. + */ +function formatMCPContent(content: MCPContent[]): string { + const parts: string[] = []; + + for (const item of content) { + switch (item.type) { + case "text": + parts.push(item.text); + break; + case "image": + parts.push(`[Image: ${item.mimeType}]`); + break; + case "resource": + if (item.resource.text) { + parts.push(`[Resource: ${item.resource.uri}]\n${item.resource.text}`); + } else { + parts.push(`[Resource: ${item.resource.uri}]`); + } + break; + } + } + + return parts.join("\n\n"); +} + +/** + * Create a unique tool name for an MCP tool. + * Prefixes with server name to avoid conflicts. + */ +export function createMCPToolName(serverName: string, toolName: string): string { + // Use underscore separator since tool names can't have special chars + return `mcp_${serverName}_${toolName}`; +} + +/** + * Parse an MCP tool name back to server and tool components. + */ +export function parseMCPToolName(name: string): { serverName: string; toolName: string } | null { + if (!name.startsWith("mcp_")) return null; + + const rest = name.slice(4); + const underscoreIdx = rest.indexOf("_"); + if (underscoreIdx === -1) return null; + + return { + serverName: rest.slice(0, underscoreIdx), + toolName: rest.slice(underscoreIdx + 1), + }; +} + +/** + * Convert an MCP tool definition to a CustomTool. + */ +export function createMCPTool( + connection: MCPServerConnection, + tool: MCPToolDefinition, +): CustomTool { + const name = createMCPToolName(connection.name, tool.name); + const schema = convertSchema(tool.inputSchema); + + return { + name, + label: `${connection.name}/${tool.name}`, + description: tool.description ?? `MCP tool from ${connection.name}`, + parameters: schema, + + async execute(_toolCallId, params, _onUpdate, _ctx, _signal): Promise> { + try { + const result = await callTool(connection, tool.name, params as Record); + + const text = formatMCPContent(result.content); + const details: MCPToolDetails = { + serverName: connection.name, + mcpToolName: tool.name, + isError: result.isError, + rawContent: result.content, + }; + + if (result.isError) { + return { + content: [{ type: "text", text: `Error: ${text}` }], + details, + }; + } + + return { + content: [{ type: "text", text }], + details, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text", text: `MCP error: ${message}` }], + details: { + serverName: connection.name, + mcpToolName: tool.name, + isError: true, + }, + }; + } + }, + }; +} + +/** + * Convert all tools from an MCP server to CustomTools. + */ +export function createMCPTools( + connection: MCPServerConnection, + tools: MCPToolDefinition[], +): CustomTool[] { + return tools.map((tool) => createMCPTool(connection, tool)); +} diff --git a/packages/coding-agent/src/core/mcp/transports/http.ts b/packages/coding-agent/src/core/mcp/transports/http.ts new file mode 100644 index 000000000..6ab2d6a03 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/transports/http.ts @@ -0,0 +1,316 @@ +/** + * MCP HTTP transport (Streamable HTTP). + * + * Implements JSON-RPC 2.0 over HTTP POST with optional SSE streaming. + * Based on MCP spec 2025-03-26. + */ + +import type { JsonRpcResponse, MCPHttpServerConfig, MCPSseServerConfig, MCPTransport } from "../types.js"; + +/** Generate unique request ID */ +function generateId(): string { + return Math.random().toString(36).slice(2) + Date.now().toString(36); +} + +/** Parse SSE data line */ +function parseSSELine(line: string): { event?: string; data?: string; id?: string } | null { + if (line.startsWith("data:")) { + return { data: line.slice(5).trim() }; + } + if (line.startsWith("event:")) { + return { event: line.slice(6).trim() }; + } + if (line.startsWith("id:")) { + return { id: line.slice(3).trim() }; + } + return null; +} + +/** + * HTTP transport for MCP servers. + * Uses POST for requests, supports SSE responses. + */ +export class HttpTransport implements MCPTransport { + private _connected = false; + private sessionId: string | null = null; + private sseConnection: AbortController | null = null; + + onClose?: () => void; + onError?: (error: Error) => void; + onNotification?: (method: string, params: unknown) => void; + + constructor(private config: MCPHttpServerConfig | MCPSseServerConfig) {} + + get connected(): boolean { + return this._connected; + } + + get url(): string { + return this.config.url; + } + + /** + * Mark transport as connected. + * HTTP doesn't need persistent connection, but we track state. + */ + async connect(): Promise { + if (this._connected) return; + this._connected = true; + } + + /** + * Start SSE listener for server-initiated messages. + * Optional - only needed if server sends notifications. + */ + async startSSEListener(): Promise { + if (!this._connected) return; + if (this.sseConnection) return; + + this.sseConnection = new AbortController(); + const headers: Record = { + Accept: "text/event-stream", + ...this.config.headers, + }; + + if (this.sessionId) { + headers["Mcp-Session-Id"] = this.sessionId; + } + + try { + const response = await fetch(this.config.url, { + method: "GET", + headers, + signal: this.sseConnection.signal, + }); + + if (response.status === 405) { + // Server doesn't support SSE listening, that's OK + this.sseConnection = null; + return; + } + + if (!response.ok || !response.body) { + this.sseConnection = null; + return; + } + + // Read SSE stream + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (this._connected) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const parsed = parseSSELine(line); + if (parsed?.data && parsed.data !== "[DONE]") { + try { + const message = JSON.parse(parsed.data); + if ("method" in message && !("id" in message)) { + this.onNotification?.(message.method, message.params); + } + } catch { + // Ignore parse errors + } + } + } + } + } catch (error) { + if (error instanceof Error && error.name !== "AbortError") { + this.onError?.(error); + } + } finally { + this.sseConnection = null; + } + } + + async request(method: string, params?: Record): Promise { + if (!this._connected) { + throw new Error("Transport not connected"); + } + + const id = generateId(); + const body = { + jsonrpc: "2.0" as const, + id, + method, + params: params ?? {}, + }; + + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...this.config.headers, + }; + + if (this.sessionId) { + headers["Mcp-Session-Id"] = this.sessionId; + } + + const response = await fetch(this.config.url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + // Check for session ID in response + const newSessionId = response.headers.get("Mcp-Session-Id"); + if (newSessionId) { + this.sessionId = newSessionId; + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(`HTTP ${response.status}: ${text}`); + } + + const contentType = response.headers.get("Content-Type") ?? ""; + + // Handle SSE response + if (contentType.includes("text/event-stream")) { + return this.parseSSEResponse(response, id); + } + + // Handle JSON response + const result = (await response.json()) as JsonRpcResponse; + + if (result.error) { + throw new Error(`MCP error ${result.error.code}: ${result.error.message}`); + } + + return result.result as T; + } + + private async parseSSEResponse(response: Response, expectedId: string | number): Promise { + if (!response.body) { + throw new Error("No response body"); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let result: T | undefined; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + const parsed = parseSSELine(line); + if (parsed?.data && parsed.data !== "[DONE]") { + try { + const message = JSON.parse(parsed.data) as JsonRpcResponse; + + // Handle our response + if ("id" in message && message.id === expectedId) { + if (message.error) { + throw new Error(`MCP error ${message.error.code}: ${message.error.message}`); + } + result = message.result as T; + } + // Handle notifications + else if ("method" in message && !("id" in message)) { + const notification = message as { method: string; params?: unknown }; + this.onNotification?.(notification.method, notification.params); + } + } catch (error) { + if (error instanceof Error && error.message.startsWith("MCP error")) { + throw error; + } + // Ignore other parse errors + } + } + } + } + + if (result === undefined) { + throw new Error("No response received"); + } + + return result; + } + + async notify(method: string, params?: Record): Promise { + if (!this._connected) { + throw new Error("Transport not connected"); + } + + const body = { + jsonrpc: "2.0" as const, + method, + params: params ?? {}, + }; + + const headers: Record = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...this.config.headers, + }; + + if (this.sessionId) { + headers["Mcp-Session-Id"] = this.sessionId; + } + + const response = await fetch(this.config.url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + // 202 Accepted is success for notifications + if (!response.ok && response.status !== 202) { + const text = await response.text(); + throw new Error(`HTTP ${response.status}: ${text}`); + } + } + + async close(): Promise { + if (!this._connected) return; + this._connected = false; + + // Abort SSE listener + if (this.sseConnection) { + this.sseConnection.abort(); + this.sseConnection = null; + } + + // Send session termination if we have a session + if (this.sessionId) { + try { + const headers: Record = { + ...this.config.headers, + "Mcp-Session-Id": this.sessionId, + }; + + await fetch(this.config.url, { + method: "DELETE", + headers, + }); + } catch { + // Ignore termination errors + } + this.sessionId = null; + } + + this.onClose?.(); + } +} + +/** + * Create and connect an HTTP transport. + */ +export async function createHttpTransport(config: MCPHttpServerConfig | MCPSseServerConfig): Promise { + const transport = new HttpTransport(config); + await transport.connect(); + return transport; +} diff --git a/packages/coding-agent/src/core/mcp/transports/index.ts b/packages/coding-agent/src/core/mcp/transports/index.ts new file mode 100644 index 000000000..1d8302050 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/transports/index.ts @@ -0,0 +1,6 @@ +/** + * MCP transport exports. + */ + +export { createHttpTransport, HttpTransport } from "./http.js"; +export { createStdioTransport, StdioTransport } from "./stdio.js"; diff --git a/packages/coding-agent/src/core/mcp/transports/stdio.ts b/packages/coding-agent/src/core/mcp/transports/stdio.ts new file mode 100644 index 000000000..cd94a7ef9 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/transports/stdio.ts @@ -0,0 +1,252 @@ +/** + * MCP stdio transport. + * + * Implements JSON-RPC 2.0 over subprocess stdin/stdout. + * Messages are newline-delimited JSON. + */ + +import { type Subprocess, spawn } from "bun"; +import type { JsonRpcResponse, MCPStdioServerConfig, MCPTransport } from "../types.js"; + +/** Generate unique request ID */ +function generateId(): string { + return Math.random().toString(36).slice(2) + Date.now().toString(36); +} + +/** + * Stdio transport for MCP servers. + * Spawns a subprocess and communicates via stdin/stdout. + */ +export class StdioTransport implements MCPTransport { + private process: Subprocess<"pipe", "pipe", "pipe"> | null = null; + private pendingRequests = new Map< + string | number, + { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + } + >(); + private buffer = ""; + private _connected = false; + private readLoop: Promise | null = null; + + onClose?: () => void; + onError?: (error: Error) => void; + onNotification?: (method: string, params: unknown) => void; + + constructor(private config: MCPStdioServerConfig) {} + + get connected(): boolean { + return this._connected; + } + + /** + * Start the subprocess and begin reading. + */ + async connect(): Promise { + if (this._connected) return; + + const args = this.config.args ?? []; + const env = { + ...process.env, + ...this.config.env, + }; + + this.process = spawn({ + cmd: [this.config.command, ...args], + cwd: this.config.cwd ?? process.cwd(), + env, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + this._connected = true; + + // Start reading stdout + this.readLoop = this.startReadLoop(); + + // Log stderr for debugging + this.startStderrLoop(); + } + + private async startReadLoop(): Promise { + if (!this.process?.stdout) return; + + const reader = this.process.stdout.getReader(); + const decoder = new TextDecoder(); + + try { + while (this._connected) { + const { done, value } = await reader.read(); + if (done) break; + + this.buffer += decoder.decode(value, { stream: true }); + this.processBuffer(); + } + } catch (error) { + if (this._connected) { + this.onError?.(error instanceof Error ? error : new Error(String(error))); + } + } finally { + reader.releaseLock(); + this.handleClose(); + } + } + + private async startStderrLoop(): Promise { + if (!this.process?.stderr) return; + + const reader = this.process.stderr.getReader(); + const decoder = new TextDecoder(); + + try { + while (this._connected) { + const { done, value } = await reader.read(); + if (done) break; + // Log stderr but don't treat as error - servers use it for logging + const text = decoder.decode(value, { stream: true }); + if (text.trim()) { + // Could expose via onStderr callback if needed + // For now, silent - MCP spec says clients MAY capture/ignore + } + } + } catch { + // Ignore stderr read errors + } finally { + reader.releaseLock(); + } + } + + private processBuffer(): void { + const lines = this.buffer.split("\n"); + // Keep incomplete last line in buffer + this.buffer = lines.pop() ?? ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + try { + const message = JSON.parse(trimmed) as JsonRpcResponse; + this.handleMessage(message); + } catch { + // Ignore malformed lines + } + } + } + + private handleMessage(message: JsonRpcResponse): void { + // Check if it's a response (has id) + if ("id" in message && message.id !== null) { + const pending = this.pendingRequests.get(message.id); + if (pending) { + this.pendingRequests.delete(message.id); + if (message.error) { + pending.reject(new Error(`MCP error ${message.error.code}: ${message.error.message}`)); + } else { + pending.resolve(message.result); + } + } + } else if ("method" in message) { + // It's a notification from server + const notification = message as { method: string; params?: unknown }; + this.onNotification?.(notification.method, notification.params); + } + } + + private handleClose(): void { + if (!this._connected) return; + this._connected = false; + + // Reject all pending requests + for (const [, pending] of this.pendingRequests) { + pending.reject(new Error("Transport closed")); + } + this.pendingRequests.clear(); + + this.onClose?.(); + } + + async request(method: string, params?: Record): Promise { + if (!this._connected || !this.process?.stdin) { + throw new Error("Transport not connected"); + } + + const id = generateId(); + const request = { + jsonrpc: "2.0" as const, + id, + method, + params: params ?? {}, + }; + + return new Promise((resolve, reject) => { + this.pendingRequests.set(id, { + resolve: resolve as (value: unknown) => void, + reject, + }); + + const message = JSON.stringify(request) + "\n"; + try { + // Bun's FileSink has write() method directly + this.process!.stdin.write(message); + this.process!.stdin.flush(); + } catch (error: unknown) { + this.pendingRequests.delete(id); + reject(error); + } + }); + } + + async notify(method: string, params?: Record): Promise { + if (!this._connected || !this.process?.stdin) { + throw new Error("Transport not connected"); + } + + const notification = { + jsonrpc: "2.0" as const, + method, + params: params ?? {}, + }; + + const message = JSON.stringify(notification) + "\n"; + // Bun's FileSink has write() method directly + this.process.stdin.write(message); + this.process.stdin.flush(); + } + + async close(): Promise { + if (!this._connected) return; + this._connected = false; + + // Reject pending requests + for (const [, pending] of this.pendingRequests) { + pending.reject(new Error("Transport closed")); + } + this.pendingRequests.clear(); + + // Kill subprocess + if (this.process) { + this.process.kill(); + this.process = null; + } + + // Wait for read loop to finish + if (this.readLoop) { + await this.readLoop.catch(() => {}); + this.readLoop = null; + } + + this.onClose?.(); + } +} + +/** + * Create and connect a stdio transport. + */ +export async function createStdioTransport(config: MCPStdioServerConfig): Promise { + const transport = new StdioTransport(config); + await transport.connect(); + return transport; +} diff --git a/packages/coding-agent/src/core/mcp/types.ts b/packages/coding-agent/src/core/mcp/types.ts new file mode 100644 index 000000000..d96049b20 --- /dev/null +++ b/packages/coding-agent/src/core/mcp/types.ts @@ -0,0 +1,220 @@ +/** + * MCP (Model Context Protocol) type definitions. + * + * Based on MCP specification 2025-03-26: + * https://modelcontextprotocol.io/specification/2025-03-26/ + */ + +// ============================================================================= +// JSON-RPC 2.0 Types +// ============================================================================= + +export interface JsonRpcRequest { + jsonrpc: "2.0"; + id: string | number; + method: string; + params?: Record; +} + +export interface JsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params?: Record; +} + +export interface JsonRpcResponse { + jsonrpc: "2.0"; + id: string | number; + result?: unknown; + error?: JsonRpcError; +} + +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +export type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse; + +// ============================================================================= +// MCP Server Configuration (.mcp.json format) +// ============================================================================= + +/** Stdio server configuration */ +export interface MCPStdioServerConfig { + type?: "stdio"; // Default if not specified + command: string; + args?: string[]; + env?: Record; + cwd?: string; +} + +/** HTTP server configuration (Streamable HTTP transport) */ +export interface MCPHttpServerConfig { + type: "http"; + url: string; + headers?: Record; +} + +/** SSE server configuration (deprecated, use HTTP) */ +export interface MCPSseServerConfig { + type: "sse"; + url: string; + headers?: Record; +} + +export type MCPServerConfig = MCPStdioServerConfig | MCPHttpServerConfig | MCPSseServerConfig; + +/** Root .mcp.json file structure */ +export interface MCPConfigFile { + mcpServers?: Record; +} + +// ============================================================================= +// MCP Protocol Types +// ============================================================================= + +/** MCP implementation info */ +export interface MCPImplementation { + name: string; + version: string; +} + +/** MCP client capabilities */ +export interface MCPClientCapabilities { + roots?: { listChanged?: boolean }; + sampling?: Record; + experimental?: Record; +} + +/** MCP server capabilities */ +export interface MCPServerCapabilities { + tools?: { listChanged?: boolean }; + resources?: { subscribe?: boolean; listChanged?: boolean }; + prompts?: { listChanged?: boolean }; + logging?: Record; + experimental?: Record; +} + +/** Initialize request params */ +export interface MCPInitializeParams { + protocolVersion: string; + capabilities: MCPClientCapabilities; + clientInfo: MCPImplementation; +} + +/** Initialize response result */ +export interface MCPInitializeResult { + protocolVersion: string; + capabilities: MCPServerCapabilities; + serverInfo: MCPImplementation; + instructions?: string; +} + +/** MCP tool definition */ +export interface MCPToolDefinition { + name: string; + description?: string; + inputSchema: { + type: "object"; + properties?: Record; + required?: string[]; + [key: string]: unknown; + }; +} + +/** tools/list response */ +export interface MCPToolsListResult { + tools: MCPToolDefinition[]; + nextCursor?: string; +} + +/** tools/call params */ +export interface MCPToolCallParams { + name: string; + arguments?: Record; +} + +/** Content types in tool results */ +export interface MCPTextContent { + type: "text"; + text: string; +} + +export interface MCPImageContent { + type: "image"; + data: string; // base64 + mimeType: string; +} + +export interface MCPResourceContent { + type: "resource"; + resource: { + uri: string; + mimeType?: string; + text?: string; + blob?: string; + }; +} + +export type MCPContent = MCPTextContent | MCPImageContent | MCPResourceContent; + +/** tools/call response */ +export interface MCPToolCallResult { + content: MCPContent[]; + isError?: boolean; +} + +// ============================================================================= +// Transport Types +// ============================================================================= + +/** Transport interface - abstracts stdio/http */ +export interface MCPTransport { + /** Send a request and wait for response */ + request(method: string, params?: Record): Promise; + + /** Send a notification (no response expected) */ + notify(method: string, params?: Record): Promise; + + /** Close the transport */ + close(): Promise; + + /** Whether the transport is connected */ + readonly connected: boolean; + + /** Event handlers */ + onClose?: () => void; + onError?: (error: Error) => void; + onNotification?: (method: string, params: unknown) => void; +} + +/** Transport factory function */ +export type TransportFactory = (config: MCPServerConfig) => Promise; + +// ============================================================================= +// MCP Client Types +// ============================================================================= + +/** Connected MCP server state */ +export interface MCPServerConnection { + /** Server name from config */ + name: string; + /** Original config */ + config: MCPServerConfig; + /** Transport instance */ + transport: MCPTransport; + /** Server info from initialize */ + serverInfo: MCPImplementation; + /** Server capabilities */ + capabilities: MCPServerCapabilities; + /** Cached tools (populated on demand) */ + tools?: MCPToolDefinition[]; +} + +/** MCP tool with server context */ +export interface MCPToolWithServer { + server: MCPServerConnection; + tool: MCPToolDefinition; +} diff --git a/packages/coding-agent/src/core/plugins/doctor.ts b/packages/coding-agent/src/core/plugins/doctor.ts new file mode 100644 index 000000000..98bc6e555 --- /dev/null +++ b/packages/coding-agent/src/core/plugins/doctor.ts @@ -0,0 +1,59 @@ +import type { DoctorCheck } from "./types.js"; + +export async function runDoctorChecks(): Promise { + const checks: DoctorCheck[] = []; + + // Check external tools + const tools = [ + { name: "fd", description: "File finder" }, + { name: "rg", description: "Ripgrep" }, + { name: "sd", description: "Find-replace" }, + { name: "sg", description: "AST-grep" }, + { name: "git", description: "Version control" }, + ]; + + for (const tool of tools) { + const path = Bun.which(tool.name); + checks.push({ + name: tool.name, + status: path ? "ok" : "warning", + message: path ? `Found at ${path}` : `${tool.description} not found - some features may be limited`, + }); + } + + // Check API keys + const apiKeys = [ + { name: "ANTHROPIC_API_KEY", description: "Anthropic API" }, + { name: "OPENAI_API_KEY", description: "OpenAI API" }, + { name: "PERPLEXITY_API_KEY", description: "Perplexity search" }, + { name: "EXA_API_KEY", description: "Exa search" }, + ]; + + for (const key of apiKeys) { + const hasKey = !!process.env[key.name]; + checks.push({ + name: key.name, + status: hasKey ? "ok" : "warning", + message: hasKey ? "Configured" : `Not set - ${key.description} unavailable`, + }); + } + + return checks; +} + +export function formatDoctorResults(checks: DoctorCheck[]): string { + const lines: string[] = ["System Health Check", "=".repeat(40), ""]; + + for (const check of checks) { + const icon = check.status === "ok" ? "✓" : check.status === "warning" ? "!" : "✗"; + lines.push(`${icon} ${check.name}: ${check.message}`); + } + + const errors = checks.filter((c) => c.status === "error").length; + const warnings = checks.filter((c) => c.status === "warning").length; + + lines.push(""); + lines.push(`Summary: ${checks.length - errors - warnings} ok, ${warnings} warnings, ${errors} errors`); + + return lines.join("\n"); +} diff --git a/packages/coding-agent/src/core/plugins/index.ts b/packages/coding-agent/src/core/plugins/index.ts new file mode 100644 index 000000000..2f570f257 --- /dev/null +++ b/packages/coding-agent/src/core/plugins/index.ts @@ -0,0 +1,38 @@ +// Plugin system exports +export { formatDoctorResults, runDoctorChecks } from "./doctor.js"; +export { + getAllPluginCommandPaths, + getAllPluginHookPaths, + getAllPluginToolPaths, + getEnabledPlugins, + getPluginSettings, + resolvePluginCommandPaths, + resolvePluginHookPaths, + resolvePluginToolPaths, +} from "./loader.js"; +export { PluginManager, parseSettingValue, validateSetting } from "./manager.js"; +export { extractPackageName, formatPluginSpec, parsePluginSpec } from "./parser.js"; +export { + getPluginsDir, + getPluginsLockfile, + getPluginsNodeModules, + getPluginsPackageJson, + getProjectPluginOverrides, +} from "./paths.js"; +export type { + BooleanSetting, + DoctorCheck, + DoctorOptions, + EnumSetting, + InstalledPlugin, + InstallOptions, + NumberSetting, + PluginFeature, + PluginManifest, + PluginRuntimeConfig, + PluginRuntimeState, + PluginSettingSchema, + PluginSettingType, + ProjectPluginOverrides, + StringSetting, +} from "./types.js"; diff --git a/packages/coding-agent/src/core/plugins/installer.ts b/packages/coding-agent/src/core/plugins/installer.ts new file mode 100644 index 000000000..4a864bbe5 --- /dev/null +++ b/packages/coding-agent/src/core/plugins/installer.ts @@ -0,0 +1,189 @@ +import { mkdir } from "fs/promises"; +import { join, resolve } from "path"; +import { getAgentDir } from "../../config.js"; +import type { InstalledPlugin } from "./types.js"; + +const PLUGINS_DIR = join(getAgentDir(), "plugins"); + +// Valid npm package name pattern (scoped and unscoped) +const VALID_PACKAGE_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[a-z0-9-._^~>=<]+)?$/i; + +/** + * Validate package name to prevent command injection + */ +function validatePackageName(name: string): void { + if (!VALID_PACKAGE_NAME.test(name)) { + throw new Error(`Invalid package name: ${name}`); + } + // Extra safety: no shell metacharacters + if (/[;&|`$(){}[\]<>\\]/.test(name)) { + throw new Error(`Invalid characters in package name: ${name}`); + } +} + +/** + * Ensure the plugins directory exists + */ +async function ensurePluginsDir(): Promise { + await mkdir(PLUGINS_DIR, { recursive: true }); + await mkdir(join(PLUGINS_DIR, "node_modules"), { recursive: true }); +} + +export async function installPlugin(packageName: string): Promise { + // Validate package name to prevent command injection + validatePackageName(packageName); + + // Ensure plugins directory exists + await ensurePluginsDir(); + + // Initialize package.json if it doesn't exist + const pkgJsonPath = join(PLUGINS_DIR, "package.json"); + if (!(await Bun.file(pkgJsonPath).exists())) { + await Bun.write(pkgJsonPath, JSON.stringify({ name: "pi-plugins", private: true, dependencies: {} }, null, 2)); + } + + // Run npm install in plugins directory + const proc = Bun.spawn(["npm", "install", packageName], { + cwd: PLUGINS_DIR, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`Failed to install ${packageName}: ${stderr}`); + } + + // Extract the actual package name (without version specifier) for path lookup + const actualName = packageName.replace(/@[^/]+$/, "").replace(/^(@[^/]+\/[^@]+).*$/, "$1"); + + // Read the installed package's package.json + const pkgPath = join(PLUGINS_DIR, "node_modules", actualName, "package.json"); + const pkgFile = Bun.file(pkgPath); + if (!(await pkgFile.exists())) { + throw new Error(`Package installed but package.json not found at ${pkgPath}`); + } + + const pkg = await pkgFile.json(); + + return { + name: pkg.name, + version: pkg.version, + path: join(PLUGINS_DIR, "node_modules", actualName), + manifest: pkg.omp || pkg.pi || { version: pkg.version }, + enabledFeatures: null, + enabled: true, + }; +} + +export async function uninstallPlugin(name: string): Promise { + // Validate package name + validatePackageName(name); + + await ensurePluginsDir(); + + const proc = Bun.spawn(["npm", "uninstall", name], { + cwd: PLUGINS_DIR, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`Failed to uninstall ${name}`); + } +} + +export async function listPlugins(): Promise { + const pkgJsonPath = join(PLUGINS_DIR, "package.json"); + if (!(await Bun.file(pkgJsonPath).exists())) { + return []; + } + + const pkg = await Bun.file(pkgJsonPath).json(); + const deps = pkg.dependencies || {}; + + const plugins: InstalledPlugin[] = []; + for (const [name, _version] of Object.entries(deps)) { + const pluginPkgPath = join(PLUGINS_DIR, "node_modules", name, "package.json"); + if (await Bun.file(pluginPkgPath).exists()) { + const pluginPkg = await Bun.file(pluginPkgPath).json(); + plugins.push({ + name, + version: pluginPkg.version, + path: join(PLUGINS_DIR, "node_modules", name), + manifest: pluginPkg.omp || pluginPkg.pi || { version: pluginPkg.version }, + enabledFeatures: null, + enabled: true, + }); + } + } + + return plugins; +} + +export async function linkPlugin(localPath: string): Promise { + const cwd = process.cwd(); + const absolutePath = resolve(cwd, localPath); + + // Validate that resolved path is within cwd to prevent path traversal + const normalizedCwd = resolve(cwd); + const normalizedPath = resolve(absolutePath); + if (!normalizedPath.startsWith(`${normalizedCwd}/`) && normalizedPath !== normalizedCwd) { + throw new Error(`Invalid path: ${localPath} resolves outside working directory`); + } + + // Validate package.json exists + const pkgFile = Bun.file(join(absolutePath, "package.json")); + if (!(await pkgFile.exists())) { + throw new Error(`package.json not found at ${absolutePath}`); + } + + let pkg: { name?: string }; + try { + pkg = await pkgFile.json(); + } catch (err) { + throw new Error(`Invalid package.json at ${absolutePath}: ${err}`); + } + + if (!pkg.name || typeof pkg.name !== "string") { + throw new Error("package.json must have a valid name field"); + } + + // Validate package name to prevent path traversal via pkg.name + if (pkg.name.includes("..") || pkg.name.includes("/") || pkg.name.includes("\\")) { + // Exception: scoped packages have one slash + if (!pkg.name.startsWith("@") || (pkg.name.match(/\//g) || []).length !== 1) { + throw new Error(`Invalid package name in package.json: ${pkg.name}`); + } + } + + await ensurePluginsDir(); + + // Create symlink in plugins/node_modules + const linkPath = join(PLUGINS_DIR, "node_modules", pkg.name); + + // For scoped packages, ensure the scope directory exists + if (pkg.name.startsWith("@")) { + const scopeDir = join(PLUGINS_DIR, "node_modules", pkg.name.split("/")[0]); + await mkdir(scopeDir, { recursive: true }); + } + + // Remove existing if present + try { + const { unlinkSync, lstatSync } = await import("fs"); + const stat = lstatSync(linkPath); + if (stat.isSymbolicLink() || stat.isDirectory()) { + unlinkSync(linkPath); + } + } catch { + // Doesn't exist, that's fine + } + + // Create symlink using fs instead of shell command + const { symlinkSync } = await import("fs"); + symlinkSync(absolutePath, linkPath); +} diff --git a/packages/coding-agent/src/core/plugins/loader.ts b/packages/coding-agent/src/core/plugins/loader.ts new file mode 100644 index 000000000..237d3b8ce --- /dev/null +++ b/packages/coding-agent/src/core/plugins/loader.ts @@ -0,0 +1,338 @@ +/** + * Plugin loader - discovers and loads tools/hooks from installed plugins. + * + * Reads enabled plugins from the runtime config and loads their tools/hooks + * based on manifest entries and enabled features. + */ + +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { + getPluginsLockfile, + getPluginsNodeModules, + getPluginsPackageJson, + getProjectPluginOverrides, +} from "./paths.js"; +import type { InstalledPlugin, PluginManifest, PluginRuntimeConfig, ProjectPluginOverrides } from "./types.js"; + +// ============================================================================= +// Runtime Config Loading +// ============================================================================= + +/** + * Load plugin runtime config from lock file. + */ +function loadRuntimeConfig(): PluginRuntimeConfig { + const lockPath = getPluginsLockfile(); + if (!existsSync(lockPath)) { + return { plugins: {}, settings: {} }; + } + try { + return JSON.parse(readFileSync(lockPath, "utf-8")); + } catch { + return { plugins: {}, settings: {} }; + } +} + +/** + * Load project-local plugin overrides. + */ +function loadProjectOverrides(cwd: string): ProjectPluginOverrides { + const overridesPath = getProjectPluginOverrides(cwd); + if (!existsSync(overridesPath)) { + return {}; + } + try { + return JSON.parse(readFileSync(overridesPath, "utf-8")); + } catch { + return {}; + } +} + +// ============================================================================= +// Plugin Discovery +// ============================================================================= + +/** + * Get list of enabled plugins with their resolved configurations. + * Respects both global runtime config and project overrides. + */ +export function getEnabledPlugins(cwd: string): InstalledPlugin[] { + const pkgJsonPath = getPluginsPackageJson(); + if (!existsSync(pkgJsonPath)) { + return []; + } + + const nodeModulesPath = getPluginsNodeModules(); + if (!existsSync(nodeModulesPath)) { + return []; + } + + const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8")); + const deps = pkg.dependencies || {}; + const runtimeConfig = loadRuntimeConfig(); + const projectOverrides = loadProjectOverrides(cwd); + const plugins: InstalledPlugin[] = []; + + for (const [name] of Object.entries(deps)) { + const pluginPkgPath = join(nodeModulesPath, name, "package.json"); + if (!existsSync(pluginPkgPath)) { + continue; + } + + const pluginPkg = JSON.parse(readFileSync(pluginPkgPath, "utf-8")); + const manifest: PluginManifest | undefined = pluginPkg.omp || pluginPkg.pi; + + if (!manifest) { + // Not a pi plugin, skip + continue; + } + + manifest.version = pluginPkg.version; + + const runtimeState = runtimeConfig.plugins[name]; + + // Check if disabled globally + if (runtimeState && !runtimeState.enabled) { + continue; + } + + // Check if disabled in project + if (projectOverrides.disabled?.includes(name)) { + continue; + } + + // Resolve enabled features (project overrides take precedence) + const enabledFeatures = projectOverrides.features?.[name] ?? runtimeState?.enabledFeatures ?? null; + + plugins.push({ + name, + version: pluginPkg.version, + path: join(nodeModulesPath, name), + manifest, + enabledFeatures, + enabled: true, + }); + } + + return plugins; +} + +// ============================================================================= +// Path Resolution +// ============================================================================= + +/** + * Resolve tool entry points for a plugin based on manifest and enabled features. + * Returns absolute paths to tool modules. + */ +export function resolvePluginToolPaths(plugin: InstalledPlugin): string[] { + const paths: string[] = []; + const manifest = plugin.manifest; + + // Base tools entry (always included if exists) + if (manifest.tools) { + const toolPath = join(plugin.path, manifest.tools); + if (existsSync(toolPath)) { + paths.push(toolPath); + } + } + + // Feature-specific tools + if (manifest.features && plugin.enabledFeatures) { + const enabledSet = new Set(plugin.enabledFeatures); + + for (const [featName, feat] of Object.entries(manifest.features)) { + if (!enabledSet.has(featName)) continue; + + if (feat.tools) { + for (const toolEntry of feat.tools) { + const toolPath = join(plugin.path, toolEntry); + if (existsSync(toolPath)) { + paths.push(toolPath); + } + } + } + } + } else if (manifest.features && plugin.enabledFeatures === null) { + // null means use defaults - enable features with default: true + for (const [_featName, feat] of Object.entries(manifest.features)) { + if (!feat.default) continue; + + if (feat.tools) { + for (const toolEntry of feat.tools) { + const toolPath = join(plugin.path, toolEntry); + if (existsSync(toolPath)) { + paths.push(toolPath); + } + } + } + } + } + + return paths; +} + +/** + * Resolve hook entry points for a plugin based on manifest and enabled features. + * Returns absolute paths to hook modules. + */ +export function resolvePluginHookPaths(plugin: InstalledPlugin): string[] { + const paths: string[] = []; + const manifest = plugin.manifest; + + // Base hooks entry (always included if exists) + if (manifest.hooks) { + const hookPath = join(plugin.path, manifest.hooks); + if (existsSync(hookPath)) { + paths.push(hookPath); + } + } + + // Feature-specific hooks + if (manifest.features && plugin.enabledFeatures) { + const enabledSet = new Set(plugin.enabledFeatures); + + for (const [featName, feat] of Object.entries(manifest.features)) { + if (!enabledSet.has(featName)) continue; + + if (feat.hooks) { + for (const hookEntry of feat.hooks) { + const hookPath = join(plugin.path, hookEntry); + if (existsSync(hookPath)) { + paths.push(hookPath); + } + } + } + } + } else if (manifest.features && plugin.enabledFeatures === null) { + // null means use defaults - enable features with default: true + for (const [_featName, feat] of Object.entries(manifest.features)) { + if (!feat.default) continue; + + if (feat.hooks) { + for (const hookEntry of feat.hooks) { + const hookPath = join(plugin.path, hookEntry); + if (existsSync(hookPath)) { + paths.push(hookPath); + } + } + } + } + } + + return paths; +} + +/** + * Resolve command file paths for a plugin based on manifest and enabled features. + * Returns absolute paths to command files (.md). + */ +export function resolvePluginCommandPaths(plugin: InstalledPlugin): string[] { + const paths: string[] = []; + const manifest = plugin.manifest; + + // Base commands (always included if exists) + if (manifest.commands) { + for (const cmdEntry of manifest.commands) { + const cmdPath = join(plugin.path, cmdEntry); + if (existsSync(cmdPath)) { + paths.push(cmdPath); + } + } + } + + // Feature-specific commands + if (manifest.features && plugin.enabledFeatures) { + const enabledSet = new Set(plugin.enabledFeatures); + + for (const [featName, feat] of Object.entries(manifest.features)) { + if (!enabledSet.has(featName)) continue; + + if (feat.commands) { + for (const cmdEntry of feat.commands) { + const cmdPath = join(plugin.path, cmdEntry); + if (existsSync(cmdPath)) { + paths.push(cmdPath); + } + } + } + } + } else if (manifest.features && plugin.enabledFeatures === null) { + // null means use defaults - enable features with default: true + for (const [_featName, feat] of Object.entries(manifest.features)) { + if (!feat.default) continue; + + if (feat.commands) { + for (const cmdEntry of feat.commands) { + const cmdPath = join(plugin.path, cmdEntry); + if (existsSync(cmdPath)) { + paths.push(cmdPath); + } + } + } + } + } + + return paths; +} + +// ============================================================================= +// Aggregated Discovery +// ============================================================================= + +/** + * Get all tool paths from all enabled plugins. + */ +export function getAllPluginToolPaths(cwd: string): string[] { + const plugins = getEnabledPlugins(cwd); + const paths: string[] = []; + + for (const plugin of plugins) { + paths.push(...resolvePluginToolPaths(plugin)); + } + + return paths; +} + +/** + * Get all hook paths from all enabled plugins. + */ +export function getAllPluginHookPaths(cwd: string): string[] { + const plugins = getEnabledPlugins(cwd); + const paths: string[] = []; + + for (const plugin of plugins) { + paths.push(...resolvePluginHookPaths(plugin)); + } + + return paths; +} + +/** + * Get all command paths from all enabled plugins. + */ +export function getAllPluginCommandPaths(cwd: string): string[] { + const plugins = getEnabledPlugins(cwd); + const paths: string[] = []; + + for (const plugin of plugins) { + paths.push(...resolvePluginCommandPaths(plugin)); + } + + return paths; +} + +/** + * Get plugin settings for use in tool/hook contexts. + * Merges global settings with project overrides. + */ +export function getPluginSettings(pluginName: string, cwd: string): Record { + const runtimeConfig = loadRuntimeConfig(); + const projectOverrides = loadProjectOverrides(cwd); + + const global = runtimeConfig.settings[pluginName] || {}; + const project = projectOverrides.settings?.[pluginName] || {}; + + return { ...global, ...project }; +} diff --git a/packages/coding-agent/src/core/plugins/manager.ts b/packages/coding-agent/src/core/plugins/manager.ts new file mode 100644 index 000000000..70c899885 --- /dev/null +++ b/packages/coding-agent/src/core/plugins/manager.ts @@ -0,0 +1,672 @@ +import { existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync, unlinkSync, writeFileSync } from "fs"; +import { join, resolve } from "path"; +import { extractPackageName, parsePluginSpec } from "./parser.js"; +import { + getPluginsDir, + getPluginsLockfile, + getPluginsNodeModules, + getPluginsPackageJson, + getProjectPluginOverrides, +} from "./paths.js"; +import type { + DoctorCheck, + DoctorOptions, + InstalledPlugin, + InstallOptions, + PluginManifest, + PluginRuntimeConfig, + PluginSettingSchema, + ProjectPluginOverrides, +} from "./types.js"; + +// ============================================================================= +// Validation +// ============================================================================= + +/** Valid npm package name pattern (scoped and unscoped, with optional version) */ +const VALID_PACKAGE_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*(@[a-z0-9-._^~>=<]+)?$/i; + +/** + * Validate package name to prevent command injection. + */ +function validatePackageName(name: string): void { + // Remove version specifier for validation + const baseName = extractPackageName(name); + if (!VALID_PACKAGE_NAME.test(baseName)) { + throw new Error(`Invalid package name: ${name}`); + } + // Extra safety: no shell metacharacters + if (/[;&|`$(){}[\]<>\\]/.test(name)) { + throw new Error(`Invalid characters in package name: ${name}`); + } +} + +// ============================================================================= +// Plugin Manager +// ============================================================================= + +export class PluginManager { + private runtimeConfig: PluginRuntimeConfig; + private cwd: string; + + constructor(cwd: string = process.cwd()) { + this.cwd = cwd; + this.runtimeConfig = this.loadRuntimeConfig(); + } + + // ========================================================================== + // Runtime Config Management + // ========================================================================== + + private loadRuntimeConfig(): PluginRuntimeConfig { + const lockPath = getPluginsLockfile(); + if (!existsSync(lockPath)) { + return { plugins: {}, settings: {} }; + } + try { + return JSON.parse(readFileSync(lockPath, "utf-8")); + } catch { + return { plugins: {}, settings: {} }; + } + } + + private saveRuntimeConfig(): void { + this.ensurePluginsDir(); + writeFileSync(getPluginsLockfile(), JSON.stringify(this.runtimeConfig, null, 2)); + } + + private loadProjectOverrides(): ProjectPluginOverrides { + const overridesPath = getProjectPluginOverrides(this.cwd); + if (!existsSync(overridesPath)) { + return {}; + } + try { + return JSON.parse(readFileSync(overridesPath, "utf-8")); + } catch { + return {}; + } + } + + // ========================================================================== + // Directory Management + // ========================================================================== + + private ensurePluginsDir(): void { + const dir = getPluginsDir(); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + const nodeModules = getPluginsNodeModules(); + if (!existsSync(nodeModules)) { + mkdirSync(nodeModules, { recursive: true }); + } + } + + private ensurePackageJson(): void { + this.ensurePluginsDir(); + const pkgJsonPath = getPluginsPackageJson(); + if (!existsSync(pkgJsonPath)) { + writeFileSync( + pkgJsonPath, + JSON.stringify( + { + name: "pi-plugins", + private: true, + dependencies: {}, + }, + null, + 2, + ), + ); + } + } + + // ========================================================================== + // Install / Uninstall + // ========================================================================== + + /** + * Install a plugin from npm with optional feature selection. + * + * @param specString - Package specifier with optional features: "pkg", "pkg[feat]", "pkg[*]", "pkg[]" + * @param options - Install options + * @returns Installed plugin metadata + */ + async install(specString: string, options: InstallOptions = {}): Promise { + const spec = parsePluginSpec(specString); + validatePackageName(spec.packageName); + + this.ensurePackageJson(); + + if (options.dryRun) { + return { + name: spec.packageName, + version: "0.0.0-dryrun", + path: "", + manifest: { version: "0.0.0-dryrun" }, + enabledFeatures: spec.features === "*" ? null : (spec.features as string[] | null), + enabled: true, + }; + } + + // Run npm install + const proc = Bun.spawn(["npm", "install", spec.packageName], { + cwd: getPluginsDir(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`npm install failed: ${stderr}`); + } + + // Resolve actual package name (strip version specifier) + const actualName = extractPackageName(spec.packageName); + const pkgPath = join(getPluginsNodeModules(), actualName, "package.json"); + + if (!existsSync(pkgPath)) { + throw new Error(`Package installed but package.json not found at ${pkgPath}`); + } + + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + const manifest: PluginManifest = pkg.omp || pkg.pi || { version: pkg.version }; + manifest.version = pkg.version; + + // Resolve enabled features + let enabledFeatures: string[] | null = null; + if (spec.features === "*") { + // All features + enabledFeatures = manifest.features ? Object.keys(manifest.features) : null; + } else if (Array.isArray(spec.features)) { + if (spec.features.length > 0) { + // Validate requested features exist + if (manifest.features) { + for (const feat of spec.features) { + if (!(feat in manifest.features)) { + throw new Error( + `Unknown feature "${feat}" in ${actualName}. Available: ${Object.keys(manifest.features).join(", ")}`, + ); + } + } + } + enabledFeatures = spec.features; + } else { + // Empty array = no optional features + enabledFeatures = []; + } + } + // null = use defaults + + // Update runtime config + this.runtimeConfig.plugins[pkg.name] = { + version: pkg.version, + enabledFeatures, + enabled: true, + }; + this.saveRuntimeConfig(); + + return { + name: pkg.name, + version: pkg.version, + path: join(getPluginsNodeModules(), actualName), + manifest, + enabledFeatures, + enabled: true, + }; + } + + /** + * Uninstall a plugin. + */ + async uninstall(name: string): Promise { + validatePackageName(name); + this.ensurePackageJson(); + + const proc = Bun.spawn(["npm", "uninstall", name], { + cwd: getPluginsDir(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error(`npm uninstall failed for ${name}`); + } + + // Remove from runtime config + delete this.runtimeConfig.plugins[name]; + delete this.runtimeConfig.settings[name]; + this.saveRuntimeConfig(); + } + + /** + * List all installed plugins. + */ + async list(): Promise { + const pkgJsonPath = getPluginsPackageJson(); + if (!existsSync(pkgJsonPath)) { + return []; + } + + const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8")); + const deps = pkg.dependencies || {}; + const projectOverrides = this.loadProjectOverrides(); + const plugins: InstalledPlugin[] = []; + + for (const [name] of Object.entries(deps)) { + const pluginPkgPath = join(getPluginsNodeModules(), name, "package.json"); + if (existsSync(pluginPkgPath)) { + const pluginPkg = JSON.parse(readFileSync(pluginPkgPath, "utf-8")); + const manifest: PluginManifest = pluginPkg.omp || pluginPkg.pi || { version: pluginPkg.version }; + manifest.version = pluginPkg.version; + + const runtimeState = this.runtimeConfig.plugins[name] || { + version: pluginPkg.version, + enabledFeatures: null, + enabled: true, + }; + + // Apply project overrides + const isDisabledInProject = projectOverrides.disabled?.includes(name) ?? false; + const projectFeatures = projectOverrides.features?.[name]; + + plugins.push({ + name, + version: pluginPkg.version, + path: join(getPluginsNodeModules(), name), + manifest, + enabledFeatures: projectFeatures ?? runtimeState.enabledFeatures, + enabled: runtimeState.enabled && !isDisabledInProject, + }); + } + } + + return plugins; + } + + /** + * Link a local plugin for development. + */ + async link(localPath: string): Promise { + const absolutePath = resolve(this.cwd, localPath); + + const pkgFile = join(absolutePath, "package.json"); + if (!existsSync(pkgFile)) { + throw new Error(`package.json not found at ${absolutePath}`); + } + + const pkg = JSON.parse(readFileSync(pkgFile, "utf-8")); + if (!pkg.name) { + throw new Error("package.json must have a name field"); + } + + this.ensurePluginsDir(); + + const linkPath = join(getPluginsNodeModules(), pkg.name); + + // Handle scoped packages + if (pkg.name.startsWith("@")) { + const scopeDir = join(getPluginsNodeModules(), pkg.name.split("/")[0]); + if (!existsSync(scopeDir)) { + mkdirSync(scopeDir, { recursive: true }); + } + } + + // Remove existing + try { + const stat = lstatSync(linkPath); + if (stat.isSymbolicLink() || stat.isDirectory()) { + unlinkSync(linkPath); + } + } catch { + // Doesn't exist + } + + symlinkSync(absolutePath, linkPath); + + const manifest: PluginManifest = pkg.omp || pkg.pi || { version: pkg.version }; + manifest.version = pkg.version; + + // Add to runtime config + this.runtimeConfig.plugins[pkg.name] = { + version: pkg.version, + enabledFeatures: null, + enabled: true, + }; + this.saveRuntimeConfig(); + + return { + name: pkg.name, + version: pkg.version, + path: absolutePath, + manifest, + enabledFeatures: null, + enabled: true, + }; + } + + // ========================================================================== + // Enable / Disable + // ========================================================================== + + /** + * Enable or disable a plugin globally. + */ + async setEnabled(name: string, enabled: boolean): Promise { + if (!this.runtimeConfig.plugins[name]) { + throw new Error(`Plugin ${name} not found in runtime config`); + } + this.runtimeConfig.plugins[name].enabled = enabled; + this.saveRuntimeConfig(); + } + + // ========================================================================== + // Features + // ========================================================================== + + /** + * Get enabled features for a plugin. + */ + getEnabledFeatures(name: string): string[] | null { + return this.runtimeConfig.plugins[name]?.enabledFeatures ?? null; + } + + /** + * Set enabled features for a plugin. + */ + async setEnabledFeatures(name: string, features: string[] | null): Promise { + if (!this.runtimeConfig.plugins[name]) { + throw new Error(`Plugin ${name} not found in runtime config`); + } + + // Validate features if setting specific ones + if (features && features.length > 0) { + const plugins = await this.list(); + const plugin = plugins.find((p) => p.name === name); + if (plugin?.manifest.features) { + for (const feat of features) { + if (!(feat in plugin.manifest.features)) { + throw new Error( + `Unknown feature "${feat}" in ${name}. Available: ${Object.keys(plugin.manifest.features).join(", ")}`, + ); + } + } + } + } + + this.runtimeConfig.plugins[name].enabledFeatures = features; + this.saveRuntimeConfig(); + } + + // ========================================================================== + // Settings + // ========================================================================== + + /** + * Get all settings for a plugin. + */ + getPluginSettings(name: string): Record { + const global = this.runtimeConfig.settings[name] || {}; + const projectOverrides = this.loadProjectOverrides(); + const project = projectOverrides.settings?.[name] || {}; + + // Project settings override global + return { ...global, ...project }; + } + + /** + * Set a plugin setting value. + */ + setPluginSetting(name: string, key: string, value: unknown): void { + if (!this.runtimeConfig.settings[name]) { + this.runtimeConfig.settings[name] = {}; + } + this.runtimeConfig.settings[name][key] = value; + this.saveRuntimeConfig(); + } + + /** + * Delete a plugin setting. + */ + deletePluginSetting(name: string, key: string): void { + if (this.runtimeConfig.settings[name]) { + delete this.runtimeConfig.settings[name][key]; + this.saveRuntimeConfig(); + } + } + + // ========================================================================== + // Doctor + // ========================================================================== + + /** + * Run health checks on the plugin system. + */ + async doctor(options: DoctorOptions = {}): Promise { + const checks: DoctorCheck[] = []; + + // Check 1: Plugins directory exists + const pluginsDir = getPluginsDir(); + checks.push({ + name: "plugins_directory", + status: existsSync(pluginsDir) ? "ok" : "warning", + message: existsSync(pluginsDir) ? `Found at ${pluginsDir}` : "Not created yet", + }); + + // Check 2: package.json exists + const pkgJsonPath = getPluginsPackageJson(); + const hasPkgJson = existsSync(pkgJsonPath); + checks.push({ + name: "package_manifest", + status: hasPkgJson ? "ok" : "warning", + message: hasPkgJson ? "Found" : "Not created yet", + }); + + // Check 3: node_modules exists + const nodeModulesPath = getPluginsNodeModules(); + const hasNodeModules = existsSync(nodeModulesPath); + checks.push({ + name: "node_modules", + status: hasNodeModules ? "ok" : hasPkgJson ? "error" : "warning", + message: hasNodeModules ? "Found" : "Missing (run npm install in plugins dir)", + }); + + if (!hasPkgJson) { + return checks; + } + + // Check each installed plugin + const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8")); + const deps = pkg.dependencies || {}; + + for (const [name] of Object.entries(deps)) { + const pluginPath = join(nodeModulesPath, name); + const pluginPkgPath = join(pluginPath, "package.json"); + + if (!existsSync(pluginPath)) { + const fixed = options.fix ? await this.fixMissingPlugin() : false; + checks.push({ + name: `plugin:${name}`, + status: "error", + message: "Missing from node_modules", + fixed, + }); + continue; + } + + if (!existsSync(pluginPkgPath)) { + checks.push({ + name: `plugin:${name}`, + status: "error", + message: "Missing package.json", + }); + continue; + } + + const pluginPkg = JSON.parse(readFileSync(pluginPkgPath, "utf-8")); + const hasManifest = !!(pluginPkg.omp || pluginPkg.pi); + const manifest: PluginManifest | undefined = pluginPkg.omp || pluginPkg.pi; + + checks.push({ + name: `plugin:${name}`, + status: hasManifest ? "ok" : "warning", + message: hasManifest + ? `v${pluginPkg.version}${pluginPkg.description ? ` - ${pluginPkg.description}` : ""}` + : `v${pluginPkg.version} - No omp/pi manifest (not a pi plugin)`, + }); + + // Check tools path exists if specified + if (manifest?.tools) { + const toolsPath = join(pluginPath, manifest.tools); + if (!existsSync(toolsPath)) { + checks.push({ + name: `plugin:${name}:tools`, + status: "error", + message: `Tools entry "${manifest.tools}" not found`, + }); + } + } + + // Check hooks path exists if specified + if (manifest?.hooks) { + const hooksPath = join(pluginPath, manifest.hooks); + if (!existsSync(hooksPath)) { + checks.push({ + name: `plugin:${name}:hooks`, + status: "error", + message: `Hooks entry "${manifest.hooks}" not found`, + }); + } + } + + // Check enabled features exist in manifest + const runtimeState = this.runtimeConfig.plugins[name]; + if (runtimeState?.enabledFeatures && manifest?.features) { + for (const feat of runtimeState.enabledFeatures) { + if (!(feat in manifest.features)) { + const fixed = options.fix ? this.removeInvalidFeature(name, feat) : false; + checks.push({ + name: `plugin:${name}:feature:${feat}`, + status: "warning", + message: `Enabled feature "${feat}" not in manifest`, + fixed, + }); + } + } + } + } + + // Check for orphaned runtime config entries + for (const name of Object.keys(this.runtimeConfig.plugins)) { + if (!(name in deps)) { + const fixed = options.fix ? this.removeOrphanedConfig(name) : false; + checks.push({ + name: `orphan:${name}`, + status: "warning", + message: "Plugin in config but not installed", + fixed, + }); + } + } + + return checks; + } + + private async fixMissingPlugin(): Promise { + try { + const proc = Bun.spawn(["npm", "install"], { + cwd: getPluginsDir(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + return (await proc.exited) === 0; + } catch { + return false; + } + } + + private removeInvalidFeature(name: string, feat: string): boolean { + const state = this.runtimeConfig.plugins[name]; + if (state?.enabledFeatures) { + state.enabledFeatures = state.enabledFeatures.filter((f) => f !== feat); + this.saveRuntimeConfig(); + return true; + } + return false; + } + + private removeOrphanedConfig(name: string): boolean { + delete this.runtimeConfig.plugins[name]; + delete this.runtimeConfig.settings[name]; + this.saveRuntimeConfig(); + return true; + } +} + +// ============================================================================= +// Setting Validation +// ============================================================================= + +export interface ValidationResult { + valid: boolean; + error?: string; +} + +/** + * Validate a setting value against its schema. + */ +export function validateSetting(value: unknown, schema: PluginSettingSchema): ValidationResult { + switch (schema.type) { + case "string": + if (typeof value !== "string") { + return { valid: false, error: "Expected string" }; + } + break; + + case "number": + if (typeof value !== "number" || Number.isNaN(value)) { + return { valid: false, error: "Expected number" }; + } + if (schema.min !== undefined && value < schema.min) { + return { valid: false, error: `Must be >= ${schema.min}` }; + } + if (schema.max !== undefined && value > schema.max) { + return { valid: false, error: `Must be <= ${schema.max}` }; + } + break; + + case "boolean": + if (typeof value !== "boolean") { + return { valid: false, error: "Expected boolean" }; + } + break; + + case "enum": + if (!schema.values.includes(String(value))) { + return { valid: false, error: `Must be one of: ${schema.values.join(", ")}` }; + } + break; + } + + return { valid: true }; +} + +/** + * Parse a string value according to a setting schema's type. + */ +export function parseSettingValue(valueStr: string, schema: PluginSettingSchema): unknown { + switch (schema.type) { + case "number": + return Number(valueStr); + + case "boolean": + return valueStr === "true" || valueStr === "yes" || valueStr === "1"; + default: + return valueStr; + } +} diff --git a/packages/coding-agent/src/core/plugins/parser.ts b/packages/coding-agent/src/core/plugins/parser.ts new file mode 100644 index 000000000..9ae1faa23 --- /dev/null +++ b/packages/coding-agent/src/core/plugins/parser.ts @@ -0,0 +1,105 @@ +/** + * Feature bracket parser for plugin specifiers. + * + * Supports syntax like: + * - "my-plugin" -> base features (null) + * - "my-plugin[search,web]" -> specific features + * - "my-plugin[*]" -> all features + * - "my-plugin[]" -> no optional features + * - "@scope/plugin@1.2.3[feat]" -> scoped with version and features + */ + +export interface ParsedPluginSpec { + /** Package name (may include version specifier like @1.0.0) */ + packageName: string; + /** + * Feature selection: + * - null: use defaults (base features on first install, preserve on reinstall) + * - "*": all features + * - string[]: specific features (empty array = no optional features) + */ + features: string[] | null | "*"; +} + +/** + * Parse plugin specifier with feature bracket syntax. + * + * @example + * parsePluginSpec("my-plugin") // { packageName: "my-plugin", features: null } + * parsePluginSpec("my-plugin[search,web]") // { packageName: "my-plugin", features: ["search", "web"] } + * parsePluginSpec("my-plugin[*]") // { packageName: "my-plugin", features: "*" } + * parsePluginSpec("my-plugin[]") // { packageName: "my-plugin", features: [] } + * parsePluginSpec("@scope/pkg@1.2.3[feat]") // { packageName: "@scope/pkg@1.2.3", features: ["feat"] } + */ +export function parsePluginSpec(spec: string): ParsedPluginSpec { + // Find the last bracket pair (to handle version specifiers like @1.0.0) + const bracketStart = spec.lastIndexOf("["); + const bracketEnd = spec.lastIndexOf("]"); + + // No brackets or malformed -> base features + if (bracketStart === -1 || bracketEnd === -1 || bracketEnd < bracketStart) { + return { packageName: spec, features: null }; + } + + const packageName = spec.slice(0, bracketStart); + const featureStr = spec.slice(bracketStart + 1, bracketEnd).trim(); + + // All features + if (featureStr === "*") { + return { packageName, features: "*" }; + } + + // No optional features + if (featureStr === "") { + return { packageName, features: [] }; + } + + // Specific features (comma-separated) + const features = featureStr + .split(",") + .map((f) => f.trim()) + .filter(Boolean); + + return { packageName, features }; +} + +/** + * Format a parsed plugin spec back to string form. + * + * @example + * formatPluginSpec({ packageName: "pkg", features: null }) // "pkg" + * formatPluginSpec({ packageName: "pkg", features: "*" }) // "pkg[*]" + * formatPluginSpec({ packageName: "pkg", features: [] }) // "pkg[]" + * formatPluginSpec({ packageName: "pkg", features: ["a", "b"] }) // "pkg[a,b]" + */ +export function formatPluginSpec(spec: ParsedPluginSpec): string { + if (spec.features === null) { + return spec.packageName; + } + if (spec.features === "*") { + return `${spec.packageName}[*]`; + } + if (spec.features.length === 0) { + return `${spec.packageName}[]`; + } + return `${spec.packageName}[${spec.features.join(",")}]`; +} + +/** + * Extract the base package name without version specifier. + * Used for path lookups after npm install. + * + * @example + * extractPackageName("lodash@4.17.21") // "lodash" + * extractPackageName("@scope/pkg@1.0.0") // "@scope/pkg" + * extractPackageName("@scope/pkg") // "@scope/pkg" + */ +export function extractPackageName(specifier: string): string { + // Handle scoped packages: @scope/name@version -> @scope/name + if (specifier.startsWith("@")) { + const match = specifier.match(/^(@[^/]+\/[^@]+)/); + return match ? match[1] : specifier; + } + // Unscoped: name@version -> name + return specifier.replace(/@[^@]+$/, ""); +} diff --git a/packages/coding-agent/src/core/plugins/paths.ts b/packages/coding-agent/src/core/plugins/paths.ts new file mode 100644 index 000000000..14070f228 --- /dev/null +++ b/packages/coding-agent/src/core/plugins/paths.ts @@ -0,0 +1,32 @@ +import { homedir } from "os"; +import { join } from "path"; +import { CONFIG_DIR_NAME } from "../../config.js"; + +// ============================================================================= +// Plugin Directory Paths +// ============================================================================= + +/** Root plugin directory: ~/.pi/plugins (not under agent/) */ +export function getPluginsDir(): string { + return join(homedir(), CONFIG_DIR_NAME, "plugins"); +} + +/** Where npm installs packages: ~/.pi/plugins/node_modules */ +export function getPluginsNodeModules(): string { + return join(getPluginsDir(), "node_modules"); +} + +/** Plugin manifest: ~/.pi/plugins/package.json */ +export function getPluginsPackageJson(): string { + return join(getPluginsDir(), "package.json"); +} + +/** Plugin lock file: ~/.pi/plugins/pi-plugins.lock.json */ +export function getPluginsLockfile(): string { + return join(getPluginsDir(), "pi-plugins.lock.json"); +} + +/** Project-local plugin overrides: .pi/plugin-overrides.json */ +export function getProjectPluginOverrides(cwd: string): string { + return join(cwd, CONFIG_DIR_NAME, "plugin-overrides.json"); +} diff --git a/packages/coding-agent/src/core/plugins/types.ts b/packages/coding-agent/src/core/plugins/types.ts new file mode 100644 index 000000000..13a0e0846 --- /dev/null +++ b/packages/coding-agent/src/core/plugins/types.ts @@ -0,0 +1,190 @@ +// ============================================================================= +// Plugin Manifest Types (from package.json omp/pi field) +// ============================================================================= + +/** + * Feature definition for selective plugin installation. + * Features allow plugins to expose optional functionality. + */ +export interface PluginFeature { + /** Human-readable description */ + description?: string; + /** Whether this feature is enabled by default */ + default?: boolean; + /** Additional tool entry points provided by this feature */ + tools?: string[]; + /** Additional hook entry points provided by this feature */ + hooks?: string[]; + /** Additional command files provided by this feature */ + commands?: string[]; +} + +/** + * Plugin manifest from package.json omp or pi field. + */ +export interface PluginManifest { + /** Plugin display name (defaults to package name) */ + name?: string; + /** Plugin version (copied from package.json version) */ + version: string; + /** Human-readable description */ + description?: string; + + /** Entry point for base tools (relative path from package root) */ + tools?: string; + /** Entry point for base hooks (relative path from package root) */ + hooks?: string; + /** Command files (relative paths from package root) */ + commands?: string[]; + + /** Feature definitions for selective installation */ + features?: Record; + + /** Settings schema for plugin configuration */ + settings?: Record; +} + +// ============================================================================= +// Plugin Settings Schema Types +// ============================================================================= + +export type PluginSettingType = "string" | "number" | "boolean" | "enum"; + +interface PluginSettingBase { + /** Setting type */ + type: PluginSettingType; + /** Human-readable description */ + description?: string; + /** If true, mask value in UI and logs */ + secret?: boolean; + /** Environment variable to use as fallback value */ + env?: string; +} + +export interface StringSetting extends PluginSettingBase { + type: "string"; + default?: string; +} + +export interface NumberSetting extends PluginSettingBase { + type: "number"; + default?: number; + min?: number; + max?: number; + step?: number; +} + +export interface BooleanSetting extends PluginSettingBase { + type: "boolean"; + default?: boolean; +} + +export interface EnumSetting extends PluginSettingBase { + type: "enum"; + /** Allowed values */ + values: string[]; + default?: string; +} + +export type PluginSettingSchema = StringSetting | NumberSetting | BooleanSetting | EnumSetting; + +// ============================================================================= +// Installed Plugin Types +// ============================================================================= + +/** + * Represents an installed plugin with full metadata. + */ +export interface InstalledPlugin { + /** npm package name */ + name: string; + /** Installed version */ + version: string; + /** Absolute path to package directory */ + path: string; + /** Parsed omp/pi manifest */ + manifest: PluginManifest; + /** + * Enabled features: + * - null: use defaults (all features with default: true) + * - string[]: specific features enabled + */ + enabledFeatures: string[] | null; + /** Whether the plugin is enabled */ + enabled: boolean; +} + +// ============================================================================= +// Runtime Config Types (stored in pi-plugins.lock.json) +// ============================================================================= + +/** + * Per-plugin runtime state stored in lock file. + */ +export interface PluginRuntimeState { + /** Installed version */ + version: string; + /** Enabled features (null = defaults) */ + enabledFeatures: string[] | null; + /** Whether the plugin is enabled */ + enabled: boolean; +} + +/** + * Runtime configuration persisted to pi-plugins.lock.json. + * Tracks plugin states and settings across sessions. + */ +export interface PluginRuntimeConfig { + /** Plugin states keyed by package name */ + plugins: Record; + /** Plugin settings keyed by package name, then setting key */ + settings: Record>; +} + +// ============================================================================= +// Project Override Types +// ============================================================================= + +/** + * Project-local plugin overrides (stored in .pi/plugin-overrides.json). + * Allows per-project plugin configuration without modifying global state. + */ +export interface ProjectPluginOverrides { + /** Plugins to disable in this project */ + disabled?: string[]; + /** Per-plugin feature overrides */ + features?: Record; + /** Per-plugin setting overrides */ + settings?: Record>; +} + +// ============================================================================= +// Doctor Types +// ============================================================================= + +export interface DoctorCheck { + /** Check identifier */ + name: string; + /** Check result status */ + status: "ok" | "warning" | "error"; + /** Human-readable message */ + message: string; + /** Whether --fix resolved this issue */ + fixed?: boolean; +} + +// ============================================================================= +// Install Options Types +// ============================================================================= + +export interface InstallOptions { + /** Overwrite existing without prompting */ + force?: boolean; + /** Preview changes without applying */ + dryRun?: boolean; +} + +export interface DoctorOptions { + /** Attempt automatic fixes */ + fix?: boolean; +} diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 7f945bcc9..3b9202ea9 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -44,6 +44,7 @@ import { import type { CustomTool } from "./custom-tools/types.js"; import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js"; import type { HookFactory } from "./hooks/types.js"; +import { discoverAndLoadMCPTools, type MCPManager, type MCPToolsLoadResult } from "./mcp/index.js"; import { convertToLlm } from "./messages.js"; import { ModelRegistry } from "./model-registry.js"; import { SessionManager } from "./session-manager.js"; @@ -55,6 +56,7 @@ import { loadProjectContextFiles as loadContextFilesInternal, } from "./system-prompt.js"; import { time } from "./timings.js"; +import { createToolContextStore } from "./tools/context.js"; import { allTools, bashTool, @@ -120,11 +122,17 @@ export interface CreateAgentSessionOptions { /** Slash commands. Default: discovered from cwd/.pi/commands/ + agentDir/commands/ */ slashCommands?: FileSlashCommand[]; + /** Enable MCP server discovery from .mcp.json files. Default: true */ + enableMCP?: boolean; + /** Session manager. Default: SessionManager.create(cwd) */ sessionManager?: SessionManager; /** Settings manager. Default: SettingsManager.create(cwd, agentDir) */ settingsManager?: SettingsManager; + + /** Whether UI is available (enables interactive tools like ask). Default: false */ + hasUI?: boolean; } /** Result from createAgentSession */ @@ -133,6 +141,8 @@ export interface CreateAgentSessionResult { session: AgentSession; /** Custom tools result (for UI context setup in interactive mode) */ customToolsResult: CustomToolsLoadResult; + /** MCP manager for server lifecycle management (undefined if MCP disabled) */ + mcpManager?: MCPManager; /** Warning if session was restored with a different model than saved */ modelFallbackMessage?: string; } @@ -141,6 +151,7 @@ export interface CreateAgentSessionResult { export type { CustomTool } from "./custom-tools/types.js"; export type { HookAPI, HookCommandContext, HookContext, HookFactory } from "./hooks/types.js"; +export type { MCPManager, MCPServerConfig, MCPServerConnection, MCPToolsLoadResult } from "./mcp/index.js"; export type { Settings, SkillsSettings } from "./settings-manager.js"; export type { Skill } from "./skills.js"; export type { FileSlashCommand } from "./slash-commands.js"; @@ -270,6 +281,15 @@ export function discoverSlashCommands(cwd?: string, agentDir?: string): FileSlas }); } +/** + * Discover MCP servers from .mcp.json files. + * Returns the manager and loaded tools. + */ +export async function discoverMCPServers(cwd?: string): Promise { + const resolvedCwd = cwd ?? process.cwd(); + return discoverAndLoadMCPTools(resolvedCwd); +} + // API Key Helpers // System Prompt @@ -522,7 +542,30 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir); time("discoverContextFiles"); - const builtInTools = options.tools ?? createCodingTools(cwd); + // Hook runner - created early for hooks + let hookRunner: HookRunner | undefined; + if (options.hooks !== undefined) { + if (options.hooks.length > 0) { + const loadedHooks = createLoadedHooksFromDefinitions(options.hooks); + hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry); + } + } else { + // Discover hooks, merging with additional paths + const configuredPaths = [...settingsManager.getHookPaths(), ...(options.additionalHookPaths ?? [])]; + const { hooks, errors } = await discoverAndLoadHooks(configuredPaths, cwd, agentDir); + time("discoverAndLoadHooks"); + for (const { path, error } of errors) { + console.error(`Failed to load hook "${path}": ${error}`); + } + if (hooks.length > 0) { + hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry); + } + } + + const sessionContext = { + getSessionFile: () => sessionManager.getSessionFile() ?? null, + }; + const builtInTools = options.tools ?? createCodingTools(cwd, options.hasUI ?? false, sessionContext); time("createCodingTools"); let customToolsResult: CustomToolsLoadResult; @@ -548,29 +591,31 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } } - let hookRunner: HookRunner | undefined; - if (options.hooks !== undefined) { - if (options.hooks.length > 0) { - const loadedHooks = createLoadedHooksFromDefinitions(options.hooks); - hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry); + // Discover MCP tools from .mcp.json files + let mcpManager: MCPManager | undefined; + const enableMCP = options.enableMCP ?? true; + if (enableMCP) { + const mcpResult = await discoverAndLoadMCPTools(cwd); + time("discoverAndLoadMCPTools"); + mcpManager = mcpResult.manager; + + // Log MCP errors + for (const { path, error } of mcpResult.errors) { + console.error(`MCP "${path}": ${error}`); } - } else { - // Discover hooks, merging with additional paths - const configuredPaths = [...settingsManager.getHookPaths(), ...(options.additionalHookPaths ?? [])]; - const { hooks, errors } = await discoverAndLoadHooks(configuredPaths, cwd, agentDir); - time("discoverAndLoadHooks"); - for (const { path, error } of errors) { - console.error(`Failed to load hook "${path}": ${error}`); - } - if (hooks.length > 0) { - hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry); + + // Merge MCP tools into custom tools result + if (mcpResult.tools.length > 0) { + customToolsResult = { + ...customToolsResult, + tools: [...customToolsResult.tools, ...mcpResult.tools], + }; } } - // Wrap custom tools with context getter (agent/session assigned below, accessed at execute time) let agent: Agent; let session: AgentSession; - const wrappedCustomTools = wrapCustomTools(customToolsResult.tools, () => ({ + const getSessionContext = () => ({ sessionManager, modelRegistry, model: agent.state.model, @@ -579,7 +624,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} abort: () => { session.abort(); }, - })); + }); + const toolContextStore = createToolContextStore(getSessionContext); + const wrappedCustomTools = wrapCustomTools(customToolsResult.tools, getSessionContext); + const baseSetUIContext = customToolsResult.setUIContext; + customToolsResult = { + ...customToolsResult, + setUIContext: (uiContext, hasUI) => { + toolContextStore.setUIContext(uiContext, hasUI); + baseSetUIContext(uiContext, hasUI); + }, + }; let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools]; time("combineTools"); @@ -627,6 +682,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } : undefined, queueMode: settingsManager.getQueueMode(), + getToolContext: toolContextStore.getContext, getApiKey: async () => { const currentModel = agent.state.model; if (!currentModel) { @@ -668,6 +724,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return { session, customToolsResult, + mcpManager, modelFallbackMessage, }; } diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 4231655ff..4b6b9e67c 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -34,6 +34,15 @@ export interface TerminalSettings { showImages?: boolean; // default: true (only relevant if terminal supports images) } +export interface ExaSettings { + enabled?: boolean; // default: true (master toggle for all Exa tools) + enableSearch?: boolean; // default: true (search, deep, code, crawl) + enableLinkedin?: boolean; // default: true + enableCompany?: boolean; // default: true + enableResearcher?: boolean; // default: true + enableWebsets?: boolean; // default: true +} + export interface Settings { lastChangelogVersion?: string; defaultProvider?: string; @@ -52,6 +61,7 @@ export interface Settings { skills?: SkillsSettings; terminal?: TerminalSettings; enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) + exa?: ExaSettings; } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ @@ -371,4 +381,23 @@ export class SettingsManager { getEnabledModels(): string[] | undefined { return this.settings.enabledModels; } + + getExaSettings(): Required { + return { + enabled: this.settings.exa?.enabled ?? true, + enableSearch: this.settings.exa?.enableSearch ?? true, + enableLinkedin: this.settings.exa?.enableLinkedin ?? true, + enableCompany: this.settings.exa?.enableCompany ?? true, + enableResearcher: this.settings.exa?.enableResearcher ?? true, + enableWebsets: this.settings.exa?.enableWebsets ?? true, + }; + } + + setExaEnabled(enabled: boolean): void { + if (!this.globalSettings.exa) { + this.globalSettings.exa = {}; + } + this.globalSettings.exa.enabled = enabled; + this.save(); + } } diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 25f480633..0bca18571 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -101,7 +101,11 @@ export function substituteArgs(content: string, args: string[]): string { /** * Recursively scan a directory for .md files (and symlinks to .md files) and load them as slash commands */ -function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: string = ""): FileSlashCommand[] { +function loadCommandsFromDir( + dir: string, + source: "builtin" | "user" | "project", + subdir: string = "", +): FileSlashCommand[] { const commands: FileSlashCommand[] = []; if (!existsSync(dir)) { @@ -127,7 +131,9 @@ function loadCommandsFromDir(dir: string, source: "user" | "project", subdir: st // Build source string let sourceStr: string; - if (source === "user") { + if (source === "builtin") { + sourceStr = subdir ? `(builtin:${subdir})` : "(builtin)"; + } else if (source === "user") { sourceStr = subdir ? `(user:${subdir})` : "(user)"; } else { sourceStr = subdir ? `(project:${subdir})` : "(project)"; @@ -174,23 +180,49 @@ export interface LoadSlashCommandsOptions { /** * Load all custom slash commands from: - * 1. Global: agentDir/commands/ - * 2. Project: cwd/{CONFIG_DIR_NAME}/commands/ + * 1. Builtin: package commands/ + * 2. Global: agentDir/commands/ + * 3. Project: cwd/{CONFIG_DIR_NAME}/commands/ */ export function loadSlashCommands(options: LoadSlashCommandsOptions = {}): FileSlashCommand[] { const resolvedCwd = options.cwd ?? process.cwd(); const resolvedAgentDir = options.agentDir ?? getCommandsDir(); const commands: FileSlashCommand[] = []; + const seenNames = new Set(); - // 1. Load global commands from agentDir/commands/ + // 1. Builtin commands (from package) + const builtinDir = join(import.meta.dir, "../commands"); + if (existsSync(builtinDir)) { + const builtinCommands = loadCommandsFromDir(builtinDir, "builtin"); + for (const cmd of builtinCommands) { + if (!seenNames.has(cmd.name)) { + commands.push(cmd); + seenNames.add(cmd.name); + } + } + } + + // 2. Load global commands from agentDir/commands/ // Note: if agentDir is provided, it should be the agent dir, not the commands dir const globalCommandsDir = options.agentDir ? join(options.agentDir, "commands") : resolvedAgentDir; - commands.push(...loadCommandsFromDir(globalCommandsDir, "user")); + const globalCommands = loadCommandsFromDir(globalCommandsDir, "user"); + for (const cmd of globalCommands) { + if (!seenNames.has(cmd.name)) { + commands.push(cmd); + seenNames.add(cmd.name); + } + } - // 2. Load project commands from cwd/{CONFIG_DIR_NAME}/commands/ + // 3. Load project commands from cwd/{CONFIG_DIR_NAME}/commands/ const projectCommandsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "commands"); - commands.push(...loadCommandsFromDir(projectCommandsDir, "project")); + const projectCommands = loadCommandsFromDir(projectCommandsDir, "project"); + for (const cmd of projectCommands) { + if (!seenNames.has(cmd.name)) { + commands.push(cmd); + seenNames.add(cmd.name); + } + } return commands; } diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 6ed67f053..303a6b113 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -62,6 +62,8 @@ ${commitsText}`; /** Tool descriptions for system prompt */ const toolDescriptions: Record = { + ask: "Ask user for input or clarification", + ast: "Perform AST-level code analysis and transformations", read: "Read file contents", bash: "Execute bash commands (ls, grep, find, etc.)", edit: "Make surgical edits to files (find exact text and replace)", @@ -69,6 +71,12 @@ const toolDescriptions: Record = { grep: "Search file contents for patterns (respects .gitignore)", find: "Find files by glob pattern (respects .gitignore)", ls: "List directory contents", + lsp: "Language server integration for code intelligence", + notebook: "Edit Jupyter notebook cells", + replace: "Find and replace text across multiple files", + task: "Spawn a sub-agent to handle complex tasks", + web_fetch: "Fetch and render URLs into clean text for LLM consumption", + web_search: "Search the web for information", }; /** Resolve input as file path or literal string */ diff --git a/packages/coding-agent/src/core/tools/ask.ts b/packages/coding-agent/src/core/tools/ask.ts new file mode 100644 index 000000000..c721a0ad7 --- /dev/null +++ b/packages/coding-agent/src/core/tools/ask.ts @@ -0,0 +1,211 @@ +/** + * Ask Tool - Interactive user prompting during execution + * + * Use this tool when you need to ask the user questions during execution. + * This allows you to: + * 1. Gather user preferences or requirements + * 2. Clarify ambiguous instructions + * 3. Get decisions on implementation choices as you work + * 4. Offer choices to the user about what direction to take + * + * Usage notes: + * - Users will always be able to select "Other" to provide custom text input + * - Use multi: true to allow multiple answers to be selected for a question + * - If you recommend a specific option, make that the first option in the list + * and add "(Recommended)" at the end of the label + */ + +import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core"; +import { Type } from "@sinclair/typebox"; + +// ============================================================================= +// Types +// ============================================================================= + +const OptionItem = Type.Object({ + label: Type.String({ description: "Display label for this option" }), +}); + +const askSchema = Type.Object({ + question: Type.String({ description: "The question to ask the user" }), + options: Type.Array(OptionItem, { + description: "Available options for the user to choose from.", + minItems: 1, + }), + multi: Type.Optional( + Type.Boolean({ + description: "Allow multiple options to be selected (default: false)", + default: false, + }), + ), +}); + +export interface AskToolDetails { + question: string; + options: string[]; + multi: boolean; + selectedOptions: string[]; + customInput?: string; +} + +// ============================================================================= +// Constants +// ============================================================================= + +const OTHER_OPTION = "Other (type your own)"; +const DONE_OPTION = "✓ Done selecting"; + +const DESCRIPTION = `Use this tool when you need to ask the user questions during execution. This allows you to: +1. Gather user preferences or requirements +2. Clarify ambiguous instructions +3. Get decisions on implementation choices as you work +4. Offer choices to the user about what direction to take. + +Usage notes: +- Users will always be able to select "Other" to provide custom text input +- Use multi: true to allow multiple answers to be selected for a question +- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label + +Example usage: + + +assistant: Let me ask which features you want to include. +assistant: Uses the ask tool: +{ + "question": "Which features should I implement?", + "options": [ + {"label": "Authentication"}, + {"label": "API endpoints"}, + {"label": "Database models"}, + {"label": "Unit tests"}, + {"label": "Documentation"} + ], + "multi": true +} +`; + +// ============================================================================= +// Tool Implementation +// ============================================================================= + +export function createAskTool(_cwd: string): AgentTool { + return { + name: "ask", + label: "Ask", + description: DESCRIPTION, + parameters: askSchema, + + async execute( + _toolCallId: string, + params: { question: string; options: Array<{ label: string }>; multi?: boolean }, + _signal?: AbortSignal, + _onUpdate?: AgentToolUpdateCallback, + context?: AgentToolContext, + ) { + const { question, options, multi = false } = params; + const optionLabels = options.map((o) => o.label); + + // Headless fallback - return error if no UI available + if (!context?.hasUI || !context.ui) { + return { + content: [ + { + type: "text" as const, + text: "Error: User prompt requires interactive mode", + }, + ], + details: { + question, + options: optionLabels, + multi, + selectedOptions: [], + }, + }; + } + + const { ui } = context; + let selectedOptions: string[] = []; + let customInput: string | undefined; + + if (multi) { + // Multi-select: show checkboxes in the label to indicate selection state + const selected = new Set(); + + while (true) { + // Build options with checkbox indicators + const opts: string[] = []; + + // Add "Done" option if any selected + if (selected.size > 0) { + opts.push(DONE_OPTION); + } + + // Add all options with [X] or [ ] prefix + for (const opt of optionLabels) { + const checkbox = selected.has(opt) ? "[X]" : "[ ]"; + opts.push(`${checkbox} ${opt}`); + } + + // Add "Other" option + opts.push(OTHER_OPTION); + + const prefix = selected.size > 0 ? `(${selected.size} selected) ` : ""; + const choice = await ui.select(`${prefix}${question}`, opts); + + if (choice === undefined || choice === DONE_OPTION) break; + + if (choice === OTHER_OPTION) { + const input = await ui.input("Enter your response:"); + if (input) customInput = input; + break; + } + + // Toggle selection - extract the actual option name + const optMatch = choice.match(/^\[.\] (.+)$/); + if (optMatch) { + const opt = optMatch[1]; + if (selected.has(opt)) { + selected.delete(opt); + } else { + selected.add(opt); + } + } + } + selectedOptions = Array.from(selected); + } else { + // Single select with "Other" option + const choice = await ui.select(question, [...optionLabels, OTHER_OPTION]); + if (choice === OTHER_OPTION) { + const input = await ui.input("Enter your response:"); + if (input) customInput = input; + } else if (choice) { + selectedOptions = [choice]; + } + } + + const details: AskToolDetails = { + question, + options: optionLabels, + multi, + selectedOptions, + customInput, + }; + + let responseText: string; + if (customInput) { + responseText = `User provided custom input: ${customInput}`; + } else if (selectedOptions.length > 0) { + responseText = multi + ? `User selected: ${selectedOptions.join(", ")}` + : `User selected: ${selectedOptions[0]}`; + } else { + responseText = "User cancelled the selection"; + } + + return { content: [{ type: "text" as const, text: responseText }], details }; + }, + }; +} + +/** Default ask tool using process.cwd() - for backwards compatibility (no UI) */ +export const askTool = createAskTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/ast.ts b/packages/coding-agent/src/core/tools/ast.ts new file mode 100644 index 000000000..5483a12a5 --- /dev/null +++ b/packages/coding-agent/src/core/tools/ast.ts @@ -0,0 +1,271 @@ +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { Type } from "@sinclair/typebox"; +import type { Subprocess } from "bun"; +import { ensureTool } from "../../utils/tools-manager.js"; +import { resolveToCwd } from "./path-utils.js"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; + +const astSchema = Type.Object({ + action: Type.Union([Type.Literal("search"), Type.Literal("preview"), Type.Literal("apply")], { + description: "Action: search (find matches), preview (show proposed changes), apply (make changes)", + }), + pattern: Type.String({ description: "AST pattern to match (e.g., 'console.log($$$)')" }), + replacement: Type.Optional(Type.String({ description: "Replacement pattern (required for preview/apply)" })), + path: Type.Optional(Type.String({ description: "File or directory path (default: current directory)" })), + lang: Type.Optional(Type.String({ description: "Language (rust, typescript, python, etc.)" })), + max_results: Type.Optional(Type.Number({ description: "Limit results (default: 100)" })), +}); + +export interface AstToolDetails { + truncation?: TruncationResult; + matchCount?: number; + fileCount?: number; + mode?: "search" | "preview" | "apply"; + files?: string[]; + truncated?: boolean; + error?: string; +} + +export function createAstTool(cwd: string): AgentTool { + return { + name: "ast", + label: "ast", + description: `AST-level structural search/replace using ast-grep. + +Actions: +- search: Find matches (read-only) +- preview: Show proposed changes without applying (read-only) +- apply: Make changes to files (destructive) + +Safety workflow: search → preview → apply + +Pattern syntax: +- $NAME for single node wildcards (e.g., $FUNC, $ARG) +- $$$ for multiple nodes (variadic match) +- Examples: 'console.log($$$)', 'fn($A, $B)' + +Output truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB.`, + parameters: astSchema, + execute: async ( + _toolCallId: string, + { + action, + pattern, + replacement, + path: targetPath, + lang, + max_results, + }: { + action: "search" | "preview" | "apply"; + pattern: string; + replacement?: string; + path?: string; + lang?: string; + max_results?: number; + }, + signal?: AbortSignal, + ) => { + if (signal?.aborted) { + throw new Error("Operation aborted"); + } + + const sgPath = await ensureTool("sg", true); + if (!sgPath) { + throw new Error("ast-grep (sg) is not available and could not be downloaded"); + } + + if ((action === "preview" || action === "apply") && !replacement) { + throw new Error(`replacement parameter is required for ${action} action`); + } + + const resolvedPath = targetPath ? resolveToCwd(targetPath, cwd) : cwd; + const maxResults = Math.max(1, max_results ?? 100); + + const args: string[] = []; + + // Add pattern + args.push("-p", pattern); + + // Add action-specific flags + if (action === "apply") { + args.push("-r", replacement!, "--update-all", "--json"); + } else if (action === "preview") { + // Preview: rewrite flag but no --update-all + args.push("-r", replacement!, "--json"); + } else { + // search action + args.push("--json"); + } + + // Add language if specified + if (lang) { + args.push("--lang", lang); + } + + // Add path + args.push(resolvedPath); + + const child: Subprocess = Bun.spawn([sgPath, ...args], { + cwd: resolvedPath, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + let stdout = ""; + let stderr = ""; + let aborted = false; + + const onAbort = () => { + aborted = true; + child.kill(); + }; + + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + // Read streams using Bun's ReadableStream API + const stdoutReader = (child.stdout as ReadableStream).getReader(); + const stderrReader = (child.stderr as ReadableStream).getReader(); + const decoder = new TextDecoder(); + + await Promise.all([ + (async () => { + while (true) { + const { done, value } = await stdoutReader.read(); + if (done) break; + stdout += decoder.decode(value, { stream: true }); + } + })(), + (async () => { + while (true) { + const { done, value } = await stderrReader.read(); + if (done) break; + stderr += decoder.decode(value, { stream: true }); + } + })(), + ]); + + const exitCode = await child.exited; + + // Cleanup + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + if (aborted) { + throw new Error("Operation aborted"); + } + + // Exit code 1 = no matches (not an error), 0 = matches found + if (exitCode !== 0 && exitCode !== 1 && stderr.trim()) { + const errorMsg = stderr.trim() || `ast-grep exited with code ${exitCode}`; + return { + content: [{ type: "text", text: `Error: ${errorMsg}` }], + details: { mode: action, error: errorMsg } as AstToolDetails, + }; + } + + const output = stdout.trim(); + + // Parse JSON lines (each line is a JSON object) + const lines = output.split("\n").filter(Boolean); + const files = new Set(); + const matches: Array<{ file: string; line: number; text: string; replacement?: string }> = []; + let matchCount = 0; + + for (const line of lines) { + try { + const obj = JSON.parse(line); + const filePath = obj.file || obj.path; + if (filePath) { + const relPath = filePath.startsWith(cwd) ? filePath.slice(cwd.length + 1) : filePath; + files.add(relPath); + matchCount++; + if (matches.length < maxResults) { + matches.push({ + file: relPath, + line: obj.range?.start?.line ?? obj.start?.line ?? 0, + text: obj.text || obj.matched || "", + replacement: obj.replacement, + }); + } + } + } catch { + // Skip malformed lines + } + } + + const truncated = matchCount > maxResults; + const fileCount = files.size; + const details: AstToolDetails = { + mode: action, + matchCount, + fileCount, + files: Array.from(files).slice(0, 50), + truncated, + }; + + if (matchCount === 0) { + const noMatchMsg = action === "apply" ? "No changes made" : "No matches found"; + return { + content: [{ type: "text", text: noMatchMsg }], + details, + }; + } + + // Format output based on action + let formattedOutput: string; + if (action === "apply") { + formattedOutput = `Applied ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}:\n`; + formattedOutput += Array.from(files).join("\n"); + } else if (action === "preview") { + formattedOutput = `Preview of ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}:\n\n`; + for (const m of matches) { + formattedOutput += `${m.file}:${m.line}\n`; + formattedOutput += ` - ${m.text}\n`; + if (m.replacement !== undefined) { + formattedOutput += ` + ${m.replacement}\n`; + } + formattedOutput += "\n"; + } + } else { + // search mode + formattedOutput = `Found ${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}:\n\n`; + for (const m of matches) { + formattedOutput += `${m.file}:${m.line}: ${m.text}\n`; + } + } + + if (truncated) { + formattedOutput += `\n... truncated at ${maxResults} results (${matchCount} total)`; + } + + // Apply truncation + const truncation = truncateHead(formattedOutput); + let finalOutput = truncation.content || formattedOutput; + + if (truncation.truncated) { + details.truncation = truncation; + + const startLine = 1; + const endLine = truncation.outputLines; + + if (truncation.truncatedBy === "lines") { + finalOutput += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}]`; + } else { + finalOutput += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit)]`; + } + } + + return { + content: [{ type: "text", text: finalOutput }], + details, + }; + }, + }; +} + +/** Default ast tool using process.cwd() - for backwards compatibility */ +export const astTool = createAstTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/context.ts b/packages/coding-agent/src/core/tools/context.ts new file mode 100644 index 000000000..a08dfb4b4 --- /dev/null +++ b/packages/coding-agent/src/core/tools/context.ts @@ -0,0 +1,32 @@ +import type { AgentToolContext } from "@mariozechner/pi-agent-core"; +import type { CustomToolContext } from "../custom-tools/types.js"; +import type { HookUIContext } from "../hooks/types.js"; + +declare module "@mariozechner/pi-agent-core" { + interface AgentToolContext extends CustomToolContext { + ui?: HookUIContext; + hasUI?: boolean; + } +} + +export interface ToolContextStore { + getContext(): AgentToolContext; + setUIContext(uiContext: HookUIContext, hasUI: boolean): void; +} + +export function createToolContextStore(getBaseContext: () => CustomToolContext): ToolContextStore { + let uiContext: HookUIContext | undefined; + let hasUI = false; + + return { + getContext: () => ({ + ...getBaseContext(), + ui: uiContext, + hasUI, + }), + setUIContext: (context, uiAvailable) => { + uiContext = context; + hasUI = uiAvailable; + }, + }; +} diff --git a/packages/coding-agent/src/core/tools/exa/company.ts b/packages/coding-agent/src/core/tools/exa/company.ts new file mode 100644 index 000000000..31e84d1f0 --- /dev/null +++ b/packages/coding-agent/src/core/tools/exa/company.ts @@ -0,0 +1,59 @@ +/** + * Exa Company Tool + * + * Research companies using Exa's comprehensive data sources. + */ + +import { Type } from "@sinclair/typebox"; +import type { CustomTool } from "../../custom-tools/types.js"; +import type { ExaRenderDetails } from "./types.js"; + +/** exa_company - Company research */ +export const companyTool: CustomTool = { + name: "exa_company", + label: "Exa Company", + description: `Research companies using Exa's comprehensive data sources. + +Returns detailed company information including overview, news, financials, and key people. + +Parameters: +- company_name: Name of the company to research (e.g., "OpenAI", "Google", "Y Combinator")`, + + parameters: Type.Object({ + company_name: Type.String({ description: "Name of the company to research" }), + }), + + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { + try { + const { findApiKey, callExaTool, formatSearchResults, isSearchResponse } = await import("./mcp-client.js"); + + const apiKey = await findApiKey(); + if (!apiKey) { + return { + content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }], + details: { error: "EXA_API_KEY not found", toolName: "exa_company" }, + }; + } + const response = await callExaTool("company_research_exa", params, apiKey); + + if (isSearchResponse(response)) { + const formatted = formatSearchResults(response); + return { + content: [{ type: "text" as const, text: formatted }], + details: { response, toolName: "exa_company" }, + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }], + details: { raw: response, toolName: "exa_company" }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + details: { error: message, toolName: "exa_company" }, + }; + } + }, +}; diff --git a/packages/coding-agent/src/core/tools/exa/index.ts b/packages/coding-agent/src/core/tools/exa/index.ts new file mode 100644 index 000000000..58d6954c8 --- /dev/null +++ b/packages/coding-agent/src/core/tools/exa/index.ts @@ -0,0 +1,64 @@ +/** + * Exa MCP Tools + * + * 22 tools for Exa's MCP servers: + * - 4 search tools (search, deep, code, crawl) + * - 1 LinkedIn search tool + * - 1 company research tool + * - 2 researcher tools (start, poll) + * - 14 websets tools (CRUD, items, search, enrichment, monitor) + */ + +import type { CustomTool } from "../../custom-tools/types.js"; +import type { ExaSettings } from "../../settings-manager.js"; +import { companyTool } from "./company.js"; +import { linkedinTool } from "./linkedin.js"; +import { researcherTools } from "./researcher.js"; +import { searchTools } from "./search.js"; +import type { ExaRenderDetails } from "./types.js"; +import { websetsTools } from "./websets.js"; + +/** All Exa tools (22 total) - static export for backward compatibility */ +export const exaTools: CustomTool[] = [ + ...searchTools, + linkedinTool, + companyTool, + ...researcherTools, + ...websetsTools, +]; + +/** Get Exa tools filtered by settings */ +export function getExaTools(settings: Required): CustomTool[] { + if (!settings.enabled) return []; + + const tools: CustomTool[] = []; + + if (settings.enableSearch) tools.push(...searchTools); + if (settings.enableLinkedin) tools.push(linkedinTool); + if (settings.enableCompany) tools.push(companyTool); + if (settings.enableResearcher) tools.push(...researcherTools); + if (settings.enableWebsets) tools.push(...websetsTools); + + return tools; +} + +export { companyTool } from "./company.js"; +export { linkedinTool } from "./linkedin.js"; +export { logExaError, logViewError } from "./logger.js"; +export { + callExaTool, + callWebsetsTool, + createMCPToolFromServer, + createMCPWrappedTool, + fetchMCPToolSchema, + findApiKey, + formatSearchResults, + isSearchResponse, +} from "./mcp-client.js"; +export { renderExaCall, renderExaResult } from "./render.js"; +export { researcherTools } from "./researcher.js"; +// Re-export individual modules for selective importing +export { searchTools } from "./search.js"; +// Re-export types and utilities +export type { ExaRenderDetails, ExaSearchResponse, ExaSearchResult, MCPToolWrapperConfig } from "./types.js"; +export { websetsTools } from "./websets.js"; diff --git a/packages/coding-agent/src/core/tools/exa/linkedin.ts b/packages/coding-agent/src/core/tools/exa/linkedin.ts new file mode 100644 index 000000000..dccc27c49 --- /dev/null +++ b/packages/coding-agent/src/core/tools/exa/linkedin.ts @@ -0,0 +1,59 @@ +/** + * Exa LinkedIn Tool + * + * Search LinkedIn for people, companies, and professional content. + */ + +import { Type } from "@sinclair/typebox"; +import type { CustomTool } from "../../custom-tools/types.js"; +import type { ExaRenderDetails } from "./types.js"; + +/** exa_linkedin - LinkedIn search */ +export const linkedinTool: CustomTool = { + name: "exa_linkedin", + label: "Exa LinkedIn", + description: `Search LinkedIn for people, companies, and professional content using Exa. + +Returns LinkedIn search results with profiles, posts, and company information. + +Parameters: +- query: LinkedIn search query (e.g., "Software Engineer at OpenAI", "Y Combinator companies")`, + + parameters: Type.Object({ + query: Type.String({ description: "LinkedIn search query" }), + }), + + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { + try { + const { findApiKey, callExaTool, formatSearchResults, isSearchResponse } = await import("./mcp-client.js"); + + const apiKey = await findApiKey(); + if (!apiKey) { + return { + content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }], + details: { error: "EXA_API_KEY not found", toolName: "exa_linkedin" }, + }; + } + const response = await callExaTool("linkedin_search_exa", params, apiKey); + + if (isSearchResponse(response)) { + const formatted = formatSearchResults(response); + return { + content: [{ type: "text" as const, text: formatted }], + details: { response, toolName: "exa_linkedin" }, + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }], + details: { raw: response, toolName: "exa_linkedin" }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + details: { error: message, toolName: "exa_linkedin" }, + }; + } + }, +}; diff --git a/packages/coding-agent/src/core/tools/exa/logger.ts b/packages/coding-agent/src/core/tools/exa/logger.ts new file mode 100644 index 000000000..6d533f31a --- /dev/null +++ b/packages/coding-agent/src/core/tools/exa/logger.ts @@ -0,0 +1,56 @@ +/** + * Exa Error Logger + * + * Append-only logging to ~/.pi/ for debugging production issues. + */ + +import { appendFileSync, existsSync, mkdirSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import { CONFIG_DIR_NAME } from "../../../config.js"; + +/** Get the base config directory (e.g., ~/.pi/) */ +function getConfigDir(): string { + return join(homedir(), CONFIG_DIR_NAME); +} + +/** Log file paths */ +const LOG_FILES = { + exa: "exa_errors.log", + view: "view_errors.log", +} as const; + +type LogType = keyof typeof LOG_FILES; + +/** Format a log entry with timestamp */ +function formatEntry(message: string, context?: Record): string { + const timestamp = new Date().toISOString(); + const contextStr = context ? ` ${JSON.stringify(context)}` : ""; + return `[${timestamp}] ${message}${contextStr}\n`; +} + +/** Append to log file (creates directory if needed) */ +export function logError(type: LogType, message: string, context?: Record): void { + try { + const configDir = getConfigDir(); + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); + } + + const logPath = join(configDir, LOG_FILES[type]); + const entry = formatEntry(message, context); + appendFileSync(logPath, entry); + } catch { + // Silently ignore logging failures - we don't want to break tool execution + } +} + +/** Log MCP fetch/call errors */ +export function logExaError(message: string, context?: Record): void { + logError("exa", message, context); +} + +/** Log render/view errors */ +export function logViewError(message: string, context?: Record): void { + logError("view", message, context); +} diff --git a/packages/coding-agent/src/core/tools/exa/mcp-client.ts b/packages/coding-agent/src/core/tools/exa/mcp-client.ts new file mode 100644 index 000000000..742f1be73 --- /dev/null +++ b/packages/coding-agent/src/core/tools/exa/mcp-client.ts @@ -0,0 +1,367 @@ +/** + * Exa MCP Client + * + * Client for interacting with Exa MCP servers via JSON-RPC 2.0 over HTTPS. + */ + +import type { TSchema } from "@sinclair/typebox"; +import type { CustomTool } from "../../custom-tools/types.js"; +import { logExaError } from "./logger.js"; +import type { + ExaRenderDetails, + ExaSearchResponse, + ExaSearchResult, + MCPCallResponse, + MCPTool, + MCPToolsResponse, + MCPToolWrapperConfig, +} from "./types.js"; + +/** Find EXA_API_KEY from process.env or .env files */ +export async function findApiKey(): Promise { + // Check process.env first + if (process.env.EXA_API_KEY) { + return process.env.EXA_API_KEY; + } + + // Try loading from .env files in cwd and home + const cwd = process.cwd(); + const home = process.env.HOME ?? process.env.USERPROFILE ?? "~"; + + for (const dir of [cwd, home]) { + const envPath = `${dir}/.env`; + try { + const file = Bun.file(envPath); + if (await file.exists()) { + const content = await file.text(); + const match = content.match(/^EXA_API_KEY=(.+)$/m); + if (match?.[1]) { + return match[1].trim().replace(/^["']|["']$/g, ""); + } + } + } catch { + // Ignore read errors + } + } + + return null; +} + +/** Parse SSE response format (lines starting with "data: ") */ +function parseSSE(text: string): unknown { + const lines = text.split("\n"); + for (const line of lines) { + if (line.startsWith("data: ")) { + const data = line.slice(6).trim(); + if (data === "[DONE]") continue; + try { + return JSON.parse(data); + } catch { + // Try next line + } + } + } + // Fallback: try parsing entire response as JSON + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** Call MCP server with JSON-RPC 2.0 */ +export async function callMCP(url: string, method: string, params?: Record): Promise { + const body = { + jsonrpc: "2.0", + id: Math.random().toString(36).slice(2), + method, + params: params ?? {}, + }; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorMsg = `MCP request failed: ${response.status} ${response.statusText}`; + logExaError(errorMsg, { url, method, params }); + throw new Error(errorMsg); + } + + const text = await response.text(); + const result = parseSSE(text); + + if (!result) { + logExaError("Failed to parse MCP response", { url, method, responseText: text.slice(0, 500) }); + throw new Error("Failed to parse MCP response"); + } + + return result; +} + +/** Fetch available tools from Exa MCP */ +export async function fetchExaTools(apiKey: string, toolNames: string[]): Promise { + const url = `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(apiKey)}&toolNames=${encodeURIComponent(toolNames.join(","))}`; + const response = (await callMCP(url, "tools/list")) as MCPToolsResponse; + + if (response.error) { + logExaError("MCP tools/list error", { toolNames, error: response.error }); + throw new Error(`MCP error: ${response.error.message}`); + } + + return response.result?.tools ?? []; +} + +/** Fetch available tools from Websets MCP */ +export async function fetchWebsetsTools(apiKey: string): Promise { + const url = `https://websetsmcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(apiKey)}`; + const response = (await callMCP(url, "tools/list")) as MCPToolsResponse; + + if (response.error) { + logExaError("Websets MCP tools/list error", { error: response.error }); + throw new Error(`MCP error: ${response.error.message}`); + } + + return response.result?.tools ?? []; +} + +/** Call a tool on Exa MCP (simplified: toolName as first arg for easier use) */ +export async function callExaTool(toolName: string, args: Record, apiKey: string): Promise { + const url = `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(apiKey)}&tools=${encodeURIComponent(toolName)}`; + const response = (await callMCP(url, "tools/call", { + name: toolName, + arguments: args, + })) as MCPCallResponse; + + if (response.error) { + logExaError("MCP tools/call error", { toolName, args, error: response.error }); + throw new Error(`MCP error: ${response.error.message}`); + } + + return response.result; +} + +/** Call a tool on Websets MCP */ +export async function callWebsetsTool( + apiKey: string, + toolName: string, + args: Record, +): Promise { + const url = `https://websetsmcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(apiKey)}`; + const response = (await callMCP(url, "tools/call", { + name: toolName, + arguments: args, + })) as MCPCallResponse; + + if (response.error) { + logExaError("Websets MCP tools/call error", { toolName, args, error: response.error }); + throw new Error(`MCP error: ${response.error.message}`); + } + + return response.result; +} + +/** Parse Exa markdown format into SearchResponse */ +export function parseExaMarkdown(text: string): ExaSearchResponse | null { + const results: ExaSearchResult[] = []; + const lines = text.split("\n"); + let currentResult: Partial | null = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Match result header: ## Title + if (line.startsWith("## ")) { + if (currentResult?.title) { + results.push(currentResult as ExaSearchResult); + } + currentResult = { title: line.slice(3).trim() }; + continue; + } + + if (!currentResult) continue; + + // Match URL: **URL:** ... + if (line.startsWith("**URL:**")) { + currentResult.url = line.slice(8).trim(); + continue; + } + + // Match Author: **Author:** ... + if (line.startsWith("**Author:**")) { + currentResult.author = line.slice(11).trim(); + continue; + } + + // Match Published Date: **Published Date:** ... + if (line.startsWith("**Published Date:**")) { + currentResult.publishedDate = line.slice(19).trim(); + continue; + } + + // Match Text: **Text:** ... + if (line.startsWith("**Text:**")) { + currentResult.text = line.slice(9).trim(); + continue; + } + + // Accumulate text content + if (currentResult.text && line && !line.startsWith("**")) { + currentResult.text += ` ${line}`; + } + } + + // Add last result + if (currentResult?.title) { + results.push(currentResult as ExaSearchResult); + } + + if (results.length === 0) return null; + + return { + results, + statuses: results.map((r, i) => ({ id: r.id ?? `result-${i}`, status: "success" })), + }; +} + +/** Format search results for LLM */ +export function formatSearchResults(data: ExaSearchResponse): string { + const results = data.results ?? []; + if (results.length === 0) return "No results found."; + + let output = ""; + for (let i = 0; i < results.length; i++) { + const r = results[i]; + output += `\n## ${r.title ?? "Untitled"}`; + if (r.url) output += `\n**URL:** ${r.url}`; + if (r.author) output += `\n**Author:** ${r.author}`; + if (r.publishedDate) output += `\n**Published Date:** ${r.publishedDate}`; + if (r.text) output += `\n**Text:** ${r.text}`; + if (r.highlights?.length) { + output += `\n**Highlights:**`; + for (const h of r.highlights) { + output += `\n- ${h}`; + } + } + output += "\n"; + } + + if (data.costDollars) { + output += `\n**Cost:** $${data.costDollars.total.toFixed(4)}`; + } + if (data.searchTime) { + output += `\n**Search Time:** ${data.searchTime.toFixed(2)}s`; + } + + return output.trim(); +} + +/** Check if result is a search response */ +export function isSearchResponse(data: unknown): data is ExaSearchResponse { + return ( + typeof data === "object" && + data !== null && + ("results" in data || "statuses" in data || "costDollars" in data || "searchTime" in data) + ); +} + +/** Cache for MCP tool schemas (keyed by MCP tool name) */ +const mcpSchemaCache = new Map(); + +/** Fetch and cache MCP tool schema */ +export async function fetchMCPToolSchema( + apiKey: string, + mcpToolName: string, + isWebsetsTool = false, +): Promise { + const cacheKey = `${isWebsetsTool ? "websets" : "exa"}:${mcpToolName}`; + if (mcpSchemaCache.has(cacheKey)) { + return mcpSchemaCache.get(cacheKey)!; + } + + try { + const tools = isWebsetsTool ? await fetchWebsetsTools(apiKey) : await fetchExaTools(apiKey, [mcpToolName]); + const tool = tools.find((t) => t.name === mcpToolName); + if (tool) { + mcpSchemaCache.set(cacheKey, tool); + return tool; + } + } catch { + // Fall through to return null + } + return null; +} + +/** + * Create a CustomTool dynamically from MCP tool metadata. + * + * This allows tools to be generated from MCP server schemas without hardcoding, + * reducing drift when MCP servers add new parameters. + */ +export function createMCPWrappedTool( + config: MCPToolWrapperConfig, + schema: TSchema, + description: string, +): CustomTool { + return { + name: config.name, + label: config.label, + description, + parameters: schema, + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { + try { + const apiKey = await findApiKey(); + if (!apiKey) { + return { + content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }], + details: { error: "EXA_API_KEY not found", toolName: config.name }, + }; + } + + const response = config.isWebsetsTool + ? await callWebsetsTool(apiKey, config.mcpToolName, params as Record) + : await callExaTool(config.mcpToolName, params as Record, apiKey); + + if (isSearchResponse(response)) { + const formatted = formatSearchResults(response); + return { + content: [{ type: "text" as const, text: formatted }], + details: { response, toolName: config.name }, + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }], + details: { raw: response, toolName: config.name }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + details: { error: message, toolName: config.name }, + }; + } + }, + }; +} + +/** + * Create a CustomTool by fetching schema from MCP server. + * + * Falls back to provided fallback schema if MCP fetch fails. + */ +export async function createMCPToolFromServer( + apiKey: string, + config: MCPToolWrapperConfig, + fallbackSchema: TSchema, + fallbackDescription: string, +): Promise> { + const mcpTool = await fetchMCPToolSchema(apiKey, config.mcpToolName, config.isWebsetsTool); + const schema = mcpTool?.inputSchema ?? fallbackSchema; + const description = mcpTool?.description ?? fallbackDescription; + return createMCPWrappedTool(config, schema, description); +} diff --git a/packages/coding-agent/src/core/tools/exa/render.ts b/packages/coding-agent/src/core/tools/exa/render.ts new file mode 100644 index 000000000..beb5a15bd --- /dev/null +++ b/packages/coding-agent/src/core/tools/exa/render.ts @@ -0,0 +1,187 @@ +/** + * Exa TUI Rendering + * + * Tree-based rendering with collapsed/expanded states for Exa search results. + */ + +import type { Component } from "@mariozechner/pi-tui"; +import { Text } from "@mariozechner/pi-tui"; +import type { Theme } from "../../../modes/interactive/theme/theme.js"; +import type { RenderResultOptions } from "../../custom-tools/types.js"; +import { logViewError } from "./logger.js"; +import type { ExaRenderDetails } from "./types.js"; + +// Tree formatting constants +const TREE_MID = "├─"; +const TREE_END = "└─"; +const TREE_PIPE = "│"; +const TREE_SPACE = " "; +const TREE_HOOK = "⎿"; + +/** Truncate text to max length with ellipsis */ +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return `${text.slice(0, maxLen - 1)}…`; +} + +/** Extract domain from URL */ +function getDomain(url: string): string { + try { + const u = new URL(url); + return u.hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +/** Get first N lines of text as preview */ +function getPreviewLines(text: string, maxLines: number, maxLineLen: number): string[] { + const lines = text.split("\n").filter((l) => l.trim()); + return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen)); +} + +/** Render Exa result with tree-based layout */ +export function renderExaResult( + result: { content: Array<{ type: string; text?: string }>; details?: ExaRenderDetails }, + options: RenderResultOptions, + theme: Theme, +): Component { + const { expanded } = options; + const details = result.details; + + // Handle error case + if (details?.error) { + logViewError("Exa render error", { error: details.error, toolName: details.toolName }); + return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); + } + + const response = details?.response; + if (!response) { + // Non-search response: show raw result + if (details?.raw) { + const rawText = typeof details.raw === "string" ? details.raw : JSON.stringify(details.raw, null, 2); + const preview = expanded ? rawText : truncate(rawText, 200); + const toolLabel = details?.toolName ?? "Exa"; + return new Text( + `${theme.fg("success", "●")} ${theme.fg("toolTitle", toolLabel)}\n ${theme.fg("dim", TREE_PIPE)} ${preview}`, + 0, + 0, + ); + } + return new Text(theme.fg("error", "No response data"), 0, 0); + } + + const results = response.results ?? []; + const resultCount = results.length; + const cost = response.costDollars?.total; + const time = response.searchTime; + + // Build header: ● Exa Search · N results · $X.XX · Xs + const icon = resultCount > 0 ? theme.fg("success", "●") : theme.fg("warning", "●"); + const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); + const toolLabel = details?.toolName ?? "Exa Search"; + + let headerParts = `${icon} ${theme.fg("toolTitle", toolLabel)} · ${theme.fg("dim", `${resultCount} result${resultCount !== 1 ? "s" : ""}`)}`; + + if (cost !== undefined) { + headerParts += ` · ${theme.fg("muted", `$${cost.toFixed(4)}`)}`; + } + if (time !== undefined) { + headerParts += ` · ${theme.fg("muted", `${time.toFixed(2)}s`)}`; + } + + let text = headerParts + expandHint; + + if (!expanded) { + // Collapsed view: show 3-line preview from first result + if (resultCount > 0) { + const first = results[0]; + const previewText = first.text ?? first.title ?? ""; + const previewLines = getPreviewLines(previewText, 3, 100); + + for (const line of previewLines) { + text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", line)}`; + } + + const totalLines = previewText.split("\n").filter((l) => l.trim()).length; + if (totalLines > 3) { + text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("muted", `… ${totalLines - 3} more lines`)}`; + } + + if (resultCount > 1) { + text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `${resultCount - 1} more result${resultCount !== 2 ? "s" : ""}`)}`; + } + } + } else { + // Expanded view: full results tree + if (resultCount > 0) { + text += `\n ${theme.fg("dim", TREE_PIPE)}`; + text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("accent", "Results")}`; + + for (let i = 0; i < results.length; i++) { + const res = results[i]; + const isLast = i === results.length - 1; + const branch = isLast ? TREE_END : TREE_MID; + const cont = isLast ? TREE_SPACE : TREE_PIPE; + + // Title + domain + const title = truncate(res.title ?? "Untitled", 60); + const domain = res.url ? getDomain(res.url) : ""; + const domainPart = domain ? theme.fg("dim", ` (${domain})`) : ""; + + text += `\n ${theme.fg("dim", TREE_SPACE)} ${theme.fg("dim", branch)} ${theme.fg("accent", title)}${domainPart}`; + + // URL + if (res.url) { + text += `\n ${theme.fg("dim", cont)} ${theme.fg("dim", TREE_HOOK)} ${theme.fg("mdLinkUrl", res.url)}`; + } + + // Author + if (res.author) { + text += `\n ${theme.fg("dim", cont)} ${theme.fg("muted", `Author: ${res.author}`)}`; + } + + // Published date + if (res.publishedDate) { + text += `\n ${theme.fg("dim", cont)} ${theme.fg("muted", `Published: ${res.publishedDate}`)}`; + } + + // Text content + if (res.text) { + const textLines = res.text.split("\n").filter((l) => l.trim()); + const displayLines = textLines.slice(0, 5); // Show first 5 lines + for (const line of displayLines) { + text += `\n ${theme.fg("dim", cont)} ${truncate(line.trim(), 90)}`; + } + if (textLines.length > 5) { + text += `\n ${theme.fg("dim", cont)} ${theme.fg("muted", `… ${textLines.length - 5} more lines`)}`; + } + } + + // Highlights + if (res.highlights?.length) { + text += `\n ${theme.fg("dim", cont)} ${theme.fg("accent", "Highlights:")}`; + for (let j = 0; j < Math.min(res.highlights.length, 3); j++) { + const h = res.highlights[j]; + text += `\n ${theme.fg("dim", cont)} ${theme.fg("muted", `• ${truncate(h, 80)}`)}`; + } + if (res.highlights.length > 3) { + text += `\n ${theme.fg("dim", cont)} ${theme.fg("muted", `… ${res.highlights.length - 3} more`)}`; + } + } + } + } + } + + return new Text(text, 0, 0); +} + +/** Render Exa call (query/args preview) */ +export function renderExaCall(args: Record, toolName: string, theme: Theme): Component { + const query = typeof args.query === "string" ? truncate(args.query, 80) : ""; + const numResults = typeof args.num_results === "number" ? args.num_results : undefined; + const detail = numResults ? theme.fg("dim", ` (${numResults} results)`) : ""; + + const text = `${theme.fg("toolTitle", toolName)} ${theme.fg("muted", query)}${detail}`; + return new Text(text, 0, 0); +} diff --git a/packages/coding-agent/src/core/tools/exa/researcher.ts b/packages/coding-agent/src/core/tools/exa/researcher.ts new file mode 100644 index 000000000..27d8a5fc4 --- /dev/null +++ b/packages/coding-agent/src/core/tools/exa/researcher.ts @@ -0,0 +1,90 @@ +/** + * Exa Researcher Tools + * + * Async research tasks with polling for completion. + */ + +import { Type } from "@sinclair/typebox"; +import type { CustomTool } from "../../custom-tools/types.js"; +import { callExaTool, findApiKey } from "./mcp-client.js"; +import type { ExaRenderDetails } from "./types.js"; + +const researcherStartTool: CustomTool = { + name: "exa_researcher_start", + label: "Start Deep Research", + description: + "Start an asynchronous deep research task using Exa's researcher. Returns a task_id for polling completion.", + parameters: Type.Object({ + query: Type.String({ description: "Research query to investigate" }), + depth: Type.Optional( + Type.Number({ + description: "Research depth (1-5, default: 3)", + minimum: 1, + maximum: 5, + }), + ), + breadth: Type.Optional( + Type.Number({ + description: "Research breadth (1-5, default: 3)", + minimum: 1, + maximum: 5, + }), + ), + }), + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { + try { + const apiKey = await findApiKey(); + if (!apiKey) { + return { + content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }], + details: { error: "EXA_API_KEY not found", toolName: "exa_researcher_start" }, + }; + } + const result = await callExaTool("deep_researcher_start", params as Record, apiKey); + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + details: { raw: result, toolName: "exa_researcher_start" }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + details: { error: message, toolName: "exa_researcher_start" }, + }; + } + }, +}; + +const researcherPollTool: CustomTool = { + name: "exa_researcher_poll", + label: "Poll Research Status", + description: + "Poll the status of an asynchronous research task. Returns status (pending|running|completed|failed) and result if completed.", + parameters: Type.Object({ + task_id: Type.String({ description: "Task ID returned from exa_researcher_start" }), + }), + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { + try { + const apiKey = await findApiKey(); + if (!apiKey) { + return { + content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }], + details: { error: "EXA_API_KEY not found", toolName: "exa_researcher_poll" }, + }; + } + const result = await callExaTool("deep_researcher_check", params as Record, apiKey); + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + details: { raw: result, toolName: "exa_researcher_poll" }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + details: { error: message, toolName: "exa_researcher_poll" }, + }; + } + }, +}; + +export const researcherTools: CustomTool[] = [researcherStartTool, researcherPollTool]; diff --git a/packages/coding-agent/src/core/tools/exa/search.ts b/packages/coding-agent/src/core/tools/exa/search.ts new file mode 100644 index 000000000..11f924c99 --- /dev/null +++ b/packages/coding-agent/src/core/tools/exa/search.ts @@ -0,0 +1,337 @@ +/** + * Exa Search Tools + * + * Basic neural/keyword search, deep research, code search, and URL crawling. + */ + +import { Type } from "@sinclair/typebox"; +import type { CustomTool } from "../../custom-tools/types.js"; +import type { ExaRenderDetails } from "./types.js"; + +/** exa_search - Basic neural/keyword search */ +const exaSearchTool: CustomTool = { + name: "exa_search", + label: "Exa Search", + description: `Search the web using Exa's neural or keyword search. + +Returns structured search results with optional text content and highlights. + +Parameters: +- query: Search query (required) +- type: Search type - "neural" (semantic), "keyword" (exact), or "auto" (default: auto) +- include_domains: Array of domains to include in results +- exclude_domains: Array of domains to exclude from results +- start_published_date: Filter results published after this date (ISO 8601) +- end_published_date: Filter results published before this date (ISO 8601) +- use_autoprompt: Let Exa optimize your query automatically (default: true) +- text: Include page text content in results (default: false, costs more) +- highlights: Include highlighted relevant snippets (default: false) +- num_results: Maximum number of results to return (default: 10, max: 100)`, + + parameters: Type.Object({ + query: Type.String({ description: "Search query" }), + type: Type.Optional( + Type.Union([Type.Literal("keyword"), Type.Literal("neural"), Type.Literal("auto")], { + description: "Search type - neural (semantic), keyword (exact), or auto", + }), + ), + include_domains: Type.Optional( + Type.Array(Type.String(), { + description: "Only include results from these domains", + }), + ), + exclude_domains: Type.Optional( + Type.Array(Type.String(), { + description: "Exclude results from these domains", + }), + ), + start_published_date: Type.Optional( + Type.String({ + description: "Filter results published after this date (ISO 8601 format)", + }), + ), + end_published_date: Type.Optional( + Type.String({ + description: "Filter results published before this date (ISO 8601 format)", + }), + ), + use_autoprompt: Type.Optional( + Type.Boolean({ + description: "Let Exa optimize your query automatically (default: true)", + }), + ), + text: Type.Optional( + Type.Boolean({ + description: "Include page text content in results (costs more, default: false)", + }), + ), + highlights: Type.Optional( + Type.Boolean({ + description: "Include highlighted relevant snippets (default: false)", + }), + ), + num_results: Type.Optional( + Type.Number({ + description: "Maximum number of results to return (default: 10, max: 100)", + minimum: 1, + maximum: 100, + }), + ), + }), + + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { + try { + // Dynamic imports to avoid circular dependencies + const { findApiKey, callExaTool, formatSearchResults, isSearchResponse } = await import("./mcp-client.js"); + + const apiKey = await findApiKey(); + if (!apiKey) { + return { + content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }], + details: { error: "EXA_API_KEY not found", toolName: "exa_search" }, + }; + } + const response = await callExaTool("web_search", params, apiKey); + + if (isSearchResponse(response)) { + const formatted = formatSearchResults(response); + return { + content: [{ type: "text" as const, text: formatted }], + details: { response, toolName: "exa_search" }, + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }], + details: { raw: response, toolName: "exa_search" }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + details: { error: message, toolName: "exa_search" }, + }; + } + }, +}; + +/** exa_search_deep - AI-synthesized deep research */ +const exaSearchDeepTool: CustomTool = { + name: "exa_search_deep", + label: "Exa Deep Search", + description: `Perform AI-synthesized deep research using Exa. + +Returns comprehensive research with synthesized answers and multiple sources. + +Similar parameters to exa_search, optimized for research depth.`, + + parameters: Type.Object({ + query: Type.String({ description: "Research query" }), + type: Type.Optional( + Type.Union([Type.Literal("keyword"), Type.Literal("neural"), Type.Literal("auto")], { + description: "Search type - neural (semantic), keyword (exact), or auto", + }), + ), + include_domains: Type.Optional( + Type.Array(Type.String(), { + description: "Only include results from these domains", + }), + ), + exclude_domains: Type.Optional( + Type.Array(Type.String(), { + description: "Exclude results from these domains", + }), + ), + start_published_date: Type.Optional( + Type.String({ + description: "Filter results published after this date (ISO 8601 format)", + }), + ), + end_published_date: Type.Optional( + Type.String({ + description: "Filter results published before this date (ISO 8601 format)", + }), + ), + use_autoprompt: Type.Optional( + Type.Boolean({ + description: "Let Exa optimize your query automatically (default: true)", + }), + ), + text: Type.Optional( + Type.Boolean({ + description: "Include page text content in results (costs more, default: false)", + }), + ), + highlights: Type.Optional( + Type.Boolean({ + description: "Include highlighted relevant snippets (default: false)", + }), + ), + num_results: Type.Optional( + Type.Number({ + description: "Maximum number of results to return (default: 10, max: 100)", + minimum: 1, + maximum: 100, + }), + ), + }), + + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { + try { + const { findApiKey, callExaTool, formatSearchResults, isSearchResponse } = await import("./mcp-client.js"); + + const apiKey = await findApiKey(); + if (!apiKey) { + return { + content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }], + details: { error: "EXA_API_KEY not found", toolName: "exa_search_deep" }, + }; + } + const response = await callExaTool("deep_search_exa", params, apiKey); + + if (isSearchResponse(response)) { + const formatted = formatSearchResults(response); + return { + content: [{ type: "text" as const, text: formatted }], + details: { response, toolName: "exa_search_deep" }, + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }], + details: { raw: response, toolName: "exa_search_deep" }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + details: { error: message, toolName: "exa_search_deep" }, + }; + } + }, +}; + +/** exa_search_code - Code-focused search */ +const exaSearchCodeTool: CustomTool = { + name: "exa_search_code", + label: "Exa Code Search", + description: `Search for code examples and technical documentation using Exa. + +Optimized for finding code snippets, API documentation, and technical content. + +Parameters: +- query: Code or technical search query (required) +- code_context: Additional context about what you're looking for`, + + parameters: Type.Object({ + query: Type.String({ description: "Code or technical search query" }), + code_context: Type.Optional( + Type.String({ + description: "Additional context about what you're looking for", + }), + ), + }), + + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { + try { + const { findApiKey, callExaTool, formatSearchResults, isSearchResponse } = await import("./mcp-client.js"); + + const apiKey = await findApiKey(); + if (!apiKey) { + return { + content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }], + details: { error: "EXA_API_KEY not found", toolName: "exa_search_code" }, + }; + } + const response = await callExaTool("get_code_context_exa", params, apiKey); + + if (isSearchResponse(response)) { + const formatted = formatSearchResults(response); + return { + content: [{ type: "text" as const, text: formatted }], + details: { response, toolName: "exa_search_code" }, + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }], + details: { raw: response, toolName: "exa_search_code" }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + details: { error: message, toolName: "exa_search_code" }, + }; + } + }, +}; + +/** exa_crawl - URL content extraction */ +const exaCrawlTool: CustomTool = { + name: "exa_crawl", + label: "Exa Crawl", + description: `Extract content from a specific URL using Exa. + +Returns the page content with optional text and highlights. + +Parameters: +- url: URL to crawl (required) +- text: Include full page text content (default: false) +- highlights: Include highlighted relevant snippets (default: false)`, + + parameters: Type.Object({ + url: Type.String({ description: "URL to crawl and extract content from" }), + text: Type.Optional( + Type.Boolean({ + description: "Include full page text content (default: false)", + }), + ), + highlights: Type.Optional( + Type.Boolean({ + description: "Include highlighted relevant snippets (default: false)", + }), + ), + }), + + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { + try { + const { findApiKey, callExaTool, formatSearchResults, isSearchResponse } = await import("./mcp-client.js"); + + const apiKey = await findApiKey(); + if (!apiKey) { + return { + content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }], + details: { error: "EXA_API_KEY not found", toolName: "exa_crawl" }, + }; + } + const response = await callExaTool("crawling_exa", params, apiKey); + + if (isSearchResponse(response)) { + const formatted = formatSearchResults(response); + return { + content: [{ type: "text" as const, text: formatted }], + details: { response, toolName: "exa_crawl" }, + }; + } + + return { + content: [{ type: "text" as const, text: JSON.stringify(response, null, 2) }], + details: { raw: response, toolName: "exa_crawl" }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + details: { error: message, toolName: "exa_crawl" }, + }; + } + }, +}; + +export const searchTools: CustomTool[] = [ + exaSearchTool, + exaSearchDeepTool, + exaSearchCodeTool, + exaCrawlTool, +]; diff --git a/packages/coding-agent/src/core/tools/exa/types.ts b/packages/coding-agent/src/core/tools/exa/types.ts new file mode 100644 index 000000000..c92718b5e --- /dev/null +++ b/packages/coding-agent/src/core/tools/exa/types.ts @@ -0,0 +1,168 @@ +/** + * Exa MCP Types + * + * Types for the Exa MCP client and tool implementations. + */ + +import type { TSchema } from "@sinclair/typebox"; + +/** MCP endpoint URLs */ +export const EXA_MCP_URL = "https://mcp.exa.ai/mcp"; +export const WEBSETS_MCP_URL = "https://websetsmcp.exa.ai/mcp"; + +/** MCP tool definition from server */ +export interface MCPTool { + name: string; + description: string; + inputSchema: TSchema; +} + +/** Tool wrapper config for dynamic MCP tool creation */ +export interface MCPToolWrapperConfig { + /** Our tool name (e.g., "exa_search") */ + name: string; + /** Display label for UI */ + label: string; + /** MCP tool name to call (e.g., "web_search") */ + mcpToolName: string; + /** Whether this is a websets tool (uses different MCP endpoint) */ + isWebsetsTool?: boolean; +} + +/** MCP tools/list response */ +export interface MCPToolsResponse { + result?: { + tools: MCPTool[]; + }; + error?: { + code: number; + message: string; + }; +} + +/** MCP tools/call response */ +export interface MCPCallResponse { + result?: { + content?: Array<{ type: string; text?: string }>; + }; + error?: { + code: number; + message: string; + }; +} + +/** Search result from Exa */ +export interface ExaSearchResult { + id?: string; + title?: string; + url?: string; + author?: string; + publishedDate?: string; + text?: string; + highlights?: string[]; + image?: string; + favicon?: string; +} + +/** Search response from Exa */ +export interface ExaSearchResponse { + results?: ExaSearchResult[]; + statuses?: Array<{ id: string; status: string; source?: string }>; + costDollars?: { total: number }; + searchTime?: number; + requestId?: string; +} + +/** Researcher task status */ +export interface ResearcherStatus { + id: string; + status: "pending" | "running" | "completed" | "failed"; + result?: string; + error?: string; +} + +/** Webset definition */ +export interface Webset { + id: string; + name: string; + description?: string; + createdAt?: string; + updatedAt?: string; +} + +/** Webset item */ +export interface WebsetItem { + id: string; + websetId: string; + url: string; + title?: string; + content?: string; + metadata?: Record; +} + +/** Webset search */ +export interface WebsetSearch { + id: string; + websetId: string; + query: string; + status: "pending" | "running" | "completed" | "cancelled"; + resultCount?: number; +} + +/** Webset enrichment */ +export interface WebsetEnrichment { + id: string; + websetId: string; + name: string; + prompt: string; + status: "pending" | "running" | "completed" | "cancelled"; +} + +/** Tool name mappings: MCP name -> our tool name */ +export const EXA_TOOL_MAPPINGS = { + // Search tools + web_search: "exa_search", + deep_search_exa: "exa_search_deep", + get_code_context_exa: "exa_search_code", + crawling_exa: "exa_crawl", + // LinkedIn + linkedin_search_exa: "exa_linkedin", + // Company + company_research_exa: "exa_company", + // Researcher + deep_researcher_start: "exa_researcher_start", + deep_researcher_check: "exa_researcher_poll", +} as const; + +export const WEBSETS_TOOL_MAPPINGS = { + create_webset: "webset_create", + list_websets: "webset_list", + get_webset: "webset_get", + update_webset: "webset_update", + delete_webset: "webset_delete", + list_webset_items: "webset_items_list", + get_item: "webset_item_get", + create_search: "webset_search_create", + get_search: "webset_search_get", + cancel_search: "webset_search_cancel", + create_enrichment: "webset_enrichment_create", + get_enrichment: "webset_enrichment_get", + update_enrichment: "webset_enrichment_update", + delete_enrichment: "webset_enrichment_delete", + cancel_enrichment: "webset_enrichment_cancel", + create_monitor: "webset_monitor_create", +} as const; + +export type ExaMcpToolName = keyof typeof EXA_TOOL_MAPPINGS; +export type WebsetsMcpToolName = keyof typeof WEBSETS_TOOL_MAPPINGS; +export type ExaToolName = (typeof EXA_TOOL_MAPPINGS)[ExaMcpToolName]; +export type WebsetsToolName = (typeof WEBSETS_TOOL_MAPPINGS)[WebsetsMcpToolName]; + +/** Render details for TUI */ +export interface ExaRenderDetails { + response?: ExaSearchResponse; + error?: string; + toolName?: string; + /** Raw result for non-search responses */ + raw?: unknown; +} diff --git a/packages/coding-agent/src/core/tools/exa/websets.ts b/packages/coding-agent/src/core/tools/exa/websets.ts new file mode 100644 index 000000000..21a2dade7 --- /dev/null +++ b/packages/coding-agent/src/core/tools/exa/websets.ts @@ -0,0 +1,248 @@ +/** + * Exa Websets Tools + * + * CRUD operations for websets, items, searches, enrichments, and monitoring. + */ + +import { Type } from "@sinclair/typebox"; +import type { CustomTool } from "../../custom-tools/types.js"; +import { callWebsetsTool, findApiKey } from "./mcp-client.js"; +import type { ExaRenderDetails } from "./types.js"; + +/** Helper to create a websets tool with proper execute signature */ +function createWebsetTool( + name: string, + label: string, + description: string, + parameters: ReturnType, + mcpToolName: string, +): CustomTool { + return { + name, + label, + description, + parameters, + async execute(_toolCallId, params, _onUpdate, _ctx, _signal) { + try { + const apiKey = await findApiKey(); + if (!apiKey) { + return { + content: [{ type: "text" as const, text: "Error: EXA_API_KEY not found" }], + details: { error: "EXA_API_KEY not found", toolName: name }, + }; + } + const result = await callWebsetsTool(apiKey, mcpToolName, params as Record); + return { + content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }], + details: { raw: result, toolName: name }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + details: { error: message, toolName: name }, + }; + } + }, + }; +} + +// CRUD Operations +const websetCreateTool = createWebsetTool( + "webset_create", + "Create Webset", + "Create a new webset collection for organizing web content.", + Type.Object({ + name: Type.String({ description: "Name of the webset" }), + description: Type.Optional(Type.String({ description: "Optional description" })), + }), + "create_webset", +); + +const websetListTool = createWebsetTool( + "webset_list", + "List Websets", + "List all websets in your account.", + Type.Object({}), + "list_websets", +); + +const websetGetTool = createWebsetTool( + "webset_get", + "Get Webset", + "Get details of a specific webset by ID.", + Type.Object({ + id: Type.String({ description: "Webset ID" }), + }), + "get_webset", +); + +const websetUpdateTool = createWebsetTool( + "webset_update", + "Update Webset", + "Update a webset's name or description.", + Type.Object({ + id: Type.String({ description: "Webset ID" }), + name: Type.Optional(Type.String({ description: "New name" })), + description: Type.Optional(Type.String({ description: "New description" })), + }), + "update_webset", +); + +const websetDeleteTool = createWebsetTool( + "webset_delete", + "Delete Webset", + "Delete a webset and all its contents.", + Type.Object({ + id: Type.String({ description: "Webset ID" }), + }), + "delete_webset", +); + +// Item Management +const websetItemsListTool = createWebsetTool( + "webset_items_list", + "List Webset Items", + "List items in a webset with optional pagination.", + Type.Object({ + webset_id: Type.String({ description: "Webset ID" }), + limit: Type.Optional(Type.Number({ description: "Number of items to return" })), + offset: Type.Optional(Type.Number({ description: "Pagination offset" })), + }), + "list_webset_items", +); + +const websetItemGetTool = createWebsetTool( + "webset_item_get", + "Get Webset Item", + "Get a specific item from a webset.", + Type.Object({ + webset_id: Type.String({ description: "Webset ID" }), + item_id: Type.String({ description: "Item ID" }), + }), + "get_item", +); + +// Search Operations +const websetSearchCreateTool = createWebsetTool( + "webset_search_create", + "Create Webset Search", + "Create a new search within a webset.", + Type.Object({ + webset_id: Type.String({ description: "Webset ID" }), + query: Type.String({ description: "Search query" }), + }), + "create_search", +); + +const websetSearchGetTool = createWebsetTool( + "webset_search_get", + "Get Webset Search", + "Get the status and results of a webset search.", + Type.Object({ + webset_id: Type.String({ description: "Webset ID" }), + search_id: Type.String({ description: "Search ID" }), + }), + "get_search", +); + +const websetSearchCancelTool = createWebsetTool( + "webset_search_cancel", + "Cancel Webset Search", + "Cancel a running webset search.", + Type.Object({ + webset_id: Type.String({ description: "Webset ID" }), + search_id: Type.String({ description: "Search ID" }), + }), + "cancel_search", +); + +// Enrichment Operations +const websetEnrichmentCreateTool = createWebsetTool( + "webset_enrichment_create", + "Create Enrichment", + "Create a new enrichment task for a webset.", + Type.Object({ + webset_id: Type.String({ description: "Webset ID" }), + name: Type.String({ description: "Enrichment name" }), + prompt: Type.String({ description: "Enrichment prompt" }), + }), + "create_enrichment", +); + +const websetEnrichmentGetTool = createWebsetTool( + "webset_enrichment_get", + "Get Enrichment", + "Get the status and results of an enrichment task.", + Type.Object({ + webset_id: Type.String({ description: "Webset ID" }), + enrichment_id: Type.String({ description: "Enrichment ID" }), + }), + "get_enrichment", +); + +const websetEnrichmentUpdateTool = createWebsetTool( + "webset_enrichment_update", + "Update Enrichment", + "Update an enrichment's name or prompt.", + Type.Object({ + webset_id: Type.String({ description: "Webset ID" }), + enrichment_id: Type.String({ description: "Enrichment ID" }), + name: Type.Optional(Type.String({ description: "New name" })), + prompt: Type.Optional(Type.String({ description: "New prompt" })), + }), + "update_enrichment", +); + +const websetEnrichmentDeleteTool = createWebsetTool( + "webset_enrichment_delete", + "Delete Enrichment", + "Delete an enrichment task.", + Type.Object({ + webset_id: Type.String({ description: "Webset ID" }), + enrichment_id: Type.String({ description: "Enrichment ID" }), + }), + "delete_enrichment", +); + +const websetEnrichmentCancelTool = createWebsetTool( + "webset_enrichment_cancel", + "Cancel Enrichment", + "Cancel a running enrichment task.", + Type.Object({ + webset_id: Type.String({ description: "Webset ID" }), + enrichment_id: Type.String({ description: "Enrichment ID" }), + }), + "cancel_enrichment", +); + +// Monitoring +const websetMonitorCreateTool = createWebsetTool( + "webset_monitor_create", + "Create Monitor", + "Create a monitoring task for a webset with optional webhook notifications.", + Type.Object({ + webset_id: Type.String({ description: "Webset ID" }), + webhook_url: Type.Optional(Type.String({ description: "Webhook URL for notifications" })), + }), + "create_monitor", +); + +export const websetsTools: CustomTool[] = [ + websetCreateTool, + websetListTool, + websetGetTool, + websetUpdateTool, + websetDeleteTool, + websetItemsListTool, + websetItemGetTool, + websetSearchCreateTool, + websetSearchGetTool, + websetSearchCancelTool, + websetEnrichmentCreateTool, + websetEnrichmentGetTool, + websetEnrichmentUpdateTool, + websetEnrichmentDeleteTool, + websetEnrichmentCancelTool, + websetMonitorCreateTool, +]; diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts index d74a4ed62..bc0ed9930 100644 --- a/packages/coding-agent/src/core/tools/find.ts +++ b/packages/coding-agent/src/core/tools/find.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, type Stats, statSync } from "node:fs"; import path from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; @@ -13,6 +13,16 @@ const findSchema = Type.Object({ }), path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })), limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })), + hidden: Type.Optional(Type.Boolean({ description: "Include hidden files (default: false)" })), + sortByMtime: Type.Optional( + Type.Boolean({ description: "Sort results by modification time, most recent first (default: false)" }), + ), + type: Type.Optional( + Type.Union([Type.Literal("file"), Type.Literal("dir"), Type.Literal("all")], { + description: + "Filter by type: 'file' for files only, 'dir' for directories only, 'all' for both (default: 'all')", + }), + ), }); const DEFAULT_LIMIT = 1000; @@ -20,6 +30,11 @@ const DEFAULT_LIMIT = 1000; export interface FindToolDetails { truncation?: TruncationResult; resultLimitReached?: number; + // Fields for TUI rendering + fileCount?: number; + files?: string[]; + truncated?: boolean; + error?: string; } export function createFindTool(cwd: string): AgentTool { @@ -30,7 +45,21 @@ export function createFindTool(cwd: string): AgentTool { parameters: findSchema, execute: async ( _toolCallId: string, - { pattern, path: searchDir, limit }: { pattern: string; path?: string; limit?: number }, + { + pattern, + path: searchDir, + limit, + hidden, + sortByMtime, + type, + }: { + pattern: string; + path?: string; + limit?: number; + hidden?: boolean; + sortByMtime?: boolean; + type?: "file" | "dir" | "all"; + }, signal?: AbortSignal, ) => { return new Promise((resolve, reject) => { @@ -53,16 +82,29 @@ export function createFindTool(cwd: string): AgentTool { const searchPath = resolveToCwd(searchDir || ".", cwd); const effectiveLimit = limit ?? DEFAULT_LIMIT; + const effectiveType = type ?? "all"; + const includeHidden = hidden ?? false; + const shouldSortByMtime = sortByMtime ?? false; // Build fd arguments const args: string[] = [ "--glob", // Use glob pattern "--color=never", // No ANSI colors - "--hidden", // Search hidden files (but still respect .gitignore) "--max-results", String(effectiveLimit), ]; + if (includeHidden) { + args.push("--hidden"); + } + + // Add type filter + if (effectiveType === "file") { + args.push("--type", "f"); + } else if (effectiveType === "dir") { + args.push("--type", "d"); + } + // Include .gitignore files (root + nested) so fd respects them even outside git repos const gitignoreFiles = new Set(); const rootGitignore = path.join(searchPath, ".gitignore"); @@ -114,13 +156,14 @@ export function createFindTool(cwd: string): AgentTool { if (!output) { resolve({ content: [{ type: "text", text: "No files found matching pattern" }], - details: undefined, + details: { fileCount: 0, files: [], truncated: false }, }); return; } const lines = output.split("\n"); const relativized: string[] = []; + const mtimes: number[] = []; for (const rawLine of lines) { const line = rawLine.replace(/\r$/, "").trim(); @@ -141,6 +184,25 @@ export function createFindTool(cwd: string): AgentTool { } relativized.push(relativePath); + + // Collect mtime if sorting is requested + if (shouldSortByMtime) { + try { + const fullPath = path.join(searchPath, relativePath); + const stat: Stats = statSync(fullPath); + mtimes.push(stat.mtimeMs); + } catch { + mtimes.push(0); + } + } + } + + // Sort by mtime if requested (most recent first) + if (shouldSortByMtime && relativized.length > 0) { + const indexed = relativized.map((path, idx) => ({ path, mtime: mtimes[idx] || 0 })); + indexed.sort((a, b) => b.mtime - a.mtime); + relativized.length = 0; + relativized.push(...indexed.map((item) => item.path)); } // Check if we hit the result limit @@ -151,7 +213,11 @@ export function createFindTool(cwd: string): AgentTool { const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); let resultOutput = truncation.content; - const details: FindToolDetails = {}; + const details: FindToolDetails = { + fileCount: relativized.length, + files: relativized.slice(0, 50), + truncated: resultLimitReached || truncation.truncated, + }; // Build notices const notices: string[] = []; diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts index 3ac33e36e..973071212 100644 --- a/packages/coding-agent/src/core/tools/grep.ts +++ b/packages/coding-agent/src/core/tools/grep.ts @@ -18,14 +18,31 @@ const grepSchema = Type.Object({ pattern: Type.String({ description: "Search pattern (regex or literal string)" }), path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })), glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'" })), - ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })), + type: Type.Optional(Type.String({ description: "File type filter (e.g., 'ts', 'rust', 'py')" })), + ignoreCase: Type.Optional( + Type.Boolean({ description: "Force case-insensitive search (default: false, uses smart-case otherwise)" }), + ), + caseSensitive: Type.Optional( + Type.Boolean({ description: "Force case-sensitive search (default: false, disables smart-case)" }), + ), literal: Type.Optional( Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }), ), + multiline: Type.Optional( + Type.Boolean({ description: "Enable multiline matching for cross-line patterns (default: false)" }), + ), context: Type.Optional( Type.Number({ description: "Number of lines to show before and after each match (default: 0)" }), ), limit: Type.Optional(Type.Number({ description: "Maximum number of matches to return (default: 100)" })), + outputMode: Type.Optional( + Type.Union([Type.Literal("content"), Type.Literal("files_with_matches"), Type.Literal("count")], { + description: + "Output mode: 'content' shows matching lines, 'files_with_matches' shows only file paths, 'count' shows match counts per file (default: 'content')", + }), + ), + headLimit: Type.Optional(Type.Number({ description: "Limit output to first N results (default: unlimited)" })), + offset: Type.Optional(Type.Number({ description: "Skip first N results before applying headLimit (default: 0)" })), }); const DEFAULT_LIMIT = 100; @@ -34,6 +51,13 @@ export interface GrepToolDetails { truncation?: TruncationResult; matchLimitReached?: number; linesTruncated?: boolean; + // Fields for TUI rendering + matchCount?: number; + fileCount?: number; + files?: string[]; + mode?: "content" | "files_with_matches" | "count"; + truncated?: boolean; + error?: string; } export function createGrepTool(cwd: string): AgentTool { @@ -48,18 +72,30 @@ export function createGrepTool(cwd: string): AgentTool { pattern, path: searchDir, glob, + type, ignoreCase, + caseSensitive, literal, + multiline, context, limit, + outputMode, + headLimit, + offset, }: { pattern: string; path?: string; glob?: string; + type?: string; ignoreCase?: boolean; + caseSensitive?: boolean; literal?: boolean; + multiline?: boolean; context?: number; limit?: number; + outputMode?: "content" | "files_with_matches" | "count"; + headLimit?: number; + offset?: number; }, signal?: AbortSignal, ) => { @@ -83,6 +119,9 @@ export function createGrepTool(cwd: string): AgentTool { const isDirectory = searchStat.isDirectory(); const contextValue = context && context > 0 ? context : 0; const effectiveLimit = Math.max(1, limit ?? DEFAULT_LIMIT); + const effectiveOutputMode = outputMode ?? "content"; + const effectiveOffset = offset && offset > 0 ? offset : 0; + const hasHeadLimit = headLimit !== undefined && headLimit > 0; const formatPath = (filePath: string): string => { if (isDirectory) { @@ -109,10 +148,27 @@ export function createGrepTool(cwd: string): AgentTool { return lines; }; - const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"]; + const args: string[] = []; - if (ignoreCase) { + // Base arguments depend on output mode + if (effectiveOutputMode === "files_with_matches") { + args.push("--files-with-matches", "--color=never", "--hidden"); + } else if (effectiveOutputMode === "count") { + args.push("--count", "--color=never", "--hidden"); + } else { + args.push("--json", "--line-number", "--color=never", "--hidden"); + } + + if (caseSensitive) { + args.push("--case-sensitive"); + } else if (ignoreCase) { args.push("--ignore-case"); + } else { + args.push("--smart-case"); + } + + if (multiline) { + args.push("--multiline"); } if (literal) { @@ -123,6 +179,10 @@ export function createGrepTool(cwd: string): AgentTool { args.push("--glob", glob); } + if (type) { + args.push("--type", type); + } + args.push(pattern, searchPath); const child: Subprocess = Bun.spawn([rgPath, ...args], { @@ -138,6 +198,16 @@ export function createGrepTool(cwd: string): AgentTool { let aborted = false; let killedDueToLimit = false; const outputLines: string[] = []; + const files = new Set(); + const fileList: string[] = []; + + const recordFile = (filePath: string) => { + const relative = formatPath(filePath); + if (!files.has(relative)) { + files.add(relative); + fileList.push(relative); + } + }; const stopChild = (dueToLimit: boolean = false) => { killedDueToLimit = dueToLimit; @@ -153,6 +223,144 @@ export function createGrepTool(cwd: string): AgentTool { signal.addEventListener("abort", onAbort, { once: true }); } + // For simple output modes (files_with_matches, count), process text directly + if (effectiveOutputMode === "files_with_matches" || effectiveOutputMode === "count") { + const stdoutReader = (child.stdout as ReadableStream).getReader(); + const stderrReader = (child.stderr as ReadableStream).getReader(); + const decoder = new TextDecoder(); + let stdout = ""; + + await Promise.all([ + (async () => { + while (true) { + const { done, value } = await stdoutReader.read(); + if (done) break; + stdout += decoder.decode(value, { stream: true }); + } + })(), + (async () => { + while (true) { + const { done, value } = await stderrReader.read(); + if (done) break; + stderr += decoder.decode(value, { stream: true }); + } + })(), + ]); + + const exitCode = await child.exited; + + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + if (aborted) { + throw new Error("Operation aborted"); + } + + if (exitCode !== 0 && exitCode !== 1) { + const errorMsg = stderr.trim() || `ripgrep exited with code ${exitCode}`; + throw new Error(errorMsg); + } + + const lines = stdout + .trim() + .split("\n") + .filter((line) => line.length > 0); + + if (lines.length === 0) { + return { + content: [{ type: "text", text: "No matches found" }], + details: { + matchCount: 0, + fileCount: 0, + files: [], + mode: effectiveOutputMode, + truncated: false, + }, + }; + } + + // Apply offset and headLimit + let processedLines = lines; + if (effectiveOffset > 0) { + processedLines = processedLines.slice(effectiveOffset); + } + if (hasHeadLimit) { + processedLines = processedLines.slice(0, headLimit); + } + + let simpleMatchCount = 0; + let fileCount = 0; + const simpleFiles = new Set(); + const simpleFileList: string[] = []; + + const recordSimpleFile = (filePath: string) => { + const relative = formatPath(filePath); + if (!simpleFiles.has(relative)) { + simpleFiles.add(relative); + simpleFileList.push(relative); + } + }; + + if (effectiveOutputMode === "files_with_matches") { + for (const line of lines) { + recordSimpleFile(line); + } + fileCount = simpleFiles.size; + simpleMatchCount = fileCount; + } else { + for (const line of lines) { + const separatorIndex = line.lastIndexOf(":"); + const filePart = separatorIndex === -1 ? line : line.slice(0, separatorIndex); + const countPart = separatorIndex === -1 ? "" : line.slice(separatorIndex + 1); + const count = Number.parseInt(countPart, 10); + recordSimpleFile(filePart); + if (!Number.isNaN(count)) { + simpleMatchCount += count; + } + } + fileCount = simpleFiles.size; + } + + const truncated = hasHeadLimit && processedLines.length < lines.length; + + // For count mode, format as "path:count" + if (effectiveOutputMode === "count") { + const formatted = processedLines.map((line) => { + const separatorIndex = line.lastIndexOf(":"); + const relative = formatPath(separatorIndex === -1 ? line : line.slice(0, separatorIndex)); + const count = separatorIndex === -1 ? "0" : line.slice(separatorIndex + 1); + return `${relative}:${count}`; + }); + const output = formatted.join("\n"); + return { + content: [{ type: "text", text: output }], + details: { + matchCount: simpleMatchCount, + fileCount, + files: simpleFileList.slice(0, 50), + mode: effectiveOutputMode, + truncated, + }, + }; + } + + // For files_with_matches, format paths + const formatted = processedLines.map((line) => formatPath(line)); + const output = formatted.join("\n"); + return { + content: [{ type: "text", text: output }], + details: { + matchCount: simpleMatchCount, + fileCount, + files: simpleFileList.slice(0, 50), + mode: effectiveOutputMode, + truncated, + }, + }; + } + + // Content mode - existing JSON processing const formatBlock = (filePath: string, lineNumber: number): string[] => { const relativePath = formatPath(filePath); const lines = getFileLines(filePath); @@ -202,6 +410,7 @@ export function createGrepTool(cwd: string): AgentTool { const lineNumber = event.data?.line_number; if (filePath && typeof lineNumber === "number") { + recordFile(filePath); outputLines.push(...formatBlock(filePath, lineNumber)); } @@ -266,15 +475,40 @@ export function createGrepTool(cwd: string): AgentTool { } if (matchCount === 0) { - return { content: [{ type: "text", text: "No matches found" }], details: undefined }; + return { + content: [{ type: "text", text: "No matches found" }], + details: { + matchCount: 0, + fileCount: 0, + files: [], + mode: effectiveOutputMode, + truncated: false, + }, + }; + } + + // Apply offset and headLimit to output lines + let processedLines = outputLines; + if (effectiveOffset > 0) { + processedLines = processedLines.slice(effectiveOffset); + } + if (hasHeadLimit) { + processedLines = processedLines.slice(0, headLimit); } // Apply byte truncation (no line limit since we already have match limit) - const rawOutput = outputLines.join("\n"); + const rawOutput = processedLines.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); let output = truncation.content; - const details: GrepToolDetails = {}; + const truncatedByHeadLimit = hasHeadLimit && processedLines.length < outputLines.length; + const details: GrepToolDetails = { + matchCount, + fileCount: files.size, + files: fileList.slice(0, 50), + mode: effectiveOutputMode, + truncated: matchLimitReached || truncation.truncated || truncatedByHeadLimit, + }; // Build notices const notices: string[] = []; diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 74701568c..b1233a27b 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -1,68 +1,141 @@ +export { type AskToolDetails, askTool, createAskTool } from "./ask.js"; +export { type AstToolDetails, astTool, createAstTool } from "./ast.js"; export { type BashToolDetails, bashTool, createBashTool } from "./bash.js"; export { createEditTool, editTool } from "./edit.js"; +// Exa MCP tools (22 tools) +export { exaTools } from "./exa/index.js"; +export type { ExaRenderDetails, ExaSearchResponse, ExaSearchResult } from "./exa/types.js"; export { createFindTool, type FindToolDetails, findTool } from "./find.js"; export { createGrepTool, type GrepToolDetails, grepTool } from "./grep.js"; export { createLsTool, type LsToolDetails, lsTool } from "./ls.js"; +export { createLspTool, type LspToolDetails, lspTool } from "./lsp/index.js"; +export { createNotebookTool, type NotebookToolDetails, notebookTool } from "./notebook.js"; export { createReadTool, type ReadToolDetails, readTool } from "./read.js"; +export { createReplaceTool, type ReplaceToolDetails, replaceTool } from "./replace.js"; +export { BUNDLED_AGENTS, createTaskTool, taskTool } from "./task/index.js"; export type { TruncationResult } from "./truncate.js"; +export { createWebFetchTool, type WebFetchToolDetails, webFetchCustomTool, webFetchTool } from "./web-fetch.js"; +export { + createWebSearchTool, + type WebSearchProvider, + type WebSearchResponse, + webSearchCustomTool, + webSearchTool, +} from "./web-search/index.js"; export { createWriteTool, writeTool } from "./write.js"; import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { askTool, createAskTool } from "./ask.js"; +import { astTool, createAstTool } from "./ast.js"; import { bashTool, createBashTool } from "./bash.js"; import { createEditTool, editTool } from "./edit.js"; import { createFindTool, findTool } from "./find.js"; import { createGrepTool, grepTool } from "./grep.js"; import { createLsTool, lsTool } from "./ls.js"; +import { createLspTool, lspTool } from "./lsp/index.js"; +import { createNotebookTool, notebookTool } from "./notebook.js"; import { createReadTool, readTool } from "./read.js"; +import { createReplaceTool, replaceTool } from "./replace.js"; +import { createTaskTool, taskTool } from "./task/index.js"; +import { createWebFetchTool, webFetchTool } from "./web-fetch.js"; +import { createWebSearchTool, webSearchTool } from "./web-search/index.js"; import { createWriteTool, writeTool } from "./write.js"; /** Tool type (AgentTool from pi-ai) */ -export type Tool = AgentTool; +export type Tool = AgentTool; -// Default tools for full access mode (using process.cwd()) -export const codingTools: Tool[] = [readTool, bashTool, editTool, writeTool]; +/** Context for tools that need session information */ +export interface SessionContext { + getSessionFile: () => string | null; +} -// Read-only tools for exploration without modification (using process.cwd()) -export const readOnlyTools: Tool[] = [readTool, grepTool, findTool, lsTool]; +// Factory function type +type ToolFactory = (cwd: string, sessionContext?: SessionContext) => Tool; -// All available tools (using process.cwd()) -export const allTools = { - read: readTool, - bash: bashTool, - edit: editTool, - write: writeTool, - grep: grepTool, - find: findTool, - ls: lsTool, +// Tool definitions: static tools and their factory functions +const toolDefs: Record = { + ask: { tool: askTool, create: createAskTool }, + ast: { tool: astTool, create: createAstTool }, + read: { tool: readTool, create: createReadTool }, + bash: { tool: bashTool, create: createBashTool }, + edit: { tool: editTool, create: createEditTool }, + write: { tool: writeTool, create: createWriteTool }, + grep: { tool: grepTool, create: createGrepTool }, + find: { tool: findTool, create: createFindTool }, + ls: { tool: lsTool, create: createLsTool }, + lsp: { tool: lspTool, create: createLspTool }, + notebook: { tool: notebookTool, create: createNotebookTool }, + replace: { tool: replaceTool, create: createReplaceTool }, + task: { tool: taskTool, create: (cwd, ctx) => createTaskTool(cwd, ctx) }, + web_fetch: { tool: webFetchTool, create: createWebFetchTool }, + web_search: { tool: webSearchTool, create: createWebSearchTool }, }; -export type ToolName = keyof typeof allTools; +export type ToolName = keyof typeof toolDefs; + +// Tools that require UI (excluded when hasUI is false) +const uiToolNames: ToolName[] = ["ask"]; + +// Tool sets defined by name (base sets, without UI-only tools) +const baseCodingToolNames: ToolName[] = [ + "read", + "bash", + "edit", + "write", + "grep", + "find", + "ls", + "ast", + "lsp", + "notebook", + "replace", + "task", + "web_fetch", + "web_search", +]; +const baseReadOnlyToolNames: ToolName[] = ["read", "grep", "find", "ls"]; + +// Default tools for full access mode (using process.cwd(), no UI) +export const codingTools: Tool[] = baseCodingToolNames.map((name) => toolDefs[name].tool); + +// Read-only tools for exploration without modification (using process.cwd(), no UI) +export const readOnlyTools: Tool[] = baseReadOnlyToolNames.map((name) => toolDefs[name].tool); + +// All available tools (using process.cwd(), no UI) +export const allTools = Object.fromEntries(Object.entries(toolDefs).map(([name, def]) => [name, def.tool])) as Record< + ToolName, + Tool +>; /** * Create coding tools configured for a specific working directory. + * @param cwd - Working directory for tools + * @param hasUI - Whether UI is available (includes ask tool if true) + * @param sessionContext - Optional session context for tools that need it */ -export function createCodingTools(cwd: string): Tool[] { - return [createReadTool(cwd), createBashTool(cwd), createEditTool(cwd), createWriteTool(cwd)]; +export function createCodingTools(cwd: string, hasUI = false, sessionContext?: SessionContext): Tool[] { + const names = hasUI ? [...baseCodingToolNames, ...uiToolNames] : baseCodingToolNames; + return names.map((name) => toolDefs[name].create(cwd, sessionContext)); } /** * Create read-only tools configured for a specific working directory. + * @param cwd - Working directory for tools + * @param hasUI - Whether UI is available (includes ask tool if true) + * @param sessionContext - Optional session context for tools that need it */ -export function createReadOnlyTools(cwd: string): Tool[] { - return [createReadTool(cwd), createGrepTool(cwd), createFindTool(cwd), createLsTool(cwd)]; +export function createReadOnlyTools(cwd: string, hasUI = false, sessionContext?: SessionContext): Tool[] { + const names = hasUI ? [...baseReadOnlyToolNames, ...uiToolNames] : baseReadOnlyToolNames; + return names.map((name) => toolDefs[name].create(cwd, sessionContext)); } /** * Create all tools configured for a specific working directory. + * @param cwd - Working directory for tools + * @param sessionContext - Optional session context for tools that need it */ -export function createAllTools(cwd: string): Record { - return { - read: createReadTool(cwd), - bash: createBashTool(cwd), - edit: createEditTool(cwd), - write: createWriteTool(cwd), - grep: createGrepTool(cwd), - find: createFindTool(cwd), - ls: createLsTool(cwd), - }; +export function createAllTools(cwd: string, sessionContext?: SessionContext): Record { + return Object.fromEntries( + Object.entries(toolDefs).map(([name, def]) => [name, def.create(cwd, sessionContext)]), + ) as Record; } diff --git a/packages/coding-agent/src/core/tools/lsp/client.ts b/packages/coding-agent/src/core/tools/lsp/client.ts new file mode 100644 index 000000000..2837c2fda --- /dev/null +++ b/packages/coding-agent/src/core/tools/lsp/client.ts @@ -0,0 +1,605 @@ +import * as fs from "node:fs"; +import { applyWorkspaceEdit } from "./edits.js"; +import type { + Diagnostic, + LspClient, + LspJsonRpcNotification, + LspJsonRpcRequest, + LspJsonRpcResponse, + ServerConfig, + WorkspaceEdit, +} from "./types.js"; +import { detectLanguageId, fileToUri } from "./utils.js"; + +// ============================================================================= +// Client State +// ============================================================================= + +const clients = new Map(); + +// Idle timeout: shutdown clients after 5 minutes of inactivity +const IDLE_TIMEOUT_MS = 5 * 60 * 1000; +const IDLE_CHECK_INTERVAL_MS = 60 * 1000; + +// Background task to shutdown idle clients +let idleCheckInterval: Timer | null = null; + +function startIdleChecker(): void { + if (idleCheckInterval) return; + idleCheckInterval = setInterval(() => { + const now = Date.now(); + for (const [key, client] of Array.from(clients.entries())) { + if (now - client.lastActivity > IDLE_TIMEOUT_MS) { + console.log(`[LSP] Shutting down idle client: ${key}`); + shutdownClient(key); + } + } + }, IDLE_CHECK_INTERVAL_MS); +} + +function stopIdleChecker(): void { + if (idleCheckInterval) { + clearInterval(idleCheckInterval); + idleCheckInterval = null; + } +} + +// ============================================================================= +// Client Capabilities +// ============================================================================= + +const CLIENT_CAPABILITIES = { + textDocument: { + synchronization: { + didSave: true, + dynamicRegistration: false, + willSave: false, + willSaveWaitUntil: false, + }, + hover: { + contentFormat: ["markdown", "plaintext"], + dynamicRegistration: false, + }, + definition: { + dynamicRegistration: false, + linkSupport: true, + }, + references: { + dynamicRegistration: false, + }, + documentSymbol: { + dynamicRegistration: false, + hierarchicalDocumentSymbolSupport: true, + symbolKind: { + valueSet: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26], + }, + }, + rename: { + dynamicRegistration: false, + prepareSupport: true, + }, + codeAction: { + dynamicRegistration: false, + codeActionLiteralSupport: { + codeActionKind: { + valueSet: [ + "quickfix", + "refactor", + "refactor.extract", + "refactor.inline", + "refactor.rewrite", + "source", + "source.organizeImports", + "source.fixAll", + ], + }, + }, + resolveSupport: { + properties: ["edit"], + }, + }, + publishDiagnostics: { + relatedInformation: true, + versionSupport: false, + tagSupport: { valueSet: [1, 2] }, + codeDescriptionSupport: true, + dataSupport: true, + }, + }, + workspace: { + applyEdit: true, + workspaceEdit: { + documentChanges: true, + resourceOperations: ["create", "rename", "delete"], + failureHandling: "textOnlyTransactional", + }, + configuration: true, + symbol: { + dynamicRegistration: false, + symbolKind: { + valueSet: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26], + }, + }, + }, + experimental: { + snippetTextEdit: true, + }, +}; + +// ============================================================================= +// LSP Message Protocol +// ============================================================================= + +/** + * Parse a single LSP message from a buffer. + * Returns the parsed message and remaining buffer, or null if incomplete. + */ +function parseMessage( + buffer: Uint8Array, +): { message: LspJsonRpcResponse | LspJsonRpcNotification; remaining: Uint8Array } | null { + // Only decode enough to find the header + const headerEndIndex = findHeaderEnd(buffer); + if (headerEndIndex === -1) return null; + + const headerText = new TextDecoder().decode(buffer.slice(0, headerEndIndex)); + const contentLengthMatch = headerText.match(/Content-Length: (\d+)/i); + if (!contentLengthMatch) return null; + + const contentLength = Number.parseInt(contentLengthMatch[1], 10); + const messageStart = headerEndIndex + 4; // Skip \r\n\r\n + const messageEnd = messageStart + contentLength; + + if (buffer.length < messageEnd) return null; + + const messageBytes = buffer.slice(messageStart, messageEnd); + const messageText = new TextDecoder().decode(messageBytes); + const remaining = buffer.slice(messageEnd); + + return { + message: JSON.parse(messageText), + remaining, + }; +} + +/** + * Find the end of the header section (before \r\n\r\n) + */ +function findHeaderEnd(buffer: Uint8Array): number { + for (let i = 0; i < buffer.length - 3; i++) { + if (buffer[i] === 13 && buffer[i + 1] === 10 && buffer[i + 2] === 13 && buffer[i + 3] === 10) { + return i; + } + } + return -1; +} + +/** + * Concatenate two Uint8Arrays efficiently + */ +function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array { + const result = new Uint8Array(a.length + b.length); + result.set(a); + result.set(b, a.length); + return result; +} + +async function writeMessage( + sink: import("bun").FileSink, + message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse, +): Promise { + const content = JSON.stringify(message); + const contentBytes = new TextEncoder().encode(content); + const header = `Content-Length: ${contentBytes.length}\r\n\r\n`; + const fullMessage = new TextEncoder().encode(header + content); + + sink.write(fullMessage); + await sink.flush(); +} + +// ============================================================================= +// Message Reader +// ============================================================================= + +/** + * Start background message reader for a client. + * Routes responses to pending requests and handles notifications. + */ +async function startMessageReader(client: LspClient): Promise { + if (client.isReading) return; + client.isReading = true; + + const reader = (client.process.stdout as ReadableStream).getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + client.messageBuffer = concatBuffers(client.messageBuffer, value); + + // Process all complete messages in buffer + let parsed = parseMessage(client.messageBuffer); + while (parsed) { + const { message, remaining } = parsed; + client.messageBuffer = remaining; + + // Route message + if ("id" in message && message.id !== undefined) { + // Response to a request + const pending = client.pendingRequests.get(message.id); + if (pending) { + client.pendingRequests.delete(message.id); + if ("error" in message && message.error) { + pending.reject(new Error(`LSP error: ${message.error.message}`)); + } else { + pending.resolve(message.result); + } + } else if ("method" in message) { + await handleServerRequest(client, message as LspJsonRpcRequest); + } + } else if ("method" in message) { + // Server notification + if (message.method === "textDocument/publishDiagnostics" && message.params) { + const params = message.params as { uri: string; diagnostics: Diagnostic[] }; + client.diagnostics.set(params.uri, params.diagnostics); + } + } + + parsed = parseMessage(client.messageBuffer); + } + } + } catch (err) { + // Connection closed or error - reject all pending requests + for (const pending of Array.from(client.pendingRequests.values())) { + pending.reject(new Error(`LSP connection closed: ${err}`)); + } + client.pendingRequests.clear(); + } finally { + reader.releaseLock(); + client.isReading = false; + } +} + +/** + * Handle workspace/configuration requests from the server. + */ +async function handleConfigurationRequest(client: LspClient, message: LspJsonRpcRequest): Promise { + if (typeof message.id !== "number") return; + const params = message.params as { items?: Array<{ section?: string }> }; + const items = params?.items ?? []; + const result = items.map((item) => { + const section = item.section ?? ""; + return client.config.settings?.[section] ?? {}; + }); + await sendResponse(client, message.id, result, "workspace/configuration"); +} + +/** + * Handle workspace/applyEdit requests from the server. + */ +async function handleApplyEditRequest(client: LspClient, message: LspJsonRpcRequest): Promise { + if (typeof message.id !== "number") return; + const params = message.params as { edit?: WorkspaceEdit }; + if (!params?.edit) { + await sendResponse( + client, + message.id, + { applied: false, failureReason: "No edit provided" }, + "workspace/applyEdit", + ); + return; + } + + try { + await applyWorkspaceEdit(params.edit, client.cwd); + await sendResponse(client, message.id, { applied: true }, "workspace/applyEdit"); + } catch (err) { + await sendResponse(client, message.id, { applied: false, failureReason: String(err) }, "workspace/applyEdit"); + } +} + +/** + * Respond to a server-initiated request. + */ +async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest): Promise { + if (message.method === "workspace/configuration") { + await handleConfigurationRequest(client, message); + return; + } + if (message.method === "workspace/applyEdit") { + await handleApplyEditRequest(client, message); + return; + } + if (typeof message.id !== "number") return; + await sendResponse(client, message.id, null, message.method, { + code: -32601, + message: `Method not found: ${message.method}`, + }); +} + +/** + * Send an LSP response to the server. + */ +async function sendResponse( + client: LspClient, + id: number, + result: unknown, + method: string, + error?: { code: number; message: string; data?: unknown }, +): Promise { + const response: LspJsonRpcResponse = { + jsonrpc: "2.0", + id, + ...(error ? { error } : { result }), + }; + + try { + await writeMessage(client.process.stdin as import("bun").FileSink, response); + } catch (err) { + console.error(`[LSP] Failed to respond to ${method}: ${err}`); + } +} + +// ============================================================================= +// Client Management +// ============================================================================= + +/** + * Get or create an LSP client for the given server configuration and working directory. + */ +export async function getOrCreateClient(config: ServerConfig, cwd: string): Promise { + const key = `${config.command}:${cwd}`; + + if (clients.has(key)) { + const client = clients.get(key)!; + client.lastActivity = Date.now(); + return client; + } + + const args = config.args ?? []; + const proc = Bun.spawn([config.command, ...args], { + cwd, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + + const client: LspClient = { + name: key, + cwd, + process: proc, + config, + requestId: 0, + diagnostics: new Map(), + openFiles: new Map(), + pendingRequests: new Map(), + messageBuffer: new Uint8Array(0), + isReading: false, + lastActivity: Date.now(), + }; + clients.set(key, client); + + // Start idle checker if not already running + startIdleChecker(); + + // Register crash recovery - remove client on process exit + proc.exited.then(() => { + console.log(`[LSP] Process exited: ${key}`); + clients.delete(key); + if (clients.size === 0) { + stopIdleChecker(); + } + }); + + // Start background message reader + startMessageReader(client); + + try { + // Send initialize request + const initResult = (await sendRequest(client, "initialize", { + processId: process.pid, + rootUri: fileToUri(cwd), + rootPath: cwd, + capabilities: CLIENT_CAPABILITIES, + initializationOptions: config.initOptions ?? {}, + workspaceFolders: [{ uri: fileToUri(cwd), name: cwd.split("/").pop() ?? "workspace" }], + })) as { capabilities?: unknown }; + + if (!initResult) { + throw new Error("Failed to initialize LSP: no response"); + } + + client.serverCapabilities = initResult.capabilities as LspClient["serverCapabilities"]; + + // Send initialized notification + await sendNotification(client, "initialized", {}); + + return client; + } catch (err) { + // Clean up on initialization failure + clients.delete(key); + proc.kill(); + throw err; + } +} + +/** + * Ensure a file is opened in the LSP client. + * Sends didOpen notification if the file is not already tracked. + */ +export async function ensureFileOpen(client: LspClient, filePath: string): Promise { + const uri = fileToUri(filePath); + if (client.openFiles.has(uri)) { + return; + } + + const content = fs.readFileSync(filePath, "utf-8"); + const languageId = detectLanguageId(filePath); + + await sendNotification(client, "textDocument/didOpen", { + textDocument: { + uri, + languageId, + version: 1, + text: content, + }, + }); + + client.openFiles.set(uri, { version: 1, languageId }); + client.lastActivity = Date.now(); +} + +/** + * Refresh a file in the LSP client. + * Increments version, sends didChange and didSave notifications. + */ +export async function refreshFile(client: LspClient, filePath: string): Promise { + const uri = fileToUri(filePath); + const info = client.openFiles.get(uri); + + if (!info) { + await ensureFileOpen(client, filePath); + return; + } + + const content = fs.readFileSync(filePath, "utf-8"); + info.version++; + + await sendNotification(client, "textDocument/didChange", { + textDocument: { uri, version: info.version }, + contentChanges: [{ text: content }], + }); + + await sendNotification(client, "textDocument/didSave", { + textDocument: { uri }, + text: content, + }); + + client.lastActivity = Date.now(); +} + +/** + * Shutdown a specific client by key. + */ +export function shutdownClient(key: string): void { + const client = clients.get(key); + if (!client) return; + + // Reject all pending requests + for (const pending of Array.from(client.pendingRequests.values())) { + pending.reject(new Error("LSP client shutdown")); + } + client.pendingRequests.clear(); + + // Send shutdown request (best effort, don't wait) + sendRequest(client, "shutdown", null).catch(() => {}); + + // Kill process + client.process.kill(); + clients.delete(key); + + if (clients.size === 0) { + stopIdleChecker(); + } +} + +// ============================================================================= +// LSP Protocol Methods +// ============================================================================= + +/** + * Send an LSP request and wait for response. + */ +export async function sendRequest(client: LspClient, method: string, params: unknown): Promise { + const id = ++client.requestId; + const request: LspJsonRpcRequest = { + jsonrpc: "2.0", + id, + method, + params, + }; + + client.lastActivity = Date.now(); + + return new Promise((resolve, reject) => { + // Set timeout + const timeout = setTimeout(() => { + if (client.pendingRequests.has(id)) { + client.pendingRequests.delete(id); + reject(new Error(`LSP request ${method} timed out`)); + } + }, 30000); + + // Register pending request with timeout wrapper + client.pendingRequests.set(id, { + resolve: (result) => { + clearTimeout(timeout); + resolve(result); + }, + reject: (err) => { + clearTimeout(timeout); + reject(err); + }, + method, + }); + + // Write request + writeMessage(client.process.stdin as import("bun").FileSink, request).catch((err) => { + clearTimeout(timeout); + client.pendingRequests.delete(id); + reject(err); + }); + }); +} + +/** + * Send an LSP notification (no response expected). + */ +export async function sendNotification(client: LspClient, method: string, params: unknown): Promise { + const notification: LspJsonRpcNotification = { + jsonrpc: "2.0", + method, + params, + }; + + client.lastActivity = Date.now(); + await writeMessage(client.process.stdin as import("bun").FileSink, notification); +} + +/** + * Shutdown all LSP clients. + */ +export function shutdownAll(): void { + stopIdleChecker(); + + for (const client of Array.from(clients.values())) { + // Reject all pending requests + for (const pending of Array.from(client.pendingRequests.values())) { + pending.reject(new Error("LSP client shutdown")); + } + client.pendingRequests.clear(); + + // Send shutdown request (best effort, don't wait) + sendRequest(client, "shutdown", null).catch(() => {}); + + client.process.kill(); + } + clients.clear(); +} + +// ============================================================================= +// Process Cleanup +// ============================================================================= + +// Register cleanup on module unload +if (typeof process !== "undefined") { + process.on("beforeExit", shutdownAll); + process.on("SIGINT", () => { + shutdownAll(); + process.exit(0); + }); + process.on("SIGTERM", () => { + shutdownAll(); + process.exit(0); + }); +} diff --git a/packages/coding-agent/src/core/tools/lsp/config.ts b/packages/coding-agent/src/core/tools/lsp/config.ts new file mode 100644 index 000000000..68d1749f7 --- /dev/null +++ b/packages/coding-agent/src/core/tools/lsp/config.ts @@ -0,0 +1,147 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { extname, join } from "node:path"; +import type { ServerConfig } from "./types.js"; + +export interface LspConfig { + servers: Record; +} + +// Predefined server configurations with capabilities +export const SERVERS: Record = { + "rust-analyzer": { + command: "rust-analyzer", + args: [], + fileTypes: [".rs"], + rootMarkers: ["Cargo.toml", "rust-analyzer.toml"], + initOptions: { + checkOnSave: { command: "clippy" }, + cargo: { allFeatures: true }, + procMacro: { enable: true }, + }, + capabilities: { + flycheck: true, + ssr: true, + expandMacro: true, + runnables: true, + relatedTests: true, + }, + }, + "typescript-language-server": { + command: "typescript-language-server", + args: ["--stdio"], + fileTypes: [".ts", ".tsx", ".js", ".jsx"], + rootMarkers: ["package.json", "tsconfig.json", "jsconfig.json"], + }, + gopls: { + command: "gopls", + args: ["serve"], + fileTypes: [".go"], + rootMarkers: ["go.mod", "go.work"], + }, + pyright: { + command: "pyright-langserver", + args: ["--stdio"], + fileTypes: [".py"], + rootMarkers: ["pyproject.toml", "setup.py", "requirements.txt", "Pipfile"], + }, + zls: { + command: "zls", + args: [], + fileTypes: [".zig"], + rootMarkers: ["build.zig", "build.zig.zon", "zls.json"], + }, + clangd: { + command: "clangd", + args: ["--background-index"], + fileTypes: [".c", ".cpp", ".cc", ".cxx", ".h", ".hpp"], + rootMarkers: ["compile_commands.json", "CMakeLists.txt", ".clangd"], + }, + "lua-language-server": { + command: "lua-language-server", + args: [], + fileTypes: [".lua"], + rootMarkers: [".luarc.json", ".luarc.jsonc", ".luacheckrc"], + }, +}; + +/** + * Check if any root marker file exists in the directory + */ +export function hasRootMarkers(cwd: string, markers: string[]): boolean { + return markers.some((marker) => existsSync(join(cwd, marker))); +} + +/** + * Load LSP configuration. + * + * Priority: + * 1. Project-level config from .pi/lsp.json in cwd + * 2. User-level config from ~/.pi/lsp.json + * 3. Auto-detect from project markers + available binaries + */ +export function loadConfig(cwd: string): LspConfig { + // Try to load user config + const configPaths = [join(cwd, ".pi", "lsp.json"), join(homedir(), ".pi", "lsp.json")]; + + for (const configPath of configPaths) { + if (existsSync(configPath)) { + try { + const content = readFileSync(configPath, "utf-8"); + const parsed = JSON.parse(content); + const servers = parsed.servers || parsed; + + // Filter to only enabled servers with available commands + const available: Record = {}; + for (const [name, config] of Object.entries(servers) as [string, ServerConfig][]) { + if (config.disabled) continue; + if (!Bun.which(config.command)) continue; + available[name] = config; + } + + return { servers: available }; + } catch { + // Ignore parse errors, fall through to auto-detect + } + } + } + + // Auto-detect: find servers based on project markers AND available binaries + const detected: Record = {}; + + for (const [name, config] of Object.entries(SERVERS)) { + // Check if project has root markers for this language + if (!hasRootMarkers(cwd, config.rootMarkers)) continue; + + // Check if the language server binary is available + if (!Bun.which(config.command)) continue; + + detected[name] = config; + } + + return { servers: detected }; +} + +/** + * Find the appropriate server for a file based on extension + */ +export function getServerForFile(config: LspConfig, filePath: string): [string, ServerConfig] | null { + const ext = extname(filePath).toLowerCase(); + + for (const [name, serverConfig] of Object.entries(config.servers)) { + if (serverConfig.fileTypes.includes(ext)) { + return [name, serverConfig]; + } + } + return null; +} + +/** + * Check if a server has a specific capability + */ +export function hasCapability( + config: ServerConfig, + capability: keyof NonNullable, +): boolean { + return config.capabilities?.[capability] === true; +} diff --git a/packages/coding-agent/src/core/tools/lsp/edits.ts b/packages/coding-agent/src/core/tools/lsp/edits.ts new file mode 100644 index 000000000..a4a9e6cb6 --- /dev/null +++ b/packages/coding-agent/src/core/tools/lsp/edits.ts @@ -0,0 +1,101 @@ +import { mkdir, rename, rm } from "node:fs/promises"; +import path from "node:path"; +import type { CreateFile, DeleteFile, RenameFile, TextDocumentEdit, TextEdit, WorkspaceEdit } from "./types.js"; +import { uriToFile } from "./utils.js"; + +// ============================================================================= +// Text Edit Application +// ============================================================================= + +/** + * Apply text edits to a file. + * Edits are applied in reverse order (bottom-to-top) to preserve line/character indices. + */ +export async function applyTextEdits(filePath: string, edits: TextEdit[]): Promise { + const content = await Bun.file(filePath).text(); + const lines = content.split("\n"); + + // Sort edits in reverse order (bottom-to-top, right-to-left) + const sortedEdits = [...edits].sort((a, b) => { + if (a.range.start.line !== b.range.start.line) { + return b.range.start.line - a.range.start.line; + } + return b.range.start.character - a.range.start.character; + }); + + for (const edit of sortedEdits) { + const { start, end } = edit.range; + + // Single-line edit: replace substring within same line + if (start.line === end.line) { + const line = lines[start.line] || ""; + lines[start.line] = line.slice(0, start.character) + edit.newText + line.slice(end.character); + } else { + // Multi-line edit: splice across multiple lines + const startLine = lines[start.line] || ""; + const endLine = lines[end.line] || ""; + const newContent = startLine.slice(0, start.character) + edit.newText + endLine.slice(end.character); + lines.splice(start.line, end.line - start.line + 1, ...newContent.split("\n")); + } + } + + await Bun.write(filePath, lines.join("\n")); +} + +// ============================================================================= +// Workspace Edit Application +// ============================================================================= + +/** + * Apply a workspace edit (collection of file changes). + * Returns array of applied change descriptions. + */ +export async function applyWorkspaceEdit(edit: WorkspaceEdit, cwd: string): Promise { + const applied: string[] = []; + + // Handle changes map (legacy format) + if (edit.changes) { + for (const [uri, textEdits] of Object.entries(edit.changes)) { + const filePath = uriToFile(uri); + await applyTextEdits(filePath, textEdits); + applied.push(`Applied ${textEdits.length} edit(s) to ${path.relative(cwd, filePath)}`); + } + } + + // Handle documentChanges array (modern format) + if (edit.documentChanges) { + for (const change of edit.documentChanges) { + if ("textDocument" in change && change.textDocument && "edits" in change && change.edits) { + // TextDocumentEdit + const docChange = change as TextDocumentEdit; + const filePath = uriToFile(docChange.textDocument.uri); + const textEdits = docChange.edits.filter((e): e is TextEdit => "range" in e && "newText" in e); + await applyTextEdits(filePath, textEdits); + applied.push(`Applied ${textEdits.length} edit(s) to ${path.relative(cwd, filePath)}`); + } else if ("kind" in change && change.kind) { + // Resource operations + if (change.kind === "create") { + const createOp = change as CreateFile; + const filePath = uriToFile(createOp.uri); + await mkdir(path.dirname(filePath), { recursive: true }); + await Bun.write(filePath, ""); + applied.push(`Created ${path.relative(cwd, filePath)}`); + } else if (change.kind === "rename") { + const renameOp = change as RenameFile; + const oldPath = uriToFile(renameOp.oldUri); + const newPath = uriToFile(renameOp.newUri); + await mkdir(path.dirname(newPath), { recursive: true }); + await rename(oldPath, newPath); + applied.push(`Renamed ${path.relative(cwd, oldPath)} → ${path.relative(cwd, newPath)}`); + } else if (change.kind === "delete") { + const deleteOp = change as DeleteFile; + const filePath = uriToFile(deleteOp.uri); + await rm(filePath, { recursive: true }); + applied.push(`Deleted ${path.relative(cwd, filePath)}`); + } + } + } + } + + return applied; +} diff --git a/packages/coding-agent/src/core/tools/lsp/index.ts b/packages/coding-agent/src/core/tools/lsp/index.ts new file mode 100644 index 000000000..75db23f1d --- /dev/null +++ b/packages/coding-agent/src/core/tools/lsp/index.ts @@ -0,0 +1,746 @@ +import * as fs from "node:fs"; +import path from "node:path"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { Theme } from "../../../modes/interactive/theme/theme.js"; +import { resolveToCwd } from "../path-utils.js"; +import { ensureFileOpen, getOrCreateClient, refreshFile, sendRequest } from "./client.js"; +import { getServerForFile, hasCapability, type LspConfig, loadConfig } from "./config.js"; +import { applyWorkspaceEdit } from "./edits.js"; +import { renderCall, renderResult } from "./render.js"; +import * as rustAnalyzer from "./rust-analyzer.js"; +import { + type CodeAction, + type Command, + type Diagnostic, + type DocumentSymbol, + type Hover, + type Location, + type LocationLink, + type LspClient, + type LspParams, + type LspToolDetails, + lspSchema, + type ServerConfig, + type SymbolInformation, + type WorkspaceEdit, +} from "./types.js"; +import { + extractHoverText, + fileToUri, + formatDiagnostic, + formatDiagnosticsSummary, + formatDocumentSymbol, + formatLocation, + formatSymbolInformation, + formatWorkspaceEdit, + sleep, + symbolKindToIcon, + uriToFile, +} from "./utils.js"; + +export type { LspToolDetails } from "./types.js"; + +// Cache config per cwd to avoid repeated file I/O +const configCache = new Map(); + +function getConfig(cwd: string): LspConfig { + let config = configCache.get(cwd); + if (!config) { + config = loadConfig(cwd); + configCache.set(cwd, config); + } + return config; +} + +const FILE_SEARCH_MAX_DEPTH = 5; +const IGNORED_DIRS = new Set(["node_modules", "target", "dist", "build", ".git"]); + +function findFileByExtensions(baseDir: string, extensions: string[], maxDepth: number): string | null { + const normalized = extensions.map((ext) => ext.toLowerCase()); + const search = (dir: string, depth: number): string | null => { + if (depth > maxDepth) return null; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return null; + } + + for (const entry of entries) { + if (entry.name.startsWith(".")) continue; + if (entry.isDirectory() && IGNORED_DIRS.has(entry.name)) continue; + const fullPath = path.join(dir, entry.name); + + if (entry.isFile()) { + const lowerName = entry.name.toLowerCase(); + if (normalized.some((ext) => lowerName.endsWith(ext))) { + return fullPath; + } + } else if (entry.isDirectory()) { + const found = search(fullPath, depth + 1); + if (found) return found; + } + } + return null; + }; + + return search(baseDir, 0); +} + +function findFileForServer(cwd: string, serverConfig: ServerConfig): string | null { + return findFileByExtensions(cwd, serverConfig.fileTypes, FILE_SEARCH_MAX_DEPTH); +} + +function getRustServer(config: LspConfig): [string, ServerConfig] | null { + const entries = Object.entries(config.servers) as Array<[string, ServerConfig]>; + const byName = entries.find(([name, server]) => name === "rust-analyzer" || server.command === "rust-analyzer"); + if (byName) return byName; + + for (const [name, server] of entries) { + if ( + hasCapability(server, "flycheck") || + hasCapability(server, "ssr") || + hasCapability(server, "runnables") || + hasCapability(server, "expandMacro") || + hasCapability(server, "relatedTests") + ) { + return [name, server]; + } + } + + return null; +} + +function getServerForWorkspaceAction(config: LspConfig, action: string): [string, ServerConfig] | null { + const entries = Object.entries(config.servers) as Array<[string, ServerConfig]>; + if (entries.length === 0) return null; + + if (action === "workspace_symbols") { + return entries[0]; + } + + if (action === "flycheck" || action === "ssr" || action === "runnables" || action === "reload_workspace") { + return getRustServer(config); + } + + return null; +} + +async function waitForDiagnostics(client: LspClient, uri: string, timeoutMs = 3000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const diagnostics = client.diagnostics.get(uri); + if (diagnostics !== undefined) return diagnostics; + await sleep(100); + } + return client.diagnostics.get(uri) ?? []; +} + +export function createLspTool(cwd: string): AgentTool { + return { + name: "lsp", + label: "LSP", + description: `Language server integration for code intelligence. + +Standard operations: +- diagnostics: Get errors/warnings for a file +- definition: Go to symbol definition +- references: Find all references to a symbol +- hover: Get type info and documentation +- symbols: List symbols in a file (functions, classes, etc.) +- workspace_symbols: Search for symbols across the project +- rename: Rename a symbol across the codebase +- actions: List and apply code actions (quick fixes, refactors) +- status: Show active language servers + +Rust-analyzer specific (require rust-analyzer): +- flycheck: Run clippy/cargo check +- expand_macro: Show macro expansion at cursor +- ssr: Structural search-replace +- runnables: Find runnable tests/binaries +- related_tests: Find tests for a function +- reload_workspace: Reload Cargo.toml changes`, + parameters: lspSchema, + renderCall, + renderResult, + execute: async (_toolCallId, params: LspParams, _signal) => { + const { + action, + file, + files, + line, + column, + end_line, + end_character, + query, + new_name, + replacement, + kind, + apply, + action_index, + include_declaration, + } = params; + + const config = getConfig(cwd); + + // Status action doesn't need a file + if (action === "status") { + const servers = Object.keys(config.servers); + const output = + servers.length > 0 + ? `Active language servers: ${servers.join(", ")}` + : "No language servers configured for this project"; + return { + content: [{ type: "text", text: output }], + details: { action, success: true }, + }; + } + + // Diagnostics can be batch or single-file + if (action === "diagnostics") { + const targets = files?.length ? files : file ? [file] : null; + if (!targets) { + return { + content: [{ type: "text", text: "Error: file or files parameter required for diagnostics" }], + details: { action, success: false }, + }; + } + + const detailed = Boolean(files?.length); + const results: string[] = []; + let lastServerName: string | undefined; + + for (const target of targets) { + const resolved = resolveToCwd(target, cwd); + const serverInfo = getServerForFile(config, resolved); + if (!serverInfo) { + results.push(`✗ ${target}: No language server found`); + continue; + } + + const [serverName, serverConfig] = serverInfo; + lastServerName = serverName; + + const client = await getOrCreateClient(serverConfig, cwd); + await refreshFile(client, resolved); + + const uri = fileToUri(resolved); + const diagnostics = await waitForDiagnostics(client, uri); + const relPath = path.relative(cwd, resolved); + + if (!detailed && targets.length === 1) { + if (diagnostics.length === 0) { + return { + content: [{ type: "text", text: "No diagnostics" }], + details: { action, serverName, success: true }, + }; + } + + const summary = formatDiagnosticsSummary(diagnostics); + const formatted = diagnostics.map((d) => formatDiagnostic(d, relPath)); + const output = `${summary}:\n${formatted.map((f) => ` ${f}`).join("\n")}`; + return { + content: [{ type: "text", text: output }], + details: { action, serverName, success: true }, + }; + } + + if (diagnostics.length === 0) { + results.push(`✓ ${relPath}: no issues`); + } else { + const summary = formatDiagnosticsSummary(diagnostics); + results.push(`✗ ${relPath}: ${summary}`); + for (const diag of diagnostics) { + results.push(` ${formatDiagnostic(diag, relPath)}`); + } + } + } + + return { + content: [{ type: "text", text: results.join("\n") }], + details: { action, serverName: lastServerName, success: true }, + }; + } + + const requiresFile = + !file && + action !== "workspace_symbols" && + action !== "flycheck" && + action !== "ssr" && + action !== "runnables" && + action !== "reload_workspace"; + + if (requiresFile) { + return { + content: [{ type: "text", text: "Error: file parameter required for this action" }], + details: { action, success: false }, + }; + } + + const resolvedFile = file ? resolveToCwd(file, cwd) : null; + const serverInfo = resolvedFile + ? getServerForFile(config, resolvedFile) + : getServerForWorkspaceAction(config, action); + + if (!serverInfo) { + return { + content: [{ type: "text", text: "No language server found for this action" }], + details: { action, success: false }, + }; + } + + const [serverName, serverConfig] = serverInfo; + + try { + const client = await getOrCreateClient(serverConfig, cwd); + let targetFile = resolvedFile; + if (action === "runnables" && !targetFile) { + targetFile = findFileForServer(cwd, serverConfig); + if (!targetFile) { + return { + content: [{ type: "text", text: "Error: no matching files found for runnables" }], + details: { action, serverName, success: false }, + }; + } + } + + if (targetFile) { + await ensureFileOpen(client, targetFile); + } + + const uri = targetFile ? fileToUri(targetFile) : ""; + const position = { line: (line || 1) - 1, character: (column || 1) - 1 }; + + let output: string; + + switch (action) { + // ===================================================================== + // Standard LSP Operations + // ===================================================================== + + case "definition": { + const result = (await sendRequest(client, "textDocument/definition", { + textDocument: { uri }, + position, + })) as Location | Location[] | LocationLink | LocationLink[] | null; + + if (!result) { + output = "No definition found"; + } else { + const raw = Array.isArray(result) ? result : [result]; + const locations = raw.flatMap((loc) => { + if ("uri" in loc) { + return [loc as Location]; + } + if ("targetUri" in loc) { + // Use targetSelectionRange (the precise identifier range) with fallback to targetRange + const link = loc as LocationLink; + return [{ uri: link.targetUri, range: link.targetSelectionRange ?? link.targetRange }]; + } + return []; + }); + + if (locations.length === 0) { + output = "No definition found"; + } else { + output = `Found ${locations.length} definition(s):\n${locations + .map((loc) => ` ${formatLocation(loc, cwd)}`) + .join("\n")}`; + } + } + break; + } + + case "references": { + const result = (await sendRequest(client, "textDocument/references", { + textDocument: { uri }, + position, + context: { includeDeclaration: include_declaration ?? true }, + })) as Location[] | null; + + if (!result || result.length === 0) { + output = "No references found"; + } else { + const lines = result.map((loc) => ` ${formatLocation(loc, cwd)}`); + output = `Found ${result.length} reference(s):\n${lines.join("\n")}`; + } + break; + } + + case "hover": { + const result = (await sendRequest(client, "textDocument/hover", { + textDocument: { uri }, + position, + })) as Hover | null; + + if (!result || !result.contents) { + output = "No hover information"; + } else { + output = extractHoverText(result.contents); + } + break; + } + + case "symbols": { + const result = (await sendRequest(client, "textDocument/documentSymbol", { + textDocument: { uri }, + })) as (DocumentSymbol | SymbolInformation)[] | null; + + if (!result || result.length === 0) { + output = "No symbols found"; + } else if (!targetFile) { + return { + content: [{ type: "text", text: "Error: file parameter required for symbols" }], + details: { action, serverName, success: false }, + }; + } else { + const relPath = path.relative(cwd, targetFile); + // Check if hierarchical (DocumentSymbol) or flat (SymbolInformation) + if ("selectionRange" in result[0]) { + // Hierarchical + const lines = (result as DocumentSymbol[]).flatMap((s) => formatDocumentSymbol(s)); + output = `Symbols in ${relPath}:\n${lines.join("\n")}`; + } else { + // Flat + const lines = (result as SymbolInformation[]).map((s) => { + const line = s.location.range.start.line + 1; + const icon = symbolKindToIcon(s.kind); + return `${icon} ${s.name} @ line ${line}`; + }); + output = `Symbols in ${relPath}:\n${lines.join("\n")}`; + } + } + break; + } + + case "workspace_symbols": { + if (!query) { + return { + content: [{ type: "text", text: "Error: query parameter required for workspace_symbols" }], + details: { action, serverName, success: false }, + }; + } + + const result = (await sendRequest(client, "workspace/symbol", { query })) as + | SymbolInformation[] + | null; + + if (!result || result.length === 0) { + output = `No symbols matching "${query}"`; + } else { + const lines = result.map((s) => formatSymbolInformation(s, cwd)); + output = `Found ${result.length} symbol(s) matching "${query}":\n${lines.map((l) => ` ${l}`).join("\n")}`; + } + break; + } + + case "rename": { + if (!new_name) { + return { + content: [{ type: "text", text: "Error: new_name parameter required for rename" }], + details: { action, serverName, success: false }, + }; + } + + const result = (await sendRequest(client, "textDocument/rename", { + textDocument: { uri }, + position, + newName: new_name, + })) as WorkspaceEdit | null; + + if (!result) { + output = "Rename returned no edits"; + } else { + const shouldApply = apply !== false; + if (shouldApply) { + const applied = await applyWorkspaceEdit(result, cwd); + output = `Applied rename:\n${applied.map((a) => ` ${a}`).join("\n")}`; + } else { + const preview = formatWorkspaceEdit(result, cwd); + output = `Rename preview:\n${preview.map((p) => ` ${p}`).join("\n")}`; + } + } + break; + } + + case "actions": { + if (!targetFile) { + return { + content: [{ type: "text", text: "Error: file parameter required for actions" }], + details: { action, serverName, success: false }, + }; + } + + await refreshFile(client, targetFile); + const diagnostics = await waitForDiagnostics(client, uri); + const endLine = (end_line ?? line ?? 1) - 1; + const endCharacter = (end_character ?? column ?? 1) - 1; + const range = { start: position, end: { line: endLine, character: endCharacter } }; + const relevantDiagnostics = diagnostics.filter( + (d) => d.range.start.line <= range.end.line && d.range.end.line >= range.start.line, + ); + + const codeActionContext: { diagnostics: Diagnostic[]; only?: string[] } = { + diagnostics: relevantDiagnostics, + }; + if (kind) { + codeActionContext.only = [kind]; + } + + const result = (await sendRequest(client, "textDocument/codeAction", { + textDocument: { uri }, + range, + context: codeActionContext, + })) as Array | null; + + if (!result || result.length === 0) { + output = "No code actions available"; + } else if (action_index !== undefined) { + // Apply specific action + if (action_index < 0 || action_index >= result.length) { + return { + content: [ + { + type: "text", + text: `Error: action_index ${action_index} out of range (0-${result.length - 1})`, + }, + ], + details: { action, serverName, success: false }, + }; + } + + const isCommand = (candidate: CodeAction | Command): candidate is Command => + typeof (candidate as Command).command === "string"; + const isCodeAction = (candidate: CodeAction | Command): candidate is CodeAction => + !isCommand(candidate); + const getCommandPayload = ( + candidate: CodeAction | Command, + ): { command: string; arguments?: unknown[] } | null => { + if (isCommand(candidate)) { + return { command: candidate.command, arguments: candidate.arguments }; + } + if (candidate.command) { + return { command: candidate.command.command, arguments: candidate.command.arguments }; + } + return null; + }; + + const codeAction = result[action_index]; + + // Resolve if needed + let resolvedAction = codeAction; + if ( + isCodeAction(codeAction) && + !codeAction.edit && + codeAction.data && + client.serverCapabilities?.codeActionProvider + ) { + const provider = client.serverCapabilities.codeActionProvider; + if (typeof provider === "object" && provider.resolveProvider) { + resolvedAction = (await sendRequest(client, "codeAction/resolve", codeAction)) as CodeAction; + } + } + + if (isCodeAction(resolvedAction) && resolvedAction.edit) { + const applied = await applyWorkspaceEdit(resolvedAction.edit, cwd); + output = `Applied "${codeAction.title}":\n${applied.map((a) => ` ${a}`).join("\n")}`; + } else { + const commandPayload = getCommandPayload(resolvedAction); + if (commandPayload) { + await sendRequest(client, "workspace/executeCommand", commandPayload); + output = `Executed "${codeAction.title}"`; + } else { + output = `Code action "${codeAction.title}" has no edits or command to apply`; + } + } + } else { + // List available actions + const lines = result.map((actionItem, i) => { + if ("kind" in actionItem || "isPreferred" in actionItem || "edit" in actionItem) { + const actionDetails = actionItem as CodeAction; + const preferred = actionDetails.isPreferred ? " (preferred)" : ""; + const kindInfo = actionDetails.kind ? ` [${actionDetails.kind}]` : ""; + return ` [${i}] ${actionDetails.title}${kindInfo}${preferred}`; + } + return ` [${i}] ${actionItem.title}`; + }); + output = `Available code actions:\n${lines.join("\n")}\n\nUse action_index parameter to apply a specific action.`; + } + break; + } + + // ===================================================================== + // Rust-Analyzer Specific Operations + // ===================================================================== + + case "flycheck": { + if (!hasCapability(serverConfig, "flycheck")) { + return { + content: [{ type: "text", text: "Error: flycheck requires rust-analyzer" }], + details: { action, serverName, success: false }, + }; + } + + await rustAnalyzer.flycheck(client, resolvedFile ?? undefined); + const collected: Array<{ filePath: string; diagnostic: Diagnostic }> = []; + for (const [diagUri, diags] of client.diagnostics.entries()) { + const relPath = path.relative(cwd, uriToFile(diagUri)); + for (const diag of diags) { + collected.push({ filePath: relPath, diagnostic: diag }); + } + } + + if (collected.length === 0) { + output = "Flycheck: no issues found"; + } else { + const summary = formatDiagnosticsSummary(collected.map((d) => d.diagnostic)); + const formatted = collected.slice(0, 20).map((d) => formatDiagnostic(d.diagnostic, d.filePath)); + const more = collected.length > 20 ? `\n ... and ${collected.length - 20} more` : ""; + output = `Flycheck ${summary}:\n${formatted.map((f) => ` ${f}`).join("\n")}${more}`; + } + break; + } + + case "expand_macro": { + if (!hasCapability(serverConfig, "expandMacro")) { + return { + content: [{ type: "text", text: "Error: expand_macro requires rust-analyzer" }], + details: { action, serverName, success: false }, + }; + } + + if (!targetFile) { + return { + content: [{ type: "text", text: "Error: file parameter required for expand_macro" }], + details: { action, serverName, success: false }, + }; + } + + const result = await rustAnalyzer.expandMacro(client, targetFile, line || 1, column || 1); + if (!result) { + output = "No macro expansion at this position"; + } else { + output = `Macro: ${result.name}\n\nExpansion:\n${result.expansion}`; + } + break; + } + + case "ssr": { + if (!hasCapability(serverConfig, "ssr")) { + return { + content: [{ type: "text", text: "Error: ssr requires rust-analyzer" }], + details: { action, serverName, success: false }, + }; + } + + if (!query) { + return { + content: [{ type: "text", text: "Error: query parameter (pattern) required for ssr" }], + details: { action, serverName, success: false }, + }; + } + + if (!replacement) { + return { + content: [{ type: "text", text: "Error: replacement parameter required for ssr" }], + details: { action, serverName, success: false }, + }; + } + + const shouldApply = apply === true; + const result = await rustAnalyzer.ssr(client, query, replacement, !shouldApply); + + if (shouldApply) { + const applied = await applyWorkspaceEdit(result, cwd); + output = + applied.length > 0 + ? `Applied SSR:\n${applied.map((a) => ` ${a}`).join("\n")}` + : "SSR: no matches found"; + } else { + const preview = formatWorkspaceEdit(result, cwd); + output = + preview.length > 0 + ? `SSR preview:\n${preview.map((p) => ` ${p}`).join("\n")}` + : "SSR: no matches found"; + } + break; + } + + case "runnables": { + if (!hasCapability(serverConfig, "runnables")) { + return { + content: [{ type: "text", text: "Error: runnables requires rust-analyzer" }], + details: { action, serverName, success: false }, + }; + } + + if (!targetFile) { + return { + content: [{ type: "text", text: "Error: file parameter required for runnables" }], + details: { action, serverName, success: false }, + }; + } + + const result = await rustAnalyzer.runnables(client, targetFile, line); + if (result.length === 0) { + output = "No runnables found"; + } else { + const lines = result.map((r) => { + const args = r.args?.cargoArgs?.join(" ") || ""; + return ` [${r.kind}] ${r.label}${args ? ` (cargo ${args})` : ""}`; + }); + output = `Found ${result.length} runnable(s):\n${lines.join("\n")}`; + } + break; + } + + case "related_tests": { + if (!hasCapability(serverConfig, "relatedTests")) { + return { + content: [{ type: "text", text: "Error: related_tests requires rust-analyzer" }], + details: { action, serverName, success: false }, + }; + } + + if (!targetFile) { + return { + content: [{ type: "text", text: "Error: file parameter required for related_tests" }], + details: { action, serverName, success: false }, + }; + } + + const result = await rustAnalyzer.relatedTests(client, targetFile, line || 1, column || 1); + if (result.length === 0) { + output = "No related tests found"; + } else { + output = `Found ${result.length} related test(s):\n${result.map((t) => ` ${t}`).join("\n")}`; + } + break; + } + + case "reload_workspace": { + await rustAnalyzer.reloadWorkspace(client); + output = "Workspace reloaded successfully"; + break; + } + + default: + output = `Unknown action: ${action}`; + } + + return { + content: [{ type: "text", text: output }], + details: { serverName, action, success: true }, + }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + return { + content: [{ type: "text", text: `LSP error: ${errorMessage}` }], + details: { serverName, action, success: false }, + }; + } + }, + }; +} + +export const lspTool = createLspTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/lsp/render.ts b/packages/coding-agent/src/core/tools/lsp/render.ts new file mode 100644 index 000000000..19f0f5156 --- /dev/null +++ b/packages/coding-agent/src/core/tools/lsp/render.ts @@ -0,0 +1,441 @@ +/** + * LSP Tool TUI Rendering + * + * Renders LSP tool calls and results in the TUI with: + * - Syntax-highlighted hover information + * - Color-coded diagnostics by severity + * - Grouped references and symbols + * - Collapsible/expandable views + */ + +import type { AgentToolResult, RenderResultOptions } from "@mariozechner/pi-agent-core"; +import { Text } from "@mariozechner/pi-tui"; +import { highlight, supportsLanguage } from "cli-highlight"; +import type { Theme } from "../../../modes/interactive/theme/theme.js"; +import type { LspParams, LspToolDetails } from "./types.js"; + +// ============================================================================= +// Tree Drawing Characters +// ============================================================================= + +const TREE_MID = "├─"; +const TREE_END = "└─"; +const TREE_PIPE = "│"; + +// ============================================================================= +// Call Rendering +// ============================================================================= + +/** + * Render the LSP tool call in the TUI. + * Shows: "lsp " + */ +export function renderCall(args: unknown, theme: Theme): Text { + const p = args as LspParams & { file?: string; files?: string[] }; + + let text = theme.fg("toolTitle", theme.bold("LSP ")); + text += theme.fg("accent", p.action || "?"); + + if (p.file) { + text += ` ${theme.fg("muted", p.file)}`; + } else if (p.files?.length) { + text += ` ${theme.fg("muted", `${p.files.length} file(s)`)}`; + } + + return new Text(text, 0, 0); +} + +// ============================================================================= +// Result Rendering +// ============================================================================= + +/** + * Render LSP tool result with intelligent formatting based on result type. + * Detects hover, diagnostics, references, symbols, etc. and formats accordingly. + */ +export function renderResult( + result: AgentToolResult, + options: RenderResultOptions, + theme: Theme, +): Text { + const content = result.content?.[0]; + if (!content || content.type !== "text" || !("text" in content) || !content.text) { + return new Text(theme.fg("error", "No result"), 0, 0); + } + + const text = content.text; + const lines = text.split("\n").filter((l) => l.trim()); + const expanded = options.expanded; + + // Detect result type and render accordingly + const codeBlockMatch = text.match(/```(\w*)\n([\s\S]*?)```/); + if (codeBlockMatch) { + return renderHover(codeBlockMatch, text, lines, expanded, theme); + } + + const errorMatch = text.match(/(\d+)\s+error\(s\)/); + const warningMatch = text.match(/(\d+)\s+warning\(s\)/); + if (errorMatch || warningMatch || text.includes("✗")) { + return renderDiagnostics(errorMatch, warningMatch, lines, expanded, theme); + } + + const refMatch = text.match(/(\d+)\s+reference\(s\)/); + if (refMatch) { + return renderReferences(refMatch, lines, expanded, theme); + } + + const symbolsMatch = text.match(/Symbols in (.+):/); + if (symbolsMatch) { + return renderSymbols(symbolsMatch, lines, expanded, theme); + } + + // Default fallback rendering + return renderGeneric(text, lines, expanded, theme); +} + +// ============================================================================= +// Hover Rendering +// ============================================================================= + +/** + * Render hover information with syntax-highlighted code blocks. + */ +function renderHover( + codeBlockMatch: RegExpMatchArray, + fullText: string, + _lines: string[], + expanded: boolean, + theme: Theme, +): Text { + const lang = codeBlockMatch[1] || ""; + const code = codeBlockMatch[2].trim(); + const afterCode = fullText.slice(fullText.indexOf("```", 3) + 3).trim(); + + const codeLines = highlightCode(code, lang, theme); + const icon = theme.fg("accent", "●"); + const langLabel = lang ? theme.fg("mdCodeBlockBorder", ` ${lang}`) : ""; + + if (expanded) { + let output = `${icon} ${theme.fg("toolTitle", "Hover")}${langLabel}`; + output += `\n ${theme.fg("mdCodeBlockBorder", "┌───")}`; + for (const line of codeLines) { + output += `\n ${theme.fg("mdCodeBlockBorder", "│")} ${line}`; + } + output += `\n ${theme.fg("mdCodeBlockBorder", "└───")}`; + if (afterCode) { + output += `\n ${theme.fg("muted", afterCode)}`; + } + return new Text(output, 0, 0); + } + + // Collapsed view + const firstCodeLine = codeLines[0] || ""; + const expandHint = theme.fg("dim", " (Ctrl+O to expand)"); + + let output = `${icon} ${theme.fg("toolTitle", "Hover")}${langLabel}${expandHint}`; + output += `\n ${theme.fg("mdCodeBlockBorder", "│")} ${firstCodeLine}`; + + if (codeLines.length > 1) { + output += `\n ${theme.fg("mdCodeBlockBorder", "│")} ${theme.fg("muted", `… ${codeLines.length - 1} more lines`)}`; + } + + if (afterCode) { + const docPreview = afterCode.length > 60 ? `${afterCode.slice(0, 60)}…` : afterCode; + output += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", docPreview)}`; + } else { + output += `\n ${theme.fg("mdCodeBlockBorder", "└───")}`; + } + + return new Text(output, 0, 0); +} + +/** + * Syntax highlight code using highlight.ts. + */ +function highlightCode(codeText: string, language: string, theme: Theme): string[] { + const validLang = language && supportsLanguage(language) ? language : undefined; + try { + const cliTheme = { + keyword: (s: string) => theme.fg("syntaxKeyword", s), + built_in: (s: string) => theme.fg("syntaxType", s), + literal: (s: string) => theme.fg("syntaxNumber", s), + number: (s: string) => theme.fg("syntaxNumber", s), + string: (s: string) => theme.fg("syntaxString", s), + comment: (s: string) => theme.fg("syntaxComment", s), + function: (s: string) => theme.fg("syntaxFunction", s), + title: (s: string) => theme.fg("syntaxFunction", s), + class: (s: string) => theme.fg("syntaxType", s), + type: (s: string) => theme.fg("syntaxType", s), + attr: (s: string) => theme.fg("syntaxVariable", s), + variable: (s: string) => theme.fg("syntaxVariable", s), + params: (s: string) => theme.fg("syntaxVariable", s), + operator: (s: string) => theme.fg("syntaxOperator", s), + punctuation: (s: string) => theme.fg("syntaxPunctuation", s), + }; + return highlight(codeText, { language: validLang, ignoreIllegals: true, theme: cliTheme }).split("\n"); + } catch { + return codeText.split("\n"); + } +} + +// ============================================================================= +// Diagnostics Rendering +// ============================================================================= + +/** + * Render diagnostics with color-coded severity. + */ +function renderDiagnostics( + errorMatch: RegExpMatchArray | null, + warningMatch: RegExpMatchArray | null, + lines: string[], + expanded: boolean, + theme: Theme, +): Text { + const errorCount = errorMatch ? Number.parseInt(errorMatch[1], 10) : 0; + const warnCount = warningMatch ? Number.parseInt(warningMatch[1], 10) : 0; + + const icon = + errorCount > 0 ? theme.fg("error", "●") : warnCount > 0 ? theme.fg("warning", "●") : theme.fg("success", "●"); + + const meta: string[] = []; + if (errorCount > 0) meta.push(`${errorCount} error${errorCount !== 1 ? "s" : ""}`); + if (warnCount > 0) meta.push(`${warnCount} warning${warnCount !== 1 ? "s" : ""}`); + if (meta.length === 0) meta.push("No issues"); + + const diagLines = lines.filter((l) => l.includes("✗") || /:\d+:\d+/.test(l)); + + if (expanded) { + let output = `${icon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", meta.join(", "))}`; + for (let i = 0; i < diagLines.length; i++) { + const isLast = i === diagLines.length - 1; + const branch = isLast ? TREE_END : TREE_MID; + const line = diagLines[i].trim(); + const color = line.includes("[error]") ? "error" : line.includes("[warning]") ? "warning" : "dim"; + output += `\n ${theme.fg("dim", branch)} ${theme.fg(color, line)}`; + } + return new Text(output, 0, 0); + } + + // Collapsed view + const expandHint = theme.fg("dim", " (Ctrl+O to expand)"); + let output = `${icon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", meta.join(", "))}${expandHint}`; + + const previewLines = diagLines.length > 0 ? diagLines.slice(0, 4) : lines.slice(0, 4); + for (let i = 0; i < previewLines.length; i++) { + const isLast = i === previewLines.length - 1 && diagLines.length <= 4; + const branch = isLast ? TREE_END : TREE_MID; + output += `\n ${theme.fg("dim", branch)} ${previewLines[i].trim()}`; + } + if (diagLines.length > 4) { + output += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${diagLines.length - 4} more`)}`; + } + + return new Text(output, 0, 0); +} + +// ============================================================================= +// References Rendering +// ============================================================================= + +/** + * Render references grouped by file. + */ +function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): Text { + const refCount = Number.parseInt(refMatch[1], 10); + const icon = refCount > 0 ? theme.fg("success", "●") : theme.fg("warning", "●"); + + const locLines = lines.filter((l) => /^\s*\S+:\d+:\d+/.test(l)); + + // Group by file + const byFile = new Map>(); + for (const loc of locLines) { + const match = loc.trim().match(/^(.+):(\d+):(\d+)$/); + if (match) { + const [, file, line, col] = match; + if (!byFile.has(file)) byFile.set(file, []); + byFile.get(file)!.push([line, col]); + } + } + + const files = Array.from(byFile.keys()); + + const renderGrouped = (maxFiles: number, maxLocsPerFile: number, showHint: boolean): string => { + const expandHint = showHint ? theme.fg("dim", " (Ctrl+O to expand)") : ""; + let output = `${icon} ${theme.fg("toolTitle", "References")} ${theme.fg("dim", `${refCount} found`)}${expandHint}`; + + const filesToShow = files.slice(0, maxFiles); + for (let fi = 0; fi < filesToShow.length; fi++) { + const file = filesToShow[fi]; + const locs = byFile.get(file)!; + const isLastFile = fi === filesToShow.length - 1 && files.length <= maxFiles; + const fileBranch = isLastFile ? TREE_END : TREE_MID; + const fileCont = isLastFile ? " " : `${TREE_PIPE} `; + + if (locs.length === 1) { + output += `\n ${theme.fg("dim", fileBranch)} ${theme.fg("accent", file)}:${theme.fg("muted", `${locs[0][0]}:${locs[0][1]}`)}`; + } else { + output += `\n ${theme.fg("dim", fileBranch)} ${theme.fg("accent", file)}`; + + const locsToShow = locs.slice(0, maxLocsPerFile); + const locStrs = locsToShow.map(([l, c]) => `${l}:${c}`); + const locsText = locStrs.join(", "); + const hasMore = locs.length > maxLocsPerFile; + + output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", TREE_END)} ${theme.fg("muted", locsText)}`; + if (hasMore) { + output += theme.fg("dim", ` … +${locs.length - maxLocsPerFile} more`); + } + } + } + + if (files.length > maxFiles) { + output += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${files.length - maxFiles} more files`)}`; + } + + return output; + }; + + if (expanded) { + return new Text(renderGrouped(files.length, 30, false), 0, 0); + } + + return new Text(renderGrouped(4, 10, true), 0, 0); +} + +// ============================================================================= +// Symbols Rendering +// ============================================================================= + +/** + * Render document symbols in a hierarchical tree. + */ +function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): Text { + const fileName = symbolsMatch[1]; + const icon = theme.fg("accent", "●"); + + interface SymbolInfo { + name: string; + line: string; + indent: number; + } + + const symbolLines = lines.filter((l) => l.includes("@") && l.includes("line")); + const symbols: SymbolInfo[] = []; + + for (const line of symbolLines) { + const indent = line.match(/^(\s*)/)?.[1].length ?? 0; + const symMatch = line.trim().match(/^(.+?)\s*@\s*line\s*(\d+)/); + if (symMatch) { + symbols.push({ name: symMatch[1], line: symMatch[2], indent }); + } + } + + const isLastSibling = (i: number): boolean => { + const myIndent = symbols[i].indent; + for (let j = i + 1; j < symbols.length; j++) { + const nextIndent = symbols[j].indent; + if (nextIndent === myIndent) return false; + if (nextIndent < myIndent) return true; + } + return true; + }; + + const getPrefix = (i: number): string => { + const myIndent = symbols[i].indent; + if (myIndent === 0) return " "; + + let prefix = " "; + for (let level = 2; level <= myIndent; level += 2) { + let ancestorIdx = -1; + for (let j = i - 1; j >= 0; j--) { + if (symbols[j].indent === level - 2) { + ancestorIdx = j; + break; + } + } + if (ancestorIdx >= 0 && isLastSibling(ancestorIdx)) { + prefix += " "; + } else { + prefix += `${TREE_PIPE} `; + } + } + return prefix; + }; + + const topLevelCount = symbols.filter((s) => s.indent === 0).length; + + if (expanded) { + let output = `${icon} ${theme.fg("toolTitle", "Symbols")} ${theme.fg("dim", `in ${fileName}`)}`; + + for (let i = 0; i < symbols.length; i++) { + const sym = symbols[i]; + const prefix = getPrefix(i); + const branch = isLastSibling(i) ? TREE_END : TREE_MID; + output += `\n${prefix}${theme.fg("dim", branch)} ${theme.fg("accent", sym.name)} ${theme.fg("muted", `@${sym.line}`)}`; + } + return new Text(output, 0, 0); + } + + // Collapsed: show first 4 top-level symbols + const expandHint = theme.fg("dim", " (Ctrl+O to expand)"); + let output = `${icon} ${theme.fg("toolTitle", "Symbols")} ${theme.fg("dim", `in ${fileName}`)}${expandHint}`; + + const topLevel = symbols.filter((s) => s.indent === 0).slice(0, 4); + for (let i = 0; i < topLevel.length; i++) { + const sym = topLevel[i]; + const isLast = i === topLevel.length - 1 && topLevelCount <= 4; + const branch = isLast ? TREE_END : TREE_MID; + output += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", sym.name)} ${theme.fg("muted", `@${sym.line}`)}`; + } + if (topLevelCount > 4) { + output += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${topLevelCount - 4} more`)}`; + } + + return new Text(output, 0, 0); +} + +// ============================================================================= +// Generic Rendering +// ============================================================================= + +/** + * Generic fallback rendering for unknown result types. + */ +function renderGeneric(text: string, lines: string[], expanded: boolean, theme: Theme): Text { + const hasError = text.includes("Error:") || text.includes("✗"); + const hasSuccess = text.includes("✓") || text.includes("Applied"); + + const icon = + hasError && !hasSuccess + ? theme.fg("error", "●") + : hasSuccess && !hasError + ? theme.fg("success", "●") + : theme.fg("accent", "●"); + + if (expanded) { + let output = `${icon} ${theme.fg("toolTitle", "LSP")}`; + for (const line of lines) { + output += `\n ${line}`; + } + return new Text(output, 0, 0); + } + + const firstLine = lines[0] || "No output"; + const expandHint = lines.length > 1 ? theme.fg("dim", " (Ctrl+O to expand)") : ""; + let output = `${icon} ${theme.fg("toolTitle", "LSP")} ${theme.fg("dim", firstLine.slice(0, 60))}${expandHint}`; + + if (lines.length > 1) { + const previewLines = lines.slice(1, 4); + for (let i = 0; i < previewLines.length; i++) { + const isLast = i === previewLines.length - 1 && lines.length <= 4; + const branch = isLast ? TREE_END : TREE_MID; + output += `\n ${theme.fg("dim", branch)} ${theme.fg("dim", previewLines[i].trim().slice(0, 80))}`; + } + if (lines.length > 4) { + output += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${lines.length - 4} more lines`)}`; + } + } + + return new Text(output, 0, 0); +} diff --git a/packages/coding-agent/src/core/tools/lsp/rust-analyzer.ts b/packages/coding-agent/src/core/tools/lsp/rust-analyzer.ts new file mode 100644 index 000000000..99854cc64 --- /dev/null +++ b/packages/coding-agent/src/core/tools/lsp/rust-analyzer.ts @@ -0,0 +1,145 @@ +import { sendNotification, sendRequest } from "./client.js"; +import type { Diagnostic, ExpandMacroResult, LspClient, RelatedTest, Runnable, WorkspaceEdit } from "./types.js"; +import { fileToUri } from "./utils.js"; + +/** + * Wait for specified milliseconds. + */ +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Run flycheck (cargo check) and collect diagnostics. + * Sends rust-analyzer/runFlycheck notification and waits for diagnostics to accumulate. + * + * @param client - LSP client instance + * @param file - Optional file path to check (if not provided, checks entire workspace) + * @returns Array of all collected diagnostics + */ +export async function flycheck(client: LspClient, file?: string): Promise { + const textDocument = file ? { uri: fileToUri(file) } : null; + await sendNotification(client, "rust-analyzer/runFlycheck", { textDocument }); + + // Wait for diagnostics to accumulate (2 seconds as per reference) + await sleep(2000); + + // Collect all diagnostics from client + const allDiags: Diagnostic[] = []; + for (const diags of Array.from(client.diagnostics.values())) { + allDiags.push(...diags); + } + + return allDiags; +} + +/** + * Expand macro at the given position. + * + * @param client - LSP client instance + * @param file - File path containing the macro + * @param line - 1-based line number + * @param character - 1-based character offset + * @returns ExpandMacroResult with macro name and expansion, or null if no macro at position + */ +export async function expandMacro( + client: LspClient, + file: string, + line: number, + character: number, +): Promise { + const result = (await sendRequest(client, "rust-analyzer/expandMacro", { + textDocument: { uri: fileToUri(file) }, + position: { line: line - 1, character: character - 1 }, + })) as ExpandMacroResult | null; + + return result; +} + +/** + * Perform structural search and replace (SSR). + * + * @param client - LSP client instance + * @param pattern - Search pattern + * @param replacement - Replacement pattern + * @param parseOnly - If true, returns matches only; if false, returns WorkspaceEdit to apply + * @returns WorkspaceEdit containing matches or changes to apply + */ +export async function ssr( + client: LspClient, + pattern: string, + replacement: string, + parseOnly = true, +): Promise { + const result = (await sendRequest(client, "experimental/ssr", { + query: `${pattern} ==>> ${replacement}`, + parseOnly, + textDocument: { uri: "" }, // SSR searches workspace-wide + position: { line: 0, character: 0 }, + selections: [], + })) as WorkspaceEdit; + + return result; +} + +/** + * Get runnables (tests, binaries, examples) for a file. + * + * @param client - LSP client instance + * @param file - File path to query + * @param line - Optional 1-based line number to get runnables at specific position + * @returns Array of Runnable items + */ +export async function runnables(client: LspClient, file: string, line?: number): Promise { + const params: { textDocument: { uri: string }; position?: { line: number; character: number } } = { + textDocument: { uri: fileToUri(file) }, + }; + + if (line !== undefined) { + params.position = { line: line - 1, character: 0 }; + } + + const result = (await sendRequest(client, "experimental/runnables", params)) as Runnable[]; + return result ?? []; +} + +/** + * Get related tests for a position (e.g., tests for a function). + * + * @param client - LSP client instance + * @param file - File path + * @param line - 1-based line number + * @param character - 1-based character offset + * @returns Array of test runnable labels + */ +export async function relatedTests( + client: LspClient, + file: string, + line: number, + character: number, +): Promise { + const tests = (await sendRequest(client, "rust-analyzer/relatedTests", { + textDocument: { uri: fileToUri(file) }, + position: { line: line - 1, character: character - 1 }, + })) as RelatedTest[]; + + if (!tests?.length) return []; + + const labels: string[] = []; + for (const t of tests) { + if (t.runnable?.label) { + labels.push(t.runnable.label); + } + } + + return labels; +} + +/** + * Reload workspace (re-index Cargo projects). + * + * @param client - LSP client instance + */ +export async function reloadWorkspace(client: LspClient): Promise { + await sendRequest(client, "rust-analyzer/reloadWorkspace", null); +} diff --git a/packages/coding-agent/src/core/tools/lsp/types.ts b/packages/coding-agent/src/core/tools/lsp/types.ts new file mode 100644 index 000000000..c20863baf --- /dev/null +++ b/packages/coding-agent/src/core/tools/lsp/types.ts @@ -0,0 +1,436 @@ +import { type Static, Type } from "@sinclair/typebox"; +import type { Subprocess } from "bun"; + +// ============================================================================= +// Tool Schema +// ============================================================================= + +export const lspSchema = Type.Object({ + action: Type.Union( + [ + // Standard LSP operations + Type.Literal("diagnostics"), + Type.Literal("references"), + Type.Literal("definition"), + Type.Literal("hover"), + Type.Literal("symbols"), + Type.Literal("workspace_symbols"), + Type.Literal("rename"), + Type.Literal("actions"), + Type.Literal("status"), + // Rust-analyzer specific operations + Type.Literal("flycheck"), + Type.Literal("expand_macro"), + Type.Literal("ssr"), + Type.Literal("runnables"), + Type.Literal("related_tests"), + Type.Literal("reload_workspace"), + ], + { description: "LSP action to perform" }, + ), + files: Type.Optional(Type.Array(Type.String({ description: "File paths for diagnostics" }))), + file: Type.Optional(Type.String({ description: "File path for file-specific actions" })), + line: Type.Optional(Type.Number({ description: "1-based line number" })), + column: Type.Optional(Type.Number({ description: "1-based column number" })), + end_line: Type.Optional(Type.Number({ description: "1-based end line number for ranges" })), + end_character: Type.Optional(Type.Number({ description: "1-based end column number for ranges" })), + query: Type.Optional(Type.String({ description: "Search query for symbols/SSR pattern" })), + new_name: Type.Optional(Type.String({ description: "New name for rename action" })), + replacement: Type.Optional(Type.String({ description: "Replacement for SSR action" })), + kind: Type.Optional(Type.String({ description: "Code action kind filter (quickfix, refactor, source)" })), + apply: Type.Optional(Type.Boolean({ description: "Apply edits instead of preview (default: true)" })), + action_index: Type.Optional(Type.Number({ description: "Index of code action to apply" })), + include_declaration: Type.Optional( + Type.Boolean({ description: "Include declaration in references (default: true)" }), + ), +}); + +export type LspParams = Static; + +export interface LspToolDetails { + serverName?: string; + action: string; + success: boolean; +} + +// ============================================================================= +// Core LSP Protocol Types +// ============================================================================= + +export interface Position { + line: number; + character: number; +} + +export interface Range { + start: Position; + end: Position; +} + +export interface Location { + uri: string; + range: Range; +} + +export interface LocationLink { + originSelectionRange?: Range; + targetUri: string; + targetRange: Range; + targetSelectionRange: Range; +} + +// ============================================================================= +// Diagnostics +// ============================================================================= + +export type DiagnosticSeverity = 1 | 2 | 3 | 4; // error, warning, info, hint + +export interface DiagnosticRelatedInformation { + location: Location; + message: string; +} + +export interface Diagnostic { + range: Range; + severity?: DiagnosticSeverity; + code?: string | number; + codeDescription?: { href: string }; + source?: string; + message: string; + tags?: number[]; + relatedInformation?: DiagnosticRelatedInformation[]; + data?: unknown; +} + +// ============================================================================= +// Text Edits +// ============================================================================= + +export interface TextEdit { + range: Range; + newText: string; +} + +export interface AnnotatedTextEdit extends TextEdit { + annotationId?: string; +} + +export interface TextDocumentIdentifier { + uri: string; +} + +export interface VersionedTextDocumentIdentifier extends TextDocumentIdentifier { + version: number | null; +} + +export interface OptionalVersionedTextDocumentIdentifier extends TextDocumentIdentifier { + version?: number | null; +} + +export interface TextDocumentEdit { + textDocument: OptionalVersionedTextDocumentIdentifier; + edits: (TextEdit | AnnotatedTextEdit)[]; +} + +// ============================================================================= +// Resource Operations +// ============================================================================= + +export interface CreateFileOptions { + overwrite?: boolean; + ignoreIfExists?: boolean; +} + +export interface CreateFile { + kind: "create"; + uri: string; + options?: CreateFileOptions; +} + +export interface RenameFileOptions { + overwrite?: boolean; + ignoreIfExists?: boolean; +} + +export interface RenameFile { + kind: "rename"; + oldUri: string; + newUri: string; + options?: RenameFileOptions; +} + +export interface DeleteFileOptions { + recursive?: boolean; + ignoreIfNotExists?: boolean; +} + +export interface DeleteFile { + kind: "delete"; + uri: string; + options?: DeleteFileOptions; +} + +export type DocumentChange = TextDocumentEdit | CreateFile | RenameFile | DeleteFile; + +export interface WorkspaceEdit { + changes?: Record; + documentChanges?: DocumentChange[]; + changeAnnotations?: Record; +} + +// ============================================================================= +// Code Actions +// ============================================================================= + +export type CodeActionKind = + | "quickfix" + | "refactor" + | "refactor.extract" + | "refactor.inline" + | "refactor.rewrite" + | "source" + | "source.organizeImports" + | "source.fixAll" + | string; + +export interface Command { + title: string; + command: string; + arguments?: unknown[]; +} + +export interface CodeAction { + title: string; + kind?: CodeActionKind; + diagnostics?: Diagnostic[]; + isPreferred?: boolean; + disabled?: { reason: string }; + edit?: WorkspaceEdit; + command?: Command; + data?: unknown; +} + +export interface CodeActionContext { + diagnostics: Diagnostic[]; + only?: CodeActionKind[]; + triggerKind?: 1 | 2; // Invoked = 1, Automatic = 2 +} + +// ============================================================================= +// Symbols +// ============================================================================= + +export type SymbolKind = + | 1 // File + | 2 // Module + | 3 // Namespace + | 4 // Package + | 5 // Class + | 6 // Method + | 7 // Property + | 8 // Field + | 9 // Constructor + | 10 // Enum + | 11 // Interface + | 12 // Function + | 13 // Variable + | 14 // Constant + | 15 // String + | 16 // Number + | 17 // Boolean + | 18 // Array + | 19 // Object + | 20 // Key + | 21 // Null + | 22 // EnumMember + | 23 // Struct + | 24 // Event + | 25 // Operator + | 26; // TypeParameter + +export const SYMBOL_KIND_NAMES: Record = { + 1: "File", + 2: "Module", + 3: "Namespace", + 4: "Package", + 5: "Class", + 6: "Method", + 7: "Property", + 8: "Field", + 9: "Constructor", + 10: "Enum", + 11: "Interface", + 12: "Function", + 13: "Variable", + 14: "Constant", + 15: "String", + 16: "Number", + 17: "Boolean", + 18: "Array", + 19: "Object", + 20: "Key", + 21: "Null", + 22: "EnumMember", + 23: "Struct", + 24: "Event", + 25: "Operator", + 26: "TypeParameter", +}; + +export interface DocumentSymbol { + name: string; + detail?: string; + kind: SymbolKind; + tags?: number[]; + deprecated?: boolean; + range: Range; + selectionRange: Range; + children?: DocumentSymbol[]; +} + +export interface SymbolInformation { + name: string; + kind: SymbolKind; + tags?: number[]; + deprecated?: boolean; + location: Location; + containerName?: string; +} + +// ============================================================================= +// Hover +// ============================================================================= + +export interface MarkupContent { + kind: "plaintext" | "markdown"; + value: string; +} + +export type MarkedString = string | { language: string; value: string }; + +export interface Hover { + contents: MarkupContent | MarkedString | MarkedString[]; + range?: Range; +} + +// ============================================================================= +// Server Configuration +// ============================================================================= + +export interface ServerCapabilities { + flycheck?: boolean; + ssr?: boolean; + expandMacro?: boolean; + runnables?: boolean; + relatedTests?: boolean; +} + +export interface ServerConfig { + command: string; + args?: string[]; + fileTypes: string[]; + rootMarkers: string[]; + initOptions?: Record; + settings?: Record; + disabled?: boolean; + capabilities?: ServerCapabilities; +} + +// ============================================================================= +// Client State +// ============================================================================= + +export interface OpenFile { + version: number; + languageId: string; +} + +export interface PendingRequest { + resolve: (result: unknown) => void; + reject: (error: Error) => void; + method: string; +} + +export interface LspServerCapabilities { + renameProvider?: boolean | { prepareProvider?: boolean }; + codeActionProvider?: boolean | { resolveProvider?: boolean }; + hoverProvider?: boolean; + definitionProvider?: boolean; + referencesProvider?: boolean; + documentSymbolProvider?: boolean; + workspaceSymbolProvider?: boolean; + [key: string]: unknown; +} + +export interface LspClient { + name: string; + cwd: string; + config: ServerConfig; + process: Subprocess; + requestId: number; + diagnostics: Map; + openFiles: Map; + pendingRequests: Map; + messageBuffer: Uint8Array; + isReading: boolean; + serverCapabilities?: LspServerCapabilities; + lastActivity: number; +} + +// ============================================================================= +// Rust-analyzer Specific Types +// ============================================================================= + +export interface ExpandMacroResult { + name: string; + expansion: string; +} + +export interface Runnable { + label: string; + kind: string; + args?: { + workspaceRoot?: string; + cargoArgs?: string[]; + cargoExtraArgs?: string[]; + executableArgs?: string[]; + }; + location?: { + targetUri: string; + targetRange?: Range; + targetSelectionRange?: Range; + }; +} + +export interface RelatedTest { + runnable?: { + label: string; + kind: string; + args?: Runnable["args"]; + location?: Runnable["location"]; + }; +} + +// ============================================================================= +// JSON-RPC Protocol Types +// ============================================================================= + +export interface LspJsonRpcRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params: unknown; +} + +export interface LspJsonRpcResponse { + jsonrpc: "2.0"; + id?: number; + result?: unknown; + error?: { code: number; message: string; data?: unknown }; +} + +export interface LspJsonRpcNotification { + jsonrpc: "2.0"; + method: string; + params?: unknown; +} diff --git a/packages/coding-agent/src/core/tools/lsp/utils.ts b/packages/coding-agent/src/core/tools/lsp/utils.ts new file mode 100644 index 000000000..b6b672b12 --- /dev/null +++ b/packages/coding-agent/src/core/tools/lsp/utils.ts @@ -0,0 +1,486 @@ +import path from "node:path"; +import type { + Diagnostic, + DiagnosticSeverity, + DocumentSymbol, + Location, + SymbolInformation, + SymbolKind, + TextEdit, + WorkspaceEdit, +} from "./types.js"; + +// ============================================================================= +// Language Detection +// ============================================================================= + +const LANGUAGE_MAP: Record = { + // TypeScript/JavaScript + ".ts": "typescript", + ".tsx": "typescriptreact", + ".js": "javascript", + ".jsx": "javascriptreact", + ".mjs": "javascript", + ".cjs": "javascript", + ".mts": "typescript", + ".cts": "typescript", + + // Systems languages + ".rs": "rust", + ".go": "go", + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".hpp": "cpp", + ".hxx": "cpp", + ".zig": "zig", + + // Scripting languages + ".py": "python", + ".rb": "ruby", + ".lua": "lua", + ".sh": "shellscript", + ".bash": "shellscript", + ".zsh": "shellscript", + ".fish": "fish", + ".pl": "perl", + ".php": "php", + + // JVM languages + ".java": "java", + ".kt": "kotlin", + ".kts": "kotlin", + ".scala": "scala", + ".groovy": "groovy", + ".clj": "clojure", + + // .NET languages + ".cs": "csharp", + ".fs": "fsharp", + ".vb": "vb", + + // Web + ".html": "html", + ".htm": "html", + ".css": "css", + ".scss": "scss", + ".sass": "sass", + ".less": "less", + ".vue": "vue", + ".svelte": "svelte", + + // Data formats + ".json": "json", + ".jsonc": "jsonc", + ".yaml": "yaml", + ".yml": "yaml", + ".toml": "toml", + ".xml": "xml", + ".ini": "ini", + + // Documentation + ".md": "markdown", + ".markdown": "markdown", + ".rst": "restructuredtext", + ".adoc": "asciidoc", + ".tex": "latex", + + // Other + ".sql": "sql", + ".graphql": "graphql", + ".gql": "graphql", + ".proto": "protobuf", + ".dockerfile": "dockerfile", + ".tf": "terraform", + ".hcl": "hcl", + ".nix": "nix", + ".ex": "elixir", + ".exs": "elixir", + ".erl": "erlang", + ".hrl": "erlang", + ".hs": "haskell", + ".ml": "ocaml", + ".mli": "ocaml", + ".swift": "swift", + ".r": "r", + ".R": "r", + ".jl": "julia", + ".dart": "dart", + ".elm": "elm", + ".v": "v", + ".nim": "nim", + ".cr": "crystal", + ".d": "d", + ".pas": "pascal", + ".pp": "pascal", + ".lisp": "lisp", + ".lsp": "lisp", + ".rkt": "racket", + ".scm": "scheme", + ".ps1": "powershell", + ".psm1": "powershell", + ".bat": "bat", + ".cmd": "bat", +}; + +/** + * Detect language ID from file path. + * Returns the LSP language identifier for the file type. + */ +export function detectLanguageId(filePath: string): string { + const ext = path.extname(filePath).toLowerCase(); + const basename = path.basename(filePath).toLowerCase(); + + // Handle special filenames + if (basename === "dockerfile" || basename.startsWith("dockerfile.")) { + return "dockerfile"; + } + if (basename === "makefile" || basename === "gnumakefile") { + return "makefile"; + } + if (basename === "cmakelists.txt" || ext === ".cmake") { + return "cmake"; + } + + return LANGUAGE_MAP[ext] ?? "plaintext"; +} + +// ============================================================================= +// URI Handling (Cross-Platform) +// ============================================================================= + +/** + * Convert a file path to a file:// URI. + * Handles Windows drive letters correctly. + */ +export function fileToUri(filePath: string): string { + const resolved = path.resolve(filePath); + + if (process.platform === "win32") { + // Windows: file:///C:/path/to/file + return `file:///${resolved.replace(/\\/g, "/")}`; + } + + // Unix: file:///path/to/file + return `file://${resolved}`; +} + +/** + * Convert a file:// URI to a file path. + * Handles Windows drive letters correctly. + */ +export function uriToFile(uri: string): string { + if (!uri.startsWith("file://")) { + return uri; + } + + let filePath = decodeURIComponent(uri.slice(7)); + + // Windows: file:///C:/path → C:/path (strip leading slash before drive letter) + if (process.platform === "win32" && filePath.startsWith("/") && /^[A-Za-z]:/.test(filePath.slice(1))) { + filePath = filePath.slice(1); + } + + return filePath; +} + +// ============================================================================= +// Diagnostic Formatting +// ============================================================================= + +const SEVERITY_NAMES: Record = { + 1: "error", + 2: "warning", + 3: "info", + 4: "hint", +}; + +const SEVERITY_ICONS: Record = { + 1: "✖", + 2: "⚠", + 3: "ℹ", + 4: "💡", +}; + +/** + * Convert diagnostic severity number to string name. + */ +export function severityToString(severity?: DiagnosticSeverity): string { + return SEVERITY_NAMES[severity ?? 1] ?? "unknown"; +} + +/** + * Get icon for diagnostic severity. + */ +export function severityToIcon(severity?: DiagnosticSeverity): string { + return SEVERITY_ICONS[severity ?? 1] ?? "?"; +} + +/** + * Format a diagnostic as a human-readable string. + */ +export function formatDiagnostic(diagnostic: Diagnostic, filePath: string): string { + const severity = severityToString(diagnostic.severity); + const line = diagnostic.range.start.line + 1; + const col = diagnostic.range.start.character + 1; + const source = diagnostic.source ? `[${diagnostic.source}] ` : ""; + const code = diagnostic.code ? ` (${diagnostic.code})` : ""; + + return `${filePath}:${line}:${col} [${severity}] ${source}${diagnostic.message}${code}`; +} + +/** + * Format diagnostics grouped by severity. + */ +export function formatDiagnosticsSummary(diagnostics: Diagnostic[]): string { + const counts = { error: 0, warning: 0, info: 0, hint: 0 }; + + for (const d of diagnostics) { + const sev = severityToString(d.severity); + if (sev in counts) { + counts[sev as keyof typeof counts]++; + } + } + + const parts: string[] = []; + if (counts.error > 0) parts.push(`${counts.error} error(s)`); + if (counts.warning > 0) parts.push(`${counts.warning} warning(s)`); + if (counts.info > 0) parts.push(`${counts.info} info(s)`); + if (counts.hint > 0) parts.push(`${counts.hint} hint(s)`); + + return parts.length > 0 ? parts.join(", ") : "no issues"; +} + +// ============================================================================= +// Location Formatting +// ============================================================================= + +/** + * Format a location as file:line:col relative to cwd. + */ +export function formatLocation(location: Location, cwd: string): string { + const file = path.relative(cwd, uriToFile(location.uri)); + const line = location.range.start.line + 1; + const col = location.range.start.character + 1; + return `${file}:${line}:${col}`; +} + +/** + * Format a position as line:col. + */ +export function formatPosition(line: number, col: number): string { + return `${line}:${col}`; +} + +// ============================================================================= +// WorkspaceEdit Formatting +// ============================================================================= + +/** + * Format a workspace edit as a summary of changes. + */ +export function formatWorkspaceEdit(edit: WorkspaceEdit, cwd: string): string[] { + const results: string[] = []; + + // Handle changes map (legacy format) + if (edit.changes) { + for (const [uri, textEdits] of Object.entries(edit.changes)) { + const file = path.relative(cwd, uriToFile(uri)); + results.push(`${file}: ${textEdits.length} edit${textEdits.length > 1 ? "s" : ""}`); + } + } + + // Handle documentChanges array (modern format) + if (edit.documentChanges) { + for (const change of edit.documentChanges) { + if ("edits" in change && change.textDocument) { + const file = path.relative(cwd, uriToFile(change.textDocument.uri)); + results.push(`${file}: ${change.edits.length} edit${change.edits.length > 1 ? "s" : ""}`); + } else if ("kind" in change) { + switch (change.kind) { + case "create": + results.push(`CREATE: ${path.relative(cwd, uriToFile(change.uri))}`); + break; + case "rename": + results.push( + `RENAME: ${path.relative(cwd, uriToFile(change.oldUri))} → ${path.relative(cwd, uriToFile(change.newUri))}`, + ); + break; + case "delete": + results.push(`DELETE: ${path.relative(cwd, uriToFile(change.uri))}`); + break; + } + } + } + } + + return results; +} + +/** + * Format a text edit as a preview. + */ +export function formatTextEdit(edit: TextEdit, maxLength = 50): string { + const range = `${edit.range.start.line + 1}:${edit.range.start.character + 1}`; + const preview = + edit.newText.length > maxLength + ? `${edit.newText.slice(0, maxLength).replace(/\n/g, "\\n")}...` + : edit.newText.replace(/\n/g, "\\n"); + return `line ${range} → "${preview}"`; +} + +// ============================================================================= +// Symbol Formatting +// ============================================================================= + +const SYMBOL_KIND_ICONS: Partial> = { + 5: "○", // Class + 6: "ƒ", // Method + 11: "◇", // Interface + 12: "ƒ", // Function + 13: "◆", // Variable + 14: "◆", // Constant + 10: "◎", // Enum + 23: "□", // Struct + 2: "◫", // Module +}; + +/** + * Get icon for symbol kind. + */ +export function symbolKindToIcon(kind: SymbolKind): string { + return SYMBOL_KIND_ICONS[kind] ?? "•"; +} + +/** + * Get name for symbol kind. + */ +export function symbolKindToName(kind: SymbolKind): string { + const names: Record = { + 1: "File", + 2: "Module", + 3: "Namespace", + 4: "Package", + 5: "Class", + 6: "Method", + 7: "Property", + 8: "Field", + 9: "Constructor", + 10: "Enum", + 11: "Interface", + 12: "Function", + 13: "Variable", + 14: "Constant", + 15: "String", + 16: "Number", + 17: "Boolean", + 18: "Array", + 19: "Object", + 20: "Key", + 21: "Null", + 22: "EnumMember", + 23: "Struct", + 24: "Event", + 25: "Operator", + 26: "TypeParameter", + }; + return names[kind] ?? "Unknown"; +} + +/** + * Format a document symbol with optional hierarchy. + */ +export function formatDocumentSymbol(symbol: DocumentSymbol, indent = 0): string[] { + const prefix = " ".repeat(indent); + const icon = symbolKindToIcon(symbol.kind); + const line = symbol.range.start.line + 1; + const results = [`${prefix}${icon} ${symbol.name} @ line ${line}`]; + + if (symbol.children) { + for (const child of symbol.children) { + results.push(...formatDocumentSymbol(child, indent + 1)); + } + } + + return results; +} + +/** + * Format a symbol information (flat format). + */ +export function formatSymbolInformation(symbol: SymbolInformation, cwd: string): string { + const icon = symbolKindToIcon(symbol.kind); + const location = formatLocation(symbol.location, cwd); + const container = symbol.containerName ? ` (${symbol.containerName})` : ""; + return `${icon} ${symbol.name}${container} @ ${location}`; +} + +// ============================================================================= +// Hover Content Extraction +// ============================================================================= + +/** + * Extract plain text from hover contents. + */ +export function extractHoverText( + contents: string | { kind: string; value: string } | { language: string; value: string } | unknown[], +): string { + if (typeof contents === "string") { + return contents; + } + + if (Array.isArray(contents)) { + return contents.map((c) => extractHoverText(c as string | { kind: string; value: string })).join("\n\n"); + } + + if (typeof contents === "object" && contents !== null) { + if ("value" in contents && typeof contents.value === "string") { + return contents.value; + } + } + + return String(contents); +} + +// ============================================================================= +// General Utilities +// ============================================================================= + +/** + * Sleep for the specified number of milliseconds. + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Check if a command exists in PATH. + */ +export async function commandExists(command: string): Promise { + return Bun.which(command) !== null; +} + +/** + * Truncate a string to a maximum length with ellipsis. + */ +export function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) return str; + return `${str.slice(0, maxLength - 3)}...`; +} + +/** + * Group items by a key function. + */ +export function groupBy(items: T[], keyFn: (item: T) => K): Record { + const result = {} as Record; + for (const item of items) { + const key = keyFn(item); + if (!result[key]) { + result[key] = []; + } + result[key].push(item); + } + return result; +} diff --git a/packages/coding-agent/src/core/tools/notebook.ts b/packages/coding-agent/src/core/tools/notebook.ts new file mode 100644 index 000000000..e9f913b93 --- /dev/null +++ b/packages/coding-agent/src/core/tools/notebook.ts @@ -0,0 +1,229 @@ +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { Type } from "@sinclair/typebox"; +import { resolveToCwd } from "./path-utils.js"; + +const notebookSchema = Type.Object({ + action: Type.Union([Type.Literal("edit"), Type.Literal("insert"), Type.Literal("delete")], { + description: "Action to perform on the notebook cell", + }), + notebook_path: Type.String({ description: "Path to the .ipynb file (relative or absolute)" }), + cell_index: Type.Number({ description: "0-based index of the cell to operate on" }), + content: Type.Optional(Type.String({ description: "New cell content (required for edit/insert)" })), + cell_type: Type.Optional( + Type.Union([Type.Literal("code"), Type.Literal("markdown")], { + description: "Cell type for insert (default: code)", + }), + ), +}); + +export interface NotebookToolDetails { + /** Action performed */ + action: "edit" | "insert" | "delete"; + /** Cell index operated on */ + cellIndex: number; + /** Cell type */ + cellType?: string; + /** Total cell count after operation */ + totalCells: number; +} + +interface NotebookCell { + cell_type: "code" | "markdown" | "raw"; + source: string[]; + metadata: Record; + execution_count?: number | null; + outputs?: unknown[]; +} + +interface Notebook { + cells: NotebookCell[]; + metadata: Record; + nbformat: number; + nbformat_minor: number; +} + +function splitIntoLines(content: string): string[] { + return content.split("\n").map((line, i, arr) => (i < arr.length - 1 ? `${line}\n` : line)); +} + +export function createNotebookTool(cwd: string): AgentTool { + return { + name: "notebook", + label: "notebook", + description: + "Edit Jupyter notebook (.ipynb) cells. Actions: edit (replace cell content), insert (add new cell), delete (remove cell). Cell indices are 0-based.", + parameters: notebookSchema, + execute: async ( + _toolCallId: string, + { + action, + notebook_path, + cell_index, + content, + cell_type, + }: { action: string; notebook_path: string; cell_index: number; content?: string; cell_type?: string }, + signal?: AbortSignal, + ) => { + const absolutePath = resolveToCwd(notebook_path, cwd); + + return new Promise<{ + content: Array<{ type: "text"; text: string }>; + details: NotebookToolDetails | undefined; + }>((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Operation aborted")); + return; + } + + let aborted = false; + + const onAbort = () => { + aborted = true; + reject(new Error("Operation aborted")); + }; + + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + (async () => { + try { + // Check if file exists + const file = Bun.file(absolutePath); + if (!(await file.exists())) { + if (signal) signal.removeEventListener("abort", onAbort); + reject(new Error(`Notebook not found: ${notebook_path}`)); + return; + } + + if (aborted) return; + + // Read and parse notebook + let notebook: Notebook; + try { + notebook = await file.json(); + } catch { + if (signal) signal.removeEventListener("abort", onAbort); + reject(new Error(`Invalid JSON in notebook: ${notebook_path}`)); + return; + } + + if (aborted) return; + + // Validate notebook structure + if (!notebook.cells || !Array.isArray(notebook.cells)) { + if (signal) signal.removeEventListener("abort", onAbort); + reject(new Error(`Invalid notebook structure (missing cells array): ${notebook_path}`)); + return; + } + + const cellCount = notebook.cells.length; + + // Validate cell_index based on action + if (action === "insert") { + if (cell_index < 0 || cell_index > cellCount) { + if (signal) signal.removeEventListener("abort", onAbort); + reject( + new Error( + `Cell index ${cell_index} out of range for insert (0-${cellCount}) in ${notebook_path}`, + ), + ); + return; + } + } else { + if (cell_index < 0 || cell_index >= cellCount) { + if (signal) signal.removeEventListener("abort", onAbort); + reject( + new Error(`Cell index ${cell_index} out of range (0-${cellCount - 1}) in ${notebook_path}`), + ); + return; + } + } + + // Validate content for edit/insert + if ((action === "edit" || action === "insert") && content === undefined) { + if (signal) signal.removeEventListener("abort", onAbort); + reject(new Error(`Content is required for ${action} action`)); + return; + } + + if (aborted) return; + + // Perform the action + let resultMessage: string; + let finalCellType: string | undefined; + + switch (action) { + case "edit": { + const sourceLines = splitIntoLines(content!); + notebook.cells[cell_index].source = sourceLines; + finalCellType = notebook.cells[cell_index].cell_type; + resultMessage = `Replaced cell ${cell_index} (${finalCellType})`; + break; + } + case "insert": { + const sourceLines = splitIntoLines(content!); + const newCellType = (cell_type as "code" | "markdown") || "code"; + const newCell: NotebookCell = { + cell_type: newCellType, + source: sourceLines, + metadata: {}, + }; + if (newCellType === "code") { + newCell.execution_count = null; + newCell.outputs = []; + } + notebook.cells.splice(cell_index, 0, newCell); + finalCellType = newCellType; + resultMessage = `Inserted ${newCellType} cell at position ${cell_index}`; + break; + } + case "delete": { + finalCellType = notebook.cells[cell_index].cell_type; + notebook.cells.splice(cell_index, 1); + resultMessage = `Deleted cell ${cell_index} (${finalCellType})`; + break; + } + default: { + if (signal) signal.removeEventListener("abort", onAbort); + reject(new Error(`Invalid action: ${action}`)); + return; + } + } + + if (aborted) return; + + // Write back with single-space indentation + await Bun.write(absolutePath, JSON.stringify(notebook, null, 1)); + + if (aborted) return; + + if (signal) signal.removeEventListener("abort", onAbort); + + const newCellCount = notebook.cells.length; + resolve({ + content: [ + { + type: "text", + text: `${resultMessage}. Notebook now has ${newCellCount} cells.`, + }, + ], + details: { + action: action as "edit" | "insert" | "delete", + cellIndex: cell_index, + cellType: finalCellType, + totalCells: newCellCount, + }, + }); + } catch (error: any) { + if (signal) signal.removeEventListener("abort", onAbort); + if (!aborted) reject(error); + } + })(); + }); + }, + }; +} + +/** Default notebook tool using process.cwd() */ +export const notebookTool = createNotebookTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index da3a62b6f..46850a02f 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -1,12 +1,36 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; +import { spawnSync } from "child_process"; import { constants } from "fs"; import { access, readFile } from "fs/promises"; +import { extname } from "path"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js"; import { resolveReadPath } from "./path-utils.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; +// Document types convertible via markitdown +const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]); + +function convertWithMarkitdown(filePath: string): { content: string; ok: boolean; error?: string } { + const cmd = Bun.which("markitdown"); + if (!cmd) { + return { content: "", ok: false, error: "markitdown not found" }; + } + + const result = spawnSync(cmd, [filePath], { + encoding: "utf-8", + timeout: 60000, + maxBuffer: 50 * 1024 * 1024, + }); + + if (result.status === 0 && result.stdout && result.stdout.length > 0) { + return { content: result.stdout, ok: true }; + } + + return { content: "", ok: false, error: result.stderr || "Conversion failed" }; +} + const readSchema = Type.Object({ path: Type.String({ description: "Path to the file to read (relative or absolute)" }), offset: Type.Optional(Type.Number({ description: "Line number to start reading from (1-indexed)" })), @@ -21,7 +45,10 @@ export function createReadTool(cwd: string): AgentTool { return { name: "read", label: "read", - description: `Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`, + description: `Read the contents of a file. Supports: +- Text files (truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB, use offset/limit for large files) +- Images (jpg, png, gif, webp) - sent as attachments +- Documents (pdf, docx, pptx, xlsx, epub, rtf) - converted to markdown via markitdown if available`, parameters: readSchema, execute: async ( _toolCallId: string, @@ -62,6 +89,7 @@ export function createReadTool(cwd: string): AgentTool { } const mimeType = await detectSupportedImageMimeTypeFromFile(absolutePath); + const ext = extname(absolutePath).toLowerCase(); // Read the file based on type let content: (TextContent | ImageContent)[]; @@ -76,6 +104,28 @@ export function createReadTool(cwd: string): AgentTool { { type: "text", text: `Read image file [${mimeType}]` }, { type: "image", data: base64, mimeType }, ]; + } else if (CONVERTIBLE_EXTENSIONS.has(ext)) { + // Convert document via markitdown + const result = convertWithMarkitdown(absolutePath); + if (result.ok) { + // Apply truncation to converted content + const truncation = truncateHead(result.content); + let outputText = truncation.content; + + if (truncation.truncated) { + outputText += `\n\n[Document converted via markitdown. Output truncated to ${formatSize(DEFAULT_MAX_BYTES)}]`; + details = { truncation }; + } + + content = [{ type: "text", text: outputText }]; + } else { + // markitdown not available or failed + const errorMsg = + result.error === "markitdown not found" + ? `markitdown not installed. Install with: pip install markitdown` + : result.error || "conversion failed"; + content = [{ type: "text", text: `[Cannot read ${ext} file: ${errorMsg}]` }]; + } } else { // Read as text const textContent = await readFile(absolutePath, "utf-8"); diff --git a/packages/coding-agent/src/core/tools/renderers.ts b/packages/coding-agent/src/core/tools/renderers.ts new file mode 100644 index 000000000..2ab016b63 --- /dev/null +++ b/packages/coding-agent/src/core/tools/renderers.ts @@ -0,0 +1,717 @@ +/** + * TUI renderers for built-in tools. + * + * These provide rich visualization for tool calls and results in the TUI. + */ + +import type { Component } from "@mariozechner/pi-tui"; +import { Text } from "@mariozechner/pi-tui"; +import type { Theme } from "../../modes/interactive/theme/theme.js"; +import type { RenderResultOptions } from "../custom-tools/types.js"; +import type { AskToolDetails } from "./ask.js"; +import type { AstToolDetails } from "./ast.js"; +import type { FindToolDetails } from "./find.js"; +import type { GrepToolDetails } from "./grep.js"; +import type { LsToolDetails } from "./ls.js"; +import { renderCall as renderLspCall, renderResult as renderLspResult } from "./lsp/render.js"; +import type { LspToolDetails } from "./lsp/types.js"; +import type { NotebookToolDetails } from "./notebook.js"; +import type { ReplaceToolDetails } from "./replace.js"; +import { renderCall as renderTaskCall, renderResult as renderTaskResult } from "./task/render.js"; +import type { TaskToolDetails } from "./task/types.js"; +import { renderWebFetchCall, renderWebFetchResult, type WebFetchToolDetails } from "./web-fetch.js"; +import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./web-search/render.js"; + +// Tree drawing characters +const TREE_MID = "├─"; +const TREE_END = "└─"; + +// Icons +const ICON_SUCCESS = "●"; +const ICON_WARNING = "●"; +const ICON_ERROR = "●"; +const ICON_INFO = "●"; + +interface ToolRenderer { + renderCall(args: TArgs, theme: Theme): Component; + renderResult( + result: { content: Array<{ type: string; text?: string }>; details?: TDetails }, + options: RenderResultOptions, + theme: Theme, + ): Component; +} + +// ============================================================================ +// Grep Renderer +// ============================================================================ + +interface GrepArgs { + pattern: string; + path?: string; + glob?: string; + type?: string; + ignoreCase?: boolean; + caseSensitive?: boolean; + literal?: boolean; + multiline?: boolean; + context?: number; + limit?: number; + outputMode?: string; +} + +const grepRenderer: ToolRenderer = { + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("grep ")); + text += theme.fg("accent", args.pattern || "?"); + + const meta: string[] = []; + if (args.path) meta.push(args.path); + if (args.glob) meta.push(`glob:${args.glob}`); + if (args.type) meta.push(`type:${args.type}`); + if (args.outputMode && args.outputMode !== "files_with_matches") meta.push(args.outputMode); + if (args.caseSensitive) { + meta.push("--case-sensitive"); + } else if (args.ignoreCase) { + meta.push("-i"); + } + if (args.multiline) meta.push("multiline"); + + if (meta.length > 0) { + text += ` ${theme.fg("muted", meta.join(" "))}`; + } + + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details; + + // Error case + if (details?.error) { + return new Text(`${theme.fg("error", ICON_ERROR)} ${theme.fg("error", details.error)}`, 0, 0); + } + + // Check for detailed rendering data - fall back to raw output if not available + const hasDetailedData = details?.matchCount !== undefined || details?.fileCount !== undefined; + + if (!hasDetailedData) { + // Fall back to showing raw text content + const textContent = result.content?.find((c) => c.type === "text")?.text; + if (!textContent || textContent === "No matches found") { + return new Text(`${theme.fg("warning", ICON_WARNING)} ${theme.fg("muted", "No matches found")}`, 0, 0); + } + + // Show abbreviated output + const lines = textContent.split("\n"); + const maxLines = expanded ? lines.length : 10; + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + + let text = `${theme.fg("success", ICON_SUCCESS)} ${theme.fg("toolTitle", "grep")}`; + text += `\n${displayLines.map((l) => theme.fg("toolOutput", l)).join("\n")}`; + if (remaining > 0) { + text += `\n${theme.fg("muted", `... ${remaining} more lines`)}`; + } + return new Text(text, 0, 0); + } + + const matchCount = details?.matchCount ?? 0; + const fileCount = details?.fileCount ?? 0; + const mode = details?.mode ?? "files_with_matches"; + const truncated = details?.truncated ?? details?.truncation?.truncated ?? false; + const files = details?.files ?? []; + + // No matches + if (matchCount === 0) { + return new Text(`${theme.fg("warning", ICON_WARNING)} ${theme.fg("muted", "No matches found")}`, 0, 0); + } + + // Build summary + const icon = theme.fg("success", ICON_SUCCESS); + let summary: string; + if (mode === "files_with_matches") { + summary = `${fileCount} file${fileCount !== 1 ? "s" : ""}`; + } else if (mode === "count") { + summary = `${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`; + } else { + summary = `${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`; + } + + if (truncated) { + summary += theme.fg("warning", " (truncated)"); + } + + const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); + let text = `${icon} ${theme.fg("toolTitle", "grep")} ${theme.fg("dim", summary)}${expandHint}`; + + // Show file tree if we have files + if (files.length > 0) { + const maxFiles = expanded ? files.length : Math.min(files.length, 8); + for (let i = 0; i < maxFiles; i++) { + const isLast = i === maxFiles - 1 && (expanded || files.length <= 8); + const branch = isLast ? TREE_END : TREE_MID; + text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", files[i])}`; + } + + if (!expanded && files.length > 8) { + text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${files.length - 8} more files`)}`; + } + } + + return new Text(text, 0, 0); + }, +}; + +// ============================================================================ +// Find Renderer +// ============================================================================ + +interface FindArgs { + pattern: string; + path?: string; + type?: string; + hidden?: boolean; + sortByMtime?: boolean; + limit?: number; +} + +const findRenderer: ToolRenderer = { + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("find ")); + text += theme.fg("accent", args.pattern || "*"); + + const meta: string[] = []; + if (args.path) meta.push(args.path); + if (args.type && args.type !== "all") meta.push(`type:${args.type}`); + if (args.hidden) meta.push("--hidden"); + + if (meta.length > 0) { + text += ` ${theme.fg("muted", meta.join(" "))}`; + } + + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details; + + // Error case + if (details?.error) { + return new Text(`${theme.fg("error", ICON_ERROR)} ${theme.fg("error", details.error)}`, 0, 0); + } + + // Check for detailed rendering data - fall back to parsing raw output if not available + const hasDetailedData = details?.fileCount !== undefined; + + // Get text content for fallback or to extract file list + const textContent = result.content?.find((c) => c.type === "text")?.text; + + if (!hasDetailedData) { + if (!textContent || textContent.includes("No files matching") || textContent.trim() === "") { + return new Text(`${theme.fg("warning", ICON_WARNING)} ${theme.fg("muted", "No files found")}`, 0, 0); + } + + // Parse the raw output as file list + const lines = textContent.split("\n").filter((l) => l.trim()); + const maxLines = expanded ? lines.length : Math.min(lines.length, 8); + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + + let text = `${theme.fg("success", ICON_SUCCESS)} ${theme.fg("toolTitle", "find")} ${theme.fg("dim", `${lines.length} file${lines.length !== 1 ? "s" : ""}`)}`; + for (let i = 0; i < displayLines.length; i++) { + const isLast = i === displayLines.length - 1 && remaining === 0; + const branch = isLast ? TREE_END : TREE_MID; + text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", displayLines[i])}`; + } + if (remaining > 0) { + text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${remaining} more files`)}`; + } + return new Text(text, 0, 0); + } + + const fileCount = details?.fileCount ?? 0; + const truncated = details?.truncated ?? details?.truncation?.truncated ?? false; + const files = details?.files ?? []; + + // No matches + if (fileCount === 0) { + return new Text(`${theme.fg("warning", ICON_WARNING)} ${theme.fg("muted", "No files found")}`, 0, 0); + } + + // Build summary + const icon = theme.fg("success", ICON_SUCCESS); + let summary = `${fileCount} file${fileCount !== 1 ? "s" : ""}`; + + if (truncated) { + summary += theme.fg("warning", " (truncated)"); + } + + const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); + let text = `${icon} ${theme.fg("toolTitle", "find")} ${theme.fg("dim", summary)}${expandHint}`; + + // Show file tree if we have files + if (files.length > 0) { + const maxFiles = expanded ? files.length : Math.min(files.length, 8); + for (let i = 0; i < maxFiles; i++) { + const isLast = i === maxFiles - 1 && (expanded || files.length <= 8); + const branch = isLast ? TREE_END : TREE_MID; + text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", files[i])}`; + } + + if (!expanded && files.length > 8) { + text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${files.length - 8} more files`)}`; + } + } + + return new Text(text, 0, 0); + }, +}; + +// ============================================================================ +// Replace Renderer +// ============================================================================ + +interface ReplaceArgs { + pattern: string; + replacement: string; + path?: string; + glob?: string; + literal?: boolean; + dry_run?: boolean; +} + +const replaceRenderer: ToolRenderer = { + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("replace ")); + text += theme.fg("accent", `'${args.pattern}'`); + text += theme.fg("dim", " → "); + text += theme.fg("accent", `'${args.replacement}'`); + + const meta: string[] = []; + if (args.glob) meta.push(`glob:${args.glob}`); + if (args.path) meta.push(args.path); + if (args.dry_run !== false) meta.push("preview"); + if (args.literal) meta.push("-s"); + + if (meta.length > 0) { + text += ` ${theme.fg("muted", meta.join(" "))}`; + } + + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details; + + const filesChanged = details?.filesChanged ?? 0; + const filesFailed = details?.filesFailed ?? 0; + const preview = details?.preview ?? false; + const changed = details?.changed ?? []; + const failed = details?.failed ?? []; + const truncated = details?.truncated ?? false; + + // No changes + if (filesChanged === 0 && filesFailed === 0) { + const msg = preview ? "No changes would be made" : "No changes made"; + return new Text(`${theme.fg("warning", ICON_WARNING)} ${theme.fg("muted", msg)}`, 0, 0); + } + + // Build summary + const hasErrors = filesFailed > 0; + const icon = hasErrors ? theme.fg("warning", ICON_WARNING) : theme.fg("success", ICON_SUCCESS); + + const parts: string[] = []; + if (filesChanged > 0) { + const verb = preview ? "would change" : "changed"; + parts.push(`${verb} ${filesChanged} file${filesChanged !== 1 ? "s" : ""}`); + } + if (filesFailed > 0) { + parts.push(theme.fg("error", `${filesFailed} failed`)); + } + + let summary = parts.join(", "); + if (truncated) { + summary += theme.fg("warning", " (truncated)"); + } + const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); + let text = `${icon} ${theme.fg("toolTitle", "replace")} ${theme.fg("dim", summary)}${expandHint}`; + + // Show file tree + const allFiles = [...changed, ...failed.map((f) => f.file)]; + const maxFiles = expanded ? allFiles.length : Math.min(allFiles.length, 8); + + for (let i = 0; i < maxFiles; i++) { + const isLast = i === maxFiles - 1 && (expanded || allFiles.length <= 8); + const branch = isLast ? TREE_END : TREE_MID; + const file = allFiles[i]; + const isFailed = i >= changed.length; + const color = isFailed ? "error" : "accent"; + text += `\n ${theme.fg("dim", branch)} ${theme.fg(color, file)}`; + } + + if (!expanded && allFiles.length > 8) { + text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${allFiles.length - 8} more files`)}`; + } + + return new Text(text, 0, 0); + }, +}; + +// ============================================================================ +// AST Renderer +// ============================================================================ + +interface AstArgs { + action: string; + pattern: string; + replacement?: string; + path?: string; + lang?: string; +} + +const astRenderer: ToolRenderer = { + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("ast ")); + text += theme.fg("accent", `'${args.pattern}'`); + + if (args.replacement) { + text += theme.fg("dim", " → "); + text += theme.fg("accent", `'${args.replacement}'`); + } + + const meta: string[] = []; + if (args.lang) meta.push(`lang:${args.lang}`); + if (args.action && args.action !== "search") meta.push(args.action); + if (args.path) meta.push(args.path); + + if (meta.length > 0) { + text += ` ${theme.fg("muted", meta.join(" "))}`; + } + + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details; + + // Error case + if (details?.error) { + return new Text(`${theme.fg("error", ICON_ERROR)} ${theme.fg("error", details.error)}`, 0, 0); + } + + const matchCount = details?.matchCount ?? 0; + const fileCount = details?.fileCount ?? 0; + const mode = details?.mode ?? "search"; + const truncated = details?.truncated ?? details?.truncation?.truncated ?? false; + const files = details?.files ?? []; + + // No matches + if (matchCount === 0) { + return new Text(`${theme.fg("warning", ICON_WARNING)} ${theme.fg("muted", "No matches found")}`, 0, 0); + } + + // Build summary + const icon = mode === "apply" ? theme.fg("success", ICON_SUCCESS) : theme.fg("accent", ICON_INFO); + let summary: string; + if (mode === "apply") { + summary = `Applied ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`; + } else if (mode === "preview") { + summary = `Preview: ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`; + } else { + summary = `${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`; + } + + if (truncated) { + summary += theme.fg("warning", " (truncated)"); + } + + const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); + let text = `${icon} ${theme.fg("toolTitle", "ast")} ${theme.fg("dim", summary)}${expandHint}`; + + // Show file tree + const maxFiles = expanded ? files.length : Math.min(files.length, 8); + for (let i = 0; i < maxFiles; i++) { + const isLast = i === maxFiles - 1 && (expanded || files.length <= 8); + const branch = isLast ? TREE_END : TREE_MID; + text += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", files[i])}`; + } + + if (!expanded && files.length > 8) { + text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${files.length - 8} more files`)}`; + } + + return new Text(text, 0, 0); + }, +}; + +// ============================================================================ +// Notebook Renderer +// ============================================================================ + +interface NotebookArgs { + action: string; + notebookPath: string; + cellNumber?: number; + cellType?: string; + content?: string; +} + +const notebookRenderer: ToolRenderer = { + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("notebook ")); + text += theme.fg("accent", args.action || "?"); + + const meta: string[] = []; + meta.push(args.notebookPath || "?"); + if (args.cellNumber !== undefined) meta.push(`cell:${args.cellNumber}`); + if (args.cellType) meta.push(args.cellType); + + if (meta.length > 0) { + text += ` ${theme.fg("muted", meta.join(" "))}`; + } + + return new Text(text, 0, 0); + }, + + renderResult(result, _options, theme) { + const details = result.details; + + // Error case - check for error in content + const content = result.content?.[0]; + if (content?.type === "text" && content.text?.startsWith("Error:")) { + return new Text(`${theme.fg("error", ICON_ERROR)} ${theme.fg("error", content.text)}`, 0, 0); + } + + const action = details?.action ?? "edit"; + const cellIndex = details?.cellIndex; + const cellType = details?.cellType; + const totalCells = details?.totalCells; + + // Build summary + const icon = theme.fg("success", ICON_SUCCESS); + let summary: string; + + switch (action) { + case "insert": + summary = `Inserted ${cellType || "cell"} at index ${cellIndex}`; + break; + case "delete": + summary = `Deleted cell at index ${cellIndex}`; + break; + default: + summary = `Edited ${cellType || "cell"} at index ${cellIndex}`; + } + + if (totalCells !== undefined) { + summary += ` (${totalCells} total)`; + } + + return new Text(`${icon} ${theme.fg("toolTitle", "notebook")} ${theme.fg("dim", summary)}`, 0, 0); + }, +}; + +// ============================================================================ +// Ask Renderer +// ============================================================================ + +interface AskArgs { + question: string; + options?: Array<{ label: string }>; + multi?: boolean; +} + +const askRenderer: ToolRenderer = { + renderCall(args, theme) { + if (!args.question) { + return new Text(theme.fg("error", "ask: no question provided"), 0, 0); + } + + const multiTag = args.multi ? theme.fg("muted", " [multi-select]") : ""; + let text = theme.fg("toolTitle", "? ") + theme.fg("accent", args.question) + multiTag; + + if (args.options?.length) { + for (const opt of args.options) { + text += `\n${theme.fg("dim", " ○ ")}${theme.fg("muted", opt.label)}`; + } + text += `\n${theme.fg("dim", " ○ ")}${theme.fg("muted", "Other (custom input)")}`; + } + + return new Text(text, 0, 0); + }, + + renderResult(result, _opts, theme) { + const { details } = result; + if (!details) { + const txt = result.content[0]; + return new Text(txt?.type === "text" && txt.text ? txt.text : "", 0, 0); + } + + let text = theme.fg("toolTitle", "? ") + theme.fg("accent", details.question); + + if (details.customInput) { + // Custom input provided + text += `\n${theme.fg("dim", " ⎿ ")}${theme.fg("success", details.customInput)}`; + } else if (details.selectedOptions.length > 0) { + // Show only selected options + const selected = details.selectedOptions; + if (selected.length === 1) { + text += `\n${theme.fg("dim", " ⎿ ")}${theme.fg("success", selected[0])}`; + } else { + // Multiple selections - tree format + for (let i = 0; i < selected.length; i++) { + const isLast = i === selected.length - 1; + const branch = isLast ? TREE_END : TREE_MID; + text += `\n${theme.fg("dim", ` ${branch} `)}${theme.fg("success", selected[i])}`; + } + } + } else { + text += `\n${theme.fg("dim", " ⎿ ")}${theme.fg("warning", "Cancelled")}`; + } + + return new Text(text, 0, 0); + }, +}; + +// ============================================================================ +// Export +// ============================================================================ + +// ============================================================================ +// LSP Renderer +// ============================================================================ + +interface LspArgs { + action: string; + file?: string; + files?: string[]; + line?: number; + column?: number; +} + +const lspRenderer: ToolRenderer = { + renderCall: renderLspCall, + renderResult: renderLspResult, +}; + +// ============================================================================ +// Task Renderer +// ============================================================================ + +const taskRenderer: ToolRenderer = { + renderCall: renderTaskCall, + renderResult: renderTaskResult, +}; + +// ============================================================================ +// Ls Renderer +// ============================================================================ + +interface LsArgs { + path?: string; + limit?: number; +} + +const lsRenderer: ToolRenderer = { + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("ls ")); + text += theme.fg("accent", args.path || "."); + if (args.limit !== undefined) { + text += ` ${theme.fg("muted", `(limit ${args.limit})`)}`; + } + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details; + const textContent = result.content?.find((c: any) => c.type === "text")?.text; + + if (!textContent || textContent.trim() === "") { + return new Text(`${theme.fg("warning", ICON_WARNING)} ${theme.fg("muted", "Empty directory")}`, 0, 0); + } + + const entries = textContent.split("\n").filter((l: string) => l.trim()); + const dirs = entries.filter((e: string) => e.endsWith("/")); + const files = entries.filter((e: string) => !e.endsWith("/")); + + const truncated = details?.truncation?.truncated || details?.entryLimitReached; + const icon = truncated ? theme.fg("warning", ICON_WARNING) : theme.fg("success", ICON_SUCCESS); + + let summary = `${dirs.length} dir${dirs.length !== 1 ? "s" : ""}, ${files.length} file${files.length !== 1 ? "s" : ""}`; + if (truncated) { + summary += theme.fg("warning", " (truncated)"); + } + + const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); + let text = `${icon} ${theme.fg("toolTitle", "ls")} ${theme.fg("dim", summary)}${expandHint}`; + + const maxEntries = expanded ? entries.length : Math.min(entries.length, 12); + for (let i = 0; i < maxEntries; i++) { + const entry = entries[i]; + const isLast = i === maxEntries - 1 && (expanded || entries.length <= 12); + const branch = isLast ? TREE_END : TREE_MID; + const isDir = entry.endsWith("/"); + const color = isDir ? "accent" : "toolOutput"; + text += `\n ${theme.fg("dim", branch)} ${theme.fg(color, entry)}`; + } + + if (!expanded && entries.length > 12) { + text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${entries.length - 12} more entries`)}`; + } + + return new Text(text, 0, 0); + }, +}; + +// ============================================================================ +// Web Fetch Renderer +// ============================================================================ + +interface WebFetchArgs { + url: string; + timeout?: number; + raw?: boolean; +} + +const webFetchRenderer: ToolRenderer = { + renderCall: renderWebFetchCall, + renderResult: renderWebFetchResult, +}; + +// ============================================================================ +// Web Search Renderer +// ============================================================================ + +interface WebSearchArgs { + query: string; + provider?: string; + [key: string]: unknown; +} + +const webSearchRenderer: ToolRenderer = { + renderCall: renderWebSearchCall, + renderResult: renderWebSearchResult, +}; + +// ============================================================================ +// Export +// ============================================================================ + +export const toolRenderers: Record< + string, + { + renderCall: (args: any, theme: Theme) => Component; + renderResult: (result: any, options: RenderResultOptions, theme: Theme) => Component; + } +> = { + ask: askRenderer, + grep: grepRenderer, + find: findRenderer, + replace: replaceRenderer, + ast: astRenderer, + notebook: notebookRenderer, + ls: lsRenderer, + lsp: lspRenderer, + task: taskRenderer, + web_fetch: webFetchRenderer, + web_search: webSearchRenderer, +}; diff --git a/packages/coding-agent/src/core/tools/replace.ts b/packages/coding-agent/src/core/tools/replace.ts new file mode 100644 index 000000000..d4d8f4dfc --- /dev/null +++ b/packages/coding-agent/src/core/tools/replace.ts @@ -0,0 +1,297 @@ +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { Type } from "@sinclair/typebox"; +import type { Subprocess } from "bun"; +import { ensureTool } from "../../utils/tools-manager.js"; +import { resolveToCwd } from "./path-utils.js"; + +const replaceSchema = Type.Object({ + pattern: Type.String({ description: "Regex pattern to find" }), + replacement: Type.String({ description: "Replacement string" }), + path: Type.Optional(Type.String({ description: "File or directory path (default: current directory)" })), + glob: Type.Optional(Type.String({ description: "Glob pattern to filter files (e.g., '*.ts', '**/*.tsx')" })), + literal: Type.Optional(Type.Boolean({ description: "Treat pattern as literal string, not regex (default: false)" })), + dry_run: Type.Optional(Type.Boolean({ description: "Preview changes without applying them (default: true)" })), + max_results: Type.Optional(Type.Number({ description: "Limit number of files shown in output (default: 50)" })), +}); + +export interface ReplaceToolDetails { + filesChanged: number; + filesFailed: number; + preview: boolean; + changed: string[]; + failed: Array<{ file: string; error: string }>; + truncated?: boolean; +} + +/** Helper to run a command and collect output */ +async function runCommand( + cmd: string, + args: string[], + cwd: string, + signal?: AbortSignal, +): Promise<{ stdout: string; stderr: string; exitCode: number; aborted: boolean }> { + const child: Subprocess = Bun.spawn([cmd, ...args], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + let stdout = ""; + let stderr = ""; + let aborted = false; + + const onAbort = () => { + aborted = true; + child.kill(); + }; + + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + const stdoutReader = (child.stdout as ReadableStream).getReader(); + const stderrReader = (child.stderr as ReadableStream).getReader(); + const decoder = new TextDecoder(); + + try { + await Promise.all([ + (async () => { + while (true) { + const { done, value } = await stdoutReader.read(); + if (done) break; + stdout += decoder.decode(value, { stream: true }); + } + })(), + (async () => { + while (true) { + const { done, value } = await stderrReader.read(); + if (done) break; + stderr += decoder.decode(value, { stream: true }); + } + })(), + ]); + } finally { + stdoutReader.releaseLock(); + stderrReader.releaseLock(); + } + + const exitCode = await child.exited; + + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + return { stdout, stderr, exitCode: exitCode ?? -1, aborted }; +} + +export function createReplaceTool(cwd: string): AgentTool { + return { + name: "replace", + label: "replace", + description: + "Find-and-replace across files using sd. Supports regex patterns and glob filtering. Use dry_run=false to apply changes.", + parameters: replaceSchema, + execute: async ( + _toolCallId: string, + { + pattern, + replacement, + path: targetPath, + glob, + literal, + dry_run, + max_results, + }: { + pattern: string; + replacement: string; + path?: string; + glob?: string; + literal?: boolean; + dry_run?: boolean; + max_results?: number; + }, + signal?: AbortSignal, + ) => { + if (signal?.aborted) { + throw new Error("Operation aborted"); + } + + const sdPath = await ensureTool("sd", true); + if (!sdPath) { + throw new Error("sd is not available and could not be downloaded"); + } + + const resolvedPath = targetPath ? resolveToCwd(targetPath, cwd) : cwd; + const preview = dry_run ?? true; + const maxResults = Math.max(1, max_results ?? 50); + + // Build base sd args + const sdBaseArgs: string[] = []; + if (preview) { + sdBaseArgs.push("-p"); // preview mode + } + if (literal) { + sdBaseArgs.push("-s"); // string literal mode + } + sdBaseArgs.push(pattern, replacement); + + const changed: string[] = []; + const failed: Array<{ file: string; error: string }> = []; + let changedCount = 0; + let failedCount = 0; + const outputParts: string[] = []; + + if (glob) { + // Use fd to find files, then process each file individually for error recovery + const fdPath = await ensureTool("fd", true); + if (!fdPath) { + throw new Error("fd is required for glob filtering but is not available"); + } + + // Get file list + const fdResult = await runCommand(fdPath, ["-g", glob, ".", resolvedPath, "-a"], resolvedPath, signal); + + if (fdResult.aborted) { + throw new Error("Operation aborted"); + } + + if (fdResult.exitCode !== 0) { + throw new Error(fdResult.stderr.trim() || `fd exited with code ${fdResult.exitCode}`); + } + + const files = fdResult.stdout + .trim() + .split("\n") + .filter((f) => f.length > 0); + + if (files.length === 0) { + return { + content: [{ type: "text", text: "No files matched the glob pattern" }], + details: { filesChanged: 0, filesFailed: 0, preview, changed: [], failed: [] }, + }; + } + + // Process each file + for (const file of files) { + if (signal?.aborted) { + throw new Error("Operation aborted"); + } + + const sdArgs = [...sdBaseArgs, file]; + const result = await runCommand(sdPath, sdArgs, resolvedPath, signal); + + if (result.aborted) { + throw new Error("Operation aborted"); + } + + const relPath = file.startsWith(cwd) ? file.slice(cwd.length + 1) : file; + + if (result.exitCode !== 0) { + const errorMsg = result.stderr.trim() || `sd exited with code ${result.exitCode}`; + failedCount++; + if (failed.length < maxResults) { + failed.push({ file: relPath, error: errorMsg }); + } + } else { + const output = result.stdout.trim(); + if (output) { + changedCount++; + if (changed.length < maxResults) { + changed.push(relPath); + } + if (outputParts.length < maxResults) { + outputParts.push(output); + } + } + } + } + } else { + // Single path (file or directory) - run sd directly + const sdArgs = [...sdBaseArgs, resolvedPath]; + const result = await runCommand(sdPath, sdArgs, resolvedPath, signal); + + if (result.aborted) { + throw new Error("Operation aborted"); + } + + if (result.exitCode !== 0) { + const errorMsg = + result.stderr.trim() || result.stdout.trim() || `sd exited with code ${result.exitCode}`; + throw new Error(errorMsg); + } + + const output = result.stdout.trim(); + if (output) { + outputParts.push(output); + // Extract changed files from output (sd prefixes lines with file paths) + const fileMatches = output.match(/^[^\s:]+:/gm); + if (fileMatches) { + const seen = new Set(); + for (const match of fileMatches) { + const file = match.slice(0, -1); // Remove trailing colon + if (!seen.has(file)) { + seen.add(file); + changedCount++; + if (changed.length < maxResults) { + changed.push(file); + } + } + } + } + } + } + + const truncated = changedCount > maxResults || failedCount > maxResults; + const details: ReplaceToolDetails = { + filesChanged: changedCount, + filesFailed: failedCount, + preview, + changed, + failed, + truncated, + }; + + // Build output text + let outputText: string; + if (changedCount === 0 && failedCount === 0) { + outputText = preview ? "No changes would be made" : "No changes made"; + } else { + const parts: string[] = []; + + if (changedCount > 0) { + const verb = preview ? "would change" : "changed"; + parts.push(`${verb} ${changedCount} file${changedCount !== 1 ? "s" : ""}`); + } + + if (failedCount > 0) { + parts.push(`${failedCount} file${failedCount !== 1 ? "s" : ""} failed`); + } + + outputText = parts.join(", "); + + if (outputParts.length > 0) { + outputText += `\n\n${outputParts.join("\n\n")}`; + } + + if (failedCount > 0) { + outputText += "\n\nErrors:\n"; + for (const f of failed) { + outputText += ` ${f.file}: ${f.error}\n`; + } + } + if (truncated) { + outputText += `\n... showing first ${maxResults} files`; + } + } + + return { + content: [{ type: "text", text: outputText }], + details, + }; + }, + }; +} + +/** Default replace tool using process.cwd() - for backwards compatibility */ +export const replaceTool = createReplaceTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/task/agents.ts b/packages/coding-agent/src/core/tools/task/agents.ts new file mode 100644 index 000000000..a6b832c89 --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/agents.ts @@ -0,0 +1,153 @@ +/** + * Bundled agent definitions. + * + * Agents are loaded from .md files in the bundled-agents directory. + * These serve as defaults when no user/project agents are discovered. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AgentDefinition, AgentSource } from "./types.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BUNDLED_AGENTS_DIR = path.join(__dirname, "bundled-agents"); + +/** + * Parse YAML frontmatter from markdown content. + */ +function parseFrontmatter(content: string): { frontmatter: Record; body: string } { + const frontmatter: Record = {}; + const normalized = content.replace(/\r\n/g, "\n"); + + if (!normalized.startsWith("---")) { + return { frontmatter, body: normalized }; + } + + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) { + return { frontmatter, body: normalized }; + } + + const frontmatterBlock = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + + for (const line of frontmatterBlock.split("\n")) { + const match = line.match(/^([\w-]+):\s*(.*)$/); + if (match) { + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + frontmatter[match[1]] = value; + } + } + + return { frontmatter, body }; +} + +/** + * Load a single agent from a markdown file. + */ +function loadAgentFromFile(filePath: string, source: AgentSource): AgentDefinition | null { + let content: string; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + return null; + } + + const { frontmatter, body } = parseFrontmatter(content); + + if (!frontmatter.name || !frontmatter.description) { + return null; + } + + const tools = frontmatter.tools + ?.split(",") + .map((t) => t.trim()) + .filter(Boolean); + + const recursive = + frontmatter.recursive === undefined ? false : frontmatter.recursive === "true" || frontmatter.recursive === "1"; + + return { + name: frontmatter.name, + description: frontmatter.description, + tools: tools && tools.length > 0 ? tools : undefined, + model: frontmatter.model, + recursive, + systemPrompt: body, + source, + filePath, + }; +} + +/** Cache for bundled agents */ +let bundledAgentsCache: AgentDefinition[] | null = null; + +/** + * Load all bundled agents from the bundled-agents directory. + * Results are cached after first load. + */ +export function loadBundledAgents(): AgentDefinition[] { + if (bundledAgentsCache !== null) { + return bundledAgentsCache; + } + + const agents: AgentDefinition[] = []; + + if (!fs.existsSync(BUNDLED_AGENTS_DIR)) { + bundledAgentsCache = agents; + return agents; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(BUNDLED_AGENTS_DIR, { withFileTypes: true }); + } catch { + bundledAgentsCache = agents; + return agents; + } + + for (const entry of entries) { + if (!entry.name.endsWith(".md")) continue; + + const filePath = path.join(BUNDLED_AGENTS_DIR, entry.name); + const agent = loadAgentFromFile(filePath, "bundled"); + if (agent) { + agents.push(agent); + } + } + + bundledAgentsCache = agents; + return agents; +} + +/** + * Get a bundled agent by name. + */ +export function getBundledAgent(name: string): AgentDefinition | undefined { + return loadBundledAgents().find((a) => a.name === name); +} + +/** + * Get all bundled agents as a map keyed by name. + */ +export function getBundledAgentsMap(): Map { + const map = new Map(); + for (const agent of loadBundledAgents()) { + map.set(agent.name, agent); + } + return map; +} + +/** + * Clear the bundled agents cache (for testing). + */ +export function clearBundledAgentsCache(): void { + bundledAgentsCache = null; +} + +// Re-export for backward compatibility +export const BUNDLED_AGENTS = loadBundledAgents; diff --git a/packages/coding-agent/src/core/tools/task/artifacts.ts b/packages/coding-agent/src/core/tools/task/artifacts.ts new file mode 100644 index 000000000..25e2a7e4a --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/artifacts.ts @@ -0,0 +1,114 @@ +/** + * Session artifacts for subagent outputs. + * + * When a session exists, writes agent outputs to a sibling directory. + * Otherwise uses temp files that are cleaned up after execution. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +/** + * Derive artifacts directory from session file path. + * + * /path/to/sessions/project/2026-01-01T14-28-11-636Z_uuid.jsonl + * → /path/to/sessions/project/2026-01-01T14-28-11-636Z_uuid/ + */ +export function getArtifactsDir(sessionFile: string | null): string | null { + if (!sessionFile) return null; + // Strip .jsonl extension to get directory path + if (sessionFile.endsWith(".jsonl")) { + return sessionFile.slice(0, -6); + } + return sessionFile; +} + +/** + * Ensure artifacts directory exists. + */ +export function ensureArtifactsDir(dir: string): void { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +/** + * Generate artifact file paths for an agent run. + */ +export function getArtifactPaths( + dir: string, + agentName: string, + index: number, +): { inputPath: string; outputPath: string; jsonlPath: string } { + const base = `${agentName}_${index}`; + return { + inputPath: path.join(dir, `${base}.in.md`), + outputPath: path.join(dir, `${base}.out.md`), + jsonlPath: path.join(dir, `${base}.jsonl`), + }; +} + +/** + * Write artifacts for an agent run. + */ +export async function writeArtifacts( + dir: string, + agentName: string, + index: number, + input: string, + output: string, + jsonlEvents?: string[], +): Promise<{ inputPath: string; outputPath: string; jsonlPath?: string }> { + ensureArtifactsDir(dir); + + const paths = getArtifactPaths(dir, agentName, index); + + // Write input + await fs.promises.writeFile(paths.inputPath, input, "utf-8"); + + // Write output + await fs.promises.writeFile(paths.outputPath, output, "utf-8"); + + // Write JSONL if events provided + if (jsonlEvents && jsonlEvents.length > 0) { + await fs.promises.writeFile(paths.jsonlPath, jsonlEvents.join("\n"), "utf-8"); + return paths; + } + + return { inputPath: paths.inputPath, outputPath: paths.outputPath }; +} + +/** + * Create a temporary artifacts directory. + */ +export function createTempArtifactsDir(runId?: string): string { + const id = runId || `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dir = path.join(os.tmpdir(), `pi-task-${id}`); + ensureArtifactsDir(dir); + return dir; +} + +/** + * Clean up temporary artifacts. + */ +export async function cleanupTempArtifacts(paths: string[]): Promise { + for (const p of paths) { + try { + await fs.promises.unlink(p); + } catch { + // Ignore cleanup errors + } + } +} + +/** + * Clean up a temporary directory and its contents. + */ +export async function cleanupTempDir(dir: string): Promise { + try { + await fs.promises.rm(dir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +} diff --git a/packages/coding-agent/src/core/tools/task/bundled-agents/browser.md b/packages/coding-agent/src/core/tools/task/bundled-agents/browser.md new file mode 100644 index 000000000..4aa7942c0 --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/bundled-agents/browser.md @@ -0,0 +1,71 @@ +--- +name: browser +description: Fetches and renders a single URL into clean, digestible text for extraction +tools: bash +model: claude-haiku-4-5, haiku, flash, mini +--- + +You are a web content extraction specialist. Your job is to fetch a single URL, render it into clean readable text, and extract the specific information requested. + +=== CRITICAL: EXTRACTION ONLY === +This is a SINGLE-URL extraction task. You are STRICTLY PROHIBITED from: + +- Following links to other pages (unless explicitly part of the URL) +- Performing web searches or investigations +- Running commands that install software or change system state + +Your role is EXCLUSIVELY to fetch, render, and extract from ONE URL. + +=== HOW TO FETCH === + +Use the `omp render-web` command to fetch and render the URL: + +```bash +omp render-web "" +``` + +This command automatically: + +1. Checks for LLM-friendly endpoints (llms.txt, llms.md) +2. Tries content negotiation for markdown/plain text +3. Looks for page-specific alternate feeds (RSS, Atom) +4. Falls back to lynx for HTML→text rendering +5. Pretty-prints JSON/XML if applicable +6. Reports any issues (JS-gated pages, truncation, etc.) + +Options: + +- `--raw` — Output only the content, no metadata headers +- `--json` — Structured JSON output with metadata +- `--timeout ` — Request timeout (default: 20) + +=== WORKFLOW === + +1. Run `omp render-web ""` to fetch the page +2. Review the output — check the "Method" and "Notes" fields for any issues +3. If the page appears JS-gated or incomplete, note this in your response +4. Extract the specific information requested by the caller +5. Format your findings clearly + +=== OUTPUT FORMAT === + +Always structure your response as: + +## URL + +The final URL after redirects. + +## Metadata + +``` +Content-Type: +Method: +``` + +## Extracted Information + +The specific information requested by the caller, clearly formatted. + +## Notes + +Any issues encountered (JS-gated, paywall, truncated, etc). diff --git a/packages/coding-agent/src/core/tools/task/bundled-agents/explore.md b/packages/coding-agent/src/core/tools/task/bundled-agents/explore.md new file mode 100644 index 000000000..2c3b6ec0c --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/bundled-agents/explore.md @@ -0,0 +1,82 @@ +--- +name: explore +description: Fast read-only codebase scout that returns compressed context for handoff +tools: read, grep, glob, ls, bash +model: claude-haiku-4-5, haiku, flash, mini +--- + +You are a file search specialist and codebase scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything. + +=== CRITICAL: READ-ONLY MODE === +This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from: + +- Creating or modifying files (no Write, Edit, touch, rm, mv, cp) +- Creating temporary files anywhere, including /tmp +- Using redirect operators (>, >>, |) or heredocs to write files +- Running commands that change system state (git add, git commit, npm install, pip install) + +Your role is EXCLUSIVELY to search and analyze existing code. + +Your strengths: + +- Rapidly finding files using glob patterns +- Searching code with powerful regex patterns +- Reading and analyzing file contents +- Tracing imports and dependencies + +Guidelines: + +- Use glob for broad file pattern matching +- Use grep for searching file contents with regex +- Use read when you know the specific file path +- Use bash ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail) +- Spawn multiple parallel tool calls wherever possible—you are meant to be fast +- Return file paths as absolute paths in your final response +- Communicate findings directly as a message—do NOT create output files + +Thoroughness (infer from task, default medium): + +- Quick: Targeted lookups, key files only +- Medium: Follow imports, read critical sections +- Thorough: Trace all dependencies, check tests/types + +Strategy: + +1. grep/glob to locate relevant code +2. Read key sections (not entire files unless small) +3. Identify types, interfaces, key functions +4. Note dependencies between files + +Your output will be passed to an agent who has NOT seen the files you explored. + +Output format: + +## Query + +One line summary of what was searched. + +## Files Retrieved + +List with exact line ranges: + +1. `path/to/file.ts` (lines 10-50) - Description of what's here +2. `path/to/other.ts` (lines 100-150) - Description +3. ... + +## Key Code + +Critical types, interfaces, or functions (actual code excerpts): + +```language +interface Example { + // actual code from the files +} +``` + +## Architecture + +Brief explanation of how the pieces connect. + +## Start Here + +Which file to look at first and why. diff --git a/packages/coding-agent/src/core/tools/task/bundled-agents/plan.md b/packages/coding-agent/src/core/tools/task/bundled-agents/plan.md new file mode 100644 index 000000000..e7249c99b --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/bundled-agents/plan.md @@ -0,0 +1,54 @@ +--- +name: plan +description: Software architect that explores codebase and designs implementation plans (read-only) +tools: read, grep, glob, ls, bash +model: default +--- + +You are a software architect and planning specialist. Explore the codebase and design implementation plans. + +=== CRITICAL: READ-ONLY MODE === +This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from: + +- Creating or modifying files (no Write, Edit, touch, rm, mv, cp) +- Creating temporary files anywhere, including /tmp +- Using redirect operators (>, >>, |) or heredocs to write files +- Running commands that change system state (git add, git commit, npm install, pip install) + +Your role is EXCLUSIVELY to explore and plan. You do NOT have access to file editing tools. + +## Process + +1. **Understand Requirements**: Focus on the requirements provided. + +2. **Explore Thoroughly**: + - Read any files provided in the initial prompt + - Find existing patterns and conventions using glob, grep, read + - Understand the current architecture + - Identify similar features as reference + - Trace through relevant code paths + - Use bash ONLY for read-only operations (ls, git status, git log, git diff, find, cat, head, tail) + +3. **Design Solution**: + - Create implementation approach + - Consider trade-offs and architectural decisions + - Follow existing patterns where appropriate + +4. **Detail the Plan**: + - Provide step-by-step implementation strategy + - Identify dependencies and sequencing + - Anticipate potential challenges + +## Required Output + +End your response with: + +### Critical Files for Implementation + +List 3-5 files most critical for implementing this plan: + +- `path/to/file1.ts` - Brief reason (e.g., "Core logic to modify") +- `path/to/file2.ts` - Brief reason (e.g., "Interfaces to implement") +- `path/to/file3.ts` - Brief reason (e.g., "Pattern to follow") + +REMEMBER: You can ONLY explore and plan. You CANNOT write, edit, or modify any files. diff --git a/packages/coding-agent/src/core/tools/task/bundled-agents/reviewer.md b/packages/coding-agent/src/core/tools/task/bundled-agents/reviewer.md new file mode 100644 index 000000000..d392ee390 --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/bundled-agents/reviewer.md @@ -0,0 +1,59 @@ +--- +name: reviewer +description: Expert code reviewer for PRs and implementation changes +tools: read, grep, glob, ls, bash +model: gpt-5.2-codex, gpt-5.2, codex, gpt +--- + +You are an expert code reviewer. Analyze code changes and provide thorough reviews. + +## For PR Reviews + +1. If no PR number provided, run `gh pr list` to show open PRs +2. If PR number provided: + - `gh pr view ` to get PR details + - `gh pr diff ` to get the diff +3. Analyze changes and provide review + +## For Implementation Reviews + +When reviewing implementation output from another agent: + +1. Read the files that were changed +2. Understand the context and requirements +3. Analyze the implementation quality + +## Review Focus + +- **Correctness**: Does the code do what it's supposed to? +- **Project Conventions**: Does it follow existing patterns? +- **Performance**: Any performance implications? +- **Test Coverage**: Are changes adequately tested? +- **Security**: Any security considerations? +- **Edge Cases**: Are edge cases handled? + +## Output Format + +### Overview + +What the changes do. + +### Strengths + +What's done well. + +### Issues + +Problems that should be fixed (with file:line references). + +### Suggestions + +Improvements to consider (optional, not blocking). + +### Verdict + +- ✅ **Approve**: Ready to merge/complete +- 🔄 **Request Changes**: Issues must be addressed +- 💬 **Comment**: Minor suggestions, can proceed + +Keep reviews concise but thorough. Focus on substance over style nitpicks. diff --git a/packages/coding-agent/src/core/tools/task/bundled-agents/task.md b/packages/coding-agent/src/core/tools/task/bundled-agents/task.md new file mode 100644 index 000000000..950283f6b --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/bundled-agents/task.md @@ -0,0 +1,53 @@ +--- +name: task +description: General-purpose subagent with full capabilities for delegated multi-step tasks +model: default +--- + +You are a worker agent for delegated tasks. You operate in an isolated context window to handle work without polluting the main conversation. + +Do what has been asked; nothing more, nothing less. Work autonomously using all available tools. + +Your strengths: + +- Searching for code, configurations, and patterns across large codebases +- Analyzing multiple files to understand system architecture +- Investigating complex questions that require exploring many files +- Performing multi-step research and implementation tasks + +Guidelines: + +- For file searches: Use grep/glob when you need to search broadly. Use read when you know the specific file path. +- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results. +- Be thorough: Check multiple locations, consider different naming conventions, look for related files. +- NEVER create files unless absolutely necessary. ALWAYS prefer editing existing files. +- NEVER proactively create documentation files (\*.md) or README files unless explicitly requested. +- Any file paths in your response MUST be absolute. Do NOT use relative paths. +- Include relevant code snippets in your final response. + +Output format when finished: + +## Completed + +What was done. + +## Files Changed + +- `/absolute/path/to/file.ts` - what changed + +## Key Code + +Relevant snippets or signatures touched: + +```language +// actual code +``` + +## Notes (if any) + +Anything the main agent should know. + +If handing off to another agent (e.g. reviewer), include: + +- Exact file paths changed +- Key functions/types touched (short list) diff --git a/packages/coding-agent/src/core/tools/task/bundled-commands/architect-plan.md b/packages/coding-agent/src/core/tools/task/bundled-commands/architect-plan.md new file mode 100644 index 000000000..853908f37 --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/bundled-commands/architect-plan.md @@ -0,0 +1,10 @@ +--- +description: Explore gathers context, planner creates implementation plan (no implementation) +--- + +Use the subagent tool with the chain parameter to execute this workflow: + +1. First, use the "explore" agent to find all code relevant to: $@ +2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder) + +Execute this as a chain, passing output between steps via {previous}. Do NOT implement - just return the plan. diff --git a/packages/coding-agent/src/core/tools/task/bundled-commands/implement-with-critic.md b/packages/coding-agent/src/core/tools/task/bundled-commands/implement-with-critic.md new file mode 100644 index 000000000..0509b5c81 --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/bundled-commands/implement-with-critic.md @@ -0,0 +1,11 @@ +--- +description: Task implements, reviewer reviews, task applies feedback +--- + +Use the subagent tool with the chain parameter to execute this workflow: + +1. First, use the "task" agent to implement: $@ +2. Then, use the "reviewer" agent to review the implementation from the previous step (use {previous} placeholder) +3. Finally, use the "task" agent to apply the feedback from the review (use {previous} placeholder) + +Execute this as a chain, passing output between steps via {previous}. diff --git a/packages/coding-agent/src/core/tools/task/bundled-commands/implement.md b/packages/coding-agent/src/core/tools/task/bundled-commands/implement.md new file mode 100644 index 000000000..7adb2d5d0 --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/bundled-commands/implement.md @@ -0,0 +1,11 @@ +--- +description: Full implementation workflow - explore gathers context, planner creates plan, task implements +--- + +Use the subagent tool with the chain parameter to execute this workflow: + +1. First, use the "explore" agent to find all code relevant to: $@ +2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder) +3. Finally, use the "task" agent to implement the plan from the previous step (use {previous} placeholder) + +Execute this as a chain, passing output between steps via {previous}. diff --git a/packages/coding-agent/src/core/tools/task/commands.ts b/packages/coding-agent/src/core/tools/task/commands.ts new file mode 100644 index 000000000..27699065d --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/commands.ts @@ -0,0 +1,213 @@ +/** + * Workflow commands for orchestrating multi-agent workflows. + * + * Commands are loaded from .md files with YAML frontmatter. + * They define multi-step workflows that chain agent outputs. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BUNDLED_COMMANDS_DIR = path.join(__dirname, "bundled-commands"); + +/** Workflow command definition */ +export interface WorkflowCommand { + name: string; + description: string; + instructions: string; + source: "bundled" | "user" | "project"; + filePath: string; +} + +/** + * Parse YAML frontmatter from markdown content. + */ +function parseFrontmatter(content: string): { frontmatter: Record; body: string } { + const frontmatter: Record = {}; + const normalized = content.replace(/\r\n/g, "\n"); + + if (!normalized.startsWith("---")) { + return { frontmatter, body: normalized }; + } + + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) { + return { frontmatter, body: normalized }; + } + + const frontmatterBlock = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + + for (const line of frontmatterBlock.split("\n")) { + const match = line.match(/^([\w-]+):\s*(.*)$/); + if (match) { + let value = match[2].trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + frontmatter[match[1]] = value; + } + } + + return { frontmatter, body }; +} + +/** + * Load commands from a directory. + */ +function loadCommandsFromDir(dir: string, source: "bundled" | "user" | "project"): WorkflowCommand[] { + const commands: WorkflowCommand[] = []; + + if (!fs.existsSync(dir)) { + return commands; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return commands; + } + + for (const entry of entries) { + if (!entry.name.endsWith(".md")) continue; + + const filePath = path.join(dir, entry.name); + + try { + if (!fs.statSync(filePath).isFile()) continue; + } catch { + continue; + } + + let content: string; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + continue; + } + + const { frontmatter, body } = parseFrontmatter(content); + + // Name is filename without extension + const name = entry.name.replace(/\.md$/, ""); + + commands.push({ + name, + description: frontmatter.description || "", + instructions: body, + source, + filePath, + }); + } + + return commands; +} + +/** + * Check if path is a directory. + */ +function isDirectory(p: string): boolean { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} + +/** + * Find nearest directory by walking up from cwd. + */ +function findNearestDir(cwd: string, relPath: string): string | null { + let currentDir = cwd; + while (true) { + const candidate = path.join(currentDir, relPath); + if (isDirectory(candidate)) return candidate; + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) return null; + currentDir = parentDir; + } +} + +/** Cache for bundled commands */ +let bundledCommandsCache: WorkflowCommand[] | null = null; + +/** + * Load all bundled commands. + */ +export function loadBundledCommands(): WorkflowCommand[] { + if (bundledCommandsCache !== null) { + return bundledCommandsCache; + } + + bundledCommandsCache = loadCommandsFromDir(BUNDLED_COMMANDS_DIR, "bundled"); + return bundledCommandsCache; +} + +/** + * Discover all available commands. + * + * Precedence: project > user > bundled + */ +export function discoverCommands(cwd: string): WorkflowCommand[] { + const commandMap = new Map(); + + // Bundled commands (lowest priority) + for (const cmd of loadBundledCommands()) { + commandMap.set(cmd.name, cmd); + } + + // User commands + const userPiDir = path.join(os.homedir(), ".pi", "agent", "commands"); + const userClaudeDir = path.join(os.homedir(), ".claude", "commands"); + + for (const cmd of loadCommandsFromDir(userClaudeDir, "user")) { + commandMap.set(cmd.name, cmd); + } + for (const cmd of loadCommandsFromDir(userPiDir, "user")) { + commandMap.set(cmd.name, cmd); + } + + // Project commands (highest priority) + const projectPiDir = findNearestDir(cwd, ".pi/commands"); + const projectClaudeDir = findNearestDir(cwd, ".claude/commands"); + + if (projectClaudeDir) { + for (const cmd of loadCommandsFromDir(projectClaudeDir, "project")) { + commandMap.set(cmd.name, cmd); + } + } + if (projectPiDir) { + for (const cmd of loadCommandsFromDir(projectPiDir, "project")) { + commandMap.set(cmd.name, cmd); + } + } + + return Array.from(commandMap.values()); +} + +/** + * Get a command by name. + */ +export function getCommand(commands: WorkflowCommand[], name: string): WorkflowCommand | undefined { + return commands.find((c) => c.name === name); +} + +/** + * Expand command instructions with task input. + * Replaces $@ with the provided input. + */ +export function expandCommand(command: WorkflowCommand, input: string): string { + return command.instructions.replace(/\$@/g, input); +} + +/** + * Clear the bundled commands cache (for testing). + */ +export function clearBundledCommandsCache(): void { + bundledCommandsCache = null; +} diff --git a/packages/coding-agent/src/core/tools/task/discovery.ts b/packages/coding-agent/src/core/tools/task/discovery.ts new file mode 100644 index 000000000..3ec952cc5 --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/discovery.ts @@ -0,0 +1,216 @@ +/** + * Agent discovery from filesystem. + * + * Discovers agent definitions from: + * - ~/.pi/agent/agents/*.md (user-level, primary) + * - ~/.claude/agents/*.md (user-level, fallback) + * - .pi/agents/*.md (project-level, primary) + * - .claude/agents/*.md (project-level, fallback) + * + * Agent files use markdown with YAML frontmatter. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { loadBundledAgents } from "./agents.js"; +import type { AgentDefinition, AgentScope, AgentSource } from "./types.js"; + +/** Result of agent discovery */ +export interface DiscoveryResult { + agents: AgentDefinition[]; + projectAgentsDir: string | null; +} + +/** + * Parse YAML frontmatter from markdown content. + */ +function parseFrontmatter(content: string): { frontmatter: Record; body: string } { + const frontmatter: Record = {}; + const normalized = content.replace(/\r\n/g, "\n"); + + if (!normalized.startsWith("---")) { + return { frontmatter, body: normalized }; + } + + const endIndex = normalized.indexOf("\n---", 3); + if (endIndex === -1) { + return { frontmatter, body: normalized }; + } + + const frontmatterBlock = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + + for (const line of frontmatterBlock.split("\n")) { + const match = line.match(/^([\w-]+):\s*(.*)$/); + if (match) { + let value = match[2].trim(); + // Strip quotes + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + frontmatter[match[1]] = value; + } + } + + return { frontmatter, body }; +} + +/** + * Load agents from a directory. + */ +function loadAgentsFromDir(dir: string, source: AgentSource): AgentDefinition[] { + const agents: AgentDefinition[] = []; + + if (!fs.existsSync(dir)) { + return agents; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return agents; + } + + for (const entry of entries) { + if (!entry.name.endsWith(".md")) continue; + + const filePath = path.join(dir, entry.name); + + // Handle both regular files and symlinks + try { + if (!fs.statSync(filePath).isFile()) continue; + } catch { + continue; + } + + let content: string; + try { + content = fs.readFileSync(filePath, "utf-8"); + } catch { + continue; + } + + const { frontmatter, body } = parseFrontmatter(content); + + // Require name and description + if (!frontmatter.name || !frontmatter.description) { + continue; + } + + const tools = frontmatter.tools + ?.split(",") + .map((t) => t.trim()) + .filter(Boolean); + + const recursive = + frontmatter.recursive === undefined + ? undefined + : frontmatter.recursive === "true" || frontmatter.recursive === "1"; + + agents.push({ + name: frontmatter.name, + description: frontmatter.description, + tools: tools && tools.length > 0 ? tools : undefined, + model: frontmatter.model, + recursive, + systemPrompt: body, + source, + filePath, + }); + } + + return agents; +} + +/** + * Check if path is a directory. + */ +function isDirectory(p: string): boolean { + try { + return fs.statSync(p).isDirectory(); + } catch { + return false; + } +} + +/** + * Find nearest directory by walking up from cwd. + */ +function findNearestDir(cwd: string, relPath: string): string | null { + let currentDir = cwd; + while (true) { + const candidate = path.join(currentDir, relPath); + if (isDirectory(candidate)) return candidate; + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) return null; + currentDir = parentDir; + } +} + +/** + * Discover agents from filesystem and merge with bundled agents. + * + * Precedence: project > user > bundled + * + * @param cwd - Current working directory for project agent discovery + * @param scope - Which agents to discover: 'user', 'project', or 'both' + */ +export function discoverAgents(cwd: string, scope: AgentScope): DiscoveryResult { + // Primary directories (.pi) + const userPiDir = path.join(os.homedir(), ".pi", "agent", "agents"); + const projectPiDir = findNearestDir(cwd, ".pi/agents"); + + // Fallback directories (.claude) + const userClaudeDir = path.join(os.homedir(), ".claude", "agents"); + const projectClaudeDir = findNearestDir(cwd, ".claude/agents"); + + const agentMap = new Map(); + + // Start with bundled agents (lowest priority) + for (const agent of loadBundledAgents()) { + agentMap.set(agent.name, agent); + } + + // Load user agents if scope includes user + if (scope === "user" || scope === "both") { + // .claude first (lower priority within user) + for (const agent of loadAgentsFromDir(userClaudeDir, "user")) { + agentMap.set(agent.name, agent); + } + // .pi second (higher priority within user) + for (const agent of loadAgentsFromDir(userPiDir, "user")) { + agentMap.set(agent.name, agent); + } + } + + // Load project agents if scope includes project + if (scope === "project" || scope === "both") { + // .claude first (lower priority within project) + if (projectClaudeDir) { + for (const agent of loadAgentsFromDir(projectClaudeDir, "project")) { + agentMap.set(agent.name, agent); + } + } + // .pi second (higher priority within project) + if (projectPiDir) { + for (const agent of loadAgentsFromDir(projectPiDir, "project")) { + agentMap.set(agent.name, agent); + } + } + } + + return { + agents: Array.from(agentMap.values()), + projectAgentsDir: projectPiDir, + }; +} + +/** + * Get an agent by name from discovered agents. + */ +export function getAgent(agents: AgentDefinition[], name: string): AgentDefinition | undefined { + return agents.find((a) => a.name === name); +} diff --git a/packages/coding-agent/src/core/tools/task/executor.ts b/packages/coding-agent/src/core/tools/task/executor.ts new file mode 100644 index 000000000..3fe4b7d88 --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/executor.ts @@ -0,0 +1,367 @@ +/** + * Subprocess execution for subagents. + * + * Spawns `pi` in JSON mode to execute tasks with isolated context. + * Parses JSON events for progress tracking. + */ + +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as readline from "node:readline"; +import { resolveModelPattern } from "./model-resolver.js"; +import { + type AgentDefinition, + type AgentProgress, + MAX_OUTPUT_BYTES, + MAX_OUTPUT_LINES, + PI_NO_SUBAGENTS_ENV, + type SingleResult, +} from "./types.js"; + +/** pi command: 'pi.cmd' on Windows, 'pi' elsewhere */ +const PI_CMD = process.platform === "win32" ? "pi.cmd" : "pi"; + +/** Windows shell option for spawn */ +const PI_SHELL_OPT = process.platform === "win32"; + +/** Options for subprocess execution */ +export interface ExecutorOptions { + cwd: string; + agent: AgentDefinition; + task: string; + index: number; + context?: string; + modelOverride?: string; + signal?: AbortSignal; + onProgress?: (progress: AgentProgress) => void; + sessionFile?: string | null; + persistArtifacts?: boolean; + artifactsDir?: string; +} + +/** + * Truncate output to byte and line limits. + */ +function truncateOutput(output: string): { text: string; truncated: boolean } { + let truncated = false; + let byteBudget = MAX_OUTPUT_BYTES; + let lineBudget = MAX_OUTPUT_LINES; + + let i = 0; + let lastNewlineIndex = -1; + while (i < output.length && byteBudget > 0) { + const ch = output.charCodeAt(i); + byteBudget--; + + if (ch === 10 /* \n */) { + lineBudget--; + lastNewlineIndex = i; + if (lineBudget <= 0) { + truncated = true; + break; + } + } + + i++; + } + + if (i < output.length) { + truncated = true; + } + + if (truncated && lineBudget <= 0 && lastNewlineIndex >= 0) { + output = output.slice(0, lastNewlineIndex); + } else { + output = output.slice(0, i); + } + + return { text: output, truncated }; +} + +/** + * Extract a short preview from tool args for display. + */ +function extractToolArgsPreview(args: Record): string { + // Priority order for preview + const previewKeys = ["command", "file_path", "path", "pattern", "query", "url", "task", "prompt"]; + + for (const key of previewKeys) { + if (args[key] && typeof args[key] === "string") { + const value = args[key] as string; + return value.length > 60 ? `${value.slice(0, 57)}...` : value; + } + } + + return ""; +} + +/** + * Run a single agent as a subprocess. + */ +export async function runSubprocess(options: ExecutorOptions): Promise { + const { cwd, agent, task, index, context, modelOverride, signal, onProgress } = options; + const startTime = Date.now(); + + // Initialize progress + const progress: AgentProgress = { + index, + agent: agent.name, + agentSource: agent.source, + status: "running", + task, + recentTools: [], + recentOutput: [], + toolCount: 0, + tokens: 0, + durationMs: 0, + modelOverride, + }; + + // Check if already aborted + if (signal?.aborted) { + return { + index, + agent: agent.name, + agentSource: agent.source, + task, + exitCode: 1, + output: "", + stderr: "Aborted before start", + truncated: false, + durationMs: 0, + tokens: 0, + modelOverride, + error: "Aborted", + }; + } + + // Write system prompt to temp file + const tempDir = os.tmpdir(); + const promptFile = path.join( + tempDir, + `pi-agent-${agent.name}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`, + ); + + try { + fs.writeFileSync(promptFile, agent.systemPrompt, "utf-8"); + } catch (err) { + return { + index, + agent: agent.name, + agentSource: agent.source, + task, + exitCode: 1, + output: "", + stderr: `Failed to write prompt file: ${err}`, + truncated: false, + durationMs: Date.now() - startTime, + tokens: 0, + modelOverride, + error: `Failed to write prompt file: ${err}`, + }; + } + + // Build full task with context + const fullTask = context ? `${context}\n\n${task}` : task; + + // Build args + const args: string[] = ["--mode", "json", "--non-interactive"]; + + // Add system prompt + args.push("--append-system-prompt", promptFile); + + // Add tools if specified + if (agent.tools && agent.tools.length > 0) { + args.push("--tools", agent.tools.join(",")); + } + + // Resolve and add model + const resolvedModel = resolveModelPattern(modelOverride || agent.model); + if (resolvedModel) { + args.push("--model", resolvedModel); + } + + // Add session options + if (options.sessionFile) { + args.push("--session", options.sessionFile); + } else { + args.push("--no-session"); + } + + // Add task as prompt + args.push("--prompt", fullTask); + + // Set up environment + const env = { ...process.env }; + if (!agent.recursive) { + env[PI_NO_SUBAGENTS_ENV] = "1"; + } + + // Spawn subprocess + const proc = spawn(PI_CMD, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + shell: PI_SHELL_OPT, + env, + }); + + let output = ""; + let stderr = ""; + let finalOutput = ""; + let resolved = false; + const jsonlEvents: string[] = []; + + // Handle abort signal + const onAbort = () => { + if (!resolved) { + proc.kill("SIGTERM"); + } + }; + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + // Parse JSON events from stdout + const rl = readline.createInterface({ input: proc.stdout! }); + + rl.on("line", (line) => { + if (resolved) return; + + try { + const event = JSON.parse(line); + jsonlEvents.push(line); + const now = Date.now(); + + switch (event.type) { + case "tool_execution_start": + progress.toolCount++; + progress.currentTool = event.toolName; + progress.currentToolArgs = extractToolArgsPreview(event.toolArgs || event.args || {}); + progress.currentToolStartMs = now; + break; + + case "tool_execution_end": + if (progress.currentTool) { + progress.recentTools.unshift({ + tool: progress.currentTool, + args: progress.currentToolArgs || "", + endMs: now, + }); + // Keep only last 5 + if (progress.recentTools.length > 5) { + progress.recentTools.pop(); + } + } + progress.currentTool = undefined; + progress.currentToolArgs = undefined; + progress.currentToolStartMs = undefined; + break; + + case "message_update": + case "message_end": { + // Extract text content for recent output (prefer message.content, fallback to event.content) + const messageContent = event.message?.content || event.content; + if (messageContent && Array.isArray(messageContent)) { + for (const block of messageContent) { + if (block.type === "text" && block.text) { + const lines = block.text.split("\n").filter((l: string) => l.trim()); + for (const l of lines) { + if (!progress.recentOutput.includes(l)) { + progress.recentOutput.unshift(l); + if (progress.recentOutput.length > 8) { + progress.recentOutput.pop(); + } + } + } + output += block.text; + } + } + } + // Extract usage (prefer message.usage, fallback to event.usage) + const messageUsage = event.message?.usage || event.usage; + if (messageUsage) { + progress.tokens = (messageUsage.input_tokens || 0) + (messageUsage.output_tokens || 0); + } + break; + } + + case "agent_end": + // Extract final content from messages array + if (event.messages && Array.isArray(event.messages)) { + for (const msg of event.messages) { + if (msg.content && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === "text" && block.text) { + finalOutput += block.text; + } + } + } + } + } + break; + } + + progress.durationMs = now - startTime; + onProgress?.(progress); + } catch { + // Ignore non-JSON lines + } + }); + + // Capture stderr + const stderrDecoder = new TextDecoder(); + proc.stderr?.on("data", (chunk: Buffer) => { + stderr += stderrDecoder.decode(chunk, { stream: true }); + }); + + // Wait for process to exit + const exitCode = await new Promise((resolve) => { + proc.on("close", (code) => { + resolved = true; + resolve(code ?? 1); + }); + proc.on("error", (err) => { + resolved = true; + stderr += `\nProcess error: ${err.message}`; + resolve(1); + }); + }); + + // Cleanup + if (signal) { + signal.removeEventListener("abort", onAbort); + } + + try { + fs.unlinkSync(promptFile); + } catch { + // Ignore cleanup errors + } + + // Use final output if available, otherwise accumulated output + const rawOutput = finalOutput || output; + const { text: truncatedOutput, truncated } = truncateOutput(rawOutput); + + // Update final progress + progress.status = exitCode === 0 ? "completed" : "failed"; + progress.durationMs = Date.now() - startTime; + onProgress?.(progress); + + return { + index, + agent: agent.name, + agentSource: agent.source, + task, + exitCode, + output: truncatedOutput, + stderr, + truncated, + durationMs: Date.now() - startTime, + tokens: progress.tokens, + modelOverride, + error: exitCode !== 0 && stderr ? stderr : undefined, + jsonlEvents, + }; +} diff --git a/packages/coding-agent/src/core/tools/task/index.ts b/packages/coding-agent/src/core/tools/task/index.ts new file mode 100644 index 000000000..01937166e --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/index.ts @@ -0,0 +1,396 @@ +/** + * Task tool - Delegate tasks to specialized agents. + * + * Discovers agent definitions from: + * - Bundled agents (shipped with pi-coding-agent) + * - ~/.pi/agent/agents/*.md (user-level) + * - .pi/agents/*.md (project-level) + * + * Supports: + * - Single agent execution + * - Parallel execution with concurrency limits + * - Progress tracking via JSON events + * - Session artifacts for debugging + */ + +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import type { Theme } from "../../../modes/interactive/theme/theme.js"; +import { cleanupTempDir, createTempArtifactsDir, getArtifactsDir, writeArtifacts } from "./artifacts.js"; +import { discoverAgents, getAgent } from "./discovery.js"; +import { runSubprocess } from "./executor.js"; +import { mapWithConcurrencyLimit } from "./parallel.js"; +import { renderCall, renderResult } from "./render.js"; +import { + type AgentProgress, + type AgentScope, + MAX_AGENTS_IN_DESCRIPTION, + MAX_CONCURRENCY, + MAX_PARALLEL_TASKS, + PI_NO_SUBAGENTS_ENV, + type SingleResult, + type TaskToolDetails, + taskSchema, +} from "./types.js"; + +/** Session context interface */ +interface SessionContext { + getSessionFile: () => string | null; +} + +// Re-export types and utilities +export { loadBundledAgents as BUNDLED_AGENTS } from "./agents.js"; +export { discoverCommands, expandCommand, getCommand } from "./commands.js"; +export { discoverAgents, getAgent } from "./discovery.js"; +export type { AgentDefinition, AgentProgress, AgentScope, SingleResult, TaskParams, TaskToolDetails } from "./types.js"; +export { taskSchema } from "./types.js"; + +/** + * Build dynamic tool description listing available agents. + */ +function buildDescription(cwd: string): string { + const { agents, projectAgentsDir } = discoverAgents(cwd, "both"); + + // Group agents by source + const bundled = agents.filter((a) => a.source === "bundled"); + const user = agents.filter((a) => a.source === "user"); + const project = agents.filter((a) => a.source === "project"); + + const lines: string[] = ["Spawn a sub-agent to handle complex tasks. Each agent runs in an isolated context.", ""]; + + // Bundled agents + if (bundled.length > 0) { + lines.push("**Bundled agents:**"); + for (const agent of bundled.slice(0, MAX_AGENTS_IN_DESCRIPTION)) { + const tools = agent.tools ? ` (${agent.tools.join(", ")})` : ""; + lines.push(`- \`${agent.name}\`: ${agent.description}${tools}`); + } + lines.push(""); + } + + // User agents + if (user.length > 0) { + lines.push("**User agents (~/.pi/agent/agents/):**"); + for (const agent of user.slice(0, MAX_AGENTS_IN_DESCRIPTION)) { + lines.push(`- \`${agent.name}\`: ${agent.description}`); + } + if (user.length > MAX_AGENTS_IN_DESCRIPTION) { + lines.push(`- ... and ${user.length - MAX_AGENTS_IN_DESCRIPTION} more`); + } + lines.push(""); + } + + // Project agents + if (project.length > 0) { + const dir = projectAgentsDir || ".pi/agents/"; + lines.push(`**Project agents (${dir}):**`); + for (const agent of project.slice(0, MAX_AGENTS_IN_DESCRIPTION)) { + lines.push(`- \`${agent.name}\`: ${agent.description}`); + } + if (project.length > MAX_AGENTS_IN_DESCRIPTION) { + lines.push(`- ... and ${project.length - MAX_AGENTS_IN_DESCRIPTION} more`); + } + lines.push(""); + } + + // Usage + lines.push("**Usage:**"); + lines.push("- Single: `{ agent: 'explore', prompt: 'find auth code' }`"); + lines.push("- Parallel: `{ tasks: [{ agent: 'explore', task: '...' }, ...] }`"); + lines.push("- With context: `{ context: 'shared info', tasks: [...] }`"); + lines.push(""); + lines.push("**When NOT to use:** For simple file reads, use Read directly."); + + return lines.join("\n"); +} + +/** + * Create the task tool configured for a specific working directory. + */ +export function createTaskTool( + cwd: string, + sessionContext?: SessionContext, +): AgentTool { + // Check if subagents are inhibited (recursion prevention) + if (process.env[PI_NO_SUBAGENTS_ENV]) { + return { + name: "task", + label: "Task", + description: "Sub-agents disabled (recursion prevention)", + parameters: taskSchema, + execute: async () => ({ + content: [{ type: "text", text: "Sub-agents are disabled for this agent (recursion prevention)." }], + details: { + mode: "single", + agentScope: "both", + projectAgentsDir: null, + results: [], + totalDurationMs: 0, + }, + }), + }; + } + + return { + name: "task", + label: "Task", + description: buildDescription(cwd), + parameters: taskSchema, + renderCall, + renderResult, + execute: async (_toolCallId, params, signal, onUpdate) => { + const startTime = Date.now(); + const agentScope: AgentScope = (params.agentScope as AgentScope) || "both"; + const { agents, projectAgentsDir } = discoverAgents(cwd, agentScope); + + // Derive artifacts directory + const sessionFile = sessionContext?.getSessionFile() ?? null; + const artifactsDir = sessionFile ? getArtifactsDir(sessionFile) : null; + const tempArtifactsDir = artifactsDir ? null : createTempArtifactsDir(); + const effectiveArtifactsDir = artifactsDir || tempArtifactsDir!; + + // Determine mode + const isParallel = params.tasks && params.tasks.length > 0; + + // Initialize progress tracking + const progressMap = new Map(); + + // Update callback + const emitProgress = () => { + const progress = Array.from(progressMap.values()).sort((a, b) => a.index - b.index); + onUpdate?.({ + content: [{ type: "text", text: "Running..." }], + details: { + mode: isParallel ? "parallel" : "single", + agentScope, + projectAgentsDir, + results: [], + totalDurationMs: Date.now() - startTime, + progress, + }, + }); + }; + + try { + let results: SingleResult[]; + + if (isParallel) { + // Parallel mode + const tasks = params.tasks!; + + // Validate task count + if (tasks.length > MAX_PARALLEL_TASKS) { + return { + content: [ + { + type: "text", + text: `Error: Maximum ${MAX_PARALLEL_TASKS} tasks allowed, got ${tasks.length}`, + }, + ], + details: { + mode: "parallel", + agentScope, + projectAgentsDir, + results: [], + totalDurationMs: Date.now() - startTime, + }, + }; + } + + // Validate all agents exist + for (const task of tasks) { + if (!getAgent(agents, task.agent)) { + const available = agents.map((a) => a.name).join(", "); + return { + content: [{ type: "text", text: `Unknown agent: ${task.agent}. Available: ${available}` }], + details: { + mode: "parallel", + agentScope, + projectAgentsDir, + results: [], + totalDurationMs: Date.now() - startTime, + }, + }; + } + } + + // Initialize progress for all tasks + for (let i = 0; i < tasks.length; i++) { + progressMap.set(i, { + index: i, + agent: tasks[i].agent, + agentSource: getAgent(agents, tasks[i].agent)!.source, + status: "pending", + task: tasks[i].task, + recentTools: [], + recentOutput: [], + toolCount: 0, + tokens: 0, + durationMs: 0, + modelOverride: tasks[i].model, + }); + } + emitProgress(); + + // Execute in parallel with concurrency limit + results = await mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (task, index) => { + const agent = getAgent(agents, task.agent)!; + return runSubprocess({ + cwd, + agent, + task: task.task, + index, + context: params.context, + modelOverride: task.model, + sessionFile, + persistArtifacts: !!artifactsDir, + artifactsDir: effectiveArtifactsDir, + signal, + onProgress: (progress) => { + progressMap.set(index, progress); + emitProgress(); + }, + }); + }); + } else { + // Single mode + const agentName = params.agent || "task"; + const agent = getAgent(agents, agentName); + + if (!agent) { + const available = agents.map((a) => a.name).join(", "); + return { + content: [{ type: "text", text: `Unknown agent: ${agentName}. Available: ${available}` }], + details: { + mode: "single", + agentScope, + projectAgentsDir, + results: [], + totalDurationMs: Date.now() - startTime, + }, + }; + } + + if (!params.prompt) { + return { + content: [{ type: "text", text: "Error: 'prompt' is required for single agent mode" }], + details: { + mode: "single", + agentScope, + projectAgentsDir, + results: [], + totalDurationMs: Date.now() - startTime, + }, + }; + } + + // Initialize progress + progressMap.set(0, { + index: 0, + agent: agentName, + agentSource: agent.source, + status: "pending", + task: params.prompt, + recentTools: [], + recentOutput: [], + toolCount: 0, + tokens: 0, + durationMs: 0, + modelOverride: params.model, + }); + emitProgress(); + + const result = await runSubprocess({ + cwd, + agent, + task: params.prompt, + index: 0, + context: params.context, + modelOverride: params.model, + sessionFile, + persistArtifacts: !!artifactsDir, + artifactsDir: effectiveArtifactsDir, + signal, + onProgress: (progress) => { + progressMap.set(0, progress); + emitProgress(); + }, + }); + + results = [result]; + } + + // Write artifacts + const outputPaths: string[] = []; + for (const result of results) { + const fullTask = params.context ? `${params.context}\n\n${result.task}` : result.task; + const paths = await writeArtifacts( + effectiveArtifactsDir, + result.agent, + result.index, + fullTask, + result.output, + result.jsonlEvents, + ); + outputPaths.push(paths.outputPath); + result.artifactPaths = paths; + } + + // Build final output + const successCount = results.filter((r) => r.exitCode === 0).length; + const failCount = results.length - successCount; + + let summary: string; + if (results.length === 1) { + const r = results[0]; + summary = r.exitCode === 0 ? r.output : `Error: ${r.error || r.stderr || "Unknown error"}`; + } else { + summary = `Completed ${successCount}/${results.length} tasks`; + if (failCount > 0) { + summary += ` (${failCount} failed)`; + } + summary += "\n\n"; + for (const r of results) { + const status = r.exitCode === 0 ? "✓" : "✗"; + summary += `${status} ${r.agent}: ${r.output.split("\n")[0] || "(no output)"}\n`; + } + } + + // Cleanup temp directory if used + if (tempArtifactsDir) { + await cleanupTempDir(tempArtifactsDir); + } + + return { + content: [{ type: "text", text: summary }], + details: { + mode: isParallel ? "parallel" : "single", + agentScope, + projectAgentsDir, + results, + totalDurationMs: Date.now() - startTime, + outputPaths, + }, + }; + } catch (err) { + // Cleanup temp directory on error + if (tempArtifactsDir) { + await cleanupTempDir(tempArtifactsDir); + } + + return { + content: [{ type: "text", text: `Task execution failed: ${err}` }], + details: { + mode: isParallel ? "parallel" : "single", + agentScope, + projectAgentsDir, + results: [], + totalDurationMs: Date.now() - startTime, + }, + }; + } + }, + }; +} + +// Default task tool using process.cwd() +export const taskTool = createTaskTool(process.cwd()); diff --git a/packages/coding-agent/src/core/tools/task/model-resolver.ts b/packages/coding-agent/src/core/tools/task/model-resolver.ts new file mode 100644 index 000000000..9c240b5ed --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/model-resolver.ts @@ -0,0 +1,115 @@ +/** + * Model resolution with fuzzy pattern matching. + * + * Supports: + * - Exact match: "claude-opus-4-5" + * - Fuzzy match: "opus" → "claude-opus-4-5" + * - Comma fallback: "gpt, opus" → tries gpt first, then opus + * - "default" → undefined (use system default) + */ + +import { spawnSync } from "node:child_process"; + +/** pi command: 'pi.cmd' on Windows, 'pi' elsewhere */ +const PI_CMD = process.platform === "win32" ? "pi.cmd" : "pi"; + +/** Windows shell option for spawn/spawnSync */ +const PI_SHELL_OPT = process.platform === "win32"; + +/** Cache for available models */ +let cachedModels: string[] | null = null; + +/** Cache expiry time (5 minutes) */ +let cacheExpiry = 0; + +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * Get available models from `pi --list-models`. + * Caches the result for performance. + */ +export function getAvailableModels(): string[] { + const now = Date.now(); + if (cachedModels !== null && now < cacheExpiry) { + return cachedModels; + } + + try { + const result = spawnSync(PI_CMD, ["--list-models"], { + encoding: "utf-8", + timeout: 5000, + shell: PI_SHELL_OPT, + }); + + if (result.status !== 0 || !result.stdout) { + cachedModels = []; + cacheExpiry = now + CACHE_TTL_MS; + return cachedModels; + } + + // Parse output: skip header line, extract model column + const lines = result.stdout.trim().split("\n"); + cachedModels = lines + .slice(1) // Skip header + .map((line) => { + const parts = line.trim().split(/\s+/); + return parts[1]; // Model name is second column + }) + .filter(Boolean); + + cacheExpiry = now + CACHE_TTL_MS; + return cachedModels; + } catch { + cachedModels = []; + cacheExpiry = now + CACHE_TTL_MS; + return cachedModels; + } +} + +/** + * Clear the model cache (for testing). + */ +export function clearModelCache(): void { + cachedModels = null; + cacheExpiry = 0; +} + +/** + * Resolve a fuzzy model pattern to an actual model name. + * + * Supports comma-separated patterns (e.g., "gpt, opus") - tries each in order. + * Returns undefined if pattern is "default", undefined, or no match found. + * + * @param pattern - Model pattern to resolve + * @param availableModels - Optional pre-fetched list of available models + */ +export function resolveModelPattern(pattern: string | undefined, availableModels?: string[]): string | undefined { + if (!pattern || pattern === "default") { + return undefined; + } + + const models = availableModels ?? getAvailableModels(); + if (models.length === 0) { + // Fallback: return pattern as-is if we can't get available models + return pattern; + } + + // Split by comma, try each pattern in order + const patterns = pattern + .split(",") + .map((p) => p.trim().toLowerCase()) + .filter(Boolean); + + for (const p of patterns) { + // Try exact match first + const exactMatch = models.find((m) => m.toLowerCase() === p); + if (exactMatch) return exactMatch; + + // Try fuzzy match (substring) + const fuzzyMatch = models.find((m) => m.toLowerCase().includes(p)); + if (fuzzyMatch) return fuzzyMatch; + } + + // No match found - use default model + return undefined; +} diff --git a/packages/coding-agent/src/core/tools/task/parallel.ts b/packages/coding-agent/src/core/tools/task/parallel.ts new file mode 100644 index 000000000..084dc6d38 --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/parallel.ts @@ -0,0 +1,38 @@ +/** + * Parallel execution with concurrency control. + */ + +import { MAX_CONCURRENCY } from "./types.js"; + +/** + * Execute items with a concurrency limit using a worker pool pattern. + * Results are returned in the same order as input items. + * + * @param items - Items to process + * @param concurrency - Maximum concurrent operations + * @param fn - Async function to execute for each item + */ +export async function mapWithConcurrencyLimit( + items: T[], + concurrency: number, + fn: (item: T, index: number) => Promise, +): Promise { + const limit = Math.max(1, Math.min(concurrency, items.length, MAX_CONCURRENCY)); + const results: R[] = new Array(items.length); + let nextIndex = 0; + + const worker = async (): Promise => { + while (nextIndex < items.length) { + const index = nextIndex++; + results[index] = await fn(items[index], index); + } + }; + + // Create worker pool + const workers = Array(limit) + .fill(null) + .map(() => worker()); + + await Promise.all(workers); + return results; +} diff --git a/packages/coding-agent/src/core/tools/task/render.ts b/packages/coding-agent/src/core/tools/task/render.ts new file mode 100644 index 000000000..57ae3d539 --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/render.ts @@ -0,0 +1,232 @@ +/** + * TUI rendering for task tool. + * + * Provides renderCall and renderResult functions for displaying + * task execution in the terminal UI. + */ + +import path from "node:path"; +import type { Component } from "@mariozechner/pi-tui"; +import { Text } from "@mariozechner/pi-tui"; +import type { Theme } from "../../../modes/interactive/theme/theme.js"; +import type { RenderResultOptions } from "../../custom-tools/types.js"; +import type { AgentProgress, SingleResult, TaskParams, TaskToolDetails } from "./types.js"; + +/** + * Format token count for display (e.g., 1.5k, 25k). + */ +function formatTokens(tokens: number): string { + if (tokens >= 1000) { + return `${(tokens / 1000).toFixed(1)}k`; + } + return String(tokens); +} + +/** + * Format duration for display. + */ +function formatDuration(ms: number): string { + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${(ms / 60000).toFixed(1)}m`; +} + +/** + * Truncate text to max length with ellipsis. + */ +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return `${text.slice(0, maxLen - 3)}...`; +} + +/** + * Get status icon for agent state. + */ +function getStatusIcon(status: AgentProgress["status"]): string { + switch (status) { + case "pending": + return "○"; + case "running": + return "◐"; + case "completed": + return "✓"; + case "failed": + return "✗"; + } +} + +/** + * Render the tool call arguments. + */ +export function renderCall(args: TaskParams, theme: Theme): Component { + const label = theme.fg("toolTitle", theme.bold("task")); + + if (args.tasks && args.tasks.length > 0) { + // Parallel mode + const agents = args.tasks.map((t) => t.agent).join(", "); + return new Text(`${label} ${theme.fg("muted", `${args.tasks.length} agents: ${truncate(agents, 50)}`)}`, 0, 0); + } + + // Single mode + const agentName = args.agent || "task"; + const taskPreview = args.prompt ? truncate(args.prompt, 60) : ""; + return new Text(`${label} ${theme.fg("accent", agentName)}: ${theme.fg("muted", taskPreview)}`, 0, 0); +} + +/** + * Render streaming progress for a single agent. + */ +function renderAgentProgress(progress: AgentProgress, isLast: boolean, expanded: boolean, theme: Theme): string[] { + const lines: string[] = []; + const prefix = isLast ? "└─" : "├─"; + const continuePrefix = isLast ? " " : "│ "; + + const icon = getStatusIcon(progress.status); + const iconColor = progress.status === "completed" ? "success" : progress.status === "failed" ? "error" : "accent"; + + // Main status line + let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", progress.agent)}`; + + if (progress.status === "running") { + const taskPreview = truncate(progress.task, 40); + statusLine += `: ${theme.fg("muted", taskPreview)}`; + statusLine += ` · ${theme.fg("dim", `${progress.toolCount} tools`)}`; + if (progress.tokens > 0) { + statusLine += ` · ${theme.fg("dim", `${formatTokens(progress.tokens)} tokens`)}`; + } + } else if (progress.status === "completed") { + statusLine += `: ${theme.fg("success", "done")}`; + statusLine += ` · ${theme.fg("dim", `${progress.toolCount} tools`)}`; + statusLine += ` · ${theme.fg("dim", `${formatTokens(progress.tokens)} tokens`)}`; + } else if (progress.status === "failed") { + statusLine += `: ${theme.fg("error", "failed")}`; + } + + lines.push(statusLine); + + // Current tool (if running) + if (progress.status === "running" && progress.currentTool) { + let toolLine = `${continuePrefix}⎿ ${theme.fg("muted", progress.currentTool)}`; + if (progress.currentToolArgs) { + toolLine += `: ${theme.fg("dim", truncate(progress.currentToolArgs, 40))}`; + } + if (progress.currentToolStartMs) { + const elapsed = Date.now() - progress.currentToolStartMs; + if (elapsed > 5000) { + toolLine += ` · ${theme.fg("warning", formatDuration(elapsed))}`; + } + } + lines.push(toolLine); + } + + // Expanded view: recent output and tools + if (expanded && progress.status === "running") { + // Recent output + for (const line of progress.recentOutput.slice(0, 3)) { + lines.push(`${continuePrefix} ${theme.fg("dim", truncate(line, 60))}`); + } + } + + return lines; +} + +/** + * Render final result for a single agent. + */ +function renderAgentResult(result: SingleResult, isLast: boolean, expanded: boolean, theme: Theme): string[] { + const lines: string[] = []; + const prefix = isLast ? "└─" : "├─"; + const continuePrefix = isLast ? " " : "│ "; + + const success = result.exitCode === 0; + const icon = success ? "✓" : "✗"; + const iconColor = success ? "success" : "error"; + + // Main status line + let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", result.agent)}`; + statusLine += `: ${theme.fg(iconColor, success ? "done" : "failed")}`; + statusLine += ` · ${theme.fg("dim", `${formatTokens(result.tokens)} tokens`)}`; + statusLine += ` · ${theme.fg("dim", formatDuration(result.durationMs))}`; + + if (result.truncated) { + statusLine += ` ${theme.fg("warning", "[truncated]")}`; + } + + lines.push(statusLine); + + // Output preview + const outputLines = result.output.split("\n").filter((l) => l.trim()); + const previewCount = expanded ? 8 : 3; + + for (const line of outputLines.slice(0, previewCount)) { + lines.push(`${continuePrefix}${theme.fg("dim", truncate(line, 70))}`); + } + + if (outputLines.length > previewCount) { + lines.push(`${continuePrefix}${theme.fg("dim", `... ${outputLines.length - previewCount} more lines`)}`); + } + + // Error message + if (result.error && !success) { + lines.push(`${continuePrefix}${theme.fg("error", truncate(result.error, 70))}`); + } + + return lines; +} + +/** + * Render the tool result. + */ +export function renderResult( + result: { content: Array<{ type: string; text?: string }>; details?: TaskToolDetails }, + options: RenderResultOptions, + theme: Theme, +): Component { + const { expanded, isPartial } = options; + const details = result.details; + + if (!details) { + // Fallback to simple text + const text = result.content.find((c) => c.type === "text")?.text || ""; + return new Text(theme.fg("dim", truncate(text, 100)), 0, 0); + } + + const lines: string[] = []; + + if (isPartial && details.progress) { + // Streaming progress view + details.progress.forEach((progress, i) => { + const isLast = i === details.progress!.length - 1; + lines.push(...renderAgentProgress(progress, isLast, expanded, theme)); + }); + } else if (details.results.length > 0) { + // Final results view + details.results.forEach((res, i) => { + const isLast = i === details.results.length - 1; + lines.push(...renderAgentResult(res, isLast, expanded, theme)); + }); + + // Summary line + const successCount = details.results.filter((r) => r.exitCode === 0).length; + const failCount = details.results.length - successCount; + let summary = `\n${theme.fg("dim", "Total:")} `; + summary += theme.fg("success", `${successCount} succeeded`); + if (failCount > 0) { + summary += `, ${theme.fg("error", `${failCount} failed`)}`; + } + summary += ` · ${theme.fg("dim", formatDuration(details.totalDurationMs))}`; + lines.push(summary); + + // Artifacts location + if (details.outputPaths && details.outputPaths.length > 0) { + const artifactsDir = path.dirname(details.outputPaths[0]); + lines.push(`${theme.fg("dim", "Artifacts:")} ${theme.fg("muted", artifactsDir)}`); + } + } + + if (lines.length === 0) { + return new Text(theme.fg("dim", "No results"), 0, 0); + } + + return new Text(lines.join("\n"), 0, 0); +} diff --git a/packages/coding-agent/src/core/tools/task/types.ts b/packages/coding-agent/src/core/tools/task/types.ts new file mode 100644 index 000000000..b273285ed --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/types.ts @@ -0,0 +1,121 @@ +import { StringEnum } from "@mariozechner/pi-ai"; +import { type Static, Type } from "@sinclair/typebox"; + +/** Scope for agent discovery */ +export type AgentScope = "user" | "project" | "both"; + +/** Source of an agent definition */ +export type AgentSource = "bundled" | "user" | "project"; + +/** Single task item for parallel execution */ +export const taskItemSchema = Type.Object({ + agent: Type.String({ description: "Agent name" }), + task: Type.String({ description: "Task description for the agent" }), + model: Type.Optional(Type.String({ description: "Model override for this task" })), +}); + +export type TaskItem = Static; + +/** Maximum tasks per call */ +export const MAX_PARALLEL_TASKS = 32; + +/** Maximum concurrent workers */ +export const MAX_CONCURRENCY = 16; + +/** Maximum output bytes per agent */ +export const MAX_OUTPUT_BYTES = 500_000; + +/** Maximum output lines per agent */ +export const MAX_OUTPUT_LINES = 5000; + +/** Maximum agents to show in description */ +export const MAX_AGENTS_IN_DESCRIPTION = 10; + +/** Environment variable to inhibit subagent spawning */ +export const PI_NO_SUBAGENTS_ENV = "PI_NO_SUBAGENTS"; + +/** Task tool parameters */ +export const taskSchema = Type.Object({ + // Single mode + prompt: Type.Optional(Type.String({ description: "Task description for the sub-agent (single mode)" })), + agent: Type.Optional(Type.String({ description: "Agent name (defaults to 'task')" })), + model: Type.Optional(Type.String({ description: "Model override (fuzzy pattern like 'haiku' or 'opus')" })), + + // Parallel mode + tasks: Type.Optional( + Type.Array(taskItemSchema, { + description: "Array of tasks to run in parallel", + maxItems: MAX_PARALLEL_TASKS, + }), + ), + + // Common + context: Type.Optional(Type.String({ description: "Shared context prepended to all task prompts" })), + agentScope: Type.Optional( + StringEnum(["user", "project", "both"], { + description: "Agent discovery scope: user (~/.pi), project (.pi), or both", + }), + ), + background: Type.Optional(Type.Boolean({ description: "Run in background" })), +}); + +export type TaskParams = Static; + +/** Agent definition (bundled or discovered) */ +export interface AgentDefinition { + name: string; + description: string; + systemPrompt: string; + tools?: string[]; + model?: string; + recursive?: boolean; + source: AgentSource; + filePath?: string; +} + +/** Progress tracking for a single agent */ +export interface AgentProgress { + index: number; + agent: string; + agentSource: AgentSource; + status: "pending" | "running" | "completed" | "failed"; + task: string; + currentTool?: string; + currentToolArgs?: string; + currentToolStartMs?: number; + recentTools: Array<{ tool: string; args: string; endMs: number }>; + recentOutput: string[]; + toolCount: number; + tokens: number; + durationMs: number; + modelOverride?: string; +} + +/** Result from a single agent execution */ +export interface SingleResult { + index: number; + agent: string; + agentSource: AgentSource; + task: string; + exitCode: number; + output: string; + stderr: string; + truncated: boolean; + durationMs: number; + tokens: number; + modelOverride?: string; + error?: string; + jsonlEvents?: string[]; + artifactPaths?: { inputPath: string; outputPath: string; jsonlPath?: string }; +} + +/** Tool details for TUI rendering */ +export interface TaskToolDetails { + mode: "single" | "parallel"; + agentScope: AgentScope; + projectAgentsDir: string | null; + results: SingleResult[]; + totalDurationMs: number; + outputPaths?: string[]; + progress?: AgentProgress[]; +} diff --git a/packages/coding-agent/src/core/tools/web-fetch.ts b/packages/coding-agent/src/core/tools/web-fetch.ts new file mode 100644 index 000000000..683f0484b --- /dev/null +++ b/packages/coding-agent/src/core/tools/web-fetch.ts @@ -0,0 +1,2258 @@ +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { Type } from "@sinclair/typebox"; +import { parse as parseHtml } from "node-html-parser"; + +// ============================================================================= +// Types and Constants +// ============================================================================= + +interface RenderResult { + url: string; + finalUrl: string; + contentType: string; + method: string; + content: string; + fetchedAt: string; + truncated: boolean; + notes: string[]; +} + +const DEFAULT_TIMEOUT = 20; +const MAX_BYTES = 50 * 1024 * 1024; // 50MB for binary files +const MAX_OUTPUT_CHARS = 500_000; + +// Convertible document types (markitdown supported) +const CONVERTIBLE_MIMES = new Set([ + "application/pdf", + "application/msword", + "application/vnd.ms-powerpoint", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/rtf", + "application/epub+zip", + "application/zip", + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "audio/mpeg", + "audio/wav", + "audio/ogg", +]); + +const CONVERTIBLE_EXTENSIONS = new Set([ + ".pdf", + ".doc", + ".docx", + ".ppt", + ".pptx", + ".xls", + ".xlsx", + ".rtf", + ".epub", + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".mp3", + ".wav", + ".ogg", +]); + +const isWindows = process.platform === "win32"; + +const USER_AGENTS = [ + "curl/8.0", + "Mozilla/5.0 (compatible; TextBot/1.0)", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", +]; + +// ============================================================================= +// Utilities +// ============================================================================= + +interface LoadPageResult { + content: string; + contentType: string; + finalUrl: string; + ok: boolean; + status?: number; +} + +interface LoadPageOptions { + timeout?: number; + headers?: Record; + maxBytes?: number; +} + +/** + * Check if response indicates bot blocking (Cloudflare, etc.) + */ +function isBotBlocked(status: number, content: string): boolean { + if (status === 403 || status === 503) { + const lower = content.toLowerCase(); + return ( + lower.includes("cloudflare") || + lower.includes("captcha") || + lower.includes("challenge") || + lower.includes("blocked") || + lower.includes("access denied") || + lower.includes("bot detection") + ); + } + return false; +} + +/** + * Fetch a page with timeout, size limit, and automatic retry with browser UA if blocked + */ +async function loadPage(url: string, options: LoadPageOptions = {}): Promise { + const { timeout = 20, headers = {}, maxBytes = MAX_BYTES } = options; + + for (let attempt = 0; attempt < USER_AGENTS.length; attempt++) { + const userAgent = USER_AGENTS[attempt]; + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout * 1000); + + const response = await fetch(url, { + signal: controller.signal, + headers: { + "User-Agent": userAgent, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + ...headers, + }, + redirect: "follow", + }); + + clearTimeout(timeoutId); + + const contentType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? ""; + const finalUrl = response.url; + + // Read with size limit + const reader = response.body?.getReader(); + if (!reader) { + return { content: "", contentType, finalUrl, ok: false, status: response.status }; + } + + const chunks: Uint8Array[] = []; + let totalSize = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + totalSize += value.length; + + if (totalSize > maxBytes) { + reader.cancel(); + break; + } + } + + const decoder = new TextDecoder(); + const content = decoder.decode(Buffer.concat(chunks)); + + // Check if we got blocked and should retry with browser UA + if (isBotBlocked(response.status, content) && attempt < USER_AGENTS.length - 1) { + continue; + } + + if (!response.ok) { + return { content, contentType, finalUrl, ok: false, status: response.status }; + } + + return { content, contentType, finalUrl, ok: true, status: response.status }; + } catch (_err) { + // On last attempt, return failure + if (attempt === USER_AGENTS.length - 1) { + return { content: "", contentType: "", finalUrl: url, ok: false }; + } + // Otherwise retry with next UA + } + } + + return { content: "", contentType: "", finalUrl: url, ok: false }; +} + +/** + * Execute a command and return stdout + */ +function exec( + cmd: string, + args: string[], + options?: { timeout?: number; input?: string | Buffer }, +): { stdout: string; stderr: string; ok: boolean } { + const timeout = (options?.timeout ?? DEFAULT_TIMEOUT) * 1000; + const result = spawnSync(cmd, args, { + encoding: options?.input instanceof Buffer ? "buffer" : "utf-8", + timeout, + maxBuffer: MAX_BYTES, + input: options?.input, + shell: true, + }); + return { + stdout: result.stdout?.toString() ?? "", + stderr: result.stderr?.toString() ?? "", + ok: result.status === 0, + }; +} + +/** + * Check if a command exists (cross-platform) + */ +function hasCommand(cmd: string): boolean { + const checkCmd = isWindows ? "where" : "which"; + const result = spawnSync(checkCmd, [cmd], { encoding: "utf-8", shell: true }); + return result.status === 0; +} + +/** + * Extract origin from URL + */ +function getOrigin(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.protocol}//${parsed.host}`; + } catch { + return ""; + } +} + +/** + * Normalize URL (add scheme if missing) + */ +function normalizeUrl(url: string): string { + if (!url.match(/^https?:\/\//i)) { + return `https://${url}`; + } + return url; +} + +/** + * Normalize MIME type (lowercase, strip charset/params) + */ +function normalizeMime(contentType: string): string { + return contentType.split(";")[0].trim().toLowerCase(); +} + +/** + * Get extension from URL or Content-Disposition + */ +function getExtensionHint(url: string, contentDisposition?: string): string { + // Try Content-Disposition filename first + if (contentDisposition) { + const match = contentDisposition.match(/filename[*]?=["']?([^"';\n]+)/i); + if (match) { + const ext = path.extname(match[1]).toLowerCase(); + if (ext) return ext; + } + } + + // Fall back to URL path + try { + const pathname = new URL(url).pathname; + const ext = path.extname(pathname).toLowerCase(); + if (ext) return ext; + } catch {} + + return ""; +} + +/** + * Check if content type is convertible via markitdown + */ +function isConvertible(mime: string, extensionHint: string): boolean { + if (CONVERTIBLE_MIMES.has(mime)) return true; + if (mime === "application/octet-stream" && CONVERTIBLE_EXTENSIONS.has(extensionHint)) return true; + if (CONVERTIBLE_EXTENSIONS.has(extensionHint)) return true; + return false; +} + +/** + * Check if content looks like HTML + */ +function looksLikeHtml(content: string): boolean { + const trimmed = content.trim().toLowerCase(); + return ( + trimmed.startsWith(" { + const candidates: string[] = []; + + try { + const parsed = new URL(url); + const pathname = parsed.pathname; + + if (pathname.endsWith("/")) { + // /foo/bar/ -> /foo/bar/index.html.md + candidates.push(`${parsed.origin}${pathname}index.html.md`); + } else if (pathname.includes(".")) { + // /foo/bar.html -> /foo/bar.html.md + candidates.push(`${parsed.origin}${pathname}.md`); + } else { + // /foo/bar -> /foo/bar.md + candidates.push(`${parsed.origin}${pathname}.md`); + } + } catch { + return null; + } + + for (const candidate of candidates) { + const result = await loadPage(candidate, { timeout: Math.min(timeout, 5) }); + if (result.ok && result.content.trim().length > 100 && !looksLikeHtml(result.content)) { + return result.content; + } + } + + return null; +} + +/** + * Try to fetch LLM-friendly endpoints + */ +async function tryLlmEndpoints(origin: string, timeout: number): Promise { + const endpoints = [`${origin}/.well-known/llms.txt`, `${origin}/llms.txt`, `${origin}/llms.md`]; + + for (const endpoint of endpoints) { + const result = await loadPage(endpoint, { timeout: Math.min(timeout, 5) }); + if (result.ok && result.content.trim().length > 100 && !looksLikeHtml(result.content)) { + return result.content; + } + } + return null; +} + +/** + * Try content negotiation for markdown/plain + */ +async function tryContentNegotiation(url: string, timeout: number): Promise<{ content: string; type: string } | null> { + const result = await loadPage(url, { + timeout, + headers: { Accept: "text/markdown, text/plain;q=0.9, text/html;q=0.8" }, + }); + + if (!result.ok) return null; + + const mime = normalizeMime(result.contentType); + if (mime.includes("markdown") || mime === "text/plain") { + return { content: result.content, type: result.contentType }; + } + + return null; +} + +/** + * Parse alternate links from HTML head + */ +function parseAlternateLinks(html: string, pageUrl: string): string[] { + const links: string[] = []; + + try { + const doc = parseHtml(html.slice(0, 262144)); + const alternateLinks = doc.querySelectorAll('link[rel="alternate"]'); + + for (const link of alternateLinks) { + const href = link.getAttribute("href"); + const type = link.getAttribute("type")?.toLowerCase() ?? ""; + + if (!href) continue; + + // Skip site-wide feeds + if ( + href.includes("RecentChanges") || + href.includes("Special:") || + href.includes("/feed/") || + href.includes("action=feed") + ) { + continue; + } + + if (type.includes("markdown")) { + links.push(href); + } else if ( + (type.includes("rss") || type.includes("atom") || type.includes("feed")) && + (href.includes(new URL(pageUrl).pathname) || href.includes("comments")) + ) { + links.push(href); + } + } + } catch {} + + return links; +} + +/** + * Extract document links from HTML (for PDF/DOCX wrapper pages) + */ +function extractDocumentLinks(html: string, baseUrl: string): string[] { + const links: string[] = []; + + try { + const doc = parseHtml(html); + const anchors = doc.querySelectorAll("a[href]"); + + for (const anchor of anchors) { + const href = anchor.getAttribute("href"); + if (!href) continue; + + const ext = path.extname(href).toLowerCase(); + if (CONVERTIBLE_EXTENSIONS.has(ext)) { + const resolved = href.startsWith("http") ? href : new URL(href, baseUrl).href; + links.push(resolved); + } + } + } catch {} + + return links; +} + +/** + * Strip CDATA wrapper and clean text + */ +function cleanFeedText(text: string): string { + return text + .replace(//g, "") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/<[^>]+>/g, "") // Strip HTML tags + .trim(); +} + +/** + * Parse RSS/Atom feed to markdown + */ +function parseFeedToMarkdown(content: string, maxItems = 10): string { + try { + const doc = parseHtml(content, { parseNoneClosedTags: true }); + + // Try RSS + const channel = doc.querySelector("channel"); + if (channel) { + const title = cleanFeedText(channel.querySelector("title")?.text || "RSS Feed"); + const items = channel.querySelectorAll("item").slice(0, maxItems); + + let md = `# ${title}\n\n`; + for (const item of items) { + const itemTitle = cleanFeedText(item.querySelector("title")?.text || "Untitled"); + const link = cleanFeedText(item.querySelector("link")?.text || ""); + const pubDate = cleanFeedText(item.querySelector("pubDate")?.text || ""); + const desc = cleanFeedText(item.querySelector("description")?.text || ""); + + md += `## ${itemTitle}\n`; + if (pubDate) md += `*${pubDate}*\n\n`; + if (desc) md += `${desc.slice(0, 500)}${desc.length > 500 ? "..." : ""}\n\n`; + if (link) md += `[Read more](${link})\n\n`; + md += "---\n\n"; + } + return md; + } + + // Try Atom + const feed = doc.querySelector("feed"); + if (feed) { + const title = cleanFeedText(feed.querySelector("title")?.text || "Atom Feed"); + const entries = feed.querySelectorAll("entry").slice(0, maxItems); + + let md = `# ${title}\n\n`; + for (const entry of entries) { + const entryTitle = cleanFeedText(entry.querySelector("title")?.text || "Untitled"); + const link = entry.querySelector("link")?.getAttribute("href") || ""; + const updated = cleanFeedText(entry.querySelector("updated")?.text || ""); + const summary = cleanFeedText( + entry.querySelector("summary")?.text || entry.querySelector("content")?.text || "", + ); + + md += `## ${entryTitle}\n`; + if (updated) md += `*${updated}*\n\n`; + if (summary) md += `${summary.slice(0, 500)}${summary.length > 500 ? "..." : ""}\n\n`; + if (link) md += `[Read more](${link})\n\n`; + md += "---\n\n"; + } + return md; + } + } catch {} + + return content; // Fall back to raw content +} + +/** + * Render HTML to text using lynx + */ +function renderWithLynx(html: string, timeout: number): { content: string; ok: boolean } { + const tmpFile = path.join(os.tmpdir(), `pi-render-${Date.now()}.html`); + try { + fs.writeFileSync(tmpFile, html); + // Convert path to file URL (handles Windows paths correctly) + const normalizedPath = tmpFile.replace(/\\/g, "/"); + const fileUrl = normalizedPath.startsWith("/") ? `file://${normalizedPath}` : `file:///${normalizedPath}`; + const result = exec("lynx", ["-dump", "-nolist", "-width", "120", fileUrl], { timeout }); + return { content: result.stdout, ok: result.ok }; + } finally { + try { + fs.unlinkSync(tmpFile); + } catch {} + } +} + +/** + * Check if lynx output looks JS-gated or mostly navigation + */ +function isLowQualityOutput(content: string): boolean { + const lower = content.toLowerCase(); + + // JS-gated indicators + const jsGated = [ + "enable javascript", + "javascript required", + "turn on javascript", + "please enable javascript", + "browser not supported", + ]; + if (content.length < 1024 && jsGated.some((t) => lower.includes(t))) { + return true; + } + + // Mostly navigation (high link/menu density) + const lines = content.split("\n").filter((l) => l.trim()); + const shortLines = lines.filter((l) => l.trim().length < 40); + if (lines.length > 10 && shortLines.length / lines.length > 0.7) { + return true; + } + + return false; +} + +/** + * Format JSON + */ +function formatJson(content: string): string { + try { + return JSON.stringify(JSON.parse(content), null, 2); + } catch { + return content; + } +} + +/** + * Truncate and cleanup output + */ +function finalizeOutput(content: string): { content: string; truncated: boolean } { + const cleaned = content.replace(/\n{3,}/g, "\n\n").trim(); + const truncated = cleaned.length > MAX_OUTPUT_CHARS; + return { + content: cleaned.slice(0, MAX_OUTPUT_CHARS), + truncated, + }; +} + +/** + * Fetch page as binary buffer (for convertible files) + */ +async function fetchBinary( + url: string, + timeout: number, +): Promise<{ buffer: Buffer; contentType: string; contentDisposition?: string; ok: boolean }> { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout * 1000); + + const response = await fetch(url, { + signal: controller.signal, + headers: { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0", + }, + redirect: "follow", + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return { buffer: Buffer.alloc(0), contentType: "", ok: false }; + } + + const contentType = response.headers.get("content-type") ?? ""; + const contentDisposition = response.headers.get("content-disposition") ?? undefined; + const buffer = Buffer.from(await response.arrayBuffer()); + + return { buffer, contentType, contentDisposition, ok: true }; + } catch { + return { buffer: Buffer.alloc(0), contentType: "", ok: false }; + } +} + +// ============================================================================= +// GitHub Special Handling +// ============================================================================= + +interface GitHubUrl { + type: "blob" | "tree" | "repo" | "issue" | "issues" | "pull" | "pulls" | "discussion" | "discussions" | "other"; + owner: string; + repo: string; + ref?: string; + path?: string; + number?: number; +} + +/** + * Parse GitHub URL into components + */ +function parseGitHubUrl(url: string): GitHubUrl | null { + try { + const parsed = new URL(url); + if (parsed.hostname !== "github.com") return null; + + const parts = parsed.pathname.split("/").filter(Boolean); + if (parts.length < 2) return null; + + const [owner, repo, ...rest] = parts; + + if (rest.length === 0) { + return { type: "repo", owner, repo }; + } + + const [section, ...subParts] = rest; + + switch (section) { + case "blob": + case "tree": { + const [ref, ...pathParts] = subParts; + return { type: section, owner, repo, ref, path: pathParts.join("/") }; + } + case "issues": + if (subParts.length > 0 && /^\d+$/.test(subParts[0])) { + return { type: "issue", owner, repo, number: parseInt(subParts[0], 10) }; + } + return { type: "issues", owner, repo }; + case "pull": + if (subParts.length > 0 && /^\d+$/.test(subParts[0])) { + return { type: "pull", owner, repo, number: parseInt(subParts[0], 10) }; + } + return { type: "pulls", owner, repo }; + case "pulls": + return { type: "pulls", owner, repo }; + case "discussions": + if (subParts.length > 0 && /^\d+$/.test(subParts[0])) { + return { type: "discussion", owner, repo, number: parseInt(subParts[0], 10) }; + } + return { type: "discussions", owner, repo }; + default: + return { type: "other", owner, repo }; + } + } catch { + return null; + } +} + +/** + * Convert GitHub blob URL to raw URL + */ +function toRawGitHubUrl(gh: GitHubUrl): string { + return `https://raw.githubusercontent.com/${gh.owner}/${gh.repo}/refs/heads/${gh.ref}/${gh.path}`; +} + +/** + * Fetch from GitHub API + */ +async function fetchGitHubApi(endpoint: string, timeout: number): Promise<{ data: unknown; ok: boolean }> { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout * 1000); + + const headers: Record = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "pi-web-fetch/1.0", + }; + + // Use GITHUB_TOKEN if available + const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(`https://api.github.com${endpoint}`, { + signal: controller.signal, + headers, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return { data: null, ok: false }; + } + + return { data: await response.json(), ok: true }; + } catch { + return { data: null, ok: false }; + } +} + +/** + * Render GitHub issue/PR to markdown + */ +async function renderGitHubIssue(gh: GitHubUrl, timeout: number): Promise<{ content: string; ok: boolean }> { + const endpoint = + gh.type === "pull" + ? `/repos/${gh.owner}/${gh.repo}/pulls/${gh.number}` + : `/repos/${gh.owner}/${gh.repo}/issues/${gh.number}`; + + const result = await fetchGitHubApi(endpoint, timeout); + if (!result.ok || !result.data) return { content: "", ok: false }; + + const issue = result.data as { + title: string; + number: number; + state: string; + user: { login: string }; + created_at: string; + updated_at: string; + body: string | null; + labels: Array<{ name: string }>; + comments: number; + html_url: string; + }; + + let md = `# ${issue.title}\n\n`; + md += `**#${issue.number}** · ${issue.state} · opened by @${issue.user.login}\n`; + md += `Created: ${issue.created_at} · Updated: ${issue.updated_at}\n`; + if (issue.labels.length > 0) { + md += `Labels: ${issue.labels.map((l) => l.name).join(", ")}\n`; + } + md += `\n---\n\n`; + md += issue.body || "*No description provided.*"; + md += `\n\n---\n\n`; + + // Fetch comments if any + if (issue.comments > 0) { + const commentsResult = await fetchGitHubApi( + `/repos/${gh.owner}/${gh.repo}/issues/${gh.number}/comments?per_page=50`, + timeout, + ); + if (commentsResult.ok && Array.isArray(commentsResult.data)) { + md += `## Comments (${issue.comments})\n\n`; + for (const comment of commentsResult.data as Array<{ + user: { login: string }; + created_at: string; + body: string; + }>) { + md += `### @${comment.user.login} · ${comment.created_at}\n\n`; + md += `${comment.body}\n\n---\n\n`; + } + } + } + + return { content: md, ok: true }; +} + +/** + * Render GitHub issues list to markdown + */ +async function renderGitHubIssuesList(gh: GitHubUrl, timeout: number): Promise<{ content: string; ok: boolean }> { + const result = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/issues?state=open&per_page=30`, timeout); + if (!result.ok || !Array.isArray(result.data)) return { content: "", ok: false }; + + const issues = result.data as Array<{ + number: number; + title: string; + state: string; + user: { login: string }; + created_at: string; + comments: number; + labels: Array<{ name: string }>; + pull_request?: unknown; + }>; + + let md = `# ${gh.owner}/${gh.repo} - Open Issues\n\n`; + + for (const issue of issues) { + if (issue.pull_request) continue; // Skip PRs in issues list + const labels = issue.labels.length > 0 ? ` [${issue.labels.map((l) => l.name).join(", ")}]` : ""; + md += `- **#${issue.number}** ${issue.title}${labels}\n`; + md += ` by @${issue.user.login} · ${issue.comments} comments · ${issue.created_at}\n\n`; + } + + return { content: md, ok: true }; +} + +/** + * Render GitHub repo to markdown (file list + README) + */ +async function renderGitHubRepo(gh: GitHubUrl, timeout: number): Promise<{ content: string; ok: boolean }> { + // Fetch repo info + const repoResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}`, timeout); + if (!repoResult.ok) return { content: "", ok: false }; + + const repo = repoResult.data as { + full_name: string; + description: string | null; + stargazers_count: number; + forks_count: number; + open_issues_count: number; + default_branch: string; + language: string | null; + license: { name: string } | null; + }; + + let md = `# ${repo.full_name}\n\n`; + if (repo.description) md += `${repo.description}\n\n`; + md += `Stars: ${repo.stargazers_count} · Forks: ${repo.forks_count} · Issues: ${repo.open_issues_count}\n`; + if (repo.language) md += `Language: ${repo.language}\n`; + if (repo.license) md += `License: ${repo.license.name}\n`; + md += `\n---\n\n`; + + // Fetch file tree + const treeResult = await fetchGitHubApi( + `/repos/${gh.owner}/${gh.repo}/git/trees/${repo.default_branch}?recursive=1`, + timeout, + ); + if (treeResult.ok && treeResult.data) { + const tree = (treeResult.data as { tree: Array<{ path: string; type: string }> }).tree; + md += `## Files\n\n`; + md += "```\n"; + for (const item of tree.slice(0, 100)) { + const prefix = item.type === "tree" ? "[dir] " : " "; + md += `${prefix}${item.path}\n`; + } + if (tree.length > 100) { + md += `... and ${tree.length - 100} more files\n`; + } + md += "```\n\n"; + } + + // Fetch README + const readmeResult = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/readme`, timeout); + if (readmeResult.ok && readmeResult.data) { + const readme = readmeResult.data as { content: string; encoding: string }; + if (readme.encoding === "base64") { + const decoded = Buffer.from(readme.content, "base64").toString("utf-8"); + md += `## README\n\n${decoded}`; + } + } + + return { content: md, ok: true }; +} + +/** + * Handle GitHub URLs specially + */ +async function handleGitHub(url: string, timeout: number): Promise { + const gh = parseGitHubUrl(url); + if (!gh) return null; + + const fetchedAt = new Date().toISOString(); + const notes: string[] = []; + + switch (gh.type) { + case "blob": { + // Convert to raw URL and fetch + const rawUrl = toRawGitHubUrl(gh); + notes.push(`Fetched raw: ${rawUrl}`); + const result = await loadPage(rawUrl, { timeout }); + if (result.ok) { + const output = finalizeOutput(result.content); + return { + url, + finalUrl: rawUrl, + contentType: "text/plain", + method: "github-raw", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + break; + } + + case "issue": + case "pull": { + notes.push(`Fetched via GitHub API`); + const result = await renderGitHubIssue(gh, timeout); + if (result.ok) { + const output = finalizeOutput(result.content); + return { + url, + finalUrl: url, + contentType: "text/markdown", + method: gh.type === "pull" ? "github-pr" : "github-issue", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + break; + } + + case "issues": { + notes.push(`Fetched via GitHub API`); + const result = await renderGitHubIssuesList(gh, timeout); + if (result.ok) { + const output = finalizeOutput(result.content); + return { + url, + finalUrl: url, + contentType: "text/markdown", + method: "github-issues", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + break; + } + + case "repo": { + notes.push(`Fetched via GitHub API`); + const result = await renderGitHubRepo(gh, timeout); + if (result.ok) { + const output = finalizeOutput(result.content); + return { + url, + finalUrl: url, + contentType: "text/markdown", + method: "github-repo", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + break; + } + } + + // Fall back to null (let normal rendering handle it) + return null; +} + +// ============================================================================= +// Twitter/X Special Handling (via Nitter) +// ============================================================================= + +// Active Nitter instances - check https://status.d420.de/instances for current status +const NITTER_INSTANCES = [ + "nitter.privacyredirect.com", + "nitter.tiekoetter.com", + "nitter.poast.org", + "nitter.woodland.cafe", +]; + +/** + * Handle Twitter/X URLs via Nitter + */ +async function handleTwitter(url: string, timeout: number): Promise { + try { + const parsed = new URL(url); + if (!["twitter.com", "x.com", "www.twitter.com", "www.x.com"].includes(parsed.hostname)) { + return null; + } + + const fetchedAt = new Date().toISOString(); + + // Try Nitter instances + for (const instance of NITTER_INSTANCES) { + const nitterUrl = `https://${instance}${parsed.pathname}`; + const result = await loadPage(nitterUrl, { timeout: Math.min(timeout, 10) }); + + if (result.ok && result.content.length > 500) { + // Parse the Nitter HTML + const doc = parseHtml(result.content); + + // Extract tweet content + const tweetContent = doc.querySelector(".tweet-content")?.text?.trim(); + const fullname = doc.querySelector(".fullname")?.text?.trim(); + const username = doc.querySelector(".username")?.text?.trim(); + const date = doc.querySelector(".tweet-date a")?.text?.trim(); + const stats = doc.querySelector(".tweet-stats")?.text?.trim(); + + if (tweetContent) { + let md = `# Tweet by ${fullname || "Unknown"} (${username || "@?"})\n\n`; + if (date) md += `*${date}*\n\n`; + md += `${tweetContent}\n\n`; + if (stats) md += `---\n${stats.replace(/\s+/g, " ")}\n`; + + // Check for replies/thread + const replies = doc.querySelectorAll(".timeline-item .tweet-content"); + if (replies.length > 1) { + md += `\n---\n\n## Thread/Replies\n\n`; + for (const reply of Array.from(replies).slice(1, 10)) { + const replyUser = reply.parentNode?.querySelector(".username")?.text?.trim(); + md += `**${replyUser || "@?"}**: ${reply.text?.trim()}\n\n`; + } + } + + const output = finalizeOutput(md); + return { + url, + finalUrl: nitterUrl, + contentType: "text/markdown", + method: "twitter-nitter", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes: [`Via Nitter: ${instance}`], + }; + } + } + } + } catch {} + + // X.com blocks all bots - return a helpful error instead of falling through + return { + url, + finalUrl: url, + contentType: "text/plain", + method: "twitter-blocked", + content: + "Twitter/X blocks automated access. Nitter instances were unavailable.\n\nTry:\n- Opening the link in a browser\n- Using a different Nitter instance manually\n- Checking if the tweet is available via an archive service", + fetchedAt: new Date().toISOString(), + truncated: false, + notes: ["X.com blocks bots; Nitter instances unavailable"], + }; +} + +// ============================================================================= +// Stack Overflow Special Handling +// ============================================================================= + +interface SOQuestion { + title: string; + body: string; + score: number; + owner: { display_name: string }; + creation_date: number; + tags: string[]; + answer_count: number; + is_answered: boolean; +} + +interface SOAnswer { + body: string; + score: number; + is_accepted: boolean; + owner: { display_name: string }; + creation_date: number; +} + +/** + * Convert basic HTML to markdown (for SO bodies) + */ +function htmlToBasicMarkdown(html: string): string { + return html + .replace(/
]*>/g, "\n```\n")
+		.replace(/<\/code><\/pre>/g, "\n```\n")
+		.replace(//g, "`")
+		.replace(/<\/code>/g, "`")
+		.replace(//g, "**")
+		.replace(/<\/strong>/g, "**")
+		.replace(//g, "*")
+		.replace(/<\/em>/g, "*")
+		.replace(/]*>([^<]+)<\/a>/g, "[$2]($1)")
+		.replace(/

/g, "\n\n") + .replace(/<\/p>/g, "") + .replace(//g, "\n") + .replace(/

  • /g, "- ") + .replace(/<\/li>/g, "\n") + .replace(/<\/?[uo]l>/g, "\n") + .replace(//g, (_, n) => `\n${"#".repeat(parseInt(n, 10))} `) + .replace(/<\/h\d>/g, "\n") + .replace(/
    /g, "\n> ") + .replace(/<\/blockquote>/g, "\n") + .replace(/<[^>]+>/g, "") // Strip remaining tags + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +/** + * Handle Stack Overflow URLs via API + */ +async function handleStackOverflow(url: string, timeout: number): Promise { + try { + const parsed = new URL(url); + if (!parsed.hostname.includes("stackoverflow.com") && !parsed.hostname.includes("stackexchange.com")) { + return null; + } + + // Extract question ID from URL patterns like /questions/12345/... + const match = parsed.pathname.match(/\/questions\/(\d+)/); + if (!match) return null; + + const questionId = match[1]; + const site = parsed.hostname.includes("stackoverflow") ? "stackoverflow" : parsed.hostname.split(".")[0]; + const fetchedAt = new Date().toISOString(); + + // Fetch question with answers + const apiUrl = `https://api.stackexchange.com/2.3/questions/${questionId}?order=desc&sort=votes&site=${site}&filter=withbody`; + const qResult = await loadPage(apiUrl, { timeout }); + + if (!qResult.ok) return null; + + const qData = JSON.parse(qResult.content) as { items: SOQuestion[] }; + if (!qData.items?.length) return null; + + const question = qData.items[0]; + + let md = `# ${question.title}\n\n`; + md += `**Score:** ${question.score} · **Answers:** ${question.answer_count}`; + md += question.is_answered ? " (Answered)" : ""; + md += `\n**Tags:** ${question.tags.join(", ")}\n`; + md += `**Asked by:** ${question.owner.display_name} · ${new Date(question.creation_date * 1000).toISOString().split("T")[0]}\n\n`; + md += `---\n\n## Question\n\n${htmlToBasicMarkdown(question.body)}\n\n`; + + // Fetch answers + const aUrl = `https://api.stackexchange.com/2.3/questions/${questionId}/answers?order=desc&sort=votes&site=${site}&filter=withbody`; + const aResult = await loadPage(aUrl, { timeout }); + + if (aResult.ok) { + const aData = JSON.parse(aResult.content) as { items: SOAnswer[] }; + if (aData.items?.length) { + md += `---\n\n## Answers\n\n`; + for (const answer of aData.items.slice(0, 5)) { + const accepted = answer.is_accepted ? " (Accepted)" : ""; + md += `### Score: ${answer.score}${accepted} · by ${answer.owner.display_name}\n\n`; + md += `${htmlToBasicMarkdown(answer.body)}\n\n---\n\n`; + } + } + } + + const output = finalizeOutput(md); + return { + url, + finalUrl: url, + contentType: "text/markdown", + method: "stackoverflow", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes: ["Fetched via Stack Exchange API"], + }; + } catch {} + + return null; +} + +// ============================================================================= +// Wikipedia Special Handling +// ============================================================================= + +/** + * Handle Wikipedia URLs via API + */ +async function handleWikipedia(url: string, timeout: number): Promise { + try { + const parsed = new URL(url); + // Match *.wikipedia.org + const wikiMatch = parsed.hostname.match(/^(\w+)\.wikipedia\.org$/); + if (!wikiMatch) return null; + + const lang = wikiMatch[1]; + const titleMatch = parsed.pathname.match(/\/wiki\/(.+)/); + if (!titleMatch) return null; + + const title = decodeURIComponent(titleMatch[1]); + const fetchedAt = new Date().toISOString(); + + // Use Wikipedia API to get plain text extract + const apiUrl = `https://${lang}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`; + const summaryResult = await loadPage(apiUrl, { timeout }); + + let md = ""; + + if (summaryResult.ok) { + const summary = JSON.parse(summaryResult.content) as { + title: string; + description?: string; + extract: string; + }; + md = `# ${summary.title}\n\n`; + if (summary.description) md += `*${summary.description}*\n\n`; + md += `${summary.extract}\n\n---\n\n`; + } + + // Get full article content via mobile-html or parse API + const contentUrl = `https://${lang}.wikipedia.org/api/rest_v1/page/mobile-html/${encodeURIComponent(title)}`; + const contentResult = await loadPage(contentUrl, { timeout }); + + if (contentResult.ok) { + const doc = parseHtml(contentResult.content); + + // Extract main content sections + const sections = doc.querySelectorAll("section"); + for (const section of sections) { + const heading = section.querySelector("h2, h3, h4"); + const headingText = heading?.text?.trim(); + + // Skip certain sections + if ( + headingText && + ["References", "External links", "See also", "Notes", "Further reading"].includes(headingText) + ) { + continue; + } + + if (headingText) { + const level = heading?.tagName === "H2" ? "##" : "###"; + md += `${level} ${headingText}\n\n`; + } + + const paragraphs = section.querySelectorAll("p"); + for (const p of paragraphs) { + const text = p.text?.trim(); + if (text && text.length > 20) { + md += `${text}\n\n`; + } + } + } + } + + if (!md) return null; + + const output = finalizeOutput(md); + return { + url, + finalUrl: url, + contentType: "text/markdown", + method: "wikipedia", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes: ["Fetched via Wikipedia API"], + }; + } catch {} + + return null; +} + +// ============================================================================= +// Reddit Special Handling +// ============================================================================= + +interface RedditPost { + title: string; + selftext: string; + author: string; + score: number; + num_comments: number; + created_utc: number; + subreddit: string; + url: string; + is_self: boolean; +} + +interface RedditComment { + body: string; + author: string; + score: number; + created_utc: number; + replies?: { data: { children: Array<{ data: RedditComment }> } }; +} + +/** + * Handle Reddit URLs via JSON API + */ +async function handleReddit(url: string, timeout: number): Promise { + try { + const parsed = new URL(url); + if (!parsed.hostname.includes("reddit.com")) return null; + + const fetchedAt = new Date().toISOString(); + + // Append .json to get JSON response + let jsonUrl = `${url.replace(/\/$/, "")}.json`; + if (parsed.search) { + jsonUrl = `${url.replace(/\/$/, "").replace(parsed.search, "")}.json${parsed.search}`; + } + + const result = await loadPage(jsonUrl, { timeout }); + if (!result.ok) return null; + + const data = JSON.parse(result.content); + let md = ""; + + // Handle different Reddit URL types + if (Array.isArray(data) && data.length >= 1) { + // Post page (with comments) + const postData = data[0]?.data?.children?.[0]?.data as RedditPost | undefined; + if (postData) { + md = `# ${postData.title}\n\n`; + md += `**r/${postData.subreddit}** · u/${postData.author} · ${postData.score} points · ${postData.num_comments} comments\n`; + md += `*${new Date(postData.created_utc * 1000).toISOString().split("T")[0]}*\n\n`; + + if (postData.is_self && postData.selftext) { + md += `---\n\n${postData.selftext}\n\n`; + } else if (!postData.is_self) { + md += `**Link:** ${postData.url}\n\n`; + } + + // Add comments if available + if (data.length >= 2 && data[1]?.data?.children) { + md += `---\n\n## Top Comments\n\n`; + const comments = data[1].data.children.filter((c: { kind: string }) => c.kind === "t1").slice(0, 10); + + for (const { data: comment } of comments as Array<{ data: RedditComment }>) { + md += `### u/${comment.author} · ${comment.score} points\n\n`; + md += `${comment.body}\n\n---\n\n`; + } + } + } + } else if (data?.data?.children) { + // Subreddit or listing page + const posts = data.data.children.slice(0, 20) as Array<{ data: RedditPost }>; + const subreddit = posts[0]?.data?.subreddit; + + md = `# r/${subreddit || "Reddit"}\n\n`; + for (const { data: post } of posts) { + md += `- **${post.title}** (${post.score} pts, ${post.num_comments} comments)\n`; + md += ` by u/${post.author}\n\n`; + } + } + + if (!md) return null; + + const output = finalizeOutput(md); + return { + url, + finalUrl: url, + contentType: "text/markdown", + method: "reddit", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes: ["Fetched via Reddit JSON API"], + }; + } catch {} + + return null; +} + +// ============================================================================= +// NPM Special Handling +// ============================================================================= + +/** + * Handle NPM URLs via registry API + */ +async function handleNpm(url: string, timeout: number): Promise { + try { + const parsed = new URL(url); + if (parsed.hostname !== "www.npmjs.com" && parsed.hostname !== "npmjs.com") return null; + + // Extract package name from /package/[scope/]name + const match = parsed.pathname.match(/^\/package\/(.+?)(?:\/|$)/); + if (!match) return null; + + let packageName = decodeURIComponent(match[1]); + // Handle scoped packages: /package/@scope/name + if (packageName.startsWith("@")) { + const scopeMatch = parsed.pathname.match(/^\/package\/(@[^/]+\/[^/]+)/); + if (scopeMatch) packageName = decodeURIComponent(scopeMatch[1]); + } + + const fetchedAt = new Date().toISOString(); + + // Fetch from npm registry - use /latest endpoint for smaller response + const latestUrl = `https://registry.npmjs.org/${packageName}/latest`; + const result = await loadPage(latestUrl, { timeout }); + + if (!result.ok) return null; + + let pkg: { + name: string; + version: string; + description?: string; + license?: string; + homepage?: string; + repository?: { url: string } | string; + keywords?: string[]; + maintainers?: Array<{ name: string }>; + dependencies?: Record; + readme?: string; + }; + + try { + pkg = JSON.parse(result.content); + } catch { + return null; // JSON parse failed (truncated response) + } + + let md = `# ${pkg.name}\n\n`; + if (pkg.description) md += `${pkg.description}\n\n`; + + md += `**Latest:** ${pkg.version || "unknown"}`; + if (pkg.license) md += ` · **License:** ${typeof pkg.license === "string" ? pkg.license : pkg.license}`; + md += "\n\n"; + + if (pkg.homepage) md += `**Homepage:** ${pkg.homepage}\n`; + const repoUrl = typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url; + if (repoUrl) md += `**Repository:** ${repoUrl.replace(/^git\+/, "").replace(/\.git$/, "")}\n`; + if (pkg.keywords?.length) md += `**Keywords:** ${pkg.keywords.join(", ")}\n`; + if (pkg.maintainers?.length) md += `**Maintainers:** ${pkg.maintainers.map((m) => m.name).join(", ")}\n`; + + if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) { + md += `\n## Dependencies\n\n`; + for (const [dep, version] of Object.entries(pkg.dependencies)) { + md += `- ${dep}: ${version}\n`; + } + } + + if (pkg.readme) { + md += `\n---\n\n## README\n\n${pkg.readme}\n`; + } + + const output = finalizeOutput(md); + return { + url, + finalUrl: url, + contentType: "text/markdown", + method: "npm", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes: ["Fetched via npm registry"], + }; + } catch {} + + return null; +} + +// ============================================================================= +// arXiv Special Handling +// ============================================================================= + +/** + * Handle arXiv URLs - fetch abstract + optionally PDF + */ +async function handleArxiv(url: string, timeout: number): Promise { + try { + const parsed = new URL(url); + if (parsed.hostname !== "arxiv.org") return null; + + // Extract paper ID from various URL formats + // /abs/1234.56789, /pdf/1234.56789, /abs/cs/0123456 + const match = parsed.pathname.match(/\/(abs|pdf)\/(.+?)(?:\.pdf)?$/); + if (!match) return null; + + const paperId = match[2]; + const fetchedAt = new Date().toISOString(); + const notes: string[] = []; + + // Fetch metadata via arXiv API + const apiUrl = `https://export.arxiv.org/api/query?id_list=${paperId}`; + const result = await loadPage(apiUrl, { timeout }); + + if (!result.ok) return null; + + // Parse the Atom feed response + const doc = parseHtml(result.content, { parseNoneClosedTags: true }); + const entry = doc.querySelector("entry"); + + if (!entry) return null; + + const title = entry.querySelector("title")?.text?.trim()?.replace(/\s+/g, " "); + const summary = entry.querySelector("summary")?.text?.trim(); + const authors = entry + .querySelectorAll("author name") + .map((n) => n.text?.trim()) + .filter(Boolean); + const published = entry.querySelector("published")?.text?.trim()?.split("T")[0]; + const categories = entry + .querySelectorAll("category") + .map((c) => c.getAttribute("term")) + .filter(Boolean); + const pdfLink = entry.querySelector('link[title="pdf"]')?.getAttribute("href"); + + let md = `# ${title || "arXiv Paper"}\n\n`; + if (authors.length) md += `**Authors:** ${authors.join(", ")}\n`; + if (published) md += `**Published:** ${published}\n`; + if (categories.length) md += `**Categories:** ${categories.join(", ")}\n`; + md += `**arXiv:** ${paperId}\n\n`; + md += `---\n\n## Abstract\n\n${summary || "No abstract available."}\n\n`; + + // If it was a PDF link or we want full content, try to fetch and convert PDF + if (match[1] === "pdf" || parsed.pathname.includes(".pdf")) { + if (pdfLink) { + notes.push("Fetching PDF for full content..."); + const pdfResult = await fetchBinary(pdfLink, timeout); + if (pdfResult.ok) { + const converted = convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout); + if (converted.ok && converted.content.length > 500) { + md += `---\n\n## Full Paper\n\n${converted.content}\n`; + notes.push("PDF converted via markitdown"); + } + } + } + } + + const output = finalizeOutput(md); + return { + url, + finalUrl: url, + contentType: "text/markdown", + method: "arxiv", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes: notes.length ? notes : ["Fetched via arXiv API"], + }; + } catch {} + + return null; +} + +// ============================================================================= +// IACR ePrint Special Handling +// ============================================================================= + +/** + * Handle IACR Cryptology ePrint Archive URLs + */ +async function handleIacr(url: string, timeout: number): Promise { + try { + const parsed = new URL(url); + if (parsed.hostname !== "eprint.iacr.org") return null; + + // Extract paper ID from /year/number or /year/number.pdf + const match = parsed.pathname.match(/\/(\d{4})\/(\d+)(?:\.pdf)?$/); + if (!match) return null; + + const [, year, number] = match; + const paperId = `${year}/${number}`; + const fetchedAt = new Date().toISOString(); + const notes: string[] = []; + + // Fetch the HTML page for metadata + const pageUrl = `https://eprint.iacr.org/${paperId}`; + const result = await loadPage(pageUrl, { timeout }); + + if (!result.ok) return null; + + const doc = parseHtml(result.content); + + // Extract metadata from the page + const title = + doc.querySelector("h3.mb-3")?.text?.trim() || + doc.querySelector('meta[name="citation_title"]')?.getAttribute("content"); + const authors = doc + .querySelectorAll('meta[name="citation_author"]') + .map((m) => m.getAttribute("content")) + .filter(Boolean); + // Abstract is in

    after

    Abstract
    + const abstractHeading = doc.querySelectorAll("h5").find((h) => h.text?.includes("Abstract")); + const abstract = + abstractHeading?.parentNode?.querySelector("p")?.text?.trim() || + doc.querySelector('meta[name="description"]')?.getAttribute("content"); + const keywords = doc.querySelector(".keywords")?.text?.replace("Keywords:", "").trim(); + const pubDate = doc.querySelector('meta[name="citation_publication_date"]')?.getAttribute("content"); + + let md = `# ${title || "IACR ePrint Paper"}\n\n`; + if (authors.length) md += `**Authors:** ${authors.join(", ")}\n`; + if (pubDate) md += `**Date:** ${pubDate}\n`; + md += `**ePrint:** ${paperId}\n`; + if (keywords) md += `**Keywords:** ${keywords}\n`; + md += `\n---\n\n## Abstract\n\n${abstract || "No abstract available."}\n\n`; + + // If it was a PDF link, try to fetch and convert PDF + if (parsed.pathname.endsWith(".pdf")) { + const pdfUrl = `https://eprint.iacr.org/${paperId}.pdf`; + notes.push("Fetching PDF for full content..."); + const pdfResult = await fetchBinary(pdfUrl, timeout); + if (pdfResult.ok) { + const converted = convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout); + if (converted.ok && converted.content.length > 500) { + md += `---\n\n## Full Paper\n\n${converted.content}\n`; + notes.push("PDF converted via markitdown"); + } + } + } + + const output = finalizeOutput(md); + return { + url, + finalUrl: url, + contentType: "text/markdown", + method: "iacr", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes: notes.length ? notes : ["Fetched from IACR ePrint Archive"], + }; + } catch {} + + return null; +} + +// ============================================================================= +// GitHub Gist Special Handling +// ============================================================================= + +/** + * Handle GitHub Gist URLs via API + */ +async function handleGitHubGist(url: string, timeout: number): Promise { + try { + const parsed = new URL(url); + if (parsed.hostname !== "gist.github.com") return null; + + // Extract gist ID from /username/gistId or just /gistId + const parts = parsed.pathname.split("/").filter(Boolean); + if (parts.length === 0) return null; + + // Gist ID is always the last path segment (or only segment for anonymous gists) + const gistId = parts[parts.length - 1]; + if (!gistId || !/^[a-f0-9]+$/i.test(gistId)) return null; + + const fetchedAt = new Date().toISOString(); + + // Fetch via GitHub API + const result = await fetchGitHubApi(`/gists/${gistId}`, timeout); + if (!result.ok || !result.data) return null; + + const gist = result.data as { + description: string | null; + owner?: { login: string }; + created_at: string; + updated_at: string; + files: Record; + html_url: string; + }; + + const files = Object.values(gist.files); + const owner = gist.owner?.login || "anonymous"; + + let md = `# Gist by ${owner}\n\n`; + if (gist.description) md += `${gist.description}\n\n`; + md += `**Created:** ${gist.created_at} · **Updated:** ${gist.updated_at}\n`; + md += `**Files:** ${files.length}\n\n`; + + for (const file of files) { + const lang = file.language?.toLowerCase() || ""; + md += `---\n\n## ${file.filename}\n\n`; + md += `\`\`\`${lang}\n${file.content}\n\`\`\`\n\n`; + } + + const output = finalizeOutput(md); + return { + url, + finalUrl: url, + contentType: "text/markdown", + method: "github-gist", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes: ["Fetched via GitHub API"], + }; + } catch {} + + return null; +} + +// ============================================================================= +// Unified Special Handler Dispatch +// ============================================================================= + +/** + * Try all special handlers + */ +async function handleSpecialUrls(url: string, timeout: number): Promise { + // Order matters - more specific first + return ( + (await handleGitHubGist(url, timeout)) || + (await handleGitHub(url, timeout)) || + (await handleTwitter(url, timeout)) || + (await handleStackOverflow(url, timeout)) || + (await handleWikipedia(url, timeout)) || + (await handleReddit(url, timeout)) || + (await handleNpm(url, timeout)) || + (await handleArxiv(url, timeout)) || + (await handleIacr(url, timeout)) + ); +} + +// ============================================================================= +// Main Render Function +// ============================================================================= + +/** + * Main render function implementing the full pipeline + */ +async function renderUrl(url: string, timeout: number, raw: boolean = false): Promise { + const notes: string[] = []; + const fetchedAt = new Date().toISOString(); + + // Step 0: Try special handlers for known sites (unless raw mode) + if (!raw) { + const specialResult = await handleSpecialUrls(url, timeout); + if (specialResult) return specialResult; + } + + // Step 1: Normalize URL + url = normalizeUrl(url); + const origin = getOrigin(url); + + // Step 2: Fetch page + const response = await loadPage(url, { timeout }); + if (!response.ok) { + return { + url, + finalUrl: url, + contentType: "unknown", + method: "failed", + content: "", + fetchedAt, + truncated: false, + notes: ["Failed to fetch URL"], + }; + } + + const { finalUrl, content: rawContent } = response; + const mime = normalizeMime(response.contentType); + const extHint = getExtensionHint(finalUrl); + + // Step 3: Handle convertible binary files (PDF, DOCX, etc.) + if (isConvertible(mime, extHint)) { + const binary = await fetchBinary(finalUrl, timeout); + if (binary.ok) { + const ext = getExtensionHint(finalUrl, binary.contentDisposition) || extHint; + const converted = convertWithMarkitdown(binary.buffer, ext, timeout); + if (converted.ok && converted.content.trim().length > 50) { + notes.push(`Converted with markitdown`); + const output = finalizeOutput(converted.content); + return { + url, + finalUrl, + contentType: mime, + method: "markitdown", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + } + notes.push("markitdown conversion failed"); + } + + // Step 4: Handle non-HTML text content + const isHtml = mime.includes("html") || mime.includes("xhtml"); + const isJson = mime.includes("json"); + const isXml = mime.includes("xml") && !isHtml; + const isText = mime.includes("text/plain") || mime.includes("text/markdown"); + const isFeed = mime.includes("rss") || mime.includes("atom") || mime.includes("feed"); + + if (isJson) { + const output = finalizeOutput(formatJson(rawContent)); + return { + url, + finalUrl, + contentType: mime, + method: "json", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + + if (isFeed || (isXml && (rawContent.includes(" alt.endsWith(".md") || alt.includes("markdown")); + if (markdownAlt) { + const resolved = markdownAlt.startsWith("http") ? markdownAlt : new URL(markdownAlt, finalUrl).href; + const altResult = await loadPage(resolved, { timeout }); + if (altResult.ok && altResult.content.trim().length > 100 && !looksLikeHtml(altResult.content)) { + notes.push(`Used markdown alternate: ${resolved}`); + const output = finalizeOutput(altResult.content); + return { + url, + finalUrl, + contentType: "text/markdown", + method: "alternate-markdown", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + } + + // 5B: Try URL.md suffix (llms.txt convention) + const mdSuffix = await tryMdSuffix(finalUrl, timeout); + if (mdSuffix) { + notes.push("Found .md suffix version"); + const output = finalizeOutput(mdSuffix); + return { + url, + finalUrl, + contentType: "text/markdown", + method: "md-suffix", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + + // 5C: LLM-friendly endpoints + const llmContent = await tryLlmEndpoints(origin, timeout); + if (llmContent) { + notes.push("Found llms.txt"); + const output = finalizeOutput(llmContent); + return { + url, + finalUrl, + contentType: "text/plain", + method: "llms.txt", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + + // 5D: Content negotiation + const negotiated = await tryContentNegotiation(url, timeout); + if (negotiated) { + notes.push(`Content negotiation returned ${negotiated.type}`); + const output = finalizeOutput(negotiated.content); + return { + url, + finalUrl, + contentType: normalizeMime(negotiated.type), + method: "content-negotiation", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + + // 5E: Check for feed alternates + const feedAlternates = alternates.filter((alt) => !alt.endsWith(".md") && !alt.includes("markdown")); + for (const altUrl of feedAlternates.slice(0, 2)) { + const resolved = altUrl.startsWith("http") ? altUrl : new URL(altUrl, finalUrl).href; + const altResult = await loadPage(resolved, { timeout }); + if (altResult.ok && altResult.content.trim().length > 200) { + notes.push(`Used feed alternate: ${resolved}`); + const parsed = parseFeedToMarkdown(altResult.content); + const output = finalizeOutput(parsed); + return { + url, + finalUrl, + contentType: "application/feed", + method: "alternate-feed", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + } + + // Step 6: Render HTML with lynx + if (!hasCommand("lynx")) { + notes.push("lynx not installed"); + const output = finalizeOutput(rawContent); + return { + url, + finalUrl, + contentType: mime, + method: "raw-html", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + + const lynxResult = renderWithLynx(rawContent, timeout); + if (!lynxResult.ok) { + notes.push("lynx failed"); + const output = finalizeOutput(rawContent); + return { + url, + finalUrl, + contentType: mime, + method: "raw-html", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + + // Step 7: If lynx output is low quality, try extracting document links + if (isLowQualityOutput(lynxResult.content)) { + const docLinks = extractDocumentLinks(rawContent, finalUrl); + if (docLinks.length > 0) { + const docUrl = docLinks[0]; + const binary = await fetchBinary(docUrl, timeout); + if (binary.ok) { + const ext = getExtensionHint(docUrl, binary.contentDisposition); + const converted = convertWithMarkitdown(binary.buffer, ext, timeout); + if (converted.ok && converted.content.trim().length > lynxResult.content.length) { + notes.push(`Extracted and converted document: ${docUrl}`); + const output = finalizeOutput(converted.content); + return { + url, + finalUrl, + contentType: "application/document", + method: "extracted-document", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + } + } + notes.push("Page appears to require JavaScript or is mostly navigation"); + } + + const output = finalizeOutput(lynxResult.content); + return { + url, + finalUrl, + contentType: mime, + method: "lynx", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; + } + + // Fallback: return raw content + const output = finalizeOutput(rawContent); + return { + url, + finalUrl, + contentType: mime, + method: "raw", + content: output.content, + fetchedAt, + truncated: output.truncated, + notes, + }; +} + +// ============================================================================= +// Tool Definition +// ============================================================================= + +const webFetchSchema = Type.Object({ + url: Type.String({ description: "The URL to fetch and render" }), + timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (default: 20, max: 120)" })), + raw: Type.Optional( + Type.Boolean({ description: "Return raw content without site-specific rendering or LLM-friendly transforms" }), + ), +}); + +export interface WebFetchToolDetails { + url: string; + finalUrl: string; + contentType: string; + method: string; + truncated: boolean; + notes: string[]; +} + +export function createWebFetchTool(_cwd: string): AgentTool { + return { + name: "web_fetch", + label: "web_fetch", + description: `Fetch and render a URL into clean, readable text optimized for LLM consumption. + +Features: +- Site-specific handlers for GitHub (issues, PRs, repos, gists), Stack Overflow, Wikipedia, Reddit, NPM, arXiv, IACR, and Twitter/X +- Automatic detection and use of LLM-friendly endpoints (llms.txt, .md suffixes) +- Binary file conversion (PDF, DOCX, etc.) via markitdown if available +- HTML to text rendering via lynx if available +- RSS/Atom feed parsing +- JSON pretty-printing + +Returns structured markdown content with metadata about how the content was fetched.`, + parameters: webFetchSchema, + execute: async ( + _toolCallId: string, + { url, timeout = DEFAULT_TIMEOUT, raw = false }: { url: string; timeout?: number; raw?: boolean }, + ) => { + // Clamp timeout + const effectiveTimeout = Math.min(Math.max(timeout, 1), 120); + + const result = await renderUrl(url, effectiveTimeout, raw); + + // Format output + let output = ""; + output += `URL: ${result.finalUrl}\n`; + output += `Content-Type: ${result.contentType}\n`; + output += `Method: ${result.method}\n`; + if (result.truncated) { + output += `Warning: Output was truncated\n`; + } + if (result.notes.length > 0) { + output += `Notes: ${result.notes.join("; ")}\n`; + } + output += `\n---\n\n`; + output += result.content; + + const details: WebFetchToolDetails = { + url: result.url, + finalUrl: result.finalUrl, + contentType: result.contentType, + method: result.method, + truncated: result.truncated, + notes: result.notes, + }; + + return { + content: [{ type: "text", text: output }], + details, + }; + }, + }; +} + +/** Default web fetch tool using process.cwd() - for backwards compatibility */ +export const webFetchTool = createWebFetchTool(process.cwd()); + +// ============================================================================= +// TUI Rendering +// ============================================================================= + +import type { Component } from "@mariozechner/pi-tui"; +import { Text } from "@mariozechner/pi-tui"; +import type { Theme } from "../../modes/interactive/theme/theme.js"; +import type { CustomTool, CustomToolContext, RenderResultOptions } from "../custom-tools/types.js"; + +// Tree formatting constants +const TREE_MID = "├─"; +const TREE_END = "└─"; +const TREE_PIPE = "│"; +const TREE_HOOK = "⎿"; + +/** Truncate text to max length with ellipsis */ +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return `${text.slice(0, maxLen - 1)}…`; +} + +/** Extract domain from URL */ +function getDomain(url: string): string { + try { + const u = new URL(url); + return u.hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +/** Get first N lines of text as preview */ +function getPreviewLines(text: string, maxLines: number, maxLineLen: number): string[] { + const lines = text.split("\n").filter((l) => l.trim()); + return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen)); +} + +/** Render web fetch call (URL preview) */ +export function renderWebFetchCall(args: { url: string; timeout?: number; raw?: boolean }, theme: Theme): Component { + const domain = getDomain(args.url); + const path = truncate(args.url.replace(/^https?:\/\/[^/]+/, ""), 50); + const text = `${theme.fg("toolTitle", "Web Fetch")} ${theme.fg("accent", domain)}${theme.fg("dim", path)}`; + return new Text(text, 0, 0); +} + +/** Render web fetch result with tree-based layout */ +export function renderWebFetchResult( + result: { content: Array<{ type: string; text?: string }>; details?: WebFetchToolDetails }, + options: RenderResultOptions, + theme: Theme, +): Component { + const { expanded } = options; + const details = result.details; + + if (!details) { + return new Text(theme.fg("error", "No response data"), 0, 0); + } + + const domain = getDomain(details.finalUrl); + const hasRedirect = details.url !== details.finalUrl; + const hasNotes = details.notes.length > 0; + + // Build header: ● Web Fetch (domain) · method + const icon = details.truncated ? theme.fg("warning", "●") : theme.fg("success", "●"); + const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); + let text = `${icon} ${theme.fg("toolTitle", "Web Fetch")} ${theme.fg("accent", `(${domain})`)} · ${theme.fg("dim", details.method)}${expandHint}`; + + // Get content text + const contentText = result.content[0]?.text ?? ""; + // Extract just the content part (after the --- separator) + const contentBody = contentText.includes("---\n\n") + ? contentText.split("---\n\n").slice(1).join("---\n\n") + : contentText; + + if (!expanded) { + // Collapsed view: show metadata + 3 preview lines + if (hasRedirect) { + text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("muted", "→")} ${theme.fg("mdLinkUrl", details.finalUrl)}`; + } + if (details.truncated) { + text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("warning", "⚠ truncated")}`; + } + + const previewLines = getPreviewLines(contentBody, 3, 100); + for (const line of previewLines) { + text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", line)}`; + } + const totalLines = contentBody.split("\n").filter((l) => l.trim()).length; + if (totalLines > 3) { + text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `… ${totalLines - 3} more lines`)}`; + } else { + text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", details.contentType)}`; + } + } else { + // Expanded view: full metadata tree + content + const hasMeta = hasRedirect || hasNotes || details.truncated; + + if (hasMeta) { + // Metadata section + text += `\n ${theme.fg("dim", TREE_MID)} ${theme.fg("accent", "Metadata")}`; + text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", TREE_MID)} ${theme.fg("muted", "Content-Type:")} ${details.contentType}`; + if (hasRedirect) { + text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", TREE_MID)} ${theme.fg("muted", "Redirected from:")} ${theme.fg("mdLinkUrl", details.url)}`; + text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", `${TREE_PIPE} ${TREE_HOOK} `)}${theme.fg("mdLinkUrl", details.finalUrl)}`; + } + if (details.truncated) { + text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", TREE_MID)} ${theme.fg("warning", "⚠ Output was truncated")}`; + } + if (hasNotes) { + const notesBranch = TREE_END; + text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", notesBranch)} ${theme.fg("muted", "Notes:")} ${details.notes.join("; ")}`; + } + } + + // Content section + text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("accent", "Content")}`; + const contentLines = contentBody.split("\n"); + for (let i = 0; i < contentLines.length; i++) { + const line = contentLines[i]; + const isLast = i === contentLines.length - 1; + text += `\n ${isLast ? " " : theme.fg("dim", " ")} ${line}`; + } + } + + return new Text(text, 0, 0); +} + +type WebFetchParams = { url: string; timeout?: number; raw?: boolean }; + +/** Web fetch tool as CustomTool (for TUI rendering support) */ +export const webFetchCustomTool: CustomTool = { + name: "web_fetch", + label: "Web Fetch", + description: webFetchTool.description, + parameters: webFetchSchema, + + async execute( + toolCallId: string, + params: WebFetchParams, + _onUpdate, + _ctx: CustomToolContext, + _signal?: AbortSignal, + ) { + return webFetchTool.execute(toolCallId, params); + }, + + renderCall(args: WebFetchParams, theme: Theme) { + return renderWebFetchCall(args, theme); + }, + + renderResult(result, options: RenderResultOptions, theme: Theme) { + return renderWebFetchResult(result, options, theme); + }, +}; diff --git a/packages/coding-agent/src/core/tools/web-search/auth.ts b/packages/coding-agent/src/core/tools/web-search/auth.ts new file mode 100644 index 000000000..1829e5add --- /dev/null +++ b/packages/coding-agent/src/core/tools/web-search/auth.ts @@ -0,0 +1,193 @@ +/** + * Anthropic Authentication + * + * 4-tier auth resolution: + * 1. ANTHROPIC_SEARCH_API_KEY / ANTHROPIC_SEARCH_BASE_URL env vars + * 2. Provider with api="anthropic-messages" in ~/.pi/agent/models.json + * 3. OAuth credentials in ~/.pi/agent/auth.json (with expiry check) + * 4. ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL fallback + */ + +import * as os from "node:os"; +import * as path from "node:path"; +import type { AnthropicAuthConfig, AuthJson, ModelsJson } from "./types.js"; + +const DEFAULT_BASE_URL = "https://api.anthropic.com"; + +/** Parse a .env file and return key-value pairs */ +async function parseEnvFile(filePath: string): Promise> { + const result: Record = {}; + try { + const file = Bun.file(filePath); + if (!(await file.exists())) return result; + + const content = await file.text(); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const eqIndex = trimmed.indexOf("="); + if (eqIndex === -1) continue; + + const key = trimmed.slice(0, eqIndex).trim(); + let value = trimmed.slice(eqIndex + 1).trim(); + + // Remove surrounding quotes + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + result[key] = value; + } + } catch { + // Ignore read errors + } + return result; +} + +/** Get env var from process.env or .env files */ +export async function getEnv(key: string): Promise { + if (process.env[key]) return process.env[key]; + + const localEnv = await parseEnvFile(`${process.cwd()}/.env`); + if (localEnv[key]) return localEnv[key]; + + const homeEnv = await parseEnvFile(`${os.homedir()}/.env`); + if (homeEnv[key]) return homeEnv[key]; + + return undefined; +} + +/** Read JSON file safely */ +async function readJson(filePath: string): Promise { + try { + const file = Bun.file(filePath); + if (!(await file.exists())) return null; + const content = await file.text(); + return JSON.parse(content) as T; + } catch { + return null; + } +} + +/** Check if a token is an OAuth token (sk-ant-oat* prefix) */ +export function isOAuthToken(apiKey: string): boolean { + return apiKey.includes("sk-ant-oat"); +} + +/** + * Find Anthropic auth config using 4-tier priority: + * 1. ANTHROPIC_SEARCH_API_KEY / ANTHROPIC_SEARCH_BASE_URL + * 2. Provider with api="anthropic-messages" in models.json + * 3. OAuth in auth.json (with 5-minute expiry buffer) + * 4. ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL fallback + */ +export async function findAnthropicAuth(): Promise { + const piAgentDir = path.join(os.homedir(), ".pi", "agent"); + + // 1. Explicit search-specific env vars + const searchApiKey = await getEnv("ANTHROPIC_SEARCH_API_KEY"); + const searchBaseUrl = await getEnv("ANTHROPIC_SEARCH_BASE_URL"); + if (searchApiKey) { + return { + apiKey: searchApiKey, + baseUrl: searchBaseUrl ?? DEFAULT_BASE_URL, + isOAuth: isOAuthToken(searchApiKey), + }; + } + + // 2. Provider with api="anthropic-messages" in models.json + const modelsJson = await readJson(path.join(piAgentDir, "models.json")); + if (modelsJson?.providers) { + // First pass: look for providers with actual API keys + for (const [_name, provider] of Object.entries(modelsJson.providers)) { + if (provider.api === "anthropic-messages" && provider.apiKey && provider.apiKey !== "none") { + return { + apiKey: provider.apiKey, + baseUrl: provider.baseUrl ?? DEFAULT_BASE_URL, + isOAuth: isOAuthToken(provider.apiKey), + }; + } + } + // Second pass: check for proxy mode (baseUrl but apiKey="none") + for (const [_name, provider] of Object.entries(modelsJson.providers)) { + if (provider.api === "anthropic-messages" && provider.baseUrl) { + return { + apiKey: provider.apiKey ?? "", + baseUrl: provider.baseUrl, + isOAuth: false, + }; + } + } + } + + // 3. OAuth credentials in auth.json (with 5-minute expiry buffer) + const authJson = await readJson(path.join(piAgentDir, "auth.json")); + if (authJson?.anthropic?.type === "oauth" && authJson.anthropic.access) { + const expiryBuffer = 5 * 60 * 1000; // 5 minutes + if (authJson.anthropic.expires > Date.now() + expiryBuffer) { + return { + apiKey: authJson.anthropic.access, + baseUrl: DEFAULT_BASE_URL, + isOAuth: true, + }; + } + } + + // 4. Generic ANTHROPIC_API_KEY fallback + const apiKey = await getEnv("ANTHROPIC_API_KEY"); + const baseUrl = await getEnv("ANTHROPIC_BASE_URL"); + if (apiKey) { + return { + apiKey, + baseUrl: baseUrl ?? DEFAULT_BASE_URL, + isOAuth: isOAuthToken(apiKey), + }; + } + + return null; +} + +/** Build headers for Anthropic API request */ +export function buildAnthropicHeaders(auth: AnthropicAuthConfig): Record { + const betas = ["web-search-2025-03-05"]; + + if (auth.isOAuth) { + // OAuth requires additional beta headers and stainless telemetry + betas.push("oauth-2025-04-20", "claude-code-20250219", "prompt-caching-2024-07-31"); + + return { + "anthropic-version": "2023-06-01", + authorization: `Bearer ${auth.apiKey}`, + accept: "application/json", + "content-type": "application/json", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-beta": betas.join(","), + "user-agent": "pi-coding-agent/1.0.0 (cli)", + "x-app": "cli", + // Stainless SDK telemetry headers (required for OAuth) + "x-stainless-arch": process.arch, + "x-stainless-lang": "js", + "x-stainless-os": process.platform, + "x-stainless-package-version": "1.0.0", + "x-stainless-retry-count": "0", + "x-stainless-runtime": "bun", + "x-stainless-runtime-version": Bun.version, + }; + } + + // Standard API key auth + return { + "anthropic-version": "2023-06-01", + "x-api-key": auth.apiKey, + accept: "application/json", + "content-type": "application/json", + "anthropic-beta": betas.join(","), + }; +} + +/** Build API URL (OAuth requires ?beta=true) */ +export function buildAnthropicUrl(auth: AnthropicAuthConfig): string { + const base = `${auth.baseUrl}/v1/messages`; + return auth.isOAuth ? `${base}?beta=true` : base; +} diff --git a/packages/coding-agent/src/core/tools/web-search/index.ts b/packages/coding-agent/src/core/tools/web-search/index.ts new file mode 100644 index 000000000..5f03ff104 --- /dev/null +++ b/packages/coding-agent/src/core/tools/web-search/index.ts @@ -0,0 +1,219 @@ +/** + * Unified Web Search Tool + * + * Single tool supporting Anthropic and Perplexity providers with + * provider-specific parameters exposed conditionally. + */ + +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { Type } from "@sinclair/typebox"; +import type { Theme } from "../../../modes/interactive/theme/theme.js"; +import type { CustomTool, CustomToolContext, RenderResultOptions } from "../../custom-tools/types.js"; +import { searchAnthropic } from "./providers/anthropic.js"; +import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity.js"; +import { formatAge, renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render.js"; +import type { WebSearchProvider, WebSearchResponse } from "./types.js"; + +/** Web search parameters schema */ +export const webSearchSchema = Type.Object({ + // Common + query: Type.String({ description: "Search query" }), + provider: Type.Optional( + Type.Union([Type.Literal("anthropic"), Type.Literal("perplexity")], { + description: "Search provider (auto-detected if omitted based on API keys)", + }), + ), + num_results: Type.Optional(Type.Number({ description: "Maximum number of results to return" })), + + // Common (Anthropic & Perplexity) + system_prompt: Type.Optional( + Type.String({ + description: "System prompt to guide response style", + }), + ), + max_tokens: Type.Optional( + Type.Number({ + description: "Maximum tokens in response, 1-16384, default 4096 (Anthropic only)", + minimum: 1, + maximum: 16384, + }), + ), + + // Perplexity-specific + model: Type.Optional( + Type.Union([Type.Literal("sonar"), Type.Literal("sonar-pro")], { + description: "Perplexity model - sonar (fast) or sonar-pro (comprehensive research)", + }), + ), + search_recency_filter: Type.Optional( + Type.Union([Type.Literal("day"), Type.Literal("week"), Type.Literal("month"), Type.Literal("year")], { + description: "Filter results by recency (Perplexity only)", + }), + ), + search_domain_filter: Type.Optional( + Type.Array(Type.String(), { + description: "Domain filter - include domains, prefix with - to exclude (Perplexity only)", + }), + ), + search_context_size: Type.Optional( + Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high")], { + description: "Context size for cost control (Perplexity only)", + }), + ), + return_related_questions: Type.Optional( + Type.Boolean({ + description: "Include follow-up question suggestions, default true (Perplexity only)", + }), + ), +}); + +export type WebSearchParams = { + query: string; + provider?: "anthropic" | "perplexity"; + num_results?: number; + // Anthropic + system_prompt?: string; + max_tokens?: number; + // Perplexity + model?: "sonar" | "sonar-pro"; + search_recency_filter?: "day" | "week" | "month" | "year"; + search_domain_filter?: string[]; + search_context_size?: "low" | "medium" | "high"; + return_related_questions?: boolean; +}; + +/** Detect provider based on available API keys */ +async function detectProvider(): Promise { + // Perplexity takes priority if key exists (more specialized) + const perplexityKey = await findPerplexityKey(); + if (perplexityKey) return "perplexity"; + + // Default to Anthropic + return "anthropic"; +} + +/** Format response for LLM consumption */ +function formatForLLM(response: WebSearchResponse): string { + const parts: string[] = []; + + // Add synthesized answer + if (response.answer) { + parts.push(response.answer); + } + + // Add sources + if (response.sources.length > 0) { + parts.push("\n## Sources"); + for (const [i, src] of response.sources.entries()) { + const age = formatAge(src.ageSeconds) || src.publishedDate; + const agePart = age ? ` (${age})` : ""; + parts.push(`[${i + 1}] ${src.title}${agePart}\n ${src.url}`); + } + } + + // Add related questions (Perplexity) + if (response.relatedQuestions && response.relatedQuestions.length > 0) { + parts.push("\n## Related Questions"); + for (const q of response.relatedQuestions) { + parts.push(`- ${q}`); + } + } + + return parts.join("\n"); +} + +/** Execute web search */ +async function executeWebSearch( + _toolCallId: string, + params: WebSearchParams, +): Promise<{ content: Array<{ type: "text"; text: string }>; details: WebSearchRenderDetails }> { + try { + const provider = params.provider ?? (await detectProvider()); + + let response: WebSearchResponse; + if (provider === "anthropic") { + response = await searchAnthropic({ + query: params.query, + system_prompt: params.system_prompt, + max_tokens: params.max_tokens, + num_results: params.num_results, + }); + } else { + response = await searchPerplexity({ + query: params.query, + model: params.model, + system_prompt: params.system_prompt, + search_recency_filter: params.search_recency_filter, + search_domain_filter: params.search_domain_filter, + search_context_size: params.search_context_size, + return_related_questions: params.return_related_questions, + num_results: params.num_results, + }); + } + + const text = formatForLLM(response); + + return { + content: [{ type: "text" as const, text }], + details: { response }, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [{ type: "text" as const, text: `Error: ${message}` }], + details: { response: { provider: "anthropic", sources: [] }, error: message }, + }; + } +} + +const WEB_SEARCH_DESCRIPTION = `Search the web using Anthropic or Perplexity. Returns synthesized answers with citations. + +Provider auto-detected by API key presence, or specify explicitly. + +Common: system_prompt (guides response style) +Anthropic-specific: max_tokens +Perplexity-specific: model (sonar/sonar-pro), search_recency_filter, search_domain_filter, search_context_size, return_related_questions`; + +/** Web search tool as AgentTool (for allTools export) */ +export const webSearchTool: AgentTool = { + name: "web_search", + label: "Web Search", + description: WEB_SEARCH_DESCRIPTION, + parameters: webSearchSchema, + execute: async (toolCallId, params) => { + return executeWebSearch(toolCallId, params as WebSearchParams); + }, +}; + +/** Web search tool as CustomTool (for TUI rendering support) */ +export const webSearchCustomTool: CustomTool = { + name: "web_search", + label: "Web Search", + description: WEB_SEARCH_DESCRIPTION, + parameters: webSearchSchema, + + async execute( + toolCallId: string, + params: WebSearchParams, + _onUpdate, + _ctx: CustomToolContext, + _signal?: AbortSignal, + ) { + return executeWebSearch(toolCallId, params); + }, + + renderCall(args: WebSearchParams, theme: Theme) { + return renderWebSearchCall(args, theme); + }, + + renderResult(result, options: RenderResultOptions, theme: Theme) { + return renderWebSearchResult(result, options, theme); + }, +}; + +/** Factory function for backward compatibility */ +export function createWebSearchTool(_cwd: string): AgentTool { + return webSearchTool; +} + +export type { WebSearchProvider, WebSearchResponse } from "./types.js"; diff --git a/packages/coding-agent/src/core/tools/web-search/providers/anthropic.ts b/packages/coding-agent/src/core/tools/web-search/providers/anthropic.ts new file mode 100644 index 000000000..7af2d2fb2 --- /dev/null +++ b/packages/coding-agent/src/core/tools/web-search/providers/anthropic.ts @@ -0,0 +1,198 @@ +/** + * Anthropic Web Search Provider + * + * Uses Claude's built-in web_search_20250305 tool to search the web. + * Returns synthesized answers with citations and source metadata. + */ + +import { buildAnthropicHeaders, buildAnthropicUrl, findAnthropicAuth, getEnv } from "../auth.js"; +import type { + AnthropicApiResponse, + AnthropicAuthConfig, + AnthropicCitation, + WebSearchCitation, + WebSearchResponse, + WebSearchSource, +} from "../types.js"; + +const DEFAULT_MODEL = "claude-sonnet-4-5-20250514"; +const DEFAULT_MAX_TOKENS = 4096; + +export interface AnthropicSearchParams { + query: string; + system_prompt?: string; + max_tokens?: number; + num_results?: number; +} + +/** Get model from env or use default */ +async function getModel(): Promise { + return (await getEnv("ANTHROPIC_SEARCH_MODEL")) ?? DEFAULT_MODEL; +} + +/** Call Anthropic API with web search */ +async function callWebSearch( + auth: AnthropicAuthConfig, + model: string, + query: string, + systemPrompt?: string, + maxTokens?: number, +): Promise { + const url = buildAnthropicUrl(auth); + const headers = buildAnthropicHeaders(auth); + + // Build system blocks + const systemBlocks: Array<{ type: string; text: string; cache_control?: { type: string } }> = []; + + if (auth.isOAuth) { + // OAuth requires Claude Code identity with cache_control + systemBlocks.push({ + type: "text", + text: "You are a helpful AI assistant with web search capabilities.", + cache_control: { type: "ephemeral" }, + }); + } + + if (systemPrompt) { + systemBlocks.push({ + type: "text", + text: systemPrompt, + ...(auth.isOAuth ? { cache_control: { type: "ephemeral" } } : {}), + }); + } + + const body: Record = { + model, + max_tokens: maxTokens ?? DEFAULT_MAX_TOKENS, + messages: [{ role: "user", content: query }], + tools: [{ type: "web_search_20250305", name: "web_search" }], + }; + + if (systemBlocks.length > 0) { + body.system = systemBlocks; + } + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Anthropic API error (${response.status}): ${errorText}`); + } + + return response.json() as Promise; +} + +/** Parse page_age string into seconds (e.g., "2 days ago", "3h ago", "1 week ago") */ +function parsePageAge(pageAge: string | null | undefined): number | undefined { + if (!pageAge) return undefined; + + const match = pageAge.match(/^(\d+)\s*(s|sec|second|m|min|minute|h|hour|d|day|w|week|mo|month|y|year)s?\s*(ago)?$/i); + if (!match) return undefined; + + const value = parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + + const multipliers: Record = { + s: 1, + sec: 1, + second: 1, + m: 60, + min: 60, + minute: 60, + h: 3600, + hour: 3600, + d: 86400, + day: 86400, + w: 604800, + week: 604800, + mo: 2592000, + month: 2592000, + y: 31536000, + year: 31536000, + }; + + return value * (multipliers[unit] ?? 86400); +} + +/** Parse API response into unified WebSearchResponse */ +function parseResponse(response: AnthropicApiResponse): WebSearchResponse { + const answerParts: string[] = []; + const searchQueries: string[] = []; + const sources: WebSearchSource[] = []; + const citations: WebSearchCitation[] = []; + + for (const block of response.content) { + if (block.type === "server_tool_use" && block.name === "web_search") { + // Intermediate search query + if (block.input?.query) { + searchQueries.push(block.input.query); + } + } else if (block.type === "web_search_tool_result" && block.content) { + // Search results + for (const result of block.content) { + if (result.type === "web_search_result") { + sources.push({ + title: result.title, + url: result.url, + snippet: result.encrypted_content, + publishedDate: result.page_age ?? undefined, + ageSeconds: parsePageAge(result.page_age), + }); + } + } + } else if (block.type === "text" && block.text) { + // Synthesized answer with citations + answerParts.push(block.text); + if (block.citations) { + for (const c of block.citations as AnthropicCitation[]) { + citations.push({ + url: c.url, + title: c.title, + citedText: c.cited_text, + }); + } + } + } + } + + return { + provider: "anthropic", + answer: answerParts.join("\n\n") || undefined, + sources, + citations: citations.length > 0 ? citations : undefined, + searchQueries: searchQueries.length > 0 ? searchQueries : undefined, + usage: { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + searchRequests: response.usage.server_tool_use?.web_search_requests, + }, + model: response.model, + requestId: response.id, + }; +} + +/** Execute Anthropic web search */ +export async function searchAnthropic(params: AnthropicSearchParams): Promise { + const auth = await findAnthropicAuth(); + if (!auth) { + throw new Error( + "No Anthropic credentials found. Set ANTHROPIC_API_KEY or configure OAuth in ~/.pi/agent/auth.json", + ); + } + + const model = await getModel(); + const response = await callWebSearch(auth, model, params.query, params.system_prompt, params.max_tokens); + + const result = parseResponse(response); + + // Apply num_results limit if specified + if (params.num_results && result.sources.length > params.num_results) { + result.sources = result.sources.slice(0, params.num_results); + } + + return result; +} diff --git a/packages/coding-agent/src/core/tools/web-search/providers/perplexity.ts b/packages/coding-agent/src/core/tools/web-search/providers/perplexity.ts new file mode 100644 index 000000000..b49c4a97d --- /dev/null +++ b/packages/coding-agent/src/core/tools/web-search/providers/perplexity.ts @@ -0,0 +1,195 @@ +/** + * Perplexity Web Search Provider + * + * Supports both sonar (fast) and sonar-pro (comprehensive) models. + * Returns synthesized answers with citations and related questions. + */ + +import * as os from "node:os"; +import type { + PerplexityRequest, + PerplexityResponse, + WebSearchCitation, + WebSearchResponse, + WebSearchSource, +} from "../types.js"; + +const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions"; + +export interface PerplexitySearchParams { + query: string; + model?: "sonar" | "sonar-pro"; + system_prompt?: string; + search_recency_filter?: "day" | "week" | "month" | "year"; + search_domain_filter?: string[]; + search_context_size?: "low" | "medium" | "high"; + return_related_questions?: boolean; + num_results?: number; +} + +/** Parse a .env file and return key-value pairs */ +async function parseEnvFile(filePath: string): Promise> { + const result: Record = {}; + try { + const file = Bun.file(filePath); + if (!(await file.exists())) return result; + + const content = await file.text(); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const eqIndex = trimmed.indexOf("="); + if (eqIndex === -1) continue; + + const key = trimmed.slice(0, eqIndex).trim(); + let value = trimmed.slice(eqIndex + 1).trim(); + + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + result[key] = value; + } + } catch { + // Ignore read errors + } + return result; +} + +/** Find PERPLEXITY_API_KEY from environment or .env files */ +export async function findApiKey(): Promise { + // 1. Check environment variable + if (process.env.PERPLEXITY_API_KEY) { + return process.env.PERPLEXITY_API_KEY; + } + + // 2. Check .env in current directory + const localEnv = await parseEnvFile(`${process.cwd()}/.env`); + if (localEnv.PERPLEXITY_API_KEY) { + return localEnv.PERPLEXITY_API_KEY; + } + + // 3. Check ~/.env + const homeEnv = await parseEnvFile(`${os.homedir()}/.env`); + if (homeEnv.PERPLEXITY_API_KEY) { + return homeEnv.PERPLEXITY_API_KEY; + } + + return null; +} + +/** Call Perplexity API */ +async function callPerplexity(apiKey: string, request: PerplexityRequest): Promise { + const response = await fetch(PERPLEXITY_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Perplexity API error (${response.status}): ${errorText}`); + } + + return response.json() as Promise; +} + +/** Calculate age in seconds from ISO date string */ +function dateToAgeSeconds(dateStr: string | undefined): number | undefined { + if (!dateStr) return undefined; + try { + const date = new Date(dateStr); + if (Number.isNaN(date.getTime())) return undefined; + return Math.floor((Date.now() - date.getTime()) / 1000); + } catch { + return undefined; + } +} + +/** Parse API response into unified WebSearchResponse */ +function parseResponse(response: PerplexityResponse): WebSearchResponse { + const answer = response.choices[0]?.message?.content ?? ""; + + // Build sources by matching citations to search_results + const sources: WebSearchSource[] = []; + const citations: WebSearchCitation[] = []; + + const citationUrls = response.citations ?? []; + const searchResults = response.search_results ?? []; + + for (const url of citationUrls) { + const searchResult = searchResults.find((r) => r.url === url); + sources.push({ + title: searchResult?.title ?? url, + url, + snippet: searchResult?.snippet, + publishedDate: searchResult?.date, + ageSeconds: dateToAgeSeconds(searchResult?.date), + }); + citations.push({ + url, + title: searchResult?.title ?? url, + }); + } + + return { + provider: "perplexity", + answer: answer || undefined, + sources, + citations: citations.length > 0 ? citations : undefined, + relatedQuestions: response.related_questions, + usage: { + inputTokens: response.usage.prompt_tokens, + outputTokens: response.usage.completion_tokens, + totalTokens: response.usage.total_tokens, + }, + model: response.model, + requestId: response.id, + }; +} + +/** Execute Perplexity web search */ +export async function searchPerplexity(params: PerplexitySearchParams): Promise { + const apiKey = await findApiKey(); + if (!apiKey) { + throw new Error("PERPLEXITY_API_KEY not found. Set it in environment or .env file."); + } + + const messages: PerplexityRequest["messages"] = []; + if (params.system_prompt) { + messages.push({ role: "system", content: params.system_prompt }); + } + messages.push({ role: "user", content: params.query }); + + const request: PerplexityRequest = { + model: params.model ?? "sonar", + messages, + // Default to true for related questions (unlike original which hardcoded false) + return_related_questions: params.return_related_questions ?? true, + }; + + // Add optional parameters + if (params.search_recency_filter) { + request.search_recency_filter = params.search_recency_filter; + } + if (params.search_domain_filter && params.search_domain_filter.length > 0) { + request.search_domain_filter = params.search_domain_filter; + } + if (params.search_context_size) { + request.search_context_size = params.search_context_size; + } + + const response = await callPerplexity(apiKey, request); + const result = parseResponse(response); + + // Apply num_results limit if specified + if (params.num_results && result.sources.length > params.num_results) { + result.sources = result.sources.slice(0, params.num_results); + } + + return result; +} diff --git a/packages/coding-agent/src/core/tools/web-search/render.ts b/packages/coding-agent/src/core/tools/web-search/render.ts new file mode 100644 index 000000000..2b078d6cf --- /dev/null +++ b/packages/coding-agent/src/core/tools/web-search/render.ts @@ -0,0 +1,170 @@ +/** + * Web Search TUI Rendering + * + * Tree-based rendering with collapsed/expanded states for web search results. + */ + +import type { Component } from "@mariozechner/pi-tui"; +import { Text } from "@mariozechner/pi-tui"; +import type { Theme } from "../../../modes/interactive/theme/theme.js"; +import type { RenderResultOptions } from "../../custom-tools/types.js"; +import type { WebSearchResponse } from "./types.js"; + +// Tree formatting constants +const TREE_MID = "├─"; +const TREE_END = "└─"; +const TREE_PIPE = "│"; +const TREE_SPACE = " "; +const TREE_HOOK = "⎿"; + +/** Truncate text to max length with ellipsis */ +export function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return `${text.slice(0, maxLen - 1)}…`; +} + +/** Extract domain from URL */ +export function getDomain(url: string): string { + try { + const u = new URL(url); + return u.hostname.replace(/^www\./, ""); + } catch { + return url; + } +} + +/** Format age string from seconds */ +export function formatAge(ageSeconds: number | null | undefined): string { + if (!ageSeconds) return ""; + const mins = Math.floor(ageSeconds / 60); + const hours = Math.floor(mins / 60); + const days = Math.floor(hours / 24); + const weeks = Math.floor(days / 7); + const months = Math.floor(days / 30); + + if (months > 0) return `${months}mo ago`; + if (weeks > 0) return `${weeks}w ago`; + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (mins > 0) return `${mins}m ago`; + return "just now"; +} + +/** Get first N lines of text as preview */ +export function getPreviewLines(text: string, maxLines: number, maxLineLen: number): string[] { + const lines = text.split("\n").filter((l) => l.trim()); + return lines.slice(0, maxLines).map((l) => truncate(l.trim(), maxLineLen)); +} + +export interface WebSearchRenderDetails { + response: WebSearchResponse; + error?: string; +} + +/** Render web search result with tree-based layout */ +export function renderWebSearchResult( + result: { content: Array<{ type: string; text?: string }>; details?: WebSearchRenderDetails }, + options: RenderResultOptions, + theme: Theme, +): Component { + const { expanded } = options; + const details = result.details; + + // Handle error case + if (details?.error) { + return new Text(theme.fg("error", `Error: ${details.error}`), 0, 0); + } + + const response = details?.response; + if (!response) { + return new Text(theme.fg("error", "No response data"), 0, 0); + } + + const sources = response.sources ?? []; + const sourceCount = sources.length; + const _modelName = response.model ?? response.provider; + const provider = response.provider; + + // Build header: ● Web Search (provider/model) · N sources + const icon = sourceCount > 0 ? theme.fg("success", "●") : theme.fg("warning", "●"); + const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)"); + const providerLabel = provider === "anthropic" ? "Anthropic" : "Perplexity"; + let text = `${icon} ${theme.fg("toolTitle", "Web Search")} ${theme.fg("dim", `(${providerLabel})`)} · ${theme.fg("dim", `${sourceCount} source${sourceCount !== 1 ? "s" : ""}`)}${expandHint}`; + + // Get answer text + const contentText = response.answer ?? result.content[0]?.text ?? ""; + + if (!expanded) { + // Collapsed view: show 2-3 preview lines of answer + const previewLines = getPreviewLines(contentText, 3, 100); + for (const line of previewLines) { + text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("dim", line)}`; + } + const totalLines = contentText.split("\n").filter((l) => l.trim()).length; + if (totalLines > 3) { + text += `\n ${theme.fg("dim", TREE_PIPE)} ${theme.fg("muted", `… ${totalLines - 3} more lines`)}`; + } + + // Show source count summary + if (sourceCount > 0) { + text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `${sourceCount} source${sourceCount !== 1 ? "s" : ""}`)}`; + } + } else { + // Expanded view: full answer + source tree + const answerLines = contentText.split("\n"); + for (const line of answerLines) { + text += `\n ${theme.fg("dim", TREE_PIPE)} ${line}`; + } + + // Render sources as tree + const hasRelatedQuestions = response.relatedQuestions && response.relatedQuestions.length > 0; + + if (sourceCount > 0) { + text += `\n ${theme.fg("dim", TREE_PIPE)}`; + const sourcesBranch = hasRelatedQuestions ? TREE_MID : TREE_END; + text += `\n ${theme.fg("dim", sourcesBranch)} ${theme.fg("accent", "Sources")}`; + + for (let i = 0; i < sources.length; i++) { + const src = sources[i]; + const isLast = i === sources.length - 1; + const branch = isLast ? TREE_END : TREE_MID; + const cont = isLast ? TREE_SPACE : TREE_PIPE; + const indent = hasRelatedQuestions ? TREE_PIPE : TREE_SPACE; + + // Title + domain + age + const title = truncate(src.title, 60); + const domain = getDomain(src.url); + const age = formatAge(src.ageSeconds) || src.publishedDate; + const agePart = age ? theme.fg("muted", ` · ${age}`) : ""; + + text += `\n ${theme.fg("dim", indent)} ${theme.fg("dim", branch)} ${theme.fg("accent", title)} ${theme.fg("dim", `(${domain})`)}${agePart}`; + text += `\n ${theme.fg("dim", indent)} ${theme.fg("dim", `${cont} ${TREE_HOOK} `)}${theme.fg("mdLinkUrl", src.url)}`; + } + } + + // Render related questions (Perplexity only) + if (hasRelatedQuestions) { + text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("accent", "Related Questions")}`; + const questions = response.relatedQuestions!; + for (let i = 0; i < questions.length; i++) { + const question = questions[i]; + const isLast = i === questions.length - 1; + const branch = isLast ? TREE_END : TREE_MID; + text += `\n ${theme.fg("dim", TREE_SPACE)} ${theme.fg("dim", branch)} ${theme.fg("muted", question)}`; + } + } + } + + return new Text(text, 0, 0); +} + +/** Render web search call (query preview) */ +export function renderWebSearchCall( + args: { query: string; provider?: string; [key: string]: unknown }, + theme: Theme, +): Component { + const provider = args.provider ?? "auto"; + const query = truncate(args.query, 80); + const text = `${theme.fg("toolTitle", "Web Search")} ${theme.fg("dim", `(${provider})`)} ${theme.fg("muted", query)}`; + return new Text(text, 0, 0); +} diff --git a/packages/coding-agent/src/core/tools/web-search/types.ts b/packages/coding-agent/src/core/tools/web-search/types.ts new file mode 100644 index 000000000..d791b9ecb --- /dev/null +++ b/packages/coding-agent/src/core/tools/web-search/types.ts @@ -0,0 +1,180 @@ +/** + * Web Search Types + * + * Unified types for web search responses across Anthropic and Perplexity providers. + */ + +/** Supported web search providers */ +export type WebSearchProvider = "anthropic" | "perplexity"; + +/** Source returned by search (all providers) */ +export interface WebSearchSource { + title: string; + url: string; + snippet?: string; + /** ISO date string or relative ("2d ago") */ + publishedDate?: string; + /** Age in seconds for consistent formatting */ + ageSeconds?: number; + author?: string; +} + +/** Citation with text reference (anthropic, perplexity) */ +export interface WebSearchCitation { + url: string; + title: string; + citedText?: string; +} + +/** Usage metrics */ +export interface WebSearchUsage { + inputTokens?: number; + outputTokens?: number; + /** Anthropic: number of web search requests made */ + searchRequests?: number; + /** Perplexity: combined token count */ + totalTokens?: number; +} + +/** Unified response across providers */ +export interface WebSearchResponse { + provider: WebSearchProvider; + /** Synthesized answer text (anthropic, perplexity) */ + answer?: string; + /** Search result sources */ + sources: WebSearchSource[]; + /** Text citations with context */ + citations?: WebSearchCitation[]; + /** Follow-up questions (perplexity) */ + relatedQuestions?: string[]; + /** Intermediate search queries (anthropic) */ + searchQueries?: string[]; + /** Token usage metrics */ + usage?: WebSearchUsage; + /** Model used */ + model?: string; + /** Request ID for debugging */ + requestId?: string; +} + +/** Auth configuration for Anthropic */ +export interface AnthropicAuthConfig { + apiKey: string; + baseUrl: string; + isOAuth: boolean; +} + +/** models.json structure for provider resolution */ +export interface ModelsJson { + providers?: Record< + string, + { + baseUrl?: string; + apiKey?: string; + api?: string; + } + >; +} + +/** auth.json structure for OAuth credentials */ +export interface AuthJson { + anthropic?: { + type: "oauth"; + access: string; + refresh?: string; + /** Expiry timestamp in milliseconds */ + expires: number; + }; +} + +/** Anthropic API response types */ +export interface AnthropicWebSearchResult { + type: "web_search_result"; + title: string; + url: string; + encrypted_content: string; + page_age: string | null; +} + +export interface AnthropicCitation { + type: "web_search_result_location"; + url: string; + title: string; + cited_text: string; + encrypted_index: string; +} + +export interface AnthropicContentBlock { + type: string; + /** Text content (for type="text") */ + text?: string; + /** Citations in text block */ + citations?: AnthropicCitation[]; + /** Tool name (for type="server_tool_use") */ + name?: string; + /** Tool input (for type="server_tool_use") */ + input?: { query: string }; + /** Search results (for type="web_search_tool_result") */ + content?: AnthropicWebSearchResult[]; +} + +export interface AnthropicApiResponse { + id: string; + model: string; + content: AnthropicContentBlock[]; + usage: { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + server_tool_use?: { web_search_requests: number }; + }; +} + +/** Perplexity API types */ +export interface PerplexityMessage { + role: "system" | "user" | "assistant"; + content: string; +} + +export interface PerplexityRequest { + model: string; + messages: PerplexityMessage[]; + temperature?: number; + max_tokens?: number; + search_domain_filter?: string[]; + search_recency_filter?: "day" | "week" | "month" | "year"; + return_images?: boolean; + return_related_questions?: boolean; + search_context_size?: "low" | "medium" | "high"; +} + +export interface PerplexitySearchResult { + title: string; + url: string; + date?: string; + snippet?: string; +} + +export interface PerplexityResponse { + id: string; + model: string; + created: number; + usage: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + search_context_size?: string; + }; + citations?: string[]; + search_results?: PerplexitySearchResult[]; + related_questions?: string[]; + choices: Array<{ + index: number; + finish_reason: string; + message: { + role: string; + content: string; + }; + }>; +} diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index ba2e1a522..98c8da699 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -1,4 +1,7 @@ // Core session management + +// Re-export TUI components for custom tool rendering +export { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; export { AgentSession, type AgentSessionConfig, @@ -155,7 +158,19 @@ export { } from "./core/tools/index.js"; // Main entry point export { main } from "./main.js"; -// UI components for hooks +// UI components for hooks and custom tools export { BorderedLoader } from "./modes/interactive/components/bordered-loader.js"; // Theme utilities for custom tools export { getMarkdownTheme } from "./modes/interactive/theme/theme.js"; + +// TypeBox helper for string enums (convenience for custom tools) +import { type TSchema, Type } from "@sinclair/typebox"; +export function StringEnum( + values: T, + options?: { description?: string; default?: T[number] }, +): TSchema { + return Type.Union( + values.map((v) => Type.Literal(v)), + options, + ); +} diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 9db8f45ee..6f8019f34 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -12,10 +12,10 @@ import { join } from "path"; import { type Args, parseArgs, printHelp } from "./cli/args.js"; import { processFileArguments } from "./cli/file-processor.js"; import { listModels } from "./cli/list-models.js"; +import { parsePluginArgs, printPluginHelp, runPluginCommand } from "./cli/plugin-cli.js"; import { selectSession } from "./cli/session-picker.js"; import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js"; import type { AgentSession } from "./core/agent-session.js"; - import type { LoadedCustomTool } from "./core/custom-tools/index.js"; import { exportFromFile } from "./core/export-html/index.js"; import type { HookUIContext } from "./core/index.js"; @@ -35,7 +35,7 @@ import { ensureTool } from "./utils/tools-manager.js"; async function checkForNewVersion(currentVersion: string): Promise { try { - const response = await fetch("https://registry.npmjs.org/@mariozechner/pi -coding-agent/latest"); + const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest"); if (!response.ok) return undefined; const data = (await response.json()) as { version?: string }; @@ -109,9 +109,9 @@ async function runInteractiveMode( } while (true) { - const userInput = await mode.getUserInput(); + const { text, images } = await mode.getUserInput(); try { - await session.prompt(userInput); + await session.prompt(text, { images }); } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : "Unknown error occurred"; mode.showError(errorMessage); @@ -283,6 +283,17 @@ function buildSessionOptions( export async function main(args: string[]) { time("start"); + // Handle plugin subcommand before regular parsing + const pluginCmd = parsePluginArgs(args); + if (pluginCmd) { + if (args.includes("--help") || args.includes("-h")) { + printPluginHelp(); + return; + } + await runPluginCommand(pluginCmd); + return; + } + // Run migrations const { migratedAuthProviders: migratedProviders } = runMigrations(); @@ -370,6 +381,7 @@ export async function main(args: string[]) { const sessionOptions = buildSessionOptions(parsed, scopedModels, sessionManager, modelRegistry); sessionOptions.authStorage = authStorage; sessionOptions.modelRegistry = modelRegistry; + sessionOptions.hasUI = isInteractive; // Handle CLI --api-key as runtime override (not persisted) if (parsed.apiKey) { diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 3374a0539..2989a604a 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -7,6 +7,7 @@ import { isCtrlO, isCtrlP, isCtrlT, + isCtrlV, isCtrlZ, isEscape, isShiftCtrlP, @@ -29,8 +30,16 @@ export class CustomEditor extends Editor { public onCtrlG?: () => void; public onCtrlZ?: () => void; public onQuestionMark?: () => void; + /** Called when Ctrl+V is pressed. Returns true if handled (image found), false to fall through to text paste. */ + public onCtrlV?: () => Promise; handleInput(data: string): void { + // Intercept Ctrl+V for image paste (async - fires and handles result) + if (isCtrlV(data) && this.onCtrlV) { + void this.onCtrlV(); + return; + } + // Intercept Ctrl+G for external editor if (isCtrlG(data) && this.onCtrlG) { this.onCtrlG(); diff --git a/packages/coding-agent/src/modes/interactive/components/plugin-settings.ts b/packages/coding-agent/src/modes/interactive/components/plugin-settings.ts new file mode 100644 index 000000000..a9590dbc5 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/plugin-settings.ts @@ -0,0 +1,477 @@ +/** + * Plugin settings UI components. + * + * Provides a hierarchical settings interface: + * - Plugin list (shows all installed plugins) + * - Plugin detail (enable/disable, features, config) + * - Feature toggles + * - Config value editor + */ + +import { + Container, + Input, + type SelectItem, + SelectList, + type SettingItem, + SettingsList, + Spacer, + Text, +} from "@mariozechner/pi-tui"; +import { PluginManager } from "../../../core/plugins/manager.js"; +import type { InstalledPlugin, PluginSettingSchema } from "../../../core/plugins/types.js"; +import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +// ============================================================================= +// Plugin List Component +// ============================================================================= + +export interface PluginListCallbacks { + onPluginSelect: (plugin: InstalledPlugin) => void; + onCancel: () => void; +} + +/** + * Shows list of installed plugins with enable/disable status. + * Selecting a plugin opens its detail view. + */ +export class PluginListComponent extends Container { + private selectList: SelectList; + private plugins: InstalledPlugin[]; + + constructor(plugins: InstalledPlugin[], callbacks: PluginListCallbacks) { + super(); + this.plugins = plugins; + + // Title + this.addChild(new DynamicBorder()); + this.addChild(new Text(theme.bold(theme.fg("accent", " Plugins")), 0, 0)); + this.addChild(new Spacer(1)); + + if (plugins.length === 0) { + this.addChild(new Text(theme.fg("muted", " No plugins installed"), 0, 0)); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Install with: pi plugin install "), 0, 0)); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder()); + + // Create empty list that just handles escape + this.selectList = new SelectList([], 1, getSelectListTheme()); + this.selectList.onCancel = callbacks.onCancel; + return; + } + + const items: SelectItem[] = plugins.map((p) => { + const status = p.enabled ? theme.fg("success", "●") : theme.fg("muted", "○"); + const featureCount = p.manifest.features ? Object.keys(p.manifest.features).length : 0; + const enabledCount = p.enabledFeatures?.length ?? featureCount; + + let details = `v${p.version}`; + if (featureCount > 0) { + details += ` · ${enabledCount}/${featureCount} features`; + } + + return { + value: p.name, + label: `${status} ${p.name}`, + description: details, + }; + }); + + this.selectList = new SelectList(items, Math.min(items.length, 8), getSelectListTheme()); + + this.selectList.onSelect = (item) => { + const plugin = this.plugins.find((p) => p.name === item.value); + if (plugin) { + callbacks.onPluginSelect(plugin); + } + }; + + this.selectList.onCancel = callbacks.onCancel; + + this.addChild(this.selectList); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Enter to configure · Esc to go back"), 0, 0)); + this.addChild(new DynamicBorder()); + } + + handleInput(data: string): void { + this.selectList.handleInput(data); + } +} + +// ============================================================================= +// Plugin Detail Component +// ============================================================================= + +export interface PluginDetailCallbacks { + onEnabledChange: (enabled: boolean) => void; + onFeatureChange: (feature: string, enabled: boolean) => void; + onConfigChange: (key: string, value: unknown) => void; + onBack: () => void; +} + +/** + * Shows detail settings for a single plugin: + * - Enable/disable toggle + * - Feature toggles + * - Config settings + */ +export class PluginDetailComponent extends Container { + private settingsList!: SettingsList; + private plugin: InstalledPlugin; + private manager: PluginManager; + private callbacks: PluginDetailCallbacks; + + constructor(plugin: InstalledPlugin, manager: PluginManager, callbacks: PluginDetailCallbacks) { + super(); + this.plugin = plugin; + this.manager = manager; + this.callbacks = callbacks; + + this.rebuild(); + } + + private rebuild(): void { + this.clear(); + + const plugin = this.plugin; + const manifest = plugin.manifest; + + // Header + this.addChild(new DynamicBorder()); + this.addChild(new Text(theme.bold(theme.fg("accent", ` ${plugin.name}`)), 0, 0)); + if (manifest.description) { + this.addChild(new Text(theme.fg("muted", ` ${manifest.description}`), 0, 0)); + } + this.addChild(new Spacer(1)); + + const items: SettingItem[] = []; + + // Enable/disable toggle + items.push({ + id: "__enabled__", + label: "Enabled", + description: "Enable or disable this plugin", + currentValue: plugin.enabled ? "true" : "false", + values: ["true", "false"], + }); + + // Feature toggles + if (manifest.features && Object.keys(manifest.features).length > 0) { + const enabledSet = new Set(plugin.enabledFeatures ?? []); + const defaultFeatures = Object.entries(manifest.features) + .filter(([_, f]) => f.default) + .map(([name]) => name); + + // If enabledFeatures is null, use defaults + const effectiveEnabled = plugin.enabledFeatures === null ? new Set(defaultFeatures) : enabledSet; + + for (const [featName, feat] of Object.entries(manifest.features)) { + const isEnabled = effectiveEnabled.has(featName); + items.push({ + id: `feature:${featName}`, + label: ` ${featName}`, + description: feat.description || `Enable ${featName} feature`, + currentValue: isEnabled ? "true" : "false", + values: ["true", "false"], + }); + } + } + + // Config settings + if (manifest.settings && Object.keys(manifest.settings).length > 0) { + const settings = this.manager.getPluginSettings(plugin.name); + + for (const [key, schema] of Object.entries(manifest.settings)) { + const currentValue = settings[key] ?? schema.default; + const displayValue = schema.secret && currentValue ? "••••••••" : String(currentValue ?? "(not set)"); + + if (schema.type === "boolean") { + items.push({ + id: `config:${key}`, + label: ` ${key}`, + description: schema.description || `Configure ${key}`, + currentValue: currentValue ? "true" : "false", + values: ["true", "false"], + }); + } else if (schema.type === "enum") { + items.push({ + id: `config:${key}`, + label: ` ${key}`, + description: schema.description || `Configure ${key}`, + currentValue: String(currentValue ?? schema.default ?? ""), + submenu: (cv, done) => + new ConfigEnumSubmenu( + key, + schema.description || `Select value for ${key}`, + schema.values, + cv, + (value) => { + this.callbacks.onConfigChange(key, value); + done(value); + }, + () => done(), + ), + }); + } else { + // string or number - show as submenu with input + items.push({ + id: `config:${key}`, + label: ` ${key}`, + description: schema.description || `Configure ${key}`, + currentValue: displayValue, + submenu: (cv, done) => + new ConfigInputSubmenu( + key, + schema, + cv === "(not set)" ? "" : cv, + (value) => { + const parsed = schema.type === "number" ? Number(value) : value; + this.callbacks.onConfigChange(key, parsed); + done(String(value)); + }, + () => done(), + ), + }); + } + } + } + + this.settingsList = new SettingsList( + items, + Math.min(items.length, 10), + getSettingsListTheme(), + (id, newValue) => { + if (id === "__enabled__") { + this.callbacks.onEnabledChange(newValue === "true"); + this.plugin = { ...this.plugin, enabled: newValue === "true" }; + } else if (id.startsWith("feature:")) { + const featName = id.slice(8); + this.callbacks.onFeatureChange(featName, newValue === "true"); + // Update local state + const current = new Set(this.plugin.enabledFeatures ?? []); + if (newValue === "true") { + current.add(featName); + } else { + current.delete(featName); + } + this.plugin = { ...this.plugin, enabledFeatures: [...current] }; + } else if (id.startsWith("config:")) { + const key = id.slice(7); + const schema = this.plugin.manifest.settings?.[key]; + if (schema?.type === "boolean") { + this.callbacks.onConfigChange(key, newValue === "true"); + } + } + }, + this.callbacks.onBack, + ); + + this.addChild(this.settingsList); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Enter to edit · Esc to go back"), 0, 0)); + this.addChild(new DynamicBorder()); + } + + handleInput(data: string): void { + this.settingsList.handleInput(data); + } +} + +// ============================================================================= +// Config Submenus +// ============================================================================= + +/** + * Submenu for enum config values. + */ +class ConfigEnumSubmenu extends Container { + private selectList: SelectList; + + constructor( + key: string, + description: string, + values: string[], + currentValue: string, + onSelect: (value: string) => void, + onCancel: () => void, + ) { + super(); + + this.addChild(new Text(theme.bold(theme.fg("accent", key)), 0, 0)); + if (description) { + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", description), 0, 0)); + } + this.addChild(new Spacer(1)); + + const items: SelectItem[] = values.map((v) => ({ value: v, label: v })); + this.selectList = new SelectList(items, Math.min(items.length, 8), getSelectListTheme()); + + const currentIndex = values.indexOf(currentValue); + if (currentIndex !== -1) { + this.selectList.setSelectedIndex(currentIndex); + } + + this.selectList.onSelect = (item) => onSelect(item.value); + this.selectList.onCancel = onCancel; + + this.addChild(this.selectList); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Enter to select · Esc to cancel"), 0, 0)); + } + + handleInput(data: string): void { + this.selectList.handleInput(data); + } +} + +/** + * Submenu for string/number config values with text input. + */ +class ConfigInputSubmenu extends Container { + private input: Input; + private onSubmit: (value: string) => void; + private onCancel: () => void; + + constructor( + key: string, + schema: PluginSettingSchema, + currentValue: string, + onSubmit: (value: string) => void, + onCancel: () => void, + ) { + super(); + this.onSubmit = onSubmit; + this.onCancel = onCancel; + + this.addChild(new Text(theme.bold(theme.fg("accent", key)), 0, 0)); + if (schema.description) { + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", schema.description), 0, 0)); + } + + // Type hint + let hint = `Type: ${schema.type}`; + if (schema.type === "number") { + const numSchema = schema as { min?: number; max?: number }; + if (numSchema.min !== undefined || numSchema.max !== undefined) { + hint += ` (${numSchema.min ?? ""}..${numSchema.max ?? ""})`; + } + } + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", hint), 0, 0)); + + this.addChild(new Spacer(1)); + + // Input field + this.input = new Input(); + if (!schema.secret && currentValue) { + this.input.setValue(currentValue); + } + + this.input.onSubmit = (value) => { + if (value.trim()) { + this.onSubmit(value); + } else { + this.onCancel(); + } + }; + + this.addChild(this.input); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("dim", " Enter to save · Esc to cancel"), 0, 0)); + } + + handleInput(data: string): void { + if (data === "\x1b" || data === "\x1b\x1b") { + this.onCancel(); + return; + } + this.input.handleInput(data); + } +} + +// ============================================================================= +// Main Plugin Settings Selector +// ============================================================================= + +export interface PluginSettingsCallbacks { + onClose: () => void; + onPluginChanged: () => void; +} + +/** Component with handleInput method */ +interface InputHandler { + handleInput(data: string): void; +} + +/** + * Top-level plugin settings component. + * Manages navigation between plugin list and plugin detail views. + */ +export class PluginSettingsComponent extends Container { + private manager: PluginManager; + private callbacks: PluginSettingsCallbacks; + private viewComponent: (Container & InputHandler) | null = null; + private currentView: "list" | "detail" = "list"; + private currentPlugin: InstalledPlugin | null = null; + + constructor(cwd: string, callbacks: PluginSettingsCallbacks) { + super(); + this.manager = new PluginManager(cwd); + this.callbacks = callbacks; + this.showPluginList(); + } + + private async showPluginList(): Promise { + this.currentView = "list"; + this.currentPlugin = null; + this.clear(); + + const plugins = await this.manager.list(); + + this.viewComponent = new PluginListComponent(plugins, { + onPluginSelect: (plugin) => this.showPluginDetail(plugin), + onCancel: () => this.callbacks.onClose(), + }); + + this.addChild(this.viewComponent); + } + + private showPluginDetail(plugin: InstalledPlugin): void { + this.currentView = "detail"; + this.currentPlugin = plugin; + this.clear(); + + this.viewComponent = new PluginDetailComponent(plugin, this.manager, { + onEnabledChange: async (enabled) => { + await this.manager.setEnabled(plugin.name, enabled); + this.callbacks.onPluginChanged(); + }, + onFeatureChange: async (feature, enabled) => { + const current = new Set(this.manager.getEnabledFeatures(plugin.name) ?? []); + if (enabled) { + current.add(feature); + } else { + current.delete(feature); + } + await this.manager.setEnabledFeatures(plugin.name, [...current]); + this.callbacks.onPluginChanged(); + }, + onConfigChange: (key, value) => { + this.manager.setPluginSetting(plugin.name, key, value); + this.callbacks.onPluginChanged(); + }, + onBack: () => this.showPluginList(), + }); + + this.addChild(this.viewComponent); + } + + handleInput(data: string): void { + this.viewComponent?.handleInput(data); + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 1202e3eea..a61061198 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -11,6 +11,7 @@ import { } from "@mariozechner/pi-tui"; import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; +import { PluginSettingsComponent } from "./plugin-settings.js"; const THINKING_DESCRIPTIONS: Record = { off: "No reasoning", @@ -31,6 +32,7 @@ export interface SettingsConfig { availableThemes: string[]; hideThinkingBlock: boolean; collapseChangelog: boolean; + cwd: string; } export interface SettingsCallbacks { @@ -42,6 +44,7 @@ export interface SettingsCallbacks { onThemePreview?: (theme: string) => void; onHideThinkingBlockChange: (hidden: boolean) => void; onCollapseChangelogChange: (collapsed: boolean) => void; + onPluginsChanged?: () => void; onCancel: () => void; } @@ -198,6 +201,17 @@ export class SettingsSelectorComponent extends Container { }, ), }, + { + id: "plugins", + label: "Plugins", + description: "Manage installed plugins and their settings", + currentValue: "→", + submenu: (_currentValue, done) => + new PluginSettingsComponent(config.cwd, { + onClose: () => done(), + onPluginChanged: () => callbacks.onPluginsChanged?.(), + }), + }, ]; // Only show image toggle if terminal supports it diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 5e77e9747..3ad435c75 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -13,6 +13,7 @@ import { import stripAnsi from "strip-ansi"; import type { CustomTool } from "../../../core/custom-tools/types.js"; import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff.js"; +import { toolRenderers } from "../../../core/tools/renderers.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js"; import { sanitizeBinaryOutput } from "../../../utils/shell.js"; import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; @@ -87,11 +88,13 @@ export class ToolExecutionComponent extends Container { this.addChild(new Spacer(1)); - // Always create both - contentBox for custom tools/bash, contentText for other built-ins + // Always create both - contentBox for custom tools/bash/tools with renderers, contentText for other built-ins this.contentBox = new Box(1, 1, (text: string) => theme.bg("toolPendingBg", text)); this.contentText = new Text("", 1, 1, (text: string) => theme.bg("toolPendingBg", text)); - if (customTool || toolName === "bash") { + // Use Box for custom tools, bash, or built-in tools that have renderers + const hasRenderer = toolName in toolRenderers; + if (customTool || toolName === "bash" || hasRenderer) { this.addChild(this.contentBox); } else { this.addChild(this.contentText); @@ -229,6 +232,42 @@ export class ToolExecutionComponent extends Container { this.contentBox.setBgFn(bgFn); this.contentBox.clear(); this.renderBashContent(); + } else if (this.toolName in toolRenderers) { + // Built-in tools with custom renderers + const renderer = toolRenderers[this.toolName]; + this.contentBox.setBgFn(bgFn); + this.contentBox.clear(); + + // Render call component + try { + const callComponent = renderer.renderCall(this.args, theme); + if (callComponent) { + this.contentBox.addChild(callComponent); + } + } catch { + // Fall back to default on error + this.contentBox.addChild(new Text(theme.fg("toolTitle", theme.bold(this.toolName)), 0, 0)); + } + + // Render result component if we have a result + if (this.result) { + try { + const resultComponent = renderer.renderResult( + { content: this.result.content as any, details: this.result.details }, + { expanded: this.expanded, isPartial: this.isPartial }, + theme, + ); + if (resultComponent) { + this.contentBox.addChild(resultComponent); + } + } catch { + // Fall back to showing raw output on error + const output = this.getTextOutput(); + if (output) { + this.contentBox.addChild(new Text(theme.fg("toolOutput", output), 0, 0)); + } + } + } } else { // Other built-in tools: use Text directly with caching this.contentText.setCustomBgFn(bgFn); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 8b0708691..3f2d5dff6 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -7,7 +7,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentMessage } from "@mariozechner/pi-agent-core"; -import type { AssistantMessage, Message, OAuthProvider } from "@mariozechner/pi-ai"; +import type { AssistantMessage, ImageContent, Message, OAuthProvider } from "@mariozechner/pi-ai"; import type { SlashCommand } from "@mariozechner/pi-tui"; import { CombinedAutocompleteProvider, @@ -33,7 +33,7 @@ import { loadSkills } from "../../core/skills.js"; import { loadProjectContextFiles } from "../../core/system-prompt.js"; import type { TruncationResult } from "../../core/tools/truncate.js"; import { getChangelogPath, parseChangelog } from "../../utils/changelog.js"; -import { copyToClipboard } from "../../utils/clipboard.js"; +import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard.js"; import { ArminComponent } from "./components/armin.js"; import { AssistantMessageComponent } from "./components/assistant-message.js"; import { BashExecutionComponent } from "./components/bash-execution.js"; @@ -86,7 +86,7 @@ export class InteractiveMode { private footer: FooterComponent; private version: string; private isInitialized = false; - private onInputCallback?: (text: string) => void; + private onInputCallback?: (input: { text: string; images?: ImageContent[] }) => void; private loadingAnimation: Loader | undefined = undefined; private lastSigintTime = 0; @@ -122,6 +122,9 @@ export class InteractiveMode { // Track pending bash components (shown in pending area, moved to chat on submit) private pendingBashComponents: BashExecutionComponent[] = []; + // Track pending images from clipboard paste (attached to next message) + private pendingImages: ImageContent[] = []; + // Auto-compaction state private autoCompactionLoader: Loader | undefined = undefined; private autoCompactionEscapeHandler?: () => void; @@ -686,6 +689,7 @@ export class InteractiveMode { this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility(); this.editor.onCtrlG = () => this.openExternalEditor(); this.editor.onQuestionMark = () => this.handleHotkeysCommand(); + this.editor.onCtrlV = () => this.handleImagePaste(); this.editor.onChange = (text: string) => { const wasBashMode = this.isBashMode; @@ -850,7 +854,10 @@ export class InteractiveMode { this.flushPendingBashComponents(); if (this.onInputCallback) { - this.onInputCallback(text); + // Include any pending images from clipboard paste + const images = this.pendingImages.length > 0 ? [...this.pendingImages] : undefined; + this.pendingImages = []; + this.onInputCallback({ text, images }); } this.editor.addToHistory(text); }; @@ -1294,11 +1301,11 @@ export class InteractiveMode { } } - async getUserInput(): Promise { + async getUserInput(): Promise<{ text: string; images?: ImageContent[] }> { return new Promise((resolve) => { - this.onInputCallback = (text: string) => { + this.onInputCallback = (input) => { this.onInputCallback = undefined; - resolve(text); + resolve(input); }; }); } @@ -1362,6 +1369,35 @@ export class InteractiveMode { process.kill(0, "SIGTSTP"); } + /** + * Handle Ctrl+V for image paste from clipboard. + * Returns true if an image was found and added, false otherwise. + */ + private async handleImagePaste(): Promise { + try { + const image = await readImageFromClipboard(); + if (image) { + this.pendingImages.push({ + type: "image", + data: image.data, + mimeType: image.mimeType, + }); + // Insert styled placeholder at cursor like Claude does + const imageNum = this.pendingImages.length; + const placeholder = theme.bold(theme.underline(`[Image #${imageNum}]`)); + this.editor.insertText(`${placeholder} `); + this.ui.requestRender(); + return true; + } + // No image in clipboard - show hint + this.showStatus("No image in clipboard (use terminal paste for text)"); + return false; + } catch { + this.showStatus("Failed to read clipboard"); + return false; + } + } + private updateEditorBorderColor(): void { if (this.isBashMode) { this.editor.borderColor = theme.getBashModeBorderColor(); @@ -1483,6 +1519,7 @@ export class InteractiveMode { clearEditor(): void { this.editor.setText(""); + this.pendingImages = []; this.ui.requestRender(); } @@ -1570,6 +1607,7 @@ export class InteractiveMode { availableThemes: getAvailableThemes(), hideThinkingBlock: this.hideThinkingBlock, collapseChangelog: this.settingsManager.getCollapseChangelog(), + cwd: process.cwd(), }, { onAutoCompactChange: (enabled) => { @@ -1621,6 +1659,10 @@ export class InteractiveMode { onCollapseChangelogChange: (collapsed) => { this.settingsManager.setCollapseChangelog(collapsed); }, + onPluginsChanged: () => { + // Plugin config changed - could trigger reload if needed + this.ui.requestRender(); + }, onCancel: () => { done(); this.ui.requestRender(); diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index a2f9f2aec..e07449d36 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -474,7 +474,7 @@ export class RpcClient { } } - private async send(command: RpcCommandBody): Promise { + private send(command: RpcCommandBody): Promise { if (!this.process?.stdin) { throw new Error("Client not started"); } @@ -499,9 +499,18 @@ export class RpcClient { }, }); - const writer = (this.process!.stdin as unknown as WritableStream).getWriter(); - writer.write(new TextEncoder().encode(`${JSON.stringify(fullCommand)}\n`)); - writer.releaseLock(); + // Write to stdin after registering the handler + const stdin = this.process!.stdin as import("bun").FileSink; + stdin.write(new TextEncoder().encode(`${JSON.stringify(fullCommand)}\n`)); + // flush() returns number | Promise - handle both cases + const flushResult = stdin.flush(); + if (flushResult instanceof Promise) { + flushResult.catch((err: Error) => { + this.pendingRequests.delete(id); + clearTimeout(timeout); + reject(err); + }); + } }); } diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 7a3389dc4..fe555b58b 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -20,6 +20,31 @@ async function spawnWithTimeout(cmd: string[], input: string, timeoutMs: number) } } +async function spawnAndRead(cmd: string[], timeoutMs: number): Promise { + const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Clipboard operation timed out")), timeoutMs); + }); + + try { + const [exitCode, stdout] = await Promise.race([ + Promise.all([proc.exited, new Response(proc.stdout).arrayBuffer()]), + timeoutPromise, + ]); + + if (exitCode !== 0) { + return null; + } + + return Buffer.from(stdout); + } catch { + return null; + } finally { + proc.kill(); + } +} + export async function copyToClipboard(text: string): Promise { const p = platform(); const timeout = 5000; @@ -45,3 +70,196 @@ export async function copyToClipboard(text: string): Promise { throw new Error(`Failed to copy to clipboard: ${msg}`); } } + +export interface ClipboardImage { + data: string; // base64 encoded + mimeType: string; +} + +/** + * Read image from system clipboard if available. + * Returns null if no image is in clipboard or clipboard access fails. + * + * Supported platforms: + * - Linux: requires xclip + * - macOS: uses osascript + pbpaste + * - Windows: uses PowerShell + */ +export async function readImageFromClipboard(): Promise { + const p = platform(); + const timeout = 3000; + + try { + if (p === "linux") { + return await readImageLinux(timeout); + } else if (p === "darwin") { + return await readImageMacOS(timeout); + } else if (p === "win32") { + return await readImageWindows(timeout); + } + } catch { + // Clipboard access failed silently + } + + return null; +} + +async function readImageLinux(timeout: number): Promise { + // Try Wayland first (wl-paste), then X11 (xclip) + const wayland = await readImageWayland(timeout); + if (wayland) return wayland; + + return await readImageX11(timeout); +} + +async function readImageWayland(timeout: number): Promise { + // wl-paste --list-types shows available MIME types + const types = await spawnAndRead(["wl-paste", "--list-types"], timeout); + if (!types) return null; + + const typeList = types.toString("utf-8"); + + // Try PNG first, then JPEG + const imageTypes = [ + { type: "image/png", mimeType: "image/png" }, + { type: "image/jpeg", mimeType: "image/jpeg" }, + ]; + + for (const { type, mimeType } of imageTypes) { + if (typeList.includes(type)) { + const imageData = await spawnAndRead(["wl-paste", "--type", type], timeout); + if (imageData && imageData.length > 0) { + return { + data: imageData.toString("base64"), + mimeType, + }; + } + } + } + + return null; +} + +async function readImageX11(timeout: number): Promise { + // Check available targets in clipboard + const targets = await spawnAndRead(["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"], timeout); + if (!targets) return null; + + const targetList = targets.toString("utf-8"); + + // Try PNG first (preferred), then JPEG + const imageTypes = [ + { target: "image/png", mimeType: "image/png" }, + { target: "image/jpeg", mimeType: "image/jpeg" }, + { target: "image/jpg", mimeType: "image/jpeg" }, + ]; + + for (const { target, mimeType } of imageTypes) { + if (targetList.includes(target)) { + const imageData = await spawnAndRead(["xclip", "-selection", "clipboard", "-t", target, "-o"], timeout); + if (imageData && imageData.length > 0) { + return { + data: imageData.toString("base64"), + mimeType, + }; + } + } + } + + return null; +} + +async function readImageMacOS(timeout: number): Promise { + // Use osascript to check clipboard class and read PNG data + // First check if clipboard has image data + const checkScript = ` + try + clipboard info for «class PNGf» + return "png" + on error + try + clipboard info for «class JPEG» + return "jpeg" + on error + return "none" + end try + end try + `; + + const checkProc = Bun.spawn(["osascript", "-e", checkScript], { stdout: "pipe", stderr: "pipe" }); + const checkResult = await Promise.race([ + new Response(checkProc.stdout).text(), + new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeout)), + ]).catch(() => "none"); + + await checkProc.exited; + const imageType = checkResult.trim(); + + if (imageType === "none") return null; + + // Read the actual image data using a temp file approach + // osascript can't output binary directly, so we write to a temp file + const tempFile = `/tmp/pi-clipboard-${Date.now()}.${imageType === "png" ? "png" : "jpg"}`; + const clipboardClass = imageType === "png" ? "«class PNGf»" : "«class JPEG»"; + + const readScript = ` + set imageData to the clipboard as ${clipboardClass} + set filePath to POSIX file "${tempFile}" + set fileRef to open for access filePath with write permission + write imageData to fileRef + close access fileRef + `; + + const writeProc = Bun.spawn(["osascript", "-e", readScript], { stdout: "pipe", stderr: "pipe" }); + await Promise.race([ + writeProc.exited, + new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), timeout)), + ]).catch(() => null); + + try { + const file = Bun.file(tempFile); + if (await file.exists()) { + const buffer = await file.arrayBuffer(); + await Bun.write(tempFile, ""); // Clear file + const { unlink } = await import("fs/promises"); + await unlink(tempFile).catch(() => {}); + + if (buffer.byteLength > 0) { + return { + data: Buffer.from(buffer).toString("base64"), + mimeType: imageType === "png" ? "image/png" : "image/jpeg", + }; + } + } + } catch { + // File read failed + } + + return null; +} + +async function readImageWindows(timeout: number): Promise { + // PowerShell script to read image from clipboard as base64 + const script = ` + Add-Type -AssemblyName System.Windows.Forms + $clipboard = [System.Windows.Forms.Clipboard]::GetImage() + if ($clipboard -ne $null) { + $ms = New-Object System.IO.MemoryStream + $clipboard.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + [Convert]::ToBase64String($ms.ToArray()) + } + `; + + const result = await spawnAndRead(["powershell", "-NoProfile", "-Command", script], timeout); + if (result && result.length > 0) { + const base64 = result.toString("utf-8").trim(); + if (base64.length > 0) { + return { + data: base64, + mimeType: "image/png", + }; + } + } + + return null; +} diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index c9b0f68f4..411345cc2 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -55,6 +55,44 @@ const TOOLS: Record = { return null; }, }, + sd: { + name: "sd", + repo: "chmln/sd", + binaryName: "sd", + tagPrefix: "v", + getAssetName: (version, plat, architecture) => { + if (plat === "darwin") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `sd-v${version}-${archStr}-apple-darwin.tar.gz`; + } else if (plat === "linux") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `sd-v${version}-${archStr}-unknown-linux-musl.tar.gz`; + } else if (plat === "win32") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `sd-v${version}-${archStr}-pc-windows-msvc.zip`; + } + return null; + }, + }, + sg: { + name: "ast-grep", + repo: "ast-grep/ast-grep", + binaryName: "sg", + tagPrefix: "", + getAssetName: (_version, plat, architecture) => { + if (plat === "darwin") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `ast-grep-${archStr}-apple-darwin.zip`; + } else if (plat === "linux") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `ast-grep-${archStr}-unknown-linux-gnu.zip`; + } else if (plat === "win32") { + const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; + return `ast-grep-${archStr}-pc-windows-msvc.zip`; + } + return null; + }, + }, }; // Check if a command exists in PATH by trying to run it @@ -68,7 +106,7 @@ function commandExists(cmd: string): boolean { } // Get the path to a tool (system-wide or in our tools dir) -export function getToolPath(tool: "fd" | "rg"): string | null { +export function getToolPath(tool: "fd" | "rg" | "sd" | "sg"): string | null { const config = TOOLS[tool]; if (!config) return null; @@ -127,7 +165,7 @@ async function downloadFile(url: string, dest: string): Promise { } // Download and install a tool -async function downloadTool(tool: "fd" | "rg"): Promise { +async function downloadTool(tool: "fd" | "rg" | "sd" | "sg"): Promise { const config = TOOLS[tool]; if (!config) throw new Error(`Unknown tool: ${tool}`); @@ -174,8 +212,14 @@ async function downloadTool(tool: "fd" | "rg"): Promise { } // Find the binary in extracted files - const extractedDir = join(extractDir, assetName.replace(/\.(tar\.gz|zip)$/, "")); - const extractedBinary = join(extractedDir, config.binaryName + binaryExt); + // ast-grep releases the binary directly in the zip, not in a subdirectory + let extractedBinary: string; + if (tool === "sg") { + extractedBinary = join(extractDir, config.binaryName + binaryExt); + } else { + const extractedDir = join(extractDir, assetName.replace(/\.(tar\.gz|zip)$/, "")); + extractedBinary = join(extractedDir, config.binaryName + binaryExt); + } if (existsSync(extractedBinary)) { renameSync(extractedBinary, binaryPath); @@ -198,7 +242,10 @@ async function downloadTool(tool: "fd" | "rg"): Promise { // Ensure a tool is available, downloading if necessary // Returns the path to the tool, or null if unavailable -export async function ensureTool(tool: "fd" | "rg", silent: boolean = false): Promise { +export async function ensureTool( + tool: "fd" | "rg" | "sd" | "sg", + silent: boolean = false, +): Promise { const existingPath = getToolPath(tool); if (existingPath) { return existingPath; diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 017cb1c4e..90bc3de2a 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -628,6 +628,22 @@ export class Editor implements Component { this.setTextInternal(text); } + /** Insert text at the current cursor position */ + insertText(text: string): void { + this.historyIndex = -1; + + const line = this.state.lines[this.state.cursorLine] || ""; + const before = line.slice(0, this.state.cursorCol); + const after = line.slice(this.state.cursorCol); + + this.state.lines[this.state.cursorLine] = before + text + after; + this.state.cursorCol += text.length; + + if (this.onChange) { + this.onChange(this.getText()); + } + } + // All the editor methods from before... private insertCharacter(char: string): void { this.historyIndex = -1; // Exit history browsing mode diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 00464a7aa..de2b521c5 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -44,6 +44,7 @@ export { isCtrlRight, isCtrlT, isCtrlU, + isCtrlV, isCtrlW, isCtrlZ, isDelete, diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index da8cf699a..f625e3f9d 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -35,6 +35,7 @@ const CODEPOINTS = { p: 112, t: 116, u: 117, + v: 118, w: 119, z: 122, @@ -170,6 +171,7 @@ export const Keys = { CTRL_P: kittySequence(CODEPOINTS.p, MODIFIERS.ctrl), CTRL_T: kittySequence(CODEPOINTS.t, MODIFIERS.ctrl), CTRL_U: kittySequence(CODEPOINTS.u, MODIFIERS.ctrl), + CTRL_V: kittySequence(CODEPOINTS.v, MODIFIERS.ctrl), CTRL_W: kittySequence(CODEPOINTS.w, MODIFIERS.ctrl), CTRL_Z: kittySequence(CODEPOINTS.z, MODIFIERS.ctrl), @@ -227,6 +229,7 @@ const RAW = { CTRL_P: "\x10", CTRL_T: "\x14", CTRL_U: "\x15", + CTRL_V: "\x16", CTRL_W: "\x17", CTRL_Z: "\x1a", ALT_BACKSPACE: "\x1b\x7f", @@ -352,6 +355,16 @@ export function isCtrlU(data: string): boolean { return data === RAW.CTRL_U || data === Keys.CTRL_U || matchesKittySequence(data, CODEPOINTS.u, MODIFIERS.ctrl); } +/** + * Check if input matches Ctrl+V (raw byte or Kitty protocol). + * Ignores lock key bits. + * Note: In most terminals, Ctrl+V triggers paste which sends bracketed paste markers, + * but raw mode can intercept the key before the terminal processes it. + */ +export function isCtrlV(data: string): boolean { + return data === RAW.CTRL_V || data === Keys.CTRL_V || matchesKittySequence(data, CODEPOINTS.v, MODIFIERS.ctrl); +} + /** * Check if input matches Ctrl+W (raw byte or Kitty protocol). * Ignores lock key bits.