/** * KHAELOR * File: src/tools/edit.ts * Description: The edit tool — exact-string replacement via the nine-strategy cascade, with repair-prose failures (TOOL_PROTOCOL §4). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { isWorkspaceError } from "../workspace/index.js"; import { capDiff, unifiedDiff } from "./diff.js"; import { displayPath, numberedLine, resolveToolPath } from "./format.js"; import type { ToolDefinition } from "./registry.js"; import type { MatchSpan, NearMiss, ReplacerStrategy } from "./replacers.js"; import { STRATEGY_PHRASES, applyMatches, lineNumberAt, runCascade, runMultiOccurrence, } from "./replacers.js"; import type { ToolContext, ToolResult } from "./types.js"; export interface EditInput { file_path: string; old_string: string; new_string: string; replace_all?: boolean; } const DESCRIPTION = "Replace an exact string in a file. Provide the text to find in old_string and its replacement in " + "new_string. old_string must uniquely identify one location: include 3–5 lines of surrounding " + "context exactly as it appears in the file, including whitespace and indentation. If old_string " + "matches multiple locations the edit fails and reports the line numbers — add more context and " + "retry, or set replace_all to true to change every exact occurrence (useful for renames). The tool " + "tolerates minor whitespace drift but will refuse ambiguous or disproportionate fuzzy matches. You " + "must read the file during this session before editing it. On success you get a snippet of the " + "edited region — review it instead of re-reading the file."; const SNIPPET_CONTEXT = 4; const SNIPPET_MAX_LINES = 30; /** Per-file mutex — concurrent edits to one file are serialized (§4.2). */ const fileLocks = new Map>(); async function withFileLock(key: string, fn: () => Promise): Promise { const previous = fileLocks.get(key) ?? Promise.resolve(); const run = previous.then(fn, fn); const tail = run.then( () => undefined, () => undefined, ); fileLocks.set(key, tail); try { return await run; } finally { if (fileLocks.get(key) === tail) fileLocks.delete(key); } } function errorResult(content: string, title: string, startedAt: number): ToolResult { return { content, isError: true, metadata: { title, durationMs: Date.now() - startedAt } }; } function renderSnippet(newText: string, firstMatch: MatchSpan): { snippet: string; startLine: number; endLine: number; } { const lines = newText.split("\n"); if (lines[lines.length - 1] === "") lines.pop(); const regionStart = lineNumberAt(newText, firstMatch.start); const replacementNewlines = (firstMatch.replacement.match(/\n/g) ?? []).length; const regionEnd = Math.min(lines.length, regionStart + replacementNewlines); const from = Math.max(1, regionStart - SNIPPET_CONTEXT); const to = Math.min(Math.max(lines.length, 1), regionEnd + SNIPPET_CONTEXT); const rows: string[] = []; if (to - from + 1 > SNIPPET_MAX_LINES) { const half = Math.floor(SNIPPET_MAX_LINES / 2); for (let n = from; n < from + half; n++) rows.push(numberedLine(n, lines[n - 1] ?? "")); rows.push(" ..."); for (let n = to - half + 1; n <= to; n++) rows.push(numberedLine(n, lines[n - 1] ?? "")); } else { for (let n = from; n <= to; n++) rows.push(numberedLine(n, lines[n - 1] ?? "")); } return { snippet: rows.join("\n"), startLine: from, endLine: to }; } function notFoundMessage(abs: string, nearMiss: NearMiss | undefined): string { if (nearMiss === undefined) { return ( `No replacement was performed: old_string was not found in ${abs}. ` + "Read the file and provide old_string exactly as it appears, including whitespace and indentation." ); } return ( `No replacement was performed: old_string was not found in ${abs}.\n` + `Closest near-miss is at lines ${nearMiss.startLine}–${nearMiss.endLine} ` + `(differs in ${nearMiss.kind} on line ${nearMiss.diffLine}:\n` + `expected ${JSON.stringify(nearMiss.expected)}, file has ${JSON.stringify(nearMiss.actual)}). ` + "Read that region\nand provide old_string exactly as it appears in the file." ); } async function executeEdit(input: EditInput, ctx: ToolContext): Promise { const startedAt = Date.now(); const cwd = ctx.workspace.cwd(); const abs = resolveToolPath(cwd, input.file_path); const rel = displayPath(cwd, abs); return withFileLock(abs, async () => { // Precondition: the file must exist and be readable (§4.2). let rawContent: string; try { rawContent = await ctx.workspace.readFile(abs); } catch (cause) { if (isWorkspaceError(cause)) { if (cause.code === "file-not-found") { return errorResult( `File not found: ${abs}. Check the path, or use glob to locate the file by name.`, `Edit ${rel} · not found`, startedAt, ); } if (cause.code === "file-is-directory") { return errorResult( `Path is a directory, not a file: ${abs}. Edit a file inside it instead.`, `Edit ${rel} · error`, startedAt, ); } } return errorResult( `Cannot edit ${abs}: ${cause instanceof Error ? cause.message : String(cause)}`, `Edit ${rel} · error`, startedAt, ); } if (input.old_string.length === 0) { return errorResult( `old_string is empty. To replace the entire file content use the write tool; to edit, provide the exact existing text with 3–5 lines of surrounding context.`, `Edit ${rel} · invalid`, startedAt, ); } if (input.old_string === input.new_string) { return errorResult( "old_string and new_string are identical — there is nothing to change.", `Edit ${rel} · invalid`, startedAt, ); } // Read-before-edit and external-modification checks (§4.2 = §3.2). if (ctx.fileTimes.get(abs) === undefined) { return errorResult( `Refusing to edit ${abs}: you have not read this file in this session. Read it first so you do not destroy existing content, then write or edit it.`, `Edit ${rel} · refused`, startedAt, ); } if (ctx.fileTimes.check(abs, rawContent) === "externally-modified") { return errorResult( `Refusing to edit ${abs}: the file changed on disk after you last read it (content hash mismatch). Someone else may be editing it. Re-read the file and reapply your change.`, `Edit ${rel} · refused`, startedAt, ); } // Normalize for matching: BOM split off, CRLF → LF (restored on write, §4.3). const hadBom = rawContent.startsWith("\uFEFF"); const noBom = hadBom ? rawContent.slice(1) : rawContent; const hadCrlf = noBom.includes("\r\n"); const text = hadCrlf ? noBom.replace(/\r\n/g, "\n") : noBom; const oldString = input.old_string.replace(/\r\n/g, "\n"); const newString = input.new_string.replace(/\r\n/g, "\n"); const replaceAll = input.replace_all === true; const outcome = replaceAll ? runMultiOccurrence(text, oldString, newString) : runCascade(text, oldString, newString); if (outcome.kind === "disproportionate") { return errorResult( `No replacement was performed: the only fuzzy match for old_string in ${abs} is disproportionately larger than the text you provided ` + `(${outcome.candidateLength} characters matched against ${outcome.oldLength} provided). ` + "Too little context was given for the match to be trustworthy. Re-read the file and provide the full exact text you want to replace.", `Edit ${rel} · refused`, startedAt, ); } if (outcome.kind === "not-found") { return errorResult(notFoundMessage(abs, outcome.nearMiss), `Edit ${rel} · not found`, startedAt); } // Uniqueness guard (§4.4): strategies 1–8 must resolve to one location. if (!replaceAll && outcome.matches.length > 1) { const lineNumbers = outcome.matches.map((m) => lineNumberAt(text, m.start)).join(", "); const n = outcome.matches.length; return errorResult( `No replacement was performed: old_string matches ${n} locations in ${abs} (lines ${lineNumbers}). ` + `Add more surrounding lines to old_string so it uniquely identifies one location, or set replace_all to true to change all ${n}.`, `Edit ${rel} · ambiguous`, startedAt, ); } const newText = applyMatches(text, outcome.matches); let output = hadCrlf ? newText.replace(/\n/g, "\r\n") : newText; if (hadBom) output = `\uFEFF${output}`; try { await ctx.workspace.writeFile(abs, output); } catch (cause) { return errorResult( `Failed to write ${abs}: ${cause instanceof Error ? cause.message : String(cause)}. The file was not modified.`, `Edit ${rel} · error`, startedAt, ); } ctx.fileTimes.stamp(abs, output); const diff = unifiedDiff(text, newText, `a/${rel}`, `b/${rel}`); ctx.emit({ type: "file.modified", payload: { path: rel, operation: "edit", diffStats: { added: diff.additions, removed: diff.deletions }, diff: capDiff(diff.text), toolUseId: ctx.callId, }, }); const firstMatch = outcome.matches[0] as MatchSpan; const { snippet, startLine, endLine } = renderSnippet(newText, firstMatch); const strategy: ReplacerStrategy = outcome.strategy; const count = outcome.matches.length; const headline = count === 1 ? `Edited ${abs} (1 replacement, ${STRATEGY_PHRASES[strategy]}).` : `Edited ${abs} (${count} replacements).`; return { content: `${headline}\n` + `Snippet of the edited region (lines ${startLine}–${endLine}):\n` + `${snippet}\n` + "Review the changes. Edit the file again if the result is not what you intended.", metadata: { title: `Edit ${rel} · +${diff.additions} −${diff.deletions}`, diff: diff.text, additions: diff.additions, deletions: diff.deletions, durationMs: Date.now() - startedAt, extra: { strategy, replacements: count }, }, }; }); } /** Create the edit tool definition. */ export function createEditTool(): ToolDefinition { return { name: "edit", description: DESCRIPTION, capability: "file.write", inputSchema: { type: "object", properties: { file_path: { type: "string", description: "Path of the file to edit (absolute preferred).", }, old_string: { type: "string", description: "The exact existing text to replace, with enough surrounding context to be unique in the file.", }, new_string: { type: "string", description: "The replacement text. Must differ from old_string.", }, replace_all: { type: "boolean", description: "Replace every exact occurrence of old_string instead of requiring uniqueness. Default false.", }, }, required: ["file_path", "old_string", "new_string"], }, execute: executeEdit, }; }