SPB Git

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%
11.2 KB · 298 lines typescript
Raw Blame History
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