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.
|
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
|
## Workspace
|
||||||
Root directory: ${ROOT_DIR}
|
Root directory: ${ROOT_DIR}
|
||||||
@@ -171,10 +171,24 @@ Total files: ${total}
|
|||||||
## Workspace root (${total} entries):
|
## Workspace root (${total} entries):
|
||||||
${dirSection}
|
${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
|
## Rules
|
||||||
- You may ONLY access files within the workspace root. Never use ".." or absolute paths.
|
- You may ONLY access files within the workspace root. Never use ".." or absolute paths.
|
||||||
- Undo is handled externally by the user
|
- Always inspect files with \`read_file\` before patching to ensure exact search string and indentation matches.
|
||||||
- When the user's intent is ambiguous or you need a decision to proceed, use the ask_user tool — don't guess.
|
- 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();
|
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 ────────────────────────────────────────────────────────────
|
// ─── Patch executor ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function applyPatch(patchStr) {
|
/**
|
||||||
const ops = parsePatch(patchStr);
|
* Apply a list of file patch operations.
|
||||||
if (ops.length === 0) {
|
* Each operation is an object:
|
||||||
return { success: false, error: 'No valid operations found in patch string. Check the patch format.' };
|
* - 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)
|
// Validate all operations first (fast-fail)
|
||||||
for (const op of ops) {
|
for (let idx = 0; idx < operations.length; idx++) {
|
||||||
if (op.error) {
|
const op = operations[idx];
|
||||||
return { success: false, error: op.error, failedOp: op };
|
if (!op || typeof op !== 'object') {
|
||||||
|
return { success: false, error: `Operation at index ${idx} is not an object.` };
|
||||||
}
|
}
|
||||||
if (op.type === 'UNKNOWN') {
|
if (!op.file || typeof op.file !== 'string') {
|
||||||
return { success: false, error: `Unrecognised operation: "${op.raw}"`, failedOp: op };
|
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 {
|
try {
|
||||||
resolveSafe(op.path);
|
resolveSafe(op.file);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { success: false, error: err.message, failedOp: op };
|
return { success: false, error: err.message, failedOp: op };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const applied = [];
|
const applied = [];
|
||||||
for (const op of ops) {
|
for (const op of operations) {
|
||||||
const absPath = resolveSafe(op.path);
|
const absPath = resolveSafe(op.file);
|
||||||
try {
|
try {
|
||||||
if (op.type === 'CREATE') {
|
if (op.search === null || op.search === undefined || op.search === '') {
|
||||||
// Build undo: delete the file (or restore if it existed)
|
// Create or overwrite file (with autodirectory creation)
|
||||||
let undoEntry;
|
let undoEntry;
|
||||||
if (fs.existsSync(absPath)) {
|
if (fs.existsSync(absPath)) {
|
||||||
const existing = fs.readFileSync(absPath, 'utf8');
|
const existing = fs.readFileSync(absPath, 'utf8');
|
||||||
undoEntry = { op: 'RESTORE', path: op.path, content: existing };
|
undoEntry = { op: 'RESTORE', path: op.file, content: existing };
|
||||||
} else {
|
} else {
|
||||||
undoEntry = { op: 'DELETE', path: op.path };
|
undoEntry = { op: 'DELETE', path: op.file };
|
||||||
}
|
}
|
||||||
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
||||||
fs.writeFileSync(absPath, op.content, 'utf8');
|
fs.writeFileSync(absPath, op.replace, 'utf8');
|
||||||
pushUndo(undoEntry);
|
pushUndo(undoEntry);
|
||||||
applied.push(`CREATE ${op.path}`);
|
applied.push(`CREATE/WRITE ${op.file}`);
|
||||||
|
} else {
|
||||||
} else if (op.type === 'CREATE_DIR') {
|
// Search and replace (replace first hit, optionally respecting skiplines)
|
||||||
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') {
|
|
||||||
if (!fs.existsSync(absPath)) {
|
if (!fs.existsSync(absPath)) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `DELETE failed: file not found: "${op.path}"`,
|
error: `File not found: "${op.file}"`,
|
||||||
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}"`,
|
|
||||||
failedOp: op,
|
failedOp: op,
|
||||||
appliedSoFar: applied,
|
appliedSoFar: applied,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const original = fs.readFileSync(absPath, 'utf8');
|
const original = fs.readFileSync(absPath, 'utf8');
|
||||||
if (!original.includes(op.search)) {
|
const isCrlf = original.includes('\r\n');
|
||||||
// Provide context to help the LLM fix the search string
|
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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
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` +
|
`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,
|
failedOp: op,
|
||||||
appliedSoFar: applied,
|
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');
|
fs.writeFileSync(absPath, updated, 'utf8');
|
||||||
pushUndo({ op: 'RESTORE', path: op.path, content: original });
|
pushUndo({ op: 'RESTORE', path: op.file, content: original });
|
||||||
applied.push(`SEARCH/REPLACE in ${op.path}`);
|
applied.push(`PATCH in ${op.file}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Operation "${op.type} ${op.path}" failed: ${err.message}`,
|
error: `Operation on "${op.file}" failed: ${err.message}`,
|
||||||
failedOp: op,
|
failedOp: op,
|
||||||
appliedSoFar: applied,
|
appliedSoFar: applied,
|
||||||
};
|
};
|
||||||
@@ -581,55 +560,142 @@ const askUserTool = defineTool({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const patchTool = defineTool({
|
const mkdirTool = defineTool({
|
||||||
name: 'patch',
|
name: 'mkdir',
|
||||||
description: `Apply file operations to the workspace. Supports CREATE, CREATE_DIR, SEARCH/REPLACE, DELETE, DELETE_DIR.
|
description: 'Create a directory in the workspace (automatically recursive if parent directories do not exist).',
|
||||||
|
|
||||||
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.`,
|
|
||||||
parameters: {
|
parameters: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: ['patch'],
|
required: ['dirname'],
|
||||||
properties: {
|
properties: {
|
||||||
patch: {
|
dirname: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'The patch string containing one or more operations.',
|
description: 'Directory path relative to workspace root.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
invoke: async ({ patch }) => {
|
invoke: async ({ dirname }) => {
|
||||||
const result = applyPatch(patch);
|
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) {
|
if (!result.success) {
|
||||||
// Return as a ToolError so LLMOrbiter feeds it back to the LLM
|
|
||||||
throw new ToolError(
|
throw new ToolError(
|
||||||
`Patch failed: ${result.error}` +
|
`Patch failed: ${result.error}` +
|
||||||
(result.appliedSoFar?.length ? `\nSuccessfully applied before failure: ${result.appliedSoFar.join(', ')}` : '')
|
(result.appliedSoFar?.length ? `\nSuccessfully applied before failure: ${result.appliedSoFar.join(', ')}` : '')
|
||||||
@@ -767,7 +833,7 @@ async function main() {
|
|||||||
prompt: userInput,
|
prompt: userInput,
|
||||||
messages,
|
messages,
|
||||||
model: MODEL,
|
model: MODEL,
|
||||||
tools: [listFilesTool, readFileTool, patchTool, askUserTool],
|
tools: [listFilesTool, readFileTool, patchTool, mkdirTool, rmdirTool, rmTool, askUserTool],
|
||||||
maxIterations: 20,
|
maxIterations: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user