diff --git a/package-lock.json b/package-lock.json index 09d7cd4..3f758d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@inquirer/prompts": "^7.5.1", "chalk": "^5.4.1", "dotenv": "^18.0.2", + "ignore": "^7.0.10", "llmorbiter": "file:../llmorbiter" } }, @@ -462,6 +463,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ignore": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "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", diff --git a/package.json b/package.json index ab005f5..e4cf4cd 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@inquirer/prompts": "^7.5.1", "chalk": "^5.4.1", "dotenv": "^18.0.2", + "ignore": "^7.0.10", "llmorbiter": "file:../llmorbiter" } } diff --git a/src/fido.js b/src/fido.js index a40869c..d522599 100644 --- a/src/fido.js +++ b/src/fido.js @@ -22,9 +22,10 @@ import { fileURLToPath } from 'url'; import { createInterface } from 'readline'; import dotenv from 'dotenv'; import chalk from 'chalk'; -import { input } from '@inquirer/prompts'; +import { input, select } from '@inquirer/prompts'; import { LLMOrbiter, OpenRouterProvider, defineTool, ToolError } from 'llmorbiter'; +import ignore from 'ignore'; // ─── Bootstrap ───────────────────────────────────────────────────────────────── @@ -35,8 +36,8 @@ 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 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 @@ -78,12 +79,50 @@ function resolveSafe(userPath) { // ─── Directory listing helper ────────────────────────────────────────────────── /** - * Recursively list files under a directory, relative to ROOT_DIR. - * Returns { files: string[], total: number, truncated: number } + * 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 listFilesRecursive(dir, seek = 0, limit = 50) { - const allFiles = []; - const walk = (current) => { +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 }); @@ -92,47 +131,50 @@ function listFilesRecursive(dir, seek = 0, limit = 50) { } for (const e of entries) { const full = path.join(current, e.name); - const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/'); + const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/'); + if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue; if (e.isDirectory()) { - allFiles.push(rel + '/'); - walk(full); + allEntries.push({ path: rel + '/', type: 'd', size: null }); + if (recursive) collect(full); } else { - allFiles.push(rel); + let size = null; + try { size = fs.statSync(full).size; } catch { /* skip */ } + allEntries.push({ path: rel, type: 'f', size }); } } }; - walk(dir); - const total = allFiles.length; - const sliced = allFiles.slice(seek, seek + limit); + collect(dir); + + const total = allEntries.length; + const sliced = allEntries.slice(seek, seek + limit); const truncated = total - (seek + sliced.length); - return { files: sliced, total, truncated: Math.max(0, truncated) }; + return { entries: 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'); + const { entries, total, truncated } = listDir(ROOT_DIR, { seek: 0, limit: 50, recursive: false }); + const fmt = (e) => e.type === 'd' ? `d ${e.path}` : `f ${e.size ?? '?'} ${e.path}`; + let dirSection = entries.map(fmt).join('\n'); if (truncated > 0) { - dirSection += `\n ... (${truncated} more files not shown — use list_files to explore)`; + dirSection += `\n ... (${truncated} more entries not shown — use list_files to explore)`; } - return `You are Fido, a minimal but capable coding assistant. + return `You are Fido, a 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}): +## Workspace root (${total} entries): ${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 is handled externally by the user +- When the user's intent is ambiguous or you need a decision to proceed, use the ask_user tool — don't guess. `; } @@ -190,10 +232,8 @@ function pushUndo(entry) { */ 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'); + // Normalise line endings so the regex always works regardless of CRLF/LF/CR + const lines = patchStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); let i = 0; while (i < lines.length) { @@ -225,7 +265,7 @@ function parsePatch(patchStr) { 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/); + const searchMatch = body.match(/<<<<<<< SEARCH\r?\n([\s\S]*?)\r?\n=======\r?\n([\s\S]*?)\r?\n>>>>>>> REPLACE/); if (!searchMatch) { ops.push({ type: 'SEARCH', path: opPath, error: 'Malformed SEARCH block — expected <<<<<<< SEARCH / ======= / >>>>>>> REPLACE markers' }); } else { @@ -358,8 +398,10 @@ function applyPatch(patchStr) { 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.', + '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, @@ -369,6 +411,10 @@ const listFilesTool = defineTool({ 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.', @@ -379,7 +425,7 @@ const listFilesTool = defineTool({ }, }, }, - invoke: async ({ dirname, seek = 0, limit = 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}"`); @@ -387,11 +433,11 @@ const listFilesTool = defineTool({ 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 }; + 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 + files.length} to continue.`; + result.truncationNote = `${truncated} entries not shown. Use seek=${seek + entries.length} to continue.`; } return result; }, @@ -430,8 +476,8 @@ const readFileTool = defineTool({ 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 total = allLines.length; + const slice = allLines.slice(seek, seek + limit); const truncated = total - (seek + slice.length); const result = { @@ -449,6 +495,92 @@ const readFileTool = defineTool({ }, }); +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 patchTool = defineTool({ name: 'patch', description: `Apply file operations to the workspace. Supports CREATE, CREATE_DIR, SEARCH/REPLACE, DELETE, DELETE_DIR. @@ -559,7 +691,7 @@ function printToolError(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)}`); + if (u.cost) parts.push(`cost=$${u.cost.toFixed(5)}`); console.log(chalk.dim(` 📊 ${parts.join(' ')}`)); } @@ -572,11 +704,11 @@ async function main() { 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), + 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 @@ -635,7 +767,7 @@ async function main() { prompt: userInput, messages, model: MODEL, - tools: [listFilesTool, readFileTool, patchTool], + tools: [listFilesTool, readFileTool, patchTool, askUserTool], maxIterations: 20, });