upd
This commit is contained in:
432
src/fido.js
432
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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user