diff --git a/src/fido.js b/src/fido.js index d522599..54490e2 100644 --- a/src/fido.js +++ b/src/fido.js @@ -162,7 +162,7 @@ function buildSystemPrompt() { } return `You are Fido, a coding assistant. -You can read files, list directories, and apply code patches within the workspace. +You inspect files, list directories, and apply precise code changes within the workspace. ## Workspace Root directory: ${ROOT_DIR} @@ -171,10 +171,24 @@ Total files: ${total} ## Workspace root (${total} entries): ${dirSection} +## Available Tools & Guidelines +- \`read_file(filename, seek, limit)\`: Inspect file contents and line numbers before modifying them. +- \`list_files(dirname, recursive, seek, limit)\`: Explore workspace folders (respects .gitignore). +- \`patch(operations)\`: Apply file updates or create files. + - Create or overwrite: \`{"file": "path", "search": null, "replace": "content"}\` (parent directories auto-created). + - Search & replace: \`{"file": "path", "search": "exact old text", "replace": "new text"}\`. Replaces the FIRST match. + - \`skiplines\`: (optional integer, 0-indexed) skips N lines from the top before matching. Use this if the search text appears multiple times. +- \`mkdir(dirname)\`: Create directories (auto-recursive). +- \`rmdir(dirname)\`: Remove a directory and all its contents. +- \`rm(filename)\`: Delete an individual file. +- \`ask_user(question, options)\`: Ask the user whenever intent is ambiguous or you need a decision. + ## Rules - You may ONLY access files within the workspace root. Never use ".." or absolute paths. -- 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. +- Always inspect files with \`read_file\` before patching to ensure exact search string and indentation matches. +- Prefer targeted SEARCH/REPLACE operations over rewriting entire files. +- Multiple operations in a single \`patch\` call execute sequentially. +- Undo is tracked automatically per operation and managed externally by the user. `; } @@ -203,187 +217,152 @@ function pushUndo(entry) { 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 = []; - // 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) { - 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\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 { - 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.' }; +/** + * 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 paths first (fast-fail before any writes) - for (const op of ops) { - if (op.error) { - return { success: false, error: op.error, failedOp: op }; + // 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.type === 'UNKNOWN') { - return { success: false, error: `Unrecognised operation: "${op.raw}"`, failedOp: op }; + 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.path); + resolveSafe(op.file); } catch (err) { return { success: false, error: err.message, failedOp: op }; } } const applied = []; - for (const op of ops) { - const absPath = resolveSafe(op.path); + for (const op of operations) { + const absPath = resolveSafe(op.file); try { - if (op.type === 'CREATE') { - // Build undo: delete the file (or restore if it existed) + 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.path, content: existing }; + undoEntry = { op: 'RESTORE', path: op.file, content: existing }; } else { - undoEntry = { op: 'DELETE', path: op.path }; + undoEntry = { op: 'DELETE', path: op.file }; } fs.mkdirSync(path.dirname(absPath), { recursive: true }); - fs.writeFileSync(absPath, op.content, 'utf8'); + fs.writeFileSync(absPath, op.replace, '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') { + applied.push(`CREATE/WRITE ${op.file}`); + } else { + // Search and replace (replace first hit, optionally respecting skiplines) 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}"`, + error: `File not found: "${op.file}"`, 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 + 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.path}": search text not found.\n` + + `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 the exact current content of "${op.path}" before retrying.`, + `Tip: use read_file to check current contents and line numbering of "${op.file}".`, failedOp: op, appliedSoFar: applied, }; } - const updated = original.replace(op.search, op.replace); + + // 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.path, content: original }); - applied.push(`SEARCH/REPLACE in ${op.path}`); + pushUndo({ op: 'RESTORE', path: op.file, content: original }); + applied.push(`PATCH in ${op.file}`); } } catch (err) { return { success: false, - error: `Operation "${op.type} ${op.path}" failed: ${err.message}`, + error: `Operation on "${op.file}" failed: ${err.message}`, failedOp: op, appliedSoFar: applied, }; @@ -581,55 +560,142 @@ const askUserTool = defineTool({ }, }); -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.`, +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: ['patch'], + required: ['dirname'], properties: { - patch: { + dirname: { type: 'string', - description: 'The patch string containing one or more operations.', + description: 'Directory path relative to workspace root.', }, }, }, - invoke: async ({ patch }) => { - const result = applyPatch(patch); + 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) { - // 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(', ')}` : '') @@ -767,7 +833,7 @@ async function main() { prompt: userInput, messages, model: MODEL, - tools: [listFilesTool, readFileTool, patchTool, askUserTool], + tools: [listFilesTool, readFileTool, patchTool, mkdirTool, rmdirTool, rmTool, askUserTool], maxIterations: 20, });