From dd197d8b0f5d535cca3f479a7983dbf18e9a5234 Mon Sep 17 00:00:00 2001 From: seb Date: Tue, 22 Sep 2026 18:53:05 +0200 Subject: [PATCH] upd --- src/fido.js | 651 +--------------------------------------------------- 1 file changed, 12 insertions(+), 639 deletions(-) diff --git a/src/fido.js b/src/fido.js index 54490e2..643372b 100644 --- a/src/fido.js +++ b/src/fido.js @@ -5,6 +5,10 @@ * * A sandboxed, interactive coding assistant using LLMOrbiter. * + * Entry point: wires configuration, tools, and the console REPL together. + * Shared infrastructure lives in sibling modules (config, sandbox, dir-listing, + * undo); each tool lives in its own file under src/tools/. + * * Config via .env (place in c:\src\fido\ or the working directory): * OPENROUTER_API_KEY=sk-or-... * MODEL=xiaomi/mimo-v2.6-flash @@ -16,141 +20,22 @@ * 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, select } from '@inquirer/prompts'; +import { input } from '@inquirer/prompts'; -import { LLMOrbiter, OpenRouterProvider, defineTool, ToolError } from 'llmorbiter'; -import ignore from 'ignore'; +import { LLMOrbiter, OpenRouterProvider } 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() -); +import { API_KEY, MODEL, UNDO_FILE, ROOT_DIR } from './config.js'; +import { listDir } from './dir-listing.js'; +import { loadUndoLog, getUndoLog } from './undo.js'; +import { tools } from './tools/index.js'; 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 ────────────────────────────────────────────────── - -/** - * Build an `ignore` filter that respects all .gitignore files found between - * ROOT_DIR and `dir` (inclusive), plus a hardcoded node_modules rule. - * Paths fed to ig.ignores() must be relative to ROOT_DIR and use forward slashes. - */ -function buildIgnoreFilter(dir) { - const ig = ignore().add('node_modules/'); - // Collect .gitignore files from ROOT_DIR down to dir - const rel = path.relative(ROOT_DIR, dir); - const parts = rel === '' ? [] : rel.split(path.sep); - const ancestors = [ROOT_DIR]; - for (let i = 0; i < parts.length; i++) { - ancestors.push(path.join(ROOT_DIR, ...parts.slice(0, i + 1))); - } - for (const ancestor of ancestors) { - const gi = path.join(ancestor, '.gitignore'); - if (fs.existsSync(gi)) { - try { - const rules = fs.readFileSync(gi, 'utf8'); - // Rules in a nested .gitignore are relative to that directory; - // prefix them so they work relative to ROOT_DIR. - const prefix = path.relative(ROOT_DIR, ancestor).replace(/\\/g, '/'); - const prefixed = rules - .split('\n') - .filter(l => l.trim() && !l.startsWith('#')) - .map(l => (prefix ? `${prefix}/${l.trim()}` : l.trim())); - ig.add(prefixed); - } catch { /* ignore read errors */ } - } - } - return ig; -} - -/** - * List entries of a directory, optionally recursive, respecting .gitignore. - * Returns { entries: Array<{path,type,size}>, total, truncated } - * - type: 'd' for directory, 'f' for file - * - size: file size in bytes (null for directories) - * All paths are relative to ROOT_DIR and use forward slashes. - */ -function listDir(dir, { seek = 0, limit = 50, recursive = false } = {}) { - const ig = buildIgnoreFilter(dir); - const allEntries = []; - - const collect = (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 (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue; - if (e.isDirectory()) { - allEntries.push({ path: rel + '/', type: 'd', size: null }); - if (recursive) collect(full); - } else { - let size = null; - try { size = fs.statSync(full).size; } catch { /* skip */ } - allEntries.push({ path: rel, type: 'f', size }); - } - } - }; - collect(dir); - - const total = allEntries.length; - const sliced = allEntries.slice(seek, seek + limit); - const truncated = total - (seek + sliced.length); - return { entries: sliced, total, truncated: Math.max(0, truncated) }; -} - // ─── System prompt builder ───────────────────────────────────────────────────── function buildSystemPrompt() { @@ -192,519 +77,6 @@ ${dirSection} `; } -// ─── 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 executor ──────────────────────────────────────────────────────────── - -/** - * Apply a list of file patch operations. - * Each operation is an object: - * - file: string (relative path) - * - search: string | null (string to be replaced; null or empty means create/overwrite file) - * - replace: string (string to write) - * - skiplines: number | null (optional: line number offset to start searching from, or lines to skip before matching) - */ -function applyOperations(operations) { - if (!Array.isArray(operations) || operations.length === 0) { - return { success: false, error: 'operations must be a non-empty array of objects.' }; - } - - // Validate all operations first (fast-fail) - for (let idx = 0; idx < operations.length; idx++) { - const op = operations[idx]; - if (!op || typeof op !== 'object') { - return { success: false, error: `Operation at index ${idx} is not an object.` }; - } - if (!op.file || typeof op.file !== 'string') { - return { success: false, error: `Operation at index ${idx} missing valid 'file' property.` }; - } - if (op.replace === undefined || op.replace === null || typeof op.replace !== 'string') { - return { success: false, error: `Operation at index ${idx} ('${op.file}') missing valid 'replace' string.` }; - } - try { - resolveSafe(op.file); - } catch (err) { - return { success: false, error: err.message, failedOp: op }; - } - } - - const applied = []; - for (const op of operations) { - const absPath = resolveSafe(op.file); - try { - if (op.search === null || op.search === undefined || op.search === '') { - // Create or overwrite file (with autodirectory creation) - let undoEntry; - if (fs.existsSync(absPath)) { - const existing = fs.readFileSync(absPath, 'utf8'); - undoEntry = { op: 'RESTORE', path: op.file, content: existing }; - } else { - undoEntry = { op: 'DELETE', path: op.file }; - } - fs.mkdirSync(path.dirname(absPath), { recursive: true }); - fs.writeFileSync(absPath, op.replace, 'utf8'); - pushUndo(undoEntry); - applied.push(`CREATE/WRITE ${op.file}`); - } else { - // Search and replace (replace first hit, optionally respecting skiplines) - if (!fs.existsSync(absPath)) { - return { - success: false, - error: `File not found: "${op.file}"`, - failedOp: op, - appliedSoFar: applied, - }; - } - const original = fs.readFileSync(absPath, 'utf8'); - const isCrlf = original.includes('\r\n'); - let matchIndex = -1; - let matchedLen = op.search.length; - - // First try exact match - if (typeof op.skiplines === 'number' && op.skiplines > 0) { - let currentLine = 0; - let searchFromChar = 0; - for (let i = 0; i < original.length; i++) { - if (currentLine >= op.skiplines) { - searchFromChar = i; - break; - } - if (original[i] === '\n') { - currentLine++; - if (currentLine >= op.skiplines) { - searchFromChar = i + 1; - break; - } - } - } - matchIndex = original.indexOf(op.search, searchFromChar); - } else { - matchIndex = original.indexOf(op.search); - } - - // If not found and line endings differ, try line-ending normalized match - if (matchIndex === -1 && (op.search.includes('\n') || original.includes('\r\n'))) { - const normSearch = op.search.replace(/\r\n/g, '\n'); - const normOriginal = original.replace(/\r\n/g, '\n'); - let normFromChar = 0; - - if (typeof op.skiplines === 'number' && op.skiplines > 0) { - const linesBefore = normOriginal.split('\n').slice(0, op.skiplines); - normFromChar = linesBefore.join('\n').length + (linesBefore.length > 0 ? 1 : 0); - } - - const normMatchIndex = normOriginal.indexOf(normSearch, normFromChar); - if (normMatchIndex !== -1) { - // Map normalized index back to original offset - const prefixNorm = normOriginal.slice(0, normMatchIndex); - const numPrefixNewlines = (prefixNorm.match(/\n/g) || []).length; - matchIndex = isCrlf ? normMatchIndex + numPrefixNewlines : normMatchIndex; - - const matchedNormText = normOriginal.slice(normMatchIndex, normMatchIndex + normSearch.length); - const numMatchedNewlines = (matchedNormText.match(/\n/g) || []).length; - matchedLen = isCrlf ? normSearch.length + numMatchedNewlines : normSearch.length; - } - } - - if (matchIndex === -1) { - const skipMsg = (typeof op.skiplines === 'number' && op.skiplines > 0) - ? ` (after skipping ${op.skiplines} lines)` - : ''; - return { - success: false, - error: - `SEARCH/REPLACE failed in "${op.file}": search string not found${skipMsg}.\n` + - `Search string was:\n${op.search}\n\n` + - `Tip: use read_file to check current contents and line numbering of "${op.file}".`, - failedOp: op, - appliedSoFar: applied, - }; - } - - // If file uses CRLF, ensure replacement content uses CRLF as well - let replacementText = op.replace; - if (isCrlf && !replacementText.includes('\r\n') && replacementText.includes('\n')) { - replacementText = replacementText.replace(/\n/g, '\r\n'); - } - - const updated = - original.slice(0, matchIndex) + - replacementText + - original.slice(matchIndex + matchedLen); - - fs.writeFileSync(absPath, updated, 'utf8'); - pushUndo({ op: 'RESTORE', path: op.file, content: original }); - applied.push(`PATCH in ${op.file}`); - } - } catch (err) { - return { - success: false, - error: `Operation on "${op.file}" failed: ${err.message}`, - failedOp: op, - appliedSoFar: applied, - }; - } - } - - return { success: true, applied, undoLogEntries: applied.length }; -} - -// ─── Tool definitions ────────────────────────────────────────────────────────── - -const listFilesTool = defineTool({ - name: 'list_files', - description: - 'List the immediate contents of a directory within the workspace (non-recursive by default). ' + - 'Hidden files and entries matching .gitignore patterns are excluded automatically. ' + - 'Set recursive=true to walk the full subtree. ' + - 'Use seek/limit for pagination. Paths are relative to the workspace root.', - parameters: { - type: 'object', - additionalProperties: false, - required: ['dirname'], - properties: { - dirname: { - type: 'string', - description: 'Directory path relative to workspace root. Use "." for root.', - }, - recursive: { - type: 'boolean', - description: 'If true, walk the full subtree. Default: false (immediate children only).', - }, - 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, recursive = false, 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 { entries, total, truncated } = listDir(absDir, { seek, limit, recursive }); - const result = { dirname, recursive, entries, total, returned: entries.length, seek }; - if (truncated > 0) { - result.truncated = truncated; - result.truncationNote = `${truncated} entries not shown. Use seek=${seek + entries.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 askUserTool = defineTool({ - name: 'ask_user', - description: - 'Ask the user a question when you need clarification or a decision. ' + - 'Provide a concise question and up to 3 labelled options (A, B, C). ' + - 'The user can pick an option or type a free-form answer (D). ' + - 'Use this instead of guessing whenever intent is ambiguous.', - parameters: { - type: 'object', - additionalProperties: false, - required: ['question'], - properties: { - question: { - type: 'string', - description: 'The question to ask the user.', - }, - options: { - type: 'array', - description: 'Up to 3 short option labels for the user to choose from.', - items: { type: 'string' }, - maxItems: 3, - }, - }, - }, - invoke: async ({ question, options = [] }) => { - const capped = options.slice(0, 3); - const letters = ['A', 'B', 'C']; - - console.log(''); - console.log(chalk.magenta.bold(' ❓ Fido asks:')); - console.log(chalk.white(' ' + question)); - - if (capped.length === 0) { - // No options — just free text - const answer = await input({ - message: chalk.bold.magenta('Your answer'), - prefix: chalk.magenta('›'), - theme: { - prefix: chalk.magenta('›'), - style: { answer: chalk.white, message: chalk.bold.magenta }, - }, - }); - console.log(''); - return { answer }; - } - - // Build select choices: one per option + free-text - const choices = [ - ...capped.map((opt, i) => ({ - name: `${letters[i]}) ${opt}`, - value: letters[i], - short: letters[i], - })), - { name: 'D) Type my own answer…', value: '__custom__', short: 'D' }, - ]; - - const selection = await select({ - message: question, - choices, - prefix: chalk.magenta('›'), - theme: { - prefix: chalk.magenta('›'), - style: { message: chalk.bold.magenta }, - }, - }); - - let answer; - if (selection === '__custom__') { - answer = await input({ - message: chalk.bold.magenta('Your answer'), - prefix: chalk.magenta('›'), - theme: { - prefix: chalk.magenta('›'), - style: { answer: chalk.white, message: chalk.bold.magenta }, - }, - }); - } else { - const idx = letters.indexOf(selection); - answer = capped[idx]; - } - - console.log(''); - return { answer }; - }, -}); - -const mkdirTool = defineTool({ - name: 'mkdir', - description: 'Create a directory in the workspace (automatically recursive if parent directories do not exist).', - parameters: { - type: 'object', - additionalProperties: false, - required: ['dirname'], - properties: { - dirname: { - type: 'string', - description: 'Directory path relative to workspace root.', - }, - }, - }, - invoke: async ({ dirname }) => { - const absDir = resolveSafe(dirname); - if (fs.existsSync(absDir)) { - if (!fs.statSync(absDir).isDirectory()) { - throw new ToolError(`Path "${dirname}" already exists and is not a directory.`); - } - return { message: `Directory already exists: "${dirname}"`, created: false }; - } - fs.mkdirSync(absDir, { recursive: true }); - pushUndo({ op: 'DELETE_DIR', path: dirname }); - return { message: `Directory created: "${dirname}"`, created: true }; - }, -}); - -const rmdirTool = defineTool({ - name: 'rmdir', - description: 'Remove a directory and its contents from the workspace.', - parameters: { - type: 'object', - additionalProperties: false, - required: ['dirname'], - properties: { - dirname: { - type: 'string', - description: 'Directory path relative to workspace root.', - }, - }, - }, - invoke: async ({ dirname }) => { - 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. Use rm to delete files.`); - } - fs.rmSync(absDir, { recursive: true, force: true }); - pushUndo({ op: 'DELETE_DIR_UNDO_NOTE', path: dirname, note: 'Directory was deleted; contents cannot be automatically restored.' }); - return { message: `Directory removed: "${dirname}"`, removed: true }; - }, -}); - -const rmTool = defineTool({ - name: 'rm', - description: 'Delete a file from the workspace.', - parameters: { - type: 'object', - additionalProperties: false, - required: ['filename'], - properties: { - filename: { - type: 'string', - description: 'File path relative to workspace root.', - }, - }, - }, - invoke: async ({ filename }) => { - 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. Use rmdir to delete directories.`); - } - const content = fs.readFileSync(absPath, 'utf8'); - fs.unlinkSync(absPath); - pushUndo({ op: 'CREATE', path: filename, content }); - return { message: `File deleted: "${filename}"`, deleted: true }; - }, -}); - -const patchTool = defineTool({ - name: 'patch', - description: `Apply one or more patch operations to files in the workspace. -Each operation has: -- file: (required) relative path to target file -- search: string to be replaced (exact match). If null, creating/overwriting the entire file (with automatic directory creation). -- replace: string to write -- skiplines: (optional) line number offset to skip before matching (0-indexed) if there are multiple occurrences, since patch replaces the first hit. - -Example create file: -[{"file": "src/utils.js", "search": null, "replace": "export const x = 1;\\n", "skiplines": null}] - -Example search/replace: -[{"file": "src/utils.js", "search": "export const x = 1;", "replace": "export const x = 2;", "skiplines": null}]`, - parameters: { - type: 'object', - additionalProperties: false, - required: ['operations'], - properties: { - operations: { - type: 'array', - description: 'List of patch operations to apply sequentially.', - items: { - type: 'object', - additionalProperties: false, - required: ['file', 'replace'], - properties: { - file: { - type: 'string', - description: 'Relative path to file.', - }, - search: { - type: ['string', 'null'], - description: 'String to be replaced (exact match). If null, writes/creates the entire file.', - }, - replace: { - type: 'string', - description: 'String to write.', - }, - skiplines: { - type: ['number', 'null'], - description: 'Optional line number offset to skip before searching for search text.', - }, - }, - }, - }, - }, - }, - invoke: async ({ operations }) => { - const result = applyOperations(operations); - if (!result.success) { - throw new ToolError( - `Patch failed: ${result.error}` + - (result.appliedSoFar?.length ? `\nSuccessfully applied before failure: ${result.appliedSoFar.join(', ')}` : '') - ); - } - return result; - }, -}); - // ─── Console UI ──────────────────────────────────────────────────────────────── function printBanner() { @@ -811,6 +183,7 @@ async function main() { break; } if (userInput.trim() === '/undo') { + const undoLog = getUndoLog(); if (undoLog.length === 0) { console.log(chalk.yellow('\n Nothing to undo.\n')); } else { @@ -833,7 +206,7 @@ async function main() { prompt: userInput, messages, model: MODEL, - tools: [listFilesTool, readFileTool, patchTool, mkdirTool, rmdirTool, rmTool, askUserTool], + tools, maxIterations: 20, });