This commit is contained in:
seb
2026-09-22 20:59:58 +02:00
parent 96d2b80b6c
commit ab2f953e06
23 changed files with 1678 additions and 65 deletions

View File

@@ -7,8 +7,14 @@ OPENROUTER_API_KEY=sk-or-...
# Optional: Model to use (default: xiaomi/mimo-v2.6-flash) # Optional: Model to use (default: xiaomi/mimo-v2.6-flash)
MODEL=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) # Optional: File to persist the undo log (relative to workspace root)
UNDO_FILE=.fido-undo.json 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) # Optional: Workspace root directory (defaults to cwd when running fido)
# ROOT_DIR=. # ROOT_DIR=.

View File

@@ -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"
}
]

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
node_modules/ node_modules/
.env .env
.fido-*

801
package-lock.json generated
View File

@@ -9,11 +9,15 @@
"version": "1.0.0", "version": "1.0.0",
"license": "0BSD", "license": "0BSD",
"dependencies": { "dependencies": {
"@inquirer/core": "^10.3.2",
"@inquirer/prompts": "^7.5.1", "@inquirer/prompts": "^7.5.1",
"@tavily/core": "^0.7.13",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"dotenv": "^18.0.2", "dotenv": "^18.0.2",
"ignore": "^7.0.10", "ignore": "^7.0.10",
"llmorbiter": "file:../llmorbiter" "llmorbiter": "file:../llmorbiter",
"marked": "^15.0.12",
"marked-terminal": "^7.3.0"
} }
}, },
"../llmorbiter": { "../llmorbiter": {
@@ -23,6 +27,16 @@
"dotenv": "^17.4.2" "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": { "node_modules/@inquirer/ansi": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", "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": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "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" "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": { "node_modules/chalk": {
"version": "5.6.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -393,12 +536,73 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "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": { "node_modules/chardet": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz",
"integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==",
"license": "MIT" "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": { "node_modules/cli-width": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
@@ -408,6 +612,34 @@
"node": ">= 12" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -426,6 +658,44 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT" "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": { "node_modules/dotenv": {
"version": "18.0.2", "version": "18.0.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.2.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.2.tgz",
@@ -441,12 +711,271 @@
"url": "https://dotenvx.com" "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": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT" "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": { "node_modules/iconv-lite": {
"version": "0.7.3", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@@ -481,10 +1010,100 @@
"node": ">=8" "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": { "node_modules/llmorbiter": {
"resolved": "../llmorbiter", "resolved": "../llmorbiter",
"link": true "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": { "node_modules/mute-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", "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": "^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": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -512,6 +1205,18 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -538,6 +1243,64 @@
"node": ">=8" "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": { "node_modules/wrap-ansi": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
@@ -552,6 +1315,42 @@
"node": ">=8" "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": { "node_modules/yoctocolors-cjs": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz",

View File

@@ -13,9 +13,12 @@
"dependencies": { "dependencies": {
"@inquirer/core": "^10.3.2", "@inquirer/core": "^10.3.2",
"@inquirer/prompts": "^7.5.1", "@inquirer/prompts": "^7.5.1",
"@tavily/core": "^0.7.13",
"chalk": "^5.4.1", "chalk": "^5.4.1",
"dotenv": "^18.0.2", "dotenv": "^18.0.2",
"ignore": "^7.0.10", "ignore": "^7.0.10",
"llmorbiter": "file:../llmorbiter" "llmorbiter": "file:../llmorbiter",
"marked": "^15.0.12",
"marked-terminal": "^7.3.0"
} }
} }

View File

@@ -29,9 +29,10 @@ import {
makeTheme, makeTheme,
} from '@inquirer/core'; } from '@inquirer/core';
import { filterCommands } from './slash-commands.js'; import { COMMANDS, filterCommands } from './slash-commands.js';
const MAX_SUGGESTIONS = 6; 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). */ /** Show the inline menu only while the first word is being typed (no space yet). */
function termFrom(line) { function termFrom(line) {
@@ -43,8 +44,8 @@ function termFrom(line) {
/** /**
* Replace readline's actual edit buffer, not just the visible terminal line. * Replace readline's actual edit buffer, not just the visible terminal line.
* `rl.clearLine()` only erases terminal output; using it before `rl.write()` * `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 * leaves the old buffer in place and turns a completion such as `/p` into
* `/h/help` internally. * `/p/paste` internally.
*/ */
function replaceBuffer(rl, nextValue) { function replaceBuffer(rl, nextValue) {
rl.write(null, { ctrl: true, name: 'a' }); rl.write(null, { ctrl: true, name: 'a' });
@@ -104,7 +105,7 @@ export const autocompleteInput = createPrompt((config, done) => {
const lines = suggestions.map((c, i) => { const lines = suggestions.map((c, i) => {
const marker = i === active % count ? chalk.cyan('❯') : ' '; 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) { if (count > 1) {
lines.push(chalk.dim(' ↑/↓ choose · Tab complete')); lines.push(chalk.dim(' ↑/↓ choose · Tab complete'));

View File

@@ -7,7 +7,12 @@
* OPENROUTER_API_KEY=sk-or-... * OPENROUTER_API_KEY=sk-or-...
* MODEL=xiaomi/mimo-v2.6-flash * MODEL=xiaomi/mimo-v2.6-flash
* UNDO_FILE=.fido-undo.json * 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) * 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'; 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 API_KEY = process.env.OPENROUTER_API_KEY;
export const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash'; export const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash';
export const UNDO_FILE = process.env.UNDO_FILE || '.fido-undo.json'; 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 args = process.argv.slice(2);
const dirFlagIdx = args.indexOf('--dir');
export const ROOT_DIR = path.resolve( 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()
); );

View File

@@ -17,22 +17,70 @@
* *
* Usage: * Usage:
* node src/fido.js * node src/fido.js
* node src/fido.js --dir /path/to/project * node src/fido.js /path/to/project
*/ */
import path from 'path'; import path from 'path';
import chalk from 'chalk'; 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 { 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 { listDir } from './dir-listing.js';
import { loadUndoLog, getUndoLog } from './undo.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 { autocompleteInput } from './autocomplete-input.js';
import { COMMANDS, findCommand } from './slash-commands.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) { if (!API_KEY) {
console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n')); console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n'));
process.exit(1); process.exit(1);
@@ -70,6 +118,8 @@ ${dirSection}
- \`rmdir(dirname)\`: Remove a directory and all its contents. - \`rmdir(dirname)\`: Remove a directory and all its contents.
- \`rm(filename)\`: Delete an individual file. - \`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). - \`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. - \`ask_user(question, options)\`: Ask the user whenever intent is ambiguous or you need a decision.
## Rules ## Rules
@@ -91,6 +141,7 @@ function printBanner() {
console.log(chalk.dim(` Workspace: ${ROOT_DIR}`)); console.log(chalk.dim(` Workspace: ${ROOT_DIR}`));
console.log(chalk.dim(` Model: ${MODEL}`)); console.log(chalk.dim(` Model: ${MODEL}`));
console.log(chalk.dim(` Undo log: ${path.resolve(ROOT_DIR, UNDO_FILE)}`)); 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(''); console.log('');
} }
@@ -102,10 +153,18 @@ function printAssistant(content) {
console.log(''); console.log('');
console.log(chalk.green.bold(' 🤖 Assistant')); console.log(chalk.green.bold(' 🤖 Assistant'));
printDivider(); printDivider();
// Word-wrap-friendly: just indent each line // Render markdown to ANSI-styled console output, then indent to match the UI
const lines = (content || '(no response)').split('\n'); let rendered;
for (const line of lines) { try {
console.log(chalk.white(' ' + line)); 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(''); console.log('');
} }
@@ -114,18 +173,49 @@ function printStatus(msg) {
process.stdout.write(chalk.yellow.dim(` ⚡ ${msg}\n`)); 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) { 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 str = typeof result === 'string' ? result : JSON.stringify(result);
const display = str.length > 200 ? str.slice(0, 197) + '...' : str; const display = str.length > 200 ? str.slice(0, 197) + '...' : str;
console.log(chalk.dim(` ↳ ${display}`)); 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) { function printToolError(name, error) {
console.log(chalk.red(` ✖ Tool error [${name}]: ${error}`)); console.log(chalk.red(` ✖ Tool error [${name}]: ${error}`));
} }
@@ -137,6 +227,12 @@ function printUsage(u) {
console.log(chalk.dim(` 📊 ${parts.join(' ')}`)); 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() { function printCommandHelp() {
const width = Math.max(...COMMANDS.map((c) => c.name.length)); const width = Math.max(...COMMANDS.map((c) => c.name.length));
console.log(''); console.log('');
@@ -171,14 +267,24 @@ async function promptMultiline(message = 'Compose or paste your text, then save
async function main() { async function main() {
loadUndoLog(); loadUndoLog();
loadSuspends();
printBanner(); printBanner();
const provider = new OpenRouterProvider({ apiKey: API_KEY }); const provider = new OpenRouterProvider({ apiKey: API_KEY });
const orbiter = new LLMOrbiter(provider, { const orbiter = new LLMOrbiter(provider, {
onStatus: (s) => printStatus(s), onStatus: (s) => printStatus(s),
onToolCall: (c) => printToolCall(c.name, c.args), onToolCall: (c) => {
onToolResult: (r) => printToolResult(r.name, r.result), 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), onToolError: (e) => printToolError(e.name, e.error),
onUsage: (u) => printUsage(u), onUsage: (u) => printUsage(u),
}); });
@@ -224,10 +330,6 @@ async function main() {
console.log(chalk.dim('\n Goodbye! 🐕\n')); console.log(chalk.dim('\n Goodbye! 🐕\n'));
break; break;
} }
if (command === '/help') {
printCommandHelp();
continue;
}
if (command === '/paste') { if (command === '/paste') {
try { try {
const pasted = await promptMultiline(); const pasted = await promptMultiline();
@@ -259,6 +361,78 @@ async function main() {
continue; 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(''); console.log('');
try { try {

View File

@@ -9,33 +9,22 @@
*/ */
export const COMMANDS = [ export const COMMANDS = [
{ name: '/help', description: 'Show this list of commands' },
{ name: '/paste', description: 'Compose or paste multiline input in $EDITOR' }, { 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: '/undo', description: 'Show recent file changes from the undo log' },
{ name: '/quit', description: 'Exit Fido' }, { 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). */ /** Return the commands whose name starts with `term` (case-insensitive). */
export function filterCommands(term) { export function filterCommands(term) {
const t = (term || '').toLowerCase(); const t = (term || '').toLowerCase();
return COMMANDS.filter((c) => c.name.toLowerCase().startsWith(t)); return COMMANDS.filter((c) => c.name.toLowerCase().startsWith(t));
} }
/** /** Resolve a raw input line to a command name, or `null` if unknown. */
* Resolve a raw input line to a canonical command name (e.g. "/edit" ->
* "/paste"), or `null` when the line is not a known directive.
*/
export function findCommand(line) { export function findCommand(line) {
if (typeof line !== 'string' || !line.startsWith('/')) return null; if (typeof line !== 'string' || !line.startsWith('/')) return null;
const name = line.trim().split(/\s+/)[0].toLowerCase(); const name = line.trim().split(/\s+/)[0].toLowerCase();
if (ALIASES[name]) return ALIASES[name];
return COMMANDS.some((c) => c.name === name) ? name : null; return COMMANDS.some((c) => c.name === name) ? name : null;
} }

52
src/suspend.js Normal file
View File

@@ -0,0 +1,52 @@
'use strict';
/**
* Suspend snapshots — JSON array, newest-first, persisted on every save.
*
* `/suspend <description>` 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;
}

View File

@@ -3,6 +3,7 @@
import chalk from 'chalk'; import chalk from 'chalk';
import { input, select } from '@inquirer/prompts'; import { input, select } from '@inquirer/prompts';
import { defineTool } from 'llmorbiter'; import { defineTool } from 'llmorbiter';
import { emitToolCall, clip } from './notify.js';
export const askUserTool = defineTool({ export const askUserTool = defineTool({
name: 'ask_user', name: 'ask_user',
@@ -89,3 +90,10 @@ export const askUserTool = defineTool({
return { answer }; return { answer };
}, },
}); });
/**
* Call notification: the question being asked.
*/
export function notifyCall(args = {}) {
emitToolCall(askUserTool.name, clip(JSON.stringify(args.question ?? '')));
}

View File

@@ -22,6 +22,7 @@ import chalk from 'chalk';
import { confirm } from '@inquirer/prompts'; import { confirm } from '@inquirer/prompts';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { ROOT_DIR } from '../config.js'; 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_TAIL_LINES = 100; // trailing lines of output returned to the model
const DEFAULT_TIMEOUT_MS = 30_000; // 30s hard limit per command const DEFAULT_TIMEOUT_MS = 30_000; // 30s hard limit per command
@@ -255,3 +256,25 @@ export const bashExecTool = defineTool({
return result; 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');
}

179
src/tools/browse-url.js Normal file
View File

@@ -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))}`);
}

View File

@@ -1,18 +1,24 @@
'use strict'; '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 { listFilesTool, notifyCall as listFilesNotify, notifyResult as listFilesResult } from './list-files.js';
import { readFileTool } from './read-file.js'; import { readFileTool, notifyCall as readFileNotify, notifyResult as readFileResult } from './read-file.js';
import { ripgrepTool } from './ripgrep.js'; import { ripgrepTool, notifyCall as ripgrepNotify, notifyResult as ripgrepResult } from './ripgrep.js';
import { patchTool } from './patch.js'; import { patchTool, notifyCall as patchNotify } from './patch.js';
import { mkdirTool } from './mkdir.js'; import { mkdirTool, notifyCall as mkdirNotify } from './mkdir.js';
import { rmdirTool } from './rmdir.js'; import { rmdirTool, notifyCall as rmdirNotify } from './rmdir.js';
import { rmTool } from './rm.js'; import { rmTool, notifyCall as rmNotify } from './rm.js';
import { bashExecTool } from './bash-exec.js'; import { bashExecTool, notifyCall as bashExecNotify, notifyResult as bashExecResult } from './bash-exec.js';
import { askUserTool } from './ask-user.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 { export {
listFilesTool, listFilesTool,
@@ -23,6 +29,8 @@ export {
rmdirTool, rmdirTool,
rmTool, rmTool,
bashExecTool, bashExecTool,
websearchTool,
browseUrlTool,
askUserTool, askUserTool,
}; };
@@ -35,5 +43,45 @@ export const tools = [
rmdirTool, rmdirTool,
rmTool, rmTool,
bashExecTool, bashExecTool,
websearchTool,
browseUrlTool,
askUserTool, 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]),
);

View File

@@ -4,6 +4,7 @@ import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { listDir } from '../dir-listing.js'; import { listDir } from '../dir-listing.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
export const listFilesTool = defineTool({ export const listFilesTool = defineTool({
name: 'list_files', name: 'list_files',
@@ -64,3 +65,27 @@ export const listFilesTool = defineTool({
return result; 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))}`);
}

View File

@@ -4,6 +4,7 @@ import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { pushUndo } from '../undo.js'; import { pushUndo } from '../undo.js';
import { emitToolCall, clip } from './notify.js';
export const mkdirTool = defineTool({ export const mkdirTool = defineTool({
name: 'mkdir', name: 'mkdir',
@@ -32,3 +33,10 @@ export const mkdirTool = defineTool({
return { message: `Directory created: "${dirname}"`, created: true }; 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 ?? '')));
}

89
src/tools/notify.js Normal file
View File

@@ -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: <name>(<detail>)"
*/
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 <name> <detail>"
* 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)));
}

View File

@@ -5,6 +5,7 @@ import path from 'path';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { pushUndo } from '../undo.js'; import { pushUndo } from '../undo.js';
import { emitToolCall, clip } from './notify.js';
// ─── Patch executor ──────────────────────────────────────────────────────────── // ─── Patch executor ────────────────────────────────────────────────────────────
@@ -222,3 +223,14 @@ Example search/replace:
return result; 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));
}

View File

@@ -3,6 +3,7 @@
import fs from 'fs'; import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { emitToolCall, emitToolResult, clip, formatBytes, resultSize, plural } from './notify.js';
export const readFileTool = defineTool({ export const readFileTool = defineTool({
name: 'read_file', name: 'read_file',
@@ -55,3 +56,25 @@ export const readFileTool = defineTool({
return result; 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))}`);
}

View File

@@ -13,6 +13,7 @@ import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { ROOT_DIR } from '../config.js'; import { ROOT_DIR } from '../config.js';
import { walkFiles } from '../dir-listing.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. // Files larger than this are skipped during a search to keep things responsive.
const MAX_FILE_BYTES = 2 * 1024 * 1024; const MAX_FILE_BYTES = 2 * 1024 * 1024;
@@ -272,3 +273,27 @@ export const ripgrepTool = defineTool({
return result; 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))}`);
}

View File

@@ -4,6 +4,7 @@ import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { pushUndo } from '../undo.js'; import { pushUndo } from '../undo.js';
import { emitToolCall, clip } from './notify.js';
export const rmTool = defineTool({ export const rmTool = defineTool({
name: 'rm', name: 'rm',
@@ -33,3 +34,10 @@ export const rmTool = defineTool({
return { message: `File deleted: "${filename}"`, deleted: true }; 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 ?? '')));
}

View File

@@ -4,6 +4,7 @@ import fs from 'fs';
import { defineTool, ToolError } from 'llmorbiter'; import { defineTool, ToolError } from 'llmorbiter';
import { resolveSafe } from '../sandbox.js'; import { resolveSafe } from '../sandbox.js';
import { pushUndo } from '../undo.js'; import { pushUndo } from '../undo.js';
import { emitToolCall, clip } from './notify.js';
export const rmdirTool = defineTool({ export const rmdirTool = defineTool({
name: 'rmdir', name: 'rmdir',
@@ -32,3 +33,10 @@ export const rmdirTool = defineTool({
return { message: `Directory removed: "${dirname}"`, removed: true }; 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 ?? '')));
}

137
src/tools/websearch.js Normal file
View File

@@ -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))}`);
}