From 54c50e6a00f6068f6281b4cdbe1dd34d5adca59b Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 22 Sep 2026 17:13:54 +0200 Subject: [PATCH] Genesis --- .env.example | 14 + .gitignore | 2 + package-lock.json | 558 +++++++++++++++++++++++++++++++++++++++ package.json | 19 ++ src/fido.js | 655 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1248 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/fido.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8443319 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Fido — Coding Assistant config +# Copy this to .env and fill in your values + +# Required: Your OpenRouter API key +OPENROUTER_API_KEY=sk-or-... + +# Optional: Model to use (default: xiaomi/mimo-v2.6-flash) +MODEL=xiaomi/mimo-v2.6-flash + +# Optional: File to persist the undo log (relative to workspace root) +UNDO_FILE=.fido-undo.json + +# Optional: Workspace root directory (defaults to cwd when running fido) +# ROOT_DIR=. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ec544c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.env \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..09d7cd4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,558 @@ +{ + "name": "fido", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fido", + "version": "1.0.0", + "license": "0BSD", + "dependencies": { + "@inquirer/prompts": "^7.5.1", + "chalk": "^5.4.1", + "dotenv": "^18.0.2", + "llmorbiter": "file:../llmorbiter" + } + }, + "../llmorbiter": { + "version": "0.1.0", + "license": "0BSD", + "dependencies": { + "dotenv": "^17.4.2" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "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-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.2.tgz", + "integrity": "sha512-wl0U3CxC0iobaryqEKpFJxzyp4AxQFYAKj/WWszXZr87vVHLi2yJv2syax5Vxt+u02xqNgE277rsc3lOKg9sPg==", + "license": "BSD-2-Clause", + "bin": { + "dotenv": "dist/index.cjs" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "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/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/llmorbiter": { + "resolved": "../llmorbiter", + "link": true + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ab005f5 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "fido", + "version": "1.0.0", + "description": "\"Flight Dynamics Officer\" - Coding Assistant", + "license": "0BSD", + "author": "", + "type": "module", + "main": "src/fido.js", + "scripts": { + "start": "node src/fido.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@inquirer/prompts": "^7.5.1", + "chalk": "^5.4.1", + "dotenv": "^18.0.2", + "llmorbiter": "file:../llmorbiter" + } +} diff --git a/src/fido.js b/src/fido.js new file mode 100644 index 0000000..a40869c --- /dev/null +++ b/src/fido.js @@ -0,0 +1,655 @@ +'use strict'; + +/** + * Fido — Minimal Coding Assistant + * + * A sandboxed, interactive coding assistant using LLMOrbiter. + * + * Config via .env (place in c:\src\fido\ or the working directory): + * OPENROUTER_API_KEY=sk-or-... + * MODEL=xiaomi/mimo-v2.6-flash + * UNDO_FILE=.fido-undo.json + * ROOT_DIR=. (optional, defaults to cwd) + * + * Usage: + * node src/fido.js + * node src/fido.js --dir /path/to/project + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createInterface } from 'readline'; +import dotenv from 'dotenv'; +import chalk from 'chalk'; +import { input } from '@inquirer/prompts'; + +import { LLMOrbiter, OpenRouterProvider, defineTool, ToolError } from 'llmorbiter'; + +// ─── Bootstrap ───────────────────────────────────────────────────────────────── + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Load .env from the fido project root (one level up from src/) +dotenv.config({ path: path.resolve(__dirname, '..', '.env') }); +// Also try cwd-relative .env +dotenv.config({ path: path.resolve(process.cwd(), '.env') }); + +const API_KEY = process.env.OPENROUTER_API_KEY; +const MODEL = process.env.MODEL || 'xiaomi/mimo-v2.6-flash'; +const UNDO_FILE = process.env.UNDO_FILE || '.fido-undo.json'; + +// Root dir: --dir flag wins, then env ROOT_DIR, then cwd +const args = process.argv.slice(2); +const dirFlagIdx = args.indexOf('--dir'); +const ROOT_DIR = path.resolve( + (dirFlagIdx !== -1 && args[dirFlagIdx + 1]) || process.env.ROOT_DIR || process.cwd() +); + +if (!API_KEY) { + console.error(chalk.red.bold('\n✖ Missing OPENROUTER_API_KEY — add it to .env\n')); + process.exit(1); +} + +// ─── Sandbox ────────────────────────────────────────────────────────────────── + +/** + * Resolve a user-supplied path within the sandbox root. + * Throws ToolError if the resolved path escapes the root. + */ +function resolveSafe(userPath) { + if (!userPath || typeof userPath !== 'string') { + throw new ToolError('Path must be a non-empty string.'); + } + // Strip leading slashes/backslashes so the user can't anchor to filesystem root + const stripped = userPath.replace(/^[/\\]+/, ''); + const resolved = path.resolve(ROOT_DIR, stripped); + // Must stay inside root + const rootNorm = ROOT_DIR.endsWith(path.sep) ? ROOT_DIR : ROOT_DIR + path.sep; + if (resolved !== ROOT_DIR && !resolved.startsWith(rootNorm)) { + throw new ToolError( + `Access denied: "${userPath}" resolves outside the workspace root "${ROOT_DIR}". ` + + `You may only access files within the workspace.` + ); + } + return resolved; +} + +// ─── Directory listing helper ────────────────────────────────────────────────── + +/** + * Recursively list files under a directory, relative to ROOT_DIR. + * Returns { files: string[], total: number, truncated: number } + */ +function listFilesRecursive(dir, seek = 0, limit = 50) { + const allFiles = []; + const walk = (current) => { + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const full = path.join(current, e.name); + const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/'); + if (e.isDirectory()) { + allFiles.push(rel + '/'); + walk(full); + } else { + allFiles.push(rel); + } + } + }; + walk(dir); + const total = allFiles.length; + const sliced = allFiles.slice(seek, seek + limit); + const truncated = total - (seek + sliced.length); + return { files: sliced, total, truncated: Math.max(0, truncated) }; +} + +// ─── System prompt builder ───────────────────────────────────────────────────── + +function buildSystemPrompt() { + const { files, total, truncated } = listFilesRecursive(ROOT_DIR, 0, 50); + let dirSection = files.map(f => ` ${f}`).join('\n'); + if (truncated > 0) { + dirSection += `\n ... (${truncated} more files not shown — use list_files to explore)`; + } + + return `You are Fido, a minimal but capable coding assistant. +You can read files, list directories, and apply code patches within the workspace. + +## Workspace +Root directory: ${ROOT_DIR} +Total files: ${total} + +## Current directory listing (first 50 of ${total}): +${dirSection} + +## Rules +- You may ONLY access files within the workspace root. Never use ".." or absolute paths. +- When editing files, prefer targeted SEARCH/REPLACE patches over full rewrites. +- After applying a patch, briefly confirm what you did. +- Keep responses concise and focused. +- Use the tools; do not emit raw file contents unless the user asks. +`; +} + +// ─── Undo log ───────────────────────────────────────────────────────────────── + +let undoLog = []; + +function loadUndoLog() { + const undoPath = path.resolve(ROOT_DIR, UNDO_FILE); + if (fs.existsSync(undoPath)) { + try { + undoLog = JSON.parse(fs.readFileSync(undoPath, 'utf8')); + } catch { + undoLog = []; + } + } +} + +function saveUndoLog() { + const undoPath = path.resolve(ROOT_DIR, UNDO_FILE); + fs.writeFileSync(undoPath, JSON.stringify(undoLog, null, 2) + '\n', 'utf8'); +} + +function pushUndo(entry) { + undoLog.unshift(entry); // newest first + saveUndoLog(); +} + +// ─── Patch parser ────────────────────────────────────────────────────────────── + +/** + * Parse a patch string into an array of operations. + * + * Supported operations: + * <<<<< CREATE path + * ...content... + * >>>>> + * + * <<<<< CREATE_DIR path/ + * >>>>> + * + * <<<<< SEARCH path + * <<<<<<< SEARCH + * old + * ======= + * new + * >>>>>>> REPLACE + * >>>>> + * + * <<<<< DELETE path + * >>>>> + * + * <<<<< DELETE_DIR path/ + * >>>>> + */ +function parsePatch(patchStr) { + const ops = []; + // Split on operation boundaries: lines starting with "<<<<<" + const opPattern = /^<<<<< (\w+) (.+)$/m; + // We'll scan line by line + const lines = patchStr.split('\n'); + let i = 0; + + while (i < lines.length) { + const line = lines[i].trimEnd(); + const m = line.match(/^<<<<< (\w+) (.+)$/); + if (!m) { i++; continue; } + + const opType = m[1].toUpperCase(); + const opPath = m[2].trim(); + i++; + + // Collect body until ">>>>>" (5 right-angle brackets on its own line) + const bodyLines = []; + while (i < lines.length && lines[i].trimEnd() !== '>>>>>') { + bodyLines.push(lines[i]); + i++; + } + i++; // skip the ">>>>>" + + const body = bodyLines.join('\n'); + + if (opType === 'CREATE') { + ops.push({ type: 'CREATE', path: opPath, content: body }); + } else if (opType === 'CREATE_DIR') { + ops.push({ type: 'CREATE_DIR', path: opPath }); + } else if (opType === 'DELETE') { + ops.push({ type: 'DELETE', path: opPath }); + } else if (opType === 'DELETE_DIR') { + ops.push({ type: 'DELETE_DIR', path: opPath }); + } else if (opType === 'SEARCH') { + // Parse inner <<<<<<< SEARCH / ======= / >>>>>>> REPLACE block + const searchMatch = body.match(/<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/); + if (!searchMatch) { + ops.push({ type: 'SEARCH', path: opPath, error: 'Malformed SEARCH block — expected <<<<<<< SEARCH / ======= / >>>>>>> REPLACE markers' }); + } else { + ops.push({ type: 'SEARCH', path: opPath, search: searchMatch[1], replace: searchMatch[2] }); + } + } else { + ops.push({ type: 'UNKNOWN', raw: line }); + } + } + return ops; +} + +// ─── Patch executor ──────────────────────────────────────────────────────────── + +function applyPatch(patchStr) { + const ops = parsePatch(patchStr); + if (ops.length === 0) { + return { success: false, error: 'No valid operations found in patch string. Check the patch format.' }; + } + + // Validate all paths first (fast-fail before any writes) + for (const op of ops) { + if (op.error) { + return { success: false, error: op.error, failedOp: op }; + } + if (op.type === 'UNKNOWN') { + return { success: false, error: `Unrecognised operation: "${op.raw}"`, failedOp: op }; + } + try { + resolveSafe(op.path); + } catch (err) { + return { success: false, error: err.message, failedOp: op }; + } + } + + const applied = []; + for (const op of ops) { + const absPath = resolveSafe(op.path); + try { + if (op.type === 'CREATE') { + // Build undo: delete the file (or restore if it existed) + let undoEntry; + if (fs.existsSync(absPath)) { + const existing = fs.readFileSync(absPath, 'utf8'); + undoEntry = { op: 'RESTORE', path: op.path, content: existing }; + } else { + undoEntry = { op: 'DELETE', path: op.path }; + } + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, op.content, 'utf8'); + pushUndo(undoEntry); + applied.push(`CREATE ${op.path}`); + + } else if (op.type === 'CREATE_DIR') { + if (!fs.existsSync(absPath)) { + fs.mkdirSync(absPath, { recursive: true }); + pushUndo({ op: 'DELETE_DIR', path: op.path }); + } + applied.push(`CREATE_DIR ${op.path}`); + + } else if (op.type === 'DELETE') { + if (!fs.existsSync(absPath)) { + return { + success: false, + error: `DELETE failed: file not found: "${op.path}"`, + failedOp: op, + appliedSoFar: applied, + }; + } + const content = fs.readFileSync(absPath, 'utf8'); + fs.unlinkSync(absPath); + pushUndo({ op: 'CREATE', path: op.path, content }); + applied.push(`DELETE ${op.path}`); + + } else if (op.type === 'DELETE_DIR') { + if (!fs.existsSync(absPath)) { + return { + success: false, + error: `DELETE_DIR failed: directory not found: "${op.path}"`, + failedOp: op, + appliedSoFar: applied, + }; + } + fs.rmSync(absPath, { recursive: true, force: true }); + pushUndo({ op: 'DELETE_DIR_UNDO_NOTE', path: op.path, note: 'Directory was deleted; contents cannot be automatically restored.' }); + applied.push(`DELETE_DIR ${op.path}`); + + } else if (op.type === 'SEARCH') { + if (!fs.existsSync(absPath)) { + return { + success: false, + error: `SEARCH/REPLACE failed: file not found: "${op.path}"`, + failedOp: op, + appliedSoFar: applied, + }; + } + const original = fs.readFileSync(absPath, 'utf8'); + if (!original.includes(op.search)) { + // Provide context to help the LLM fix the search string + return { + success: false, + error: + `SEARCH/REPLACE failed in "${op.path}": search text not found.\n` + + `Search string was:\n${op.search}\n\n` + + `Tip: use read_file to check the exact current content of "${op.path}" before retrying.`, + failedOp: op, + appliedSoFar: applied, + }; + } + const updated = original.replace(op.search, op.replace); + fs.writeFileSync(absPath, updated, 'utf8'); + pushUndo({ op: 'RESTORE', path: op.path, content: original }); + applied.push(`SEARCH/REPLACE in ${op.path}`); + } + } catch (err) { + return { + success: false, + error: `Operation "${op.type} ${op.path}" failed: ${err.message}`, + failedOp: op, + appliedSoFar: applied, + }; + } + } + + return { success: true, applied, undoLogEntries: applied.length }; +} + +// ─── Tool definitions ────────────────────────────────────────────────────────── + +const listFilesTool = defineTool({ + name: 'list_files', + description: + 'List files and directories within the workspace. Use seek/limit for pagination. ' + + 'Paths are relative to the workspace root. Returns truncation info when results are cut.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['dirname'], + properties: { + dirname: { + type: 'string', + description: 'Directory path relative to workspace root. Use "." for root.', + }, + seek: { + type: 'number', + description: 'Number of entries to skip (for pagination). Default: 0.', + }, + limit: { + type: 'number', + description: 'Maximum number of entries to return. Default: 50.', + }, + }, + }, + invoke: async ({ dirname, seek = 0, limit = 50 }) => { + const absDir = resolveSafe(dirname); + if (!fs.existsSync(absDir)) { + throw new ToolError(`Directory not found: "${dirname}"`); + } + if (!fs.statSync(absDir).isDirectory()) { + throw new ToolError(`"${dirname}" is not a directory.`); + } + const { files, total, truncated } = listFilesRecursive(absDir, seek, limit); + const result = { dirname, files, total, returned: files.length, seek }; + if (truncated > 0) { + result.truncated = truncated; + result.truncationNote = `${truncated} entries not shown. Use seek=${seek + files.length} to continue.`; + } + return result; + }, +}); + +const readFileTool = defineTool({ + name: 'read_file', + description: + 'Read a file from the workspace. Use seek/limit to read in chunks. ' + + 'Returns truncation info when the file is larger than the limit.', + parameters: { + type: 'object', + additionalProperties: false, + required: ['filename'], + properties: { + filename: { + type: 'string', + description: 'File path relative to workspace root.', + }, + seek: { + type: 'number', + description: 'Line number to start reading from (0-indexed). Default: 0.', + }, + limit: { + type: 'number', + description: 'Maximum number of lines to return. Default: 200.', + }, + }, + }, + invoke: async ({ filename, seek = 0, limit = 200 }) => { + const absPath = resolveSafe(filename); + if (!fs.existsSync(absPath)) { + throw new ToolError(`File not found: "${filename}"`); + } + if (!fs.statSync(absPath).isFile()) { + throw new ToolError(`"${filename}" is not a file.`); + } + const allLines = fs.readFileSync(absPath, 'utf8').split('\n'); + const total = allLines.length; + const slice = allLines.slice(seek, seek + limit); + const truncated = total - (seek + slice.length); + + const result = { + filename, + totalLines: total, + seek, + returnedLines: slice.length, + content: slice.join('\n'), + }; + if (truncated > 0) { + result.truncated = truncated; + result.truncationNote = `${truncated} lines not shown. Use seek=${seek + slice.length} to continue.`; + } + return result; + }, +}); + +const patchTool = defineTool({ + name: 'patch', + description: `Apply file operations to the workspace. Supports CREATE, CREATE_DIR, SEARCH/REPLACE, DELETE, DELETE_DIR. + +Patch format (one or more operations): + +<<<<< CREATE path/to/new_file.py +# New file contents here +def hello(): + return "world" +>>>>> + +<<<<< CREATE_DIR path/to/new_folder/ +>>>>> + +<<<<< SEARCH path/to/existing_file.py +<<<<<<< SEARCH + old_code_line_1() +======= + new_code_line_1() +>>>>>>> REPLACE +>>>>> + +<<<<< DELETE path/to/old_file.txt +>>>>> + +<<<<< DELETE_DIR path/to/old_directory/ +>>>>> + +Rules: +- Paths must be relative to workspace root. No ".." or absolute paths. +- SEARCH text must match the file exactly (including whitespace/indentation). +- Multiple operations in one patch are applied in order; if one fails the rest are skipped. +- On failure, a structured error is returned explaining what went wrong so you can fix and retry.`, + parameters: { + type: 'object', + additionalProperties: false, + required: ['patch'], + properties: { + patch: { + type: 'string', + description: 'The patch string containing one or more operations.', + }, + }, + }, + invoke: async ({ patch }) => { + const result = applyPatch(patch); + if (!result.success) { + // Return as a ToolError so LLMOrbiter feeds it back to the LLM + throw new ToolError( + `Patch failed: ${result.error}` + + (result.appliedSoFar?.length ? `\nSuccessfully applied before failure: ${result.appliedSoFar.join(', ')}` : '') + ); + } + return result; + }, +}); + +// ─── Console UI ──────────────────────────────────────────────────────────────── + +function printBanner() { + console.log(''); + console.log(chalk.cyan.bold('╔══════════════════════════════════════════════╗')); + console.log(chalk.cyan.bold('║') + chalk.white.bold(' 🐕 FIDO — Coding Assistant ') + chalk.cyan.bold('║')); + console.log(chalk.cyan.bold('╚══════════════════════════════════════════════╝')); + 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(''); +} + +function printDivider() { + console.log(chalk.dim(' ' + '─'.repeat(50))); +} + +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)); + } + console.log(''); +} + +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) { + const str = typeof result === 'string' ? result : JSON.stringify(result); + const display = str.length > 200 ? str.slice(0, 197) + '...' : str; + console.log(chalk.dim(` ↳ ${display}`)); +} + +function printToolError(name, error) { + console.log(chalk.red(` ✖ Tool error [${name}]: ${error}`)); +} + +function printUsage(u) { + const parts = [`in=${u.input}`, `out=${u.output}`]; + if (u.cached) parts.push(`cached=${u.cached}`); + if (u.cost) parts.push(`cost=$${u.cost.toFixed(5)}`); + console.log(chalk.dim(` 📊 ${parts.join(' ')}`)); +} + +// ─── Main loop ───────────────────────────────────────────────────────────────── + +async function main() { + loadUndoLog(); + 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), + onToolError: (e) => printToolError(e.name, e.error), + onUsage: (u) => printUsage(u), + }); + + // Conversation state: persisted across turns + const systemPrompt = buildSystemPrompt(); + let messages = [{ role: 'system', content: systemPrompt }]; + + console.log(chalk.dim(' Type your request below. Press Ctrl+C to exit.\n')); + + while (true) { + let userInput; + try { + userInput = await input({ + message: chalk.bold.cyan('You'), + prefix: chalk.cyan('›'), + theme: { + prefix: chalk.cyan('›'), + style: { + answer: chalk.white, + message: chalk.bold.cyan, + }, + }, + }); + } catch { + // Ctrl+C / EOF + console.log(chalk.dim('\n Goodbye! 🐕\n')); + break; + } + + if (!userInput.trim()) continue; + + // Handle special commands + if (userInput.trim() === '/quit' || userInput.trim() === '/exit') { + console.log(chalk.dim('\n Goodbye! 🐕\n')); + break; + } + if (userInput.trim() === '/undo') { + if (undoLog.length === 0) { + console.log(chalk.yellow('\n Nothing to undo.\n')); + } else { + console.log(chalk.yellow('\n Undo log (newest first):')); + for (const e of undoLog.slice(0, 10)) { + console.log(chalk.dim(` ${e.op} ${e.path}`)); + } + if (undoLog.length > 10) { + console.log(chalk.dim(` ... and ${undoLog.length - 10} more in ${UNDO_FILE}`)); + } + console.log(''); + } + continue; + } + + console.log(''); + + try { + const result = await orbiter.run({ + prompt: userInput, + messages, + model: MODEL, + tools: [listFilesTool, readFileTool, patchTool], + maxIterations: 20, + }); + + // Update conversation history for next turn + messages = result.messages; + + printAssistant(result.content); + } catch (err) { + console.log(chalk.red(`\n ✖ Error: ${err.message}\n`)); + } + } +} + +main().catch((err) => { + console.error(chalk.red.bold('\nFATAL: ' + err.message)); + process.exit(1); +});