spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/tools/edit.ts4 * Description: The edit tool — exact-string replacement via the nine-strategy cascade, with repair-prose failures (TOOL_PROTOCOL §4).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { isWorkspaceError } from "../workspace/index.js";11import { capDiff, unifiedDiff } from "./diff.js";12import { displayPath, numberedLine, resolveToolPath } from "./format.js";13import type { ToolDefinition } from "./registry.js";14import type { MatchSpan, NearMiss, ReplacerStrategy } from "./replacers.js";15import {16 STRATEGY_PHRASES,17 applyMatches,18 lineNumberAt,19 runCascade,20 runMultiOccurrence,21} from "./replacers.js";22import type { ToolContext, ToolResult } from "./types.js";2324export interface EditInput {25 file_path: string;26 old_string: string;27 new_string: string;28 replace_all?: boolean;29}3031const DESCRIPTION =32 "Replace an exact string in a file. Provide the text to find in old_string and its replacement in " +33 "new_string. old_string must uniquely identify one location: include 3–5 lines of surrounding " +34 "context exactly as it appears in the file, including whitespace and indentation. If old_string " +35 "matches multiple locations the edit fails and reports the line numbers — add more context and " +36 "retry, or set replace_all to true to change every exact occurrence (useful for renames). The tool " +37 "tolerates minor whitespace drift but will refuse ambiguous or disproportionate fuzzy matches. You " +38 "must read the file during this session before editing it. On success you get a snippet of the " +39 "edited region — review it instead of re-reading the file.";4041const SNIPPET_CONTEXT = 4;42const SNIPPET_MAX_LINES = 30;4344/** Per-file mutex — concurrent edits to one file are serialized (§4.2). */45const fileLocks = new Map<string, Promise<unknown>>();4647async function withFileLock<T>(key: string, fn: () => Promise<T>): Promise<T> {48 const previous = fileLocks.get(key) ?? Promise.resolve();49 const run = previous.then(fn, fn);50 const tail = run.then(51 () => undefined,52 () => undefined,53 );54 fileLocks.set(key, tail);55 try {56 return await run;57 } finally {58 if (fileLocks.get(key) === tail) fileLocks.delete(key);59 }60}6162function errorResult(content: string, title: string, startedAt: number): ToolResult {63 return { content, isError: true, metadata: { title, durationMs: Date.now() - startedAt } };64}6566function renderSnippet(newText: string, firstMatch: MatchSpan): {67 snippet: string;68 startLine: number;69 endLine: number;70} {71 const lines = newText.split("\n");72 if (lines[lines.length - 1] === "") lines.pop();73 const regionStart = lineNumberAt(newText, firstMatch.start);74 const replacementNewlines = (firstMatch.replacement.match(/\n/g) ?? []).length;75 const regionEnd = Math.min(lines.length, regionStart + replacementNewlines);7677 const from = Math.max(1, regionStart - SNIPPET_CONTEXT);78 const to = Math.min(Math.max(lines.length, 1), regionEnd + SNIPPET_CONTEXT);79 const rows: string[] = [];80 if (to - from + 1 > SNIPPET_MAX_LINES) {81 const half = Math.floor(SNIPPET_MAX_LINES / 2);82 for (let n = from; n < from + half; n++) rows.push(numberedLine(n, lines[n - 1] ?? ""));83 rows.push(" ...");84 for (let n = to - half + 1; n <= to; n++) rows.push(numberedLine(n, lines[n - 1] ?? ""));85 } else {86 for (let n = from; n <= to; n++) rows.push(numberedLine(n, lines[n - 1] ?? ""));87 }88 return { snippet: rows.join("\n"), startLine: from, endLine: to };89}9091function notFoundMessage(abs: string, nearMiss: NearMiss | undefined): string {92 if (nearMiss === undefined) {93 return (94 `No replacement was performed: old_string was not found in ${abs}. ` +95 "Read the file and provide old_string exactly as it appears, including whitespace and indentation."96 );97 }98 return (99 `No replacement was performed: old_string was not found in ${abs}.\n` +100 `Closest near-miss is at lines ${nearMiss.startLine}–${nearMiss.endLine} ` +101 `(differs in ${nearMiss.kind} on line ${nearMiss.diffLine}:\n` +102 `expected ${JSON.stringify(nearMiss.expected)}, file has ${JSON.stringify(nearMiss.actual)}). ` +103 "Read that region\nand provide old_string exactly as it appears in the file."104 );105}106107async function executeEdit(input: EditInput, ctx: ToolContext): Promise<ToolResult> {108 const startedAt = Date.now();109 const cwd = ctx.workspace.cwd();110 const abs = resolveToolPath(cwd, input.file_path);111 const rel = displayPath(cwd, abs);112113 return withFileLock(abs, async () => {114 // Precondition: the file must exist and be readable (§4.2).115 let rawContent: string;116 try {117 rawContent = await ctx.workspace.readFile(abs);118 } catch (cause) {119 if (isWorkspaceError(cause)) {120 if (cause.code === "file-not-found") {121 return errorResult(122 `File not found: ${abs}. Check the path, or use glob to locate the file by name.`,123 `Edit ${rel} · not found`,124 startedAt,125 );126 }127 if (cause.code === "file-is-directory") {128 return errorResult(129 `Path is a directory, not a file: ${abs}. Edit a file inside it instead.`,130 `Edit ${rel} · error`,131 startedAt,132 );133 }134 }135 return errorResult(136 `Cannot edit ${abs}: ${cause instanceof Error ? cause.message : String(cause)}`,137 `Edit ${rel} · error`,138 startedAt,139 );140 }141142 if (input.old_string.length === 0) {143 return errorResult(144 `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.`,145 `Edit ${rel} · invalid`,146 startedAt,147 );148 }149 if (input.old_string === input.new_string) {150 return errorResult(151 "old_string and new_string are identical — there is nothing to change.",152 `Edit ${rel} · invalid`,153 startedAt,154 );155 }156157 // Read-before-edit and external-modification checks (§4.2 = §3.2).158 if (ctx.fileTimes.get(abs) === undefined) {159 return errorResult(160 `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.`,161 `Edit ${rel} · refused`,162 startedAt,163 );164 }165 if (ctx.fileTimes.check(abs, rawContent) === "externally-modified") {166 return errorResult(167 `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.`,168 `Edit ${rel} · refused`,169 startedAt,170 );171 }172173 // Normalize for matching: BOM split off, CRLF → LF (restored on write, §4.3).174 const hadBom = rawContent.startsWith("\uFEFF");175 const noBom = hadBom ? rawContent.slice(1) : rawContent;176 const hadCrlf = noBom.includes("\r\n");177 const text = hadCrlf ? noBom.replace(/\r\n/g, "\n") : noBom;178 const oldString = input.old_string.replace(/\r\n/g, "\n");179 const newString = input.new_string.replace(/\r\n/g, "\n");180181 const replaceAll = input.replace_all === true;182 const outcome = replaceAll183 ? runMultiOccurrence(text, oldString, newString)184 : runCascade(text, oldString, newString);185186 if (outcome.kind === "disproportionate") {187 return errorResult(188 `No replacement was performed: the only fuzzy match for old_string in ${abs} is disproportionately larger than the text you provided ` +189 `(${outcome.candidateLength} characters matched against ${outcome.oldLength} provided). ` +190 "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.",191 `Edit ${rel} · refused`,192 startedAt,193 );194 }195 if (outcome.kind === "not-found") {196 return errorResult(notFoundMessage(abs, outcome.nearMiss), `Edit ${rel} · not found`, startedAt);197 }198199 // Uniqueness guard (§4.4): strategies 1–8 must resolve to one location.200 if (!replaceAll && outcome.matches.length > 1) {201 const lineNumbers = outcome.matches.map((m) => lineNumberAt(text, m.start)).join(", ");202 const n = outcome.matches.length;203 return errorResult(204 `No replacement was performed: old_string matches ${n} locations in ${abs} (lines ${lineNumbers}). ` +205 `Add more surrounding lines to old_string so it uniquely identifies one location, or set replace_all to true to change all ${n}.`,206 `Edit ${rel} · ambiguous`,207 startedAt,208 );209 }210211 const newText = applyMatches(text, outcome.matches);212 let output = hadCrlf ? newText.replace(/\n/g, "\r\n") : newText;213 if (hadBom) output = `\uFEFF${output}`;214215 try {216 await ctx.workspace.writeFile(abs, output);217 } catch (cause) {218 return errorResult(219 `Failed to write ${abs}: ${cause instanceof Error ? cause.message : String(cause)}. The file was not modified.`,220 `Edit ${rel} · error`,221 startedAt,222 );223 }224225 ctx.fileTimes.stamp(abs, output);226 const diff = unifiedDiff(text, newText, `a/${rel}`, `b/${rel}`);227 ctx.emit({228 type: "file.modified",229 payload: {230 path: rel,231 operation: "edit",232 diffStats: { added: diff.additions, removed: diff.deletions },233 diff: capDiff(diff.text),234 toolUseId: ctx.callId,235 },236 });237238 const firstMatch = outcome.matches[0] as MatchSpan;239 const { snippet, startLine, endLine } = renderSnippet(newText, firstMatch);240 const strategy: ReplacerStrategy = outcome.strategy;241 const count = outcome.matches.length;242 const headline =243 count === 1244 ? `Edited ${abs} (1 replacement, ${STRATEGY_PHRASES[strategy]}).`245 : `Edited ${abs} (${count} replacements).`;246247 return {248 content:249 `${headline}\n` +250 `Snippet of the edited region (lines ${startLine}–${endLine}):\n` +251 `${snippet}\n` +252 "Review the changes. Edit the file again if the result is not what you intended.",253 metadata: {254 title: `Edit ${rel} · +${diff.additions} −${diff.deletions}`,255 diff: diff.text,256 additions: diff.additions,257 deletions: diff.deletions,258 durationMs: Date.now() - startedAt,259 extra: { strategy, replacements: count },260 },261 };262 });263}264265/** Create the edit tool definition. */266export function createEditTool(): ToolDefinition<EditInput> {267 return {268 name: "edit",269 description: DESCRIPTION,270 capability: "file.write",271 inputSchema: {272 type: "object",273 properties: {274 file_path: {275 type: "string",276 description: "Path of the file to edit (absolute preferred).",277 },278 old_string: {279 type: "string",280 description:281 "The exact existing text to replace, with enough surrounding context to be unique in the file.",282 },283 new_string: {284 type: "string",285 description: "The replacement text. Must differ from old_string.",286 },287 replace_all: {288 type: "boolean",289 description:290 "Replace every exact occurrence of old_string instead of requiring uniqueness. Default false.",291 },292 },293 required: ["file_path", "old_string", "new_string"],294 },295 execute: executeEdit,296 };297}298