This commit is contained in:
seb
2026-09-22 18:29:15 +02:00
parent 9e58ffd202
commit c46439c236

View File

@@ -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,
});