diff --git a/.env.example b/.env.example index 8443319..e8a5cc2 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,14 @@ OPENROUTER_API_KEY=sk-or-... # Optional: Model to use (default: xiaomi/mimo-v2.6-flash) MODEL=xiaomi/mimo-v2.6-flash +# Optional: Tavily API key — enables the websearch and browse_url tools +TAVILY_API_KEY=tvly-dev-... + # Optional: File to persist the undo log (relative to workspace root) UNDO_FILE=.fido-undo.json +# Optional: File to persist /suspend snapshots (relative to workspace root) +SUSPEND_FILE=.fido-suspends.json + # Optional: Workspace root directory (defaults to cwd when running fido) # ROOT_DIR=. diff --git a/.fido-undo.json b/.fido-undo.json deleted file mode 100644 index 65a0b3e..0000000 --- a/.fido-undo.json +++ /dev/null @@ -1,11 +0,0 @@ -[ - { - "op": "CREATE", - "path": "tmp-cfg-test.mjs", - "content": "import { ROOT_DIR } from './src/config.js';\nconsole.log('ROOT_DIR =', ROOT_DIR);\n" - }, - { - "op": "DELETE", - "path": "tmp-cfg-test.mjs" - } -] diff --git a/.gitignore b/.gitignore index 3ec544c..07b8b2e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ node_modules/ -.env \ No newline at end of file +.env +.fido-* \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3f758d4..09a5cfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,15 @@ "version": "1.0.0", "license": "0BSD", "dependencies": { + "@inquirer/core": "^10.3.2", "@inquirer/prompts": "^7.5.1", + "@tavily/core": "^0.7.13", "chalk": "^5.4.1", "dotenv": "^18.0.2", "ignore": "^7.0.10", - "llmorbiter": "file:../llmorbiter" + "llmorbiter": "file:../llmorbiter", + "marked": "^15.0.12", + "marked-terminal": "^7.3.0" } }, "../llmorbiter": { @@ -23,6 +27,16 @@ "dotenv": "^17.4.2" } }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -357,6 +371,53 @@ } } }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@tavily/core": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@tavily/core/-/core-0.7.13.tgz", + "integrity": "sha512-6qVMzYrWPd31dIWqLk/c3F8yGedXw1cSsfbtsDqvdrkpos5vzjfo8RYWCAOGb93QI1E7wsBCTYm+dre5Rv7wvQ==", + "license": "MIT", + "dependencies": { + "axios": "^1.7.7", + "https-proxy-agent": "^7.0.6", + "js-tiktoken": "^1.0.14" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -381,6 +442,88 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -393,12 +536,73 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/chardet": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "license": "MIT" }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -408,6 +612,34 @@ "node": ">= 12" } }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -426,6 +658,44 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dotenv": { "version": "18.0.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.2.tgz", @@ -441,12 +711,271 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", @@ -481,10 +1010,100 @@ "node": ">=8" } }, + "node_modules/js-tiktoken": { + "version": "1.0.21", + "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", + "integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.5.1" + } + }, "node_modules/llmorbiter": { "resolved": "../llmorbiter", "link": true }, + "node_modules/marked": { + "version": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/marked-terminal": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.3.0.tgz", + "integrity": "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "ansi-regex": "^6.1.0", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "cli-table3": "^0.6.5", + "node-emoji": "^2.2.0", + "supports-hyperlinks": "^3.1.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "marked": ">=1 <16" + } + }, + "node_modules/marked-terminal/node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -494,6 +1113,80 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -512,6 +1205,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -538,6 +1243,64 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", @@ -552,6 +1315,42 @@ "node": ">=8" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yoctocolors-cjs": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", diff --git a/package.json b/package.json index 21ed05b..2274ade 100644 --- a/package.json +++ b/package.json @@ -13,9 +13,12 @@ "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/prompts": "^7.5.1", + "@tavily/core": "^0.7.13", "chalk": "^5.4.1", "dotenv": "^18.0.2", "ignore": "^7.0.10", - "llmorbiter": "file:../llmorbiter" + "llmorbiter": "file:../llmorbiter", + "marked": "^15.0.12", + "marked-terminal": "^7.3.0" } } diff --git a/src/autocomplete-input.js b/src/autocomplete-input.js index c00f004..091ae65 100644 --- a/src/autocomplete-input.js +++ b/src/autocomplete-input.js @@ -29,9 +29,10 @@ import { makeTheme, } from '@inquirer/core'; -import { filterCommands } from './slash-commands.js'; +import { COMMANDS, filterCommands } from './slash-commands.js'; const MAX_SUGGESTIONS = 6; +const NAME_WIDTH = Math.max(...COMMANDS.map((c) => c.name.length)); /** Show the inline menu only while the first word is being typed (no space yet). */ function termFrom(line) { @@ -43,8 +44,8 @@ function termFrom(line) { /** * Replace readline's actual edit buffer, not just the visible terminal line. * `rl.clearLine()` only erases terminal output; using it before `rl.write()` - * leaves the old buffer in place and turns a completion such as `/h` into - * `/h/help` internally. + * leaves the old buffer in place and turns a completion such as `/p` into + * `/p/paste` internally. */ function replaceBuffer(rl, nextValue) { rl.write(null, { ctrl: true, name: 'a' }); @@ -104,7 +105,7 @@ export const autocompleteInput = createPrompt((config, done) => { const lines = suggestions.map((c, i) => { const marker = i === active % count ? chalk.cyan('❯') : ' '; - return ` ${chalk.cyan(marker)} ${chalk.bold(c.name.padEnd(7))} ${chalk.dim(c.description)}`; + return ` ${chalk.cyan(marker)} ${chalk.bold(c.name.padEnd(NAME_WIDTH + 2))} ${chalk.dim(c.description)}`; }); if (count > 1) { lines.push(chalk.dim(' ↑/↓ choose · Tab complete')); diff --git a/src/config.js b/src/config.js index 564ec1b..8530619 100644 --- a/src/config.js +++ b/src/config.js @@ -7,7 +7,12 @@ * OPENROUTER_API_KEY=sk-or-... * MODEL=xiaomi/mimo-v2.6-flash * UNDO_FILE=.fido-undo.json + * SUSPEND_FILE=.fido-suspends.json (optional, stores /suspend snapshots) + * TAVILY_API_KEY=tvly-dev-... (optional, enables the websearch and browse_url tools) * ROOT_DIR=. (optional, defaults to cwd) + * + * The workspace root can also be given as the first command-line argument: + * node src/fido.js /path/to/project */ import path from 'path'; @@ -24,10 +29,11 @@ dotenv.config({ path: path.resolve(process.cwd(), '.env') }); export const API_KEY = process.env.OPENROUTER_API_KEY; export const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash'; export const UNDO_FILE = process.env.UNDO_FILE || '.fido-undo.json'; +export const SUSPEND_FILE = process.env.SUSPEND_FILE || '.fido-suspends.json'; +export const TAVILY_API_KEY = process.env.TAVILY_API_KEY; -// Root dir: --dir flag wins, then env ROOT_DIR, then cwd +// Root dir: first positional arg wins, then env ROOT_DIR, then cwd const args = process.argv.slice(2); -const dirFlagIdx = args.indexOf('--dir'); export const ROOT_DIR = path.resolve( - (dirFlagIdx !== -1 && args[dirFlagIdx + 1]) || process.env.ROOT_DIR || process.cwd() + args[0] || process.env.ROOT_DIR || process.cwd() ); diff --git a/src/fido.js b/src/fido.js index 47d2e2e..df07be7 100644 --- a/src/fido.js +++ b/src/fido.js @@ -17,22 +17,70 @@ * * Usage: * node src/fido.js - * node src/fido.js --dir /path/to/project + * node src/fido.js /path/to/project */ import path from 'path'; import chalk from 'chalk'; -import { editor } from '@inquirer/prompts'; +import { editor, input, select } from '@inquirer/prompts'; +import { marked } from 'marked'; +import { markedTerminal } from 'marked-terminal'; import { LLMOrbiter, OpenRouterProvider } from 'llmorbiter'; -import { API_KEY, MODEL, UNDO_FILE, ROOT_DIR } from './config.js'; +import { API_KEY, MODEL, UNDO_FILE, SUSPEND_FILE, ROOT_DIR } from './config.js'; import { listDir } from './dir-listing.js'; import { loadUndoLog, getUndoLog } from './undo.js'; -import { tools } from './tools/index.js'; +import { loadSuspends, addSuspend, getSuspends } from './suspend.js'; +import { tools, toolCallNotifications, toolResultNotifications } from './tools/index.js'; +import { printToolCall, emitGenericToolResult } from './tools/notify.js'; import { autocompleteInput } from './autocomplete-input.js'; import { COMMANDS, findCommand } from './slash-commands.js'; +// ─── Markdown → console renderer ─────────────────────────────────────────────── + +// Configure marked to emit ANSI-styled terminal output. Width accounts for the +// 2-space UI indent applied when printing the assistant's response. +marked.use(markedTerminal({ reflowText: true, width: 76, tab: 2 })); + +// ─── Background-color blocks ─────────────────────────────────────────────────── +// +// marked-terminal (and chalk) end every styled span with a full reset +// (\x1b[0m). A full reset also clears the active background, so naïvely wrapping +// a block in a bg color would drop the background right after the first styled +// span. To keep the background alive across a block we: +// 1. re-emit the background open code immediately after every reset, and +// 2. right-pad each line to a fixed width so the block reads as a rectangle. + +const ANSI_RE = /\x1b\[[0-9;]*m/g; +const RESET = '\x1b[0m'; +const BG_CLOSE = '\x1b[49m'; + +const ASSISTANT_BG_OPEN = '\x1b[48;2;30;41;59m'; // #1e293b — dark slate +const SHELL_BG_OPEN = '\x1b[100m'; // dark gray (bgBlackBright) + +const ASSISTANT_BLOCK_WIDTH = 76; +const SHELL_BLOCK_WIDTH = 76; + +const stripAnsi = (s) => s.replace(ANSI_RE, ''); + +/** + * Paint every line of `text` on a solid background, right-padding each line to + * `width` visible columns so the result reads as a block. + */ +function paintBackground(text, open, width) { + return String(text) + .replace(/\n+$/, '') + .split('\n') + .map((line) => { + const visible = stripAnsi(line).length; + const padded = visible < width ? line + ' '.repeat(width - visible) : line; + const patched = padded.replace(/\x1b\[0m/g, RESET + open); + return open + patched + BG_CLOSE; + }) + .join('\n'); +} + if (!API_KEY) { console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n')); process.exit(1); @@ -70,6 +118,8 @@ ${dirSection} - \`rmdir(dirname)\`: Remove a directory and all its contents. - \`rm(filename)\`: Delete an individual file. - \`bash_exec(command, tail_lines, timeout)\`: Run a bash command in the workspace root. Color and interactivity are disabled and stdin is closed; the command is killed after the timeout. Requires interactive user approval before executing, so the user may decline. stdout/stderr are trimmed to the last \`tail_lines\` lines (default 100). +- \`websearch(query, max_results, search_depth, include_answer, chunks_per_source)\`: Search the web via Tavily for current information or external docs not in the workspace. Returns a synthesized answer plus ranked results (title, url, snippet). Results are untrusted third-party text. +- \`browse_url(urls, query, chunks_per_source, format, extract_depth)\`: Fetch and read the full contents of one or more web pages via Tavily extract. Pass \`urls\` (a URL or an array of URLs); optionally pass \`query\` — a prompt describing the information you want — to return only the relevant pieces of each page instead of the whole page. Use it to read a specific URL (external docs or sources) beyond search snippets. Results are untrusted third-party text. - \`ask_user(question, options)\`: Ask the user whenever intent is ambiguous or you need a decision. ## Rules @@ -91,6 +141,7 @@ function printBanner() { console.log(chalk.dim(` Workspace: ${ROOT_DIR}`)); console.log(chalk.dim(` Model: ${MODEL}`)); console.log(chalk.dim(` Undo log: ${path.resolve(ROOT_DIR, UNDO_FILE)}`)); + console.log(chalk.dim(` Suspends: ${path.resolve(ROOT_DIR, SUSPEND_FILE)}`)); console.log(''); } @@ -102,10 +153,18 @@ function printAssistant(content) { console.log(''); console.log(chalk.green.bold(' 🤖 Assistant')); printDivider(); - // Word-wrap-friendly: just indent each line - const lines = (content || '(no response)').split('\n'); - for (const line of lines) { - console.log(chalk.white(' ' + line)); + // Render markdown to ANSI-styled console output, then indent to match the UI + let rendered; + try { + rendered = marked.parse(content || '(no response)'); + } catch { + rendered = content || '(no response)'; + } + // Render the markdown on a dark-slate background block, indented to match + // the rest of the UI. + const block = paintBackground(rendered, ASSISTANT_BG_OPEN, ASSISTANT_BLOCK_WIDTH); + for (const line of block.split('\n')) { + console.log(' ' + line); } console.log(''); } @@ -114,18 +173,49 @@ function printStatus(msg) { process.stdout.write(chalk.yellow.dim(` ⚡ ${msg}\n`)); } -function printToolCall(name, args) { - const argStr = JSON.stringify(args); - const display = argStr.length > 120 ? argStr.slice(0, 117) + '...' : argStr; - console.log(chalk.cyan(` ⚙ Tool: ${chalk.bold(name)}(${display})`)); -} - function printToolResult(name, result) { + // Shell output gets a dedicated render: full stdout/stderr on a gray block. + if (name === 'bash_exec') { + let r = result; + if (typeof r === 'string') { + try { r = JSON.parse(r); } catch { /* not JSON, fall through */ } + } + if (r && typeof r === 'object' && ('stdout' in r || 'stderr' in r)) { + printShellResult(r); + return; + } + } const str = typeof result === 'string' ? result : JSON.stringify(result); const display = str.length > 200 ? str.slice(0, 197) + '...' : str; console.log(chalk.dim(` ↳ ${display}`)); } +/** + * Render a bash_exec result: a one-line summary, then stdout/stderr on a + * dark-gray background block (indented to match the rest of the UI). + */ +function printShellResult(r) { + const meta = []; + if (r.exitCode !== undefined && r.exitCode !== null) meta.push(`exit ${r.exitCode}`); + if (r.signal) meta.push(`signal ${r.signal}`); + if (r.timedOut) meta.push('timed out'); + console.log(chalk.dim(` ↳ bash_exec${meta.length ? ' (' + meta.join(', ') + ')' : ''}`)); + + const body = []; + if (r.stdout) body.push(r.stdout.replace(/\n+$/, '')); + if (r.stderr) body.push(r.stderr.replace(/\n+$/, '')); + const text = body.join('\n').replace(/\n/g, '\n '); + if (text.trim()) { + const block = paintBackground(text, SHELL_BG_OPEN, SHELL_BLOCK_WIDTH); + for (const line of block.split('\n')) { + console.log(' ' + line); + } + } + + if (r.truncationNote) console.log(chalk.dim(` ↳ ${r.truncationNote}`)); + if (r.note) console.log(chalk.dim(` ↳ ${r.note}`)); +} + function printToolError(name, error) { console.log(chalk.red(` ✖ Tool error [${name}]: ${error}`)); } @@ -137,6 +227,12 @@ function printUsage(u) { console.log(chalk.dim(` 📊 ${parts.join(' ')}`)); } +function formatWhen(iso) { + if (!iso) return ''; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? '' : d.toLocaleString(); +} + function printCommandHelp() { const width = Math.max(...COMMANDS.map((c) => c.name.length)); console.log(''); @@ -171,14 +267,24 @@ async function promptMultiline(message = 'Compose or paste your text, then save async function main() { loadUndoLog(); + loadSuspends(); printBanner(); const provider = new OpenRouterProvider({ apiKey: API_KEY }); const orbiter = new LLMOrbiter(provider, { onStatus: (s) => printStatus(s), - onToolCall: (c) => printToolCall(c.name, c.args), - onToolResult: (r) => printToolResult(r.name, r.result), + onToolCall: (c) => { + const notify = toolCallNotifications[c.name]; + if (notify) notify(c.args); + else printToolCall(c.name, c.args); + }, + onToolResult: (r) => { + const notifyResult = toolResultNotifications[r.name]; + if (notifyResult) notifyResult(r.result); + else emitGenericToolResult(r.name, r.result); + printToolResult(r.name, r.result); + }, onToolError: (e) => printToolError(e.name, e.error), onUsage: (u) => printUsage(u), }); @@ -224,10 +330,6 @@ async function main() { console.log(chalk.dim('\n Goodbye! 🐕\n')); break; } - if (command === '/help') { - printCommandHelp(); - continue; - } if (command === '/paste') { try { const pasted = await promptMultiline(); @@ -259,6 +361,78 @@ async function main() { continue; } + if (command === '/suspend') { + // Description is the text after the command; ask for one if omitted. + let description = userInput.trim().slice('/suspend'.length).trim(); + if (!description) { + try { + description = ( + await input({ + message: chalk.bold.cyan('Short description for this suspend'), + theme: { + prefix: chalk.cyan('›'), + style: { answer: chalk.white, message: chalk.bold.cyan }, + }, + }) + ).trim(); + } catch { + console.log(chalk.yellow('\n Suspend cancelled.\n')); + continue; + } + } + if (!description) { + console.log(chalk.yellow('\n No description given — suspend cancelled.\n')); + continue; + } + if (messages.length <= 1) { + console.log(chalk.yellow('\n Nothing to suspend yet — say something first.\n')); + continue; + } + addSuspend(description, messages); + const count = getSuspends().length; + console.log(chalk.green(`\n ⏸ Suspended: ${description}`)); + console.log(chalk.dim(` ${count} snapshot${count === 1 ? '' : 's'} in ${SUSPEND_FILE}\n`)); + continue; + } + + if (command === '/recall') { + const suspends = getSuspends(); + if (suspends.length === 0) { + console.log(chalk.yellow('\n Nothing to recall.\n')); + continue; + } + let choice; + try { + choice = await select({ + message: chalk.bold.cyan('Recall a suspended conversation'), + choices: suspends.map((s, i) => ({ + name: `${s.description} ${chalk.dim(formatWhen(s.createdAt))}`, + value: i, + short: s.description, + })), + theme: { + prefix: chalk.cyan('›'), + style: { message: chalk.bold.cyan, highlight: chalk.cyan.bold }, + }, + }); + } catch { + console.log(chalk.yellow('\n Recall cancelled.\n')); + continue; + } + const snap = suspends[choice]; + const restored = Array.isArray(snap.messages) ? snap.messages : []; + // Keep the freshly built system prompt (workspace may have changed) + // and restore the rest of the conversation verbatim. + messages = [ + { role: 'system', content: systemPrompt }, + ...restored.filter((m) => m.role !== 'system'), + ]; + const turns = messages.length - 1; + console.log(chalk.green(`\n ▶ Recalled: ${snap.description}`)); + console.log(chalk.dim(` ${turns} message${turns === 1 ? '' : 's'} restored.\n`)); + continue; + } + console.log(''); try { diff --git a/src/slash-commands.js b/src/slash-commands.js index d12acf1..bcea54d 100644 --- a/src/slash-commands.js +++ b/src/slash-commands.js @@ -9,33 +9,22 @@ */ export const COMMANDS = [ - { name: '/help', description: 'Show this list of commands' }, { name: '/paste', description: 'Compose or paste multiline input in $EDITOR' }, - { name: '/edit', description: 'Alias for /paste' }, + { name: '/suspend', description: 'Save the current conversation under a short description' }, + { name: '/recall', description: 'Restore a previously suspended conversation' }, { name: '/undo', description: 'Show recent file changes from the undo log' }, { name: '/quit', description: 'Exit Fido' }, - { name: '/exit', description: 'Alias for /quit' }, ]; -/** Aliases resolve to a canonical command name at dispatch time. */ -const ALIASES = { - '/edit': '/paste', - '/exit': '/quit', -}; - /** Return the commands whose name starts with `term` (case-insensitive). */ export function filterCommands(term) { const t = (term || '').toLowerCase(); return COMMANDS.filter((c) => c.name.toLowerCase().startsWith(t)); } -/** - * Resolve a raw input line to a canonical command name (e.g. "/edit" -> - * "/paste"), or `null` when the line is not a known directive. - */ +/** Resolve a raw input line to a command name, or `null` if unknown. */ export function findCommand(line) { if (typeof line !== 'string' || !line.startsWith('/')) return null; const name = line.trim().split(/\s+/)[0].toLowerCase(); - if (ALIASES[name]) return ALIASES[name]; return COMMANDS.some((c) => c.name === name) ? name : null; } diff --git a/src/suspend.js b/src/suspend.js new file mode 100644 index 0000000..a34a8d4 --- /dev/null +++ b/src/suspend.js @@ -0,0 +1,52 @@ +'use strict'; + +/** + * Suspend snapshots — JSON array, newest-first, persisted on every save. + * + * `/suspend ` captures the current conversation state under a + * short description so it can be listed and restored later via `/recall`. + * Each entry is: + * { description, createdAt, messages } + */ + +import fs from 'fs'; +import path from 'path'; +import { ROOT_DIR, SUSPEND_FILE } from './config.js'; + +let snapshots = []; + +function suspendPath() { + return path.resolve(ROOT_DIR, SUSPEND_FILE); +} + +export function loadSuspends() { + const p = suspendPath(); + if (fs.existsSync(p)) { + try { + const data = JSON.parse(fs.readFileSync(p, 'utf8')); + snapshots = Array.isArray(data) ? data : []; + } catch { + snapshots = []; + } + } +} + +export function saveSuspends() { + fs.writeFileSync(suspendPath(), JSON.stringify(snapshots, null, 2) + '\n', 'utf8'); +} + +/** Capture `messages` under `description` (newest first) and persist. */ +export function addSuspend(description, messages) { + const entry = { + description, + createdAt: new Date().toISOString(), + messages, + }; + snapshots.unshift(entry); + saveSuspends(); + return entry; +} + +export function getSuspends() { + return snapshots; +} diff --git a/src/tools/ask-user.js b/src/tools/ask-user.js index eb35d61..f8177d6 100644 --- a/src/tools/ask-user.js +++ b/src/tools/ask-user.js @@ -3,6 +3,7 @@ import chalk from 'chalk'; import { input, select } from '@inquirer/prompts'; import { defineTool } from 'llmorbiter'; +import { emitToolCall, clip } from './notify.js'; export const askUserTool = defineTool({ name: 'ask_user', @@ -89,3 +90,10 @@ export const askUserTool = defineTool({ return { answer }; }, }); + +/** + * Call notification: the question being asked. + */ +export function notifyCall(args = {}) { + emitToolCall(askUserTool.name, clip(JSON.stringify(args.question ?? ''))); +} diff --git a/src/tools/bash-exec.js b/src/tools/bash-exec.js index b9e9920..3f2f4e7 100644 --- a/src/tools/bash-exec.js +++ b/src/tools/bash-exec.js @@ -22,6 +22,7 @@ import chalk from 'chalk'; import { confirm } from '@inquirer/prompts'; import { defineTool, ToolError } from 'llmorbiter'; import { ROOT_DIR } from '../config.js'; +import { emitToolCall, emitToolResult, clip } from './notify.js'; const DEFAULT_TAIL_LINES = 100; // trailing lines of output returned to the model const DEFAULT_TIMEOUT_MS = 30_000; // 30s hard limit per command @@ -255,3 +256,25 @@ export const bashExecTool = defineTool({ return result; }, }); + +/** + * Call notification: the command flattened to one line, plus a custom timeout + * when one is set. The approval prompt that follows shows the full command. + */ +export function notifyCall(args = {}) { + const { command, timeout } = args; + const oneLine = String(command ?? '').replace(/\s*\n\s*/g, ' ; '); + const extras = Number.isInteger(timeout) && timeout !== DEFAULT_TIMEOUT_MS ? ` timeout=${timeout}` : ''; + emitToolCall(bashExecTool.name, clip(`${oneLine}${extras}`)); +} + +/** + * Result notification: the process exit status (or signal / timeout). + */ +export function notifyResult(result = {}) { + const parts = []; + if (result.exitCode !== undefined && result.exitCode !== null) parts.push(`exit ${result.exitCode}`); + else if (result.signal) parts.push(`signal ${result.signal}`); + if (result.timedOut) parts.push('timed out'); + emitToolResult(bashExecTool.name, parts.join(' ') || 'done'); +} diff --git a/src/tools/browse-url.js b/src/tools/browse-url.js new file mode 100644 index 0000000..973ade6 --- /dev/null +++ b/src/tools/browse-url.js @@ -0,0 +1,179 @@ +'use strict'; + +/** + * browse_url — fetch and extract the contents of one or more web pages via + * Tavily, returning the page text (or just the relevant pieces when a `query` + * is supplied). + * + * Design notes: + * - Requires a Tavily API key (TAVILY_API_KEY in .env). The client is created + * lazily and cached, so a missing key surfaces as a clean ToolError only + * when the tool is actually used. + * - This tool reaches the network, so results are untrusted third-party text; + * the model should treat them accordingly. + * - `urls` accepts either a single URL string or an array of them; it is + * normalized to an array before being handed to Tavily's extract(). + * - `query` is an optional LLM prompt used to pull only the relevant pieces + * of information out of a page, so the whole page need not be returned. + */ + +import { tavily } from '@tavily/core'; +import { defineTool, ToolError } from 'llmorbiter'; +import { TAVILY_API_KEY } from '../config.js'; +import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js'; + +const DEFAULT_FORMAT = 'text'; // default output format +const FORMATS = ['markdown', 'text']; +const EXTRACT_DEPTHS = ['basic', 'advanced']; +const DEFAULT_EXTRACT_DEPTH = 'basic'; +const DEFAULT_CHUNKS_PER_SOURCE = 1; +const MAX_URLS = 20; // hard upper bound to keep requests sane + +let client = null; + +/** Lazily construct (and cache) the Tavily client, failing cleanly if unset. */ +function getClient() { + if (!TAVILY_API_KEY) { + throw new ToolError( + 'browse_url requires a Tavily API key. Add TAVILY_API_KEY=... to your .env file.' + ); + } + if (!client) { + client = tavily({ apiKey: TAVILY_API_KEY }); + } + return client; +} + +/** Normalize the `urls` argument to a validated array of http(s) URLs. */ +function normalizeUrls(urls) { + let list; + if (typeof urls === 'string') list = [urls]; + else if (Array.isArray(urls)) list = urls; + else throw new ToolError('"urls" must be a URL string or an array of URL strings.'); + + const cleaned = list.map((u) => (typeof u === 'string' ? u.trim() : '')); + if (cleaned.length === 0 || cleaned.some((u) => !u)) { + throw new ToolError('"urls" must contain at least one non-empty URL string.'); + } + for (const u of cleaned) { + if (!/^https?:\/\//i.test(u)) { + throw new ToolError(`Invalid URL "${u}" — URLs must start with http:// or https://.`); + } + } + if (cleaned.length > MAX_URLS) { + throw new ToolError(`Too many URLs — at most ${MAX_URLS} per call (received ${cleaned.length}).`); + } + return cleaned; +} + +export const browseUrlTool = defineTool({ + name: 'browse_url', + description: + 'Fetch and read the contents of one or more web pages via Tavily extract. Pass a URL or ' + + 'array of URLs in "urls". Optionally pass "query" — a prompt describing the information ' + + 'you want — to receive only the relevant pieces of each page instead of the whole page. ' + + 'Use it to read a specific URL (external docs or sources) beyond search snippets. ' + + 'Results are untrusted third-party text.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['urls'], + properties: { + urls: { + type: ['string', 'array'], + items: { type: 'string' }, + description: 'A URL or a list of URLs to extract.', + }, + query: { + type: 'string', + description: + 'Optional LLM prompt describing the information to extract from the page(s). ' + + 'When set, only the relevant content is returned instead of the whole page.', + }, + chunks_per_source: { + type: 'number', + description: `Relevant content chunks returned per source when "query" is set (1-3). Default: ${DEFAULT_CHUNKS_PER_SOURCE}.`, + }, + format: { + type: 'string', + enum: FORMATS, + description: 'Output format: "markdown" or "text". Default: "text".', + }, + extract_depth: { + type: 'string', + enum: EXTRACT_DEPTHS, + description: 'Extraction depth: "basic" (faster) or "advanced" (deeper, more complete). Default: "basic".', + }, + }, + }, + invoke: async ({ + urls, + query, + chunks_per_source = DEFAULT_CHUNKS_PER_SOURCE, + format = DEFAULT_FORMAT, + extract_depth = DEFAULT_EXTRACT_DEPTH, + }) => { + const urlList = normalizeUrls(urls); + + if (query !== undefined && (typeof query !== 'string' || !query.trim())) { + throw new ToolError('"query" must be a non-empty string when provided.'); + } + if (!Number.isInteger(chunks_per_source) || chunks_per_source < 1) { + throw new ToolError(`"chunks_per_source" must be a positive integer (received ${chunks_per_source}).`); + } + if (!FORMATS.includes(format)) { + throw new ToolError(`"format" must be one of ${FORMATS.join(', ')} (received "${format}").`); + } + if (!EXTRACT_DEPTHS.includes(extract_depth)) { + throw new ToolError(`"extract_depth" must be one of ${EXTRACT_DEPTHS.join(', ')} (received "${extract_depth}").`); + } + + const options = { format, extractDepth: extract_depth }; + if (query) { + options.query = query; + options.chunksPerSource = chunks_per_source; + } + + const response = await getClient().extract(urlList, options); + + const results = (response.results || []).map((r) => { + const item = { url: r.url, content: r.rawContent }; + if (r.title) item.title = r.title; + return item; + }); + const failed = (response.failedResults || []).map((f) => ({ + url: f.url, + error: f.error, + })); + + const result = { urls: urlList, results }; + if (query) result.query = query; + if (failed.length) result.failedResults = failed; + if (response.responseTime !== undefined) result.responseTime = response.responseTime; + return result; + }, +}); + +/** + * Call notification: the URL(s) plus any non-default extract options. + */ +export function notifyCall(args = {}) { + const { urls, query, format, extract_depth } = args; + const list = Array.isArray(urls) ? urls : [urls]; + const parts = [clip(list.filter(Boolean).join(', '))]; + if (query) parts.push(`query=${clip(JSON.stringify(query))}`); + if (format && format !== DEFAULT_FORMAT) parts.push(`format=${format}`); + if (extract_depth && extract_depth !== DEFAULT_EXTRACT_DEPTH) parts.push(`depth=${extract_depth}`); + emitToolCall(browseUrlTool.name, clip(parts.join(' '))); +} + +/** + * Result notification: pages fetched (and any failures) plus the payload size. + */ +export function notifyResult(result = {}) { + const n = Array.isArray(result.results) ? result.results.length : 0; + const failed = Array.isArray(result.failedResults) ? result.failedResults.length : 0; + let detail = plural(n, 'page'); + if (failed) detail += `, ${failed} failed`; + emitToolResult(browseUrlTool.name, `${detail} ${formatBytes(resultSize(result))}`); +} diff --git a/src/tools/index.js b/src/tools/index.js index 28a17de..c5e30b7 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -1,18 +1,24 @@ 'use strict'; /** - * Tool registry — one module per tool, aggregated here in registration order. + * Tool registry \u2014 one module per tool, aggregated here in registration order. + * + * Each tool module also exports a `notifyCall(args)` renderer. Those are + * collected into `toolCallNotifications` (tool name -> renderer) so the UI can + * print a tailored notification for each individual tool call. */ -import { listFilesTool } from './list-files.js'; -import { readFileTool } from './read-file.js'; -import { ripgrepTool } from './ripgrep.js'; -import { patchTool } from './patch.js'; -import { mkdirTool } from './mkdir.js'; -import { rmdirTool } from './rmdir.js'; -import { rmTool } from './rm.js'; -import { bashExecTool } from './bash-exec.js'; -import { askUserTool } from './ask-user.js'; +import { listFilesTool, notifyCall as listFilesNotify, notifyResult as listFilesResult } from './list-files.js'; +import { readFileTool, notifyCall as readFileNotify, notifyResult as readFileResult } from './read-file.js'; +import { ripgrepTool, notifyCall as ripgrepNotify, notifyResult as ripgrepResult } from './ripgrep.js'; +import { patchTool, notifyCall as patchNotify } from './patch.js'; +import { mkdirTool, notifyCall as mkdirNotify } from './mkdir.js'; +import { rmdirTool, notifyCall as rmdirNotify } from './rmdir.js'; +import { rmTool, notifyCall as rmNotify } from './rm.js'; +import { bashExecTool, notifyCall as bashExecNotify, notifyResult as bashExecResult } from './bash-exec.js'; +import { websearchTool, notifyCall as websearchNotify, notifyResult as websearchResult } from './websearch.js'; +import { browseUrlTool, notifyCall as browseUrlNotify, notifyResult as browseUrlResult } from './browse-url.js'; +import { askUserTool, notifyCall as askUserNotify } from './ask-user.js'; export { listFilesTool, @@ -23,6 +29,8 @@ export { rmdirTool, rmTool, bashExecTool, + websearchTool, + browseUrlTool, askUserTool, }; @@ -35,5 +43,45 @@ export const tools = [ rmdirTool, rmTool, bashExecTool, + websearchTool, + browseUrlTool, askUserTool, ]; + +/** + * Per-tool call-notification renderers, keyed by tool name. The UI looks up the + * renderer for the tool being invoked so each tool gets an individual + * notification; tools without an entry fall back to a generic renderer. + */ +export const toolCallNotifications = Object.fromEntries( + [ + [listFilesTool, listFilesNotify], + [readFileTool, readFileNotify], + [ripgrepTool, ripgrepNotify], + [patchTool, patchNotify], + [mkdirTool, mkdirNotify], + [rmdirTool, rmdirNotify], + [rmTool, rmNotify], + [bashExecTool, bashExecNotify], + [websearchTool, websearchNotify], + [browseUrlTool, browseUrlNotify], + [askUserTool, askUserNotify], + ].map(([tool, notify]) => [tool.name, notify]), +); + +/** + * Per-tool result-notification renderers, keyed by tool name. The UI looks up + * the renderer for the tool that just finished so each tool gets a tailored + * completion notification (payload size, exit code, and so on); tools without + * an entry fall back to a generic size renderer. + */ +export const toolResultNotifications = Object.fromEntries( + [ + [listFilesTool, listFilesResult], + [readFileTool, readFileResult], + [ripgrepTool, ripgrepResult], + [bashExecTool, bashExecResult], + [websearchTool, websearchResult], + [browseUrlTool, browseUrlResult], + ].map(([tool, notify]) => [tool.name, notify]), +); diff --git a/src/tools/list-files.js b/src/tools/list-files.js index 5852442..9375240 100644 --- a/src/tools/list-files.js +++ b/src/tools/list-files.js @@ -4,6 +4,7 @@ import fs from 'fs'; import { defineTool, ToolError } from 'llmorbiter'; import { resolveSafe } from '../sandbox.js'; import { listDir } from '../dir-listing.js'; +import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js'; export const listFilesTool = defineTool({ name: 'list_files', @@ -64,3 +65,27 @@ export const listFilesTool = defineTool({ return result; }, }); + +/** + * Call notification: the target directory plus any traversal flags in effect. + */ +export function notifyCall(args = {}) { + const { dirname = '.', recursive = false, includeDotDirs = false } = args; + const flags = []; + if (recursive) flags.push('recursive'); + if (includeDotDirs) flags.push('includeDotDirs'); + const detail = `${JSON.stringify(dirname)}${flags.length ? ' ' + flags.join(' ') : ''}`; + emitToolCall(listFilesTool.name, clip(detail)); +} + +/** + * Result notification: how many entries came back and the payload size. + */ +export function notifyResult(result = {}) { + const returned = result.returned ?? (Array.isArray(result.entries) ? result.entries.length : 0); + const total = result.total ?? returned; + const count = total > returned + ? `${returned} of ${total} entries` + : plural(returned, 'entry', 'entries'); + emitToolResult(listFilesTool.name, `${count} ${formatBytes(resultSize(result))}`); +} diff --git a/src/tools/mkdir.js b/src/tools/mkdir.js index 9a004df..c90421c 100644 --- a/src/tools/mkdir.js +++ b/src/tools/mkdir.js @@ -4,6 +4,7 @@ import fs from 'fs'; import { defineTool, ToolError } from 'llmorbiter'; import { resolveSafe } from '../sandbox.js'; import { pushUndo } from '../undo.js'; +import { emitToolCall, clip } from './notify.js'; export const mkdirTool = defineTool({ name: 'mkdir', @@ -32,3 +33,10 @@ export const mkdirTool = defineTool({ return { message: `Directory created: "${dirname}"`, created: true }; }, }); + +/** + * Call notification: the directory to create. + */ +export function notifyCall(args = {}) { + emitToolCall(mkdirTool.name, clip(JSON.stringify(args.dirname ?? ''))); +} diff --git a/src/tools/notify.js b/src/tools/notify.js new file mode 100644 index 0000000..abb05ee --- /dev/null +++ b/src/tools/notify.js @@ -0,0 +1,89 @@ +'use strict'; + +/** + * Shared helpers for per-tool call notifications. + * + * Every tool module exports a `notifyCall(args)` renderer that prints a + * tailored notification when the model invokes that tool. These helpers keep + * the styling consistent across tools and provide the generic fallback used + * for any tool that does not supply its own renderer. + */ + +import chalk from 'chalk'; + +// Longest argument string echoed in a notification before it is elided. +const MAX_ARG_LEN = 120; + +/** Truncate `text` to at most `max` characters, appending an ellipsis. */ +export function clip(text, max = MAX_ARG_LEN) { + const str = String(text ?? ''); + return str.length > max ? str.slice(0, max - 3) + '...' : str; +} + +/** + * Print the one-line tool-call notification: + * "\u2699 Tool: ()" + */ +export function emitToolCall(name, detail = '') { + console.log(chalk.cyan(` \u2699 Tool: ${chalk.bold(name)}(${detail})`)); +} + +/** + * Generic renderer \u2014 serialize the args as JSON and emit the notification. + * Used as the fallback for tools that do not supply their own `notifyCall`. + */ +export function printToolCall(name, args) { + emitToolCall(name, clip(JSON.stringify(args))); +} + +/** + * Print the one-line tool-completion notification, the counterpart to + * `emitToolCall`: + * " \u2713 " + * Emitted when a tool finishes (see `onToolResult`) so every call is bracketed + * by a "started" and a "finished" line. + */ +export function emitToolResult(name, detail = '') { + const suffix = detail ? ' ' + detail : ''; + console.log(chalk.green(` \u2713 ${chalk.bold(name)}${suffix}`)); +} + +/** + * Format a byte count for display. The requested unit is kilobytes; small + * payloads fall back to bytes (so a tiny result never reads as "0.0 KB") and + * large ones roll up to megabytes. + */ +export function formatBytes(bytes) { + const n = Math.max(0, Number(bytes) || 0); + if (n < 1024) return `${n} B`; + const kb = n / 1024; + return kb < 1024 ? `${kb.toFixed(1)} KB` : `${(kb / 1024).toFixed(1)} MB`; +} + +/** + * Byte length of a tool result as it is handed back to the model (the + * JSON-serialized payload). Used for the size shown in result notifications. + */ +export function resultSize(value) { + let str; + try { + str = typeof value === 'string' ? value : JSON.stringify(value ?? ''); + } catch { + str = String(value ?? ''); + } + return Buffer.byteLength(str, 'utf8'); +} + +/** Render "1 entry" / "2 entries" (or a custom plural form). */ +export function plural(n, singular, pluralForm) { + return `${n} ${n === 1 ? singular : (pluralForm ?? (singular + 's'))}`; +} + +/** + * Generic result renderer - emit a completion notification carrying the payload + * size. Used as the fallback for tools that do not supply their own + * `notifyResult`. + */ +export function emitGenericToolResult(name, result) { + emitToolResult(name, formatBytes(resultSize(result))); +} diff --git a/src/tools/patch.js b/src/tools/patch.js index 6f3f9d2..9ff8ce8 100644 --- a/src/tools/patch.js +++ b/src/tools/patch.js @@ -5,6 +5,7 @@ import path from 'path'; import { defineTool, ToolError } from 'llmorbiter'; import { resolveSafe } from '../sandbox.js'; import { pushUndo } from '../undo.js'; +import { emitToolCall, clip } from './notify.js'; // ─── Patch executor ──────────────────────────────────────────────────────────── @@ -222,3 +223,14 @@ Example search/replace: return result; }, }); + +/** + * Call notification: how many operations and which files they touch. + */ +export function notifyCall(args = {}) { + const ops = Array.isArray(args.operations) ? args.operations : []; + const files = [...new Set(ops.map((op) => op?.file).filter(Boolean))]; + const n = ops.length; + const detail = `${n} op${n === 1 ? '' : 's'} \u2192 ${files.join(', ')}`; + emitToolCall(patchTool.name, clip(detail)); +} diff --git a/src/tools/read-file.js b/src/tools/read-file.js index 8e136f4..c3bb6e4 100644 --- a/src/tools/read-file.js +++ b/src/tools/read-file.js @@ -3,6 +3,7 @@ import fs from 'fs'; import { defineTool, ToolError } from 'llmorbiter'; import { resolveSafe } from '../sandbox.js'; +import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js'; export const readFileTool = defineTool({ name: 'read_file', @@ -55,3 +56,25 @@ export const readFileTool = defineTool({ return result; }, }); + +/** + * Call notification: the file plus any non-default seek/limit windowing. + */ +export function notifyCall(args = {}) { + const { filename, seek = 0, limit = 200 } = args; + const extras = []; + if (seek) extras.push(`seek=${seek}`); + if (limit !== 200) extras.push(`limit=${limit}`); + const detail = `${JSON.stringify(filename)}${extras.length ? ' ' + extras.join(' ') : ''}`; + emitToolCall(readFileTool.name, clip(detail)); +} + +/** + * Result notification: lines returned and the payload size. + */ +export function notifyResult(result = {}) { + const returned = result.returnedLines ?? 0; + const total = result.totalLines ?? returned; + const lines = total > returned ? `${returned} of ${total} lines` : plural(returned, 'line'); + emitToolResult(readFileTool.name, `${lines} ${formatBytes(resultSize(result))}`); +} diff --git a/src/tools/ripgrep.js b/src/tools/ripgrep.js index 22b18e5..7edcdc9 100644 --- a/src/tools/ripgrep.js +++ b/src/tools/ripgrep.js @@ -13,6 +13,7 @@ import { defineTool, ToolError } from 'llmorbiter'; import { resolveSafe } from '../sandbox.js'; import { ROOT_DIR } from '../config.js'; import { walkFiles } from '../dir-listing.js'; +import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js'; // Files larger than this are skipped during a search to keep things responsive. const MAX_FILE_BYTES = 2 * 1024 * 1024; @@ -272,3 +273,27 @@ export const ripgrepTool = defineTool({ return result; }, }); + +/** + * Call notification: the pattern plus scope (path/glob) and match modifiers. + */ +export function notifyCall(args = {}) { + const { pattern, path: searchPath, glob, ignore_case = false, literal = false } = args; + const parts = [JSON.stringify(pattern)]; + if (searchPath && searchPath !== '.') parts.push(`path=${JSON.stringify(searchPath)}`); + if (glob) parts.push(`glob=${JSON.stringify(glob)}`); + if (ignore_case) parts.push('ignore_case'); + if (literal) parts.push('literal'); + emitToolCall(ripgrepTool.name, clip(parts.join(' '))); +} + +/** + * Result notification: match count and the payload size. + */ +export function notifyResult(result = {}) { + const returned = result.returned ?? (Array.isArray(result.matches) ? result.matches.length : 0); + const count = result.truncated + ? `${returned} matches (capped)` + : plural(returned, 'match', 'matches'); + emitToolResult(ripgrepTool.name, `${count} ${formatBytes(resultSize(result))}`); +} diff --git a/src/tools/rm.js b/src/tools/rm.js index d9cd3ae..e5719cd 100644 --- a/src/tools/rm.js +++ b/src/tools/rm.js @@ -4,6 +4,7 @@ import fs from 'fs'; import { defineTool, ToolError } from 'llmorbiter'; import { resolveSafe } from '../sandbox.js'; import { pushUndo } from '../undo.js'; +import { emitToolCall, clip } from './notify.js'; export const rmTool = defineTool({ name: 'rm', @@ -33,3 +34,10 @@ export const rmTool = defineTool({ return { message: `File deleted: "${filename}"`, deleted: true }; }, }); + +/** + * Call notification: the file to delete. + */ +export function notifyCall(args = {}) { + emitToolCall(rmTool.name, clip(JSON.stringify(args.filename ?? ''))); +} diff --git a/src/tools/rmdir.js b/src/tools/rmdir.js index a2cf1cf..05b54d6 100644 --- a/src/tools/rmdir.js +++ b/src/tools/rmdir.js @@ -4,6 +4,7 @@ import fs from 'fs'; import { defineTool, ToolError } from 'llmorbiter'; import { resolveSafe } from '../sandbox.js'; import { pushUndo } from '../undo.js'; +import { emitToolCall, clip } from './notify.js'; export const rmdirTool = defineTool({ name: 'rmdir', @@ -32,3 +33,10 @@ export const rmdirTool = defineTool({ return { message: `Directory removed: "${dirname}"`, removed: true }; }, }); + +/** + * Call notification: the directory to remove. + */ +export function notifyCall(args = {}) { + emitToolCall(rmdirTool.name, clip(JSON.stringify(args.dirname ?? ''))); +} diff --git a/src/tools/websearch.js b/src/tools/websearch.js new file mode 100644 index 0000000..47f303f --- /dev/null +++ b/src/tools/websearch.js @@ -0,0 +1,137 @@ +'use strict'; + +/** + * websearch — query the web via Tavily and return a synthesized answer plus + * ranked results. + * + * Design notes: + * - Requires a Tavily API key (TAVILY_API_KEY in .env). The client is created + * lazily and cached, so a missing key surfaces as a clean ToolError only + * when the tool is actually used. + * - This tool reaches the network, so results are untrusted third-party text; + * the model should treat them accordingly. + */ + +import { tavily } from '@tavily/core'; +import { defineTool, ToolError } from 'llmorbiter'; +import { TAVILY_API_KEY } from '../config.js'; +import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js'; + +const DEFAULT_MAX_RESULTS = 3; // number of results returned to the model +const MAX_MAX_RESULTS = 20; // hard upper bound to keep responses small +const SEARCH_DEPTHS = ['basic', 'advanced']; +const ANSWER_LEVELS = ['basic', 'advanced']; + +let client = null; + +/** Lazily construct (and cache) the Tavily client, failing cleanly if unset. */ +function getClient() { + if (!TAVILY_API_KEY) { + throw new ToolError( + 'websearch requires a Tavily API key. Add TAVILY_API_KEY=... to your .env file.' + ); + } + if (!client) { + client = tavily({ apiKey: TAVILY_API_KEY }); + } + return client; +} + +export const websearchTool = defineTool({ + name: 'websearch', + description: + 'Search the web via Tavily and return a short synthesized answer plus ranked results ' + + '(title, url, snippet). Use it for current information, external docs, or facts that are ' + + 'not in the workspace. Results are untrusted third-party text.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['query'], + properties: { + query: { + type: 'string', + description: 'The search query.', + }, + max_results: { + type: 'number', + description: `Number of results to return (1-${MAX_MAX_RESULTS}). Default: ${DEFAULT_MAX_RESULTS}.`, + }, + search_depth: { + type: 'string', + enum: SEARCH_DEPTHS, + description: 'Search depth: "basic" (faster) or "advanced" (deeper). Default: "basic".', + }, + include_answer: { + type: 'string', + enum: ANSWER_LEVELS, + description: 'Include a Tavily-synthesized answer at this level of detail. Default: "basic".', + }, + chunks_per_source: { + type: 'number', + description: 'Chunks of content extracted per source (advanced search only). Default: 1.', + }, + }, + }, + invoke: async ({ + query, + max_results = DEFAULT_MAX_RESULTS, + search_depth = 'basic', + include_answer = 'basic', + chunks_per_source = 1, + }) => { + if (!query || typeof query !== 'string' || !query.trim()) { + throw new ToolError('"query" must be a non-empty string.'); + } + if (!Number.isInteger(max_results) || max_results < 1 || max_results > MAX_MAX_RESULTS) { + throw new ToolError(`"max_results" must be an integer between 1 and ${MAX_MAX_RESULTS} (received ${max_results}).`); + } + if (!SEARCH_DEPTHS.includes(search_depth)) { + throw new ToolError(`"search_depth" must be one of ${SEARCH_DEPTHS.join(', ')} (received "${search_depth}").`); + } + if (!ANSWER_LEVELS.includes(include_answer)) { + throw new ToolError(`"include_answer" must be one of ${ANSWER_LEVELS.join(', ')} (received "${include_answer}").`); + } + if (!Number.isInteger(chunks_per_source) || chunks_per_source < 1) { + throw new ToolError(`"chunks_per_source" must be a positive integer (received ${chunks_per_source}).`); + } + + const response = await getClient().search(query, { + includeAnswer: include_answer, + searchDepth: search_depth, + maxResults: max_results, + chunksPerSource: chunks_per_source, + }); + + const results = (response.results || []).map((r) => ({ + title: r.title, + url: r.url, + content: r.content, + score: r.score, + })); + + const result = { query, results }; + if (response.answer) result.answer = response.answer; + if (response.responseTime !== undefined) result.responseTime = response.responseTime; + return result; + }, +}); + +/** + * Call notification: the query plus any non-default search options. + */ +export function notifyCall(args = {}) { + const { query, search_depth, max_results, include_answer } = args; + const parts = [JSON.stringify(query)]; + if (search_depth && search_depth !== 'basic') parts.push(`depth=${search_depth}`); + if (max_results && max_results !== DEFAULT_MAX_RESULTS) parts.push(`max_results=${max_results}`); + if (include_answer && include_answer !== 'basic') parts.push(`answer=${include_answer}`); + emitToolCall(websearchTool.name, clip(parts.join(' '))); +} + +/** + * Result notification: number of results and the payload size. + */ +export function notifyResult(result = {}) { + const n = Array.isArray(result.results) ? result.results.length : 0; + emitToolResult(websearchTool.name, `${plural(n, 'result')} ${formatBytes(resultSize(result))}`); +}