/** * Edit tool module. * * Supports four modes: * - Replace mode (default): oldText/newText replacement with fuzzy matching * - Patch mode: structured diff format with explicit operation type * - Hashline mode: line-addressed edits using content hashes for integrity * - Chunk mode: syntax-aware chunk-addressed edits using chunk checksums * * The mode is determined by the `edit.mode` setting. */ import * as fs from "node:fs/promises"; import * as nodePath from "node:path"; import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core"; import { StringEnum } from "@oh-my-pi/pi-ai"; import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; import { createLspWritethrough, type FileDiagnosticsResult, flushLspWritethroughBatch, type WritethroughCallback, writethroughNoop, } from "../lsp"; import { getLanguageFromPath } from "../modes/theme/theme"; import chunkEditDescription from "../prompts/tools/chunk-edit.md" with { type: "text" }; import hashlineDescription from "../prompts/tools/hashline.md" with { type: "text" }; import patchDescription from "../prompts/tools/patch.md" with { type: "text" }; import replaceDescription from "../prompts/tools/replace.md" with { type: "text" }; import type { ToolSession } from "../tools"; import { checkAutoGeneratedFile, checkAutoGeneratedFileContent } from "../tools/auto-generated-guard"; import { applyChunkEdits, type ChunkEditOperation, getChunkInfoForFile, resolveAnchorStyle } from "../tools/chunk-tree"; import { invalidateFsScanAfterDelete, invalidateFsScanAfterRename, invalidateFsScanAfterWrite, } from "../tools/fs-cache-invalidation"; import { outputMeta } from "../tools/output-meta"; import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard"; import { type EditMode, normalizeEditMode, resolveEditMode } from "../utils/edit-mode"; import { applyPatch } from "./applicator"; import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff"; import { findMatch } from "./fuzzy"; import { type Anchor, applyHashlineEdits, buildCompactHashlineDiffPreview, type HashlineEdit, parseTag, } from "./hashline"; import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize"; import { stripNewLinePrefixes } from "./prefix-stripping"; import { type EditToolDetails, getLspBatchRequest } from "./shared"; // Internal imports import type { FileSystem, Operation, PatchInput } from "./types"; import { EditMatchError } from "./types"; // ═══════════════════════════════════════════════════════════════════════════ // Re-exports // ═══════════════════════════════════════════════════════════════════════════ export { DEFAULT_EDIT_MODE, type EditMode, normalizeEditMode } from "../utils/edit-mode"; // Application export { applyPatch, defaultFileSystem, previewPatch } from "./applicator"; // Diff generation export * from "./diff"; // Fuzzy matching export * from "./fuzzy"; // Hashline export * from "./hashline"; // Normalization export * from "./normalize"; // Parsing export { normalizeCreateContent, normalizeDiff, parseHunks as parseDiffHunks } from "./parser"; // Prefix stripping export { stripHashlinePrefixes, stripNewLinePrefixes } from "./prefix-stripping"; export type { EditRenderContext, EditToolDetails } from "./shared"; // Rendering export { editToolRenderer, getLspBatchRequest } from "./shared"; export * from "./types"; // ═══════════════════════════════════════════════════════════════════════════ // Schemas // ═══════════════════════════════════════════════════════════════════════════ const replaceEditSchema = Type.Object({ path: Type.String({ description: "File path (relative or absolute)" }), old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }), new_text: Type.String({ description: "Replacement text" }), all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })), }); const patchEditSchema = Type.Object({ path: Type.String({ description: "File path" }), op: Type.Optional( StringEnum(["create", "delete", "update"], { description: "Operation (default: update)", }), ), rename: Type.Optional(Type.String({ description: "New path for move" })), diff: Type.Optional(Type.String({ description: "Diff hunks (update) or full content (create)" })), }); const CHUNK_OP_VALUES = ["replace", "delete", "append", "prepend", "after", "before"] as const; const chunkToolEditSchema = Type.Object({ target: Type.String({ description: "Chunk path from read output, with #CRC suffix for replace/delete (e.g. 'class_X.fn_y#A14F'). Use parent path without #CRC for insert ops.", }), op: Type.Optional( StringEnum(CHUNK_OP_VALUES, { description: "Edit op (default: replace). 'delete' removes target. 'append'/'prepend' insert as last/first child. 'after'/'before' insert at sibling position; require 'anchor'.", }), ), content: Type.Optional( Type.String({ description: "New content (required for replace/append/prepend/after/before)." }), ), line: Type.Optional( Type.Integer({ description: "Absolute file line for line-scoped replace (omit for whole-chunk replace)." }), ), end_line: Type.Optional(Type.Integer({ description: "End line (inclusive) for replacing a line range." })), anchor: Type.Optional( Type.String({ description: "Named child to insert relative to (required for op=after/before)." }), ), }); const chunkEditParamsSchema = Type.Object( { path: Type.String({ description: "File path" }), edits: Type.Array(chunkToolEditSchema, { description: "Chunk edits", minItems: 1, }), }, { additionalProperties: false }, ); export type ReplaceParams = Static; export type PatchParams = Static; export type ChunkToolEdit = Static; export type ChunkParams = Static; export function hashlineParseText(edit: string[] | string | null): string[] { if (edit === null) return []; if (typeof edit === "string") { const normalizedEdit = edit.endsWith("\n") ? edit.slice(0, -1) : edit; edit = normalizedEdit.replaceAll("\r", "").split("\n"); } return stripNewLinePrefixes(edit); } function flattenContent(content: string | string[] | undefined): string { if (content === undefined) return ""; if (Array.isArray(content)) return content.join("\n"); return content; } type ParsedChunkTarget = { selector: string; crc?: string; }; function parseChunkTarget(target: string): ParsedChunkTarget { const hashIndex = target.lastIndexOf("#"); if (hashIndex === -1 || hashIndex === target.length - 1) { return { selector: target }; } return { selector: target.slice(0, hashIndex), crc: target.slice(hashIndex + 1).toUpperCase(), }; } function joinChunkPath(parent: string, child: string): string { if (child.length === 0) { throw new Error("Sibling name cannot be empty."); } if (parent.length === 0 || child.startsWith(`${parent}.`)) { return child; } return `${parent}.${child}`; } function describeChunkTarget(selector: string): string { return selector.length > 0 ? `"${selector}"` : "root"; } const linesSchema = Type.Union([ Type.Array(Type.String(), { description: "content (preferred format)" }), Type.String(), Type.Null(), ]); const locSchema = Type.Union( [ Type.Literal("append"), Type.Literal("prepend"), Type.Object({ append: Type.String({ description: "anchor" }) }), Type.Object({ prepend: Type.String({ description: "anchor" }) }), Type.Object({ range: Type.Object({ pos: Type.String({ description: "first line to edit (inclusive)" }), end: Type.String({ description: "last line to edit (inclusive)" }), }), }), ], { description: "insert location" }, ); const hashlineEditSchema = Type.Object( { loc: locSchema, content: linesSchema, }, { additionalProperties: false }, ); const hashlineEditParamsSchema = Type.Object( { path: Type.String({ description: "path" }), edits: Type.Array(hashlineEditSchema, { description: "edits over $path" }), delete: Type.Optional(Type.Boolean({ description: "If true, delete $path" })), move: Type.Optional(Type.String({ description: "If set, move $path to $move" })), }, { additionalProperties: false }, ); export type HashlineToolEdit = Static; export type HashlineParams = Static; // ═══════════════════════════════════════════════════════════════════════════ // Resilient anchor resolution // ═══════════════════════════════════════════════════════════════════════════ /** * Map loc/content tool-schema edits into typed HashlineEdit objects. * * Each edit entry has a `loc` (where to edit) and `content` (what to insert/replace). * loc can be: * - "append" / "prepend" — file-level insert * - { append: anchor } / { prepend: anchor } — insert relative to anchor * - { range: { pos, end } } — replace inclusive range */ function resolveEditAnchors(edits: HashlineToolEdit[]): HashlineEdit[] { const result: HashlineEdit[] = []; for (const edit of edits) { const lines = hashlineParseText(edit.content); const loc = edit.loc; if (loc === "append") { result.push({ op: "append_file", lines }); } else if (loc === "prepend") { result.push({ op: "prepend_file", lines }); } else if (typeof loc === "object") { if ("append" in loc) { const anchor = tryParseTag(loc.append); if (!anchor) throw new Error("append requires a valid anchor."); result.push({ op: "append_at", pos: anchor, lines }); } else if ("prepend" in loc) { const anchor = tryParseTag(loc.prepend); if (!anchor) throw new Error("prepend requires a valid anchor."); result.push({ op: "prepend_at", pos: anchor, lines }); } else if ("range" in loc) { const posAnchor = tryParseTag(loc.range.pos); const endAnchor = tryParseTag(loc.range.end); if (!posAnchor || !endAnchor) throw new Error("range requires valid pos and end anchors."); result.push({ op: "replace_range", pos: posAnchor, end: endAnchor, lines }); } else { throw new Error("Unknown loc shape. Expected append, prepend, or range."); } } else { throw new Error(`Invalid loc value: ${JSON.stringify(loc)}`); } } return result; } /** Parse a tag, returning undefined instead of throwing on garbage. */ function tryParseTag(raw: string): Anchor | undefined { try { return parseTag(raw); } catch { return undefined; } } // ═══════════════════════════════════════════════════════════════════════════ // LSP FileSystem for patch mode // ═══════════════════════════════════════════════════════════════════════════ class LspFileSystem implements FileSystem { #lastDiagnostics: FileDiagnosticsResult | undefined; #fileCache: Record = {}; constructor( private readonly writethrough: ( dst: string, content: string, signal?: AbortSignal, file?: import("bun").BunFile, batch?: { id: string; flush: boolean }, ) => Promise, private readonly signal?: AbortSignal, private readonly batchRequest?: { id: string; flush: boolean }, ) {} #getFile(path: string): Bun.BunFile { if (this.#fileCache[path]) { return this.#fileCache[path]; } const file = Bun.file(path); this.#fileCache[path] = file; return file; } async exists(path: string): Promise { return this.#getFile(path).exists(); } async read(path: string): Promise { return this.#getFile(path).text(); } async readBinary(path: string): Promise { const buffer = await this.#getFile(path).arrayBuffer(); return new Uint8Array(buffer); } async write(path: string, content: string): Promise { const file = this.#getFile(path); const result = await this.writethrough(path, content, this.signal, file, this.batchRequest); if (result) { this.#lastDiagnostics = result; } } async delete(path: string): Promise { await this.#getFile(path).unlink(); } async mkdir(path: string): Promise { await fs.mkdir(path, { recursive: true }); } getDiagnostics(): FileDiagnosticsResult | undefined { return this.#lastDiagnostics; } } function mergeDiagnosticsWithWarnings( diagnostics: FileDiagnosticsResult | undefined, warnings: string[], ): FileDiagnosticsResult | undefined { if (warnings.length === 0) return diagnostics; const warningMessages = warnings.map(warning => `patch: ${warning}`); if (!diagnostics) { return { server: "patch", messages: warningMessages, summary: `Patch warnings: ${warnings.length}`, errored: false, }; } return { ...diagnostics, messages: [...warningMessages, ...diagnostics.messages], summary: `${diagnostics.summary}; Patch warnings: ${warnings.length}`, }; } // ═══════════════════════════════════════════════════════════════════════════ // Tool Class // ═══════════════════════════════════════════════════════════════════════════ type TInput = | typeof replaceEditSchema | typeof patchEditSchema | typeof hashlineEditParamsSchema | typeof chunkEditParamsSchema; function isHashlineParams( params: ReplaceParams | PatchParams | HashlineParams | ChunkParams, ): params is HashlineParams { return "edits" in params && Array.isArray(params.edits) && (params.edits.length === 0 || "loc" in params.edits[0]); } function isReplaceParams(params: ReplaceParams | PatchParams | HashlineParams | ChunkParams): params is ReplaceParams { return "old_text" in params && "new_text" in params; } function isChunkParams(params: ReplaceParams | PatchParams | HashlineParams | ChunkParams): params is ChunkParams { return "edits" in params && Array.isArray(params.edits) && params.edits.length > 0 && "target" in params.edits[0]; } /** * Edit tool implementation. * * Creates replace-mode, patch-mode, or hashline-mode behavior based on session settings. */ export class EditTool implements AgentTool { readonly name = "edit"; readonly label = "Edit"; readonly nonAbortable = true; readonly concurrency = "exclusive"; readonly strict = true; readonly #allowFuzzy: boolean; readonly #fuzzyThreshold: number; readonly #writethrough: WritethroughCallback; readonly #editMode?: EditMode | null; constructor(private readonly session: ToolSession) { const { PI_EDIT_FUZZY: editFuzzy = "auto", PI_EDIT_FUZZY_THRESHOLD: editFuzzyThreshold = "auto", PI_EDIT_VARIANT: envEditVariant = "auto", } = Bun.env; if (envEditVariant && envEditVariant !== "auto") { const editMode = normalizeEditMode(envEditVariant); if (!editMode) { throw new Error(`Invalid PI_EDIT_VARIANT: ${envEditVariant}`); } this.#editMode = editMode; } switch (editFuzzy) { case "true": case "1": this.#allowFuzzy = true; break; case "false": case "0": this.#allowFuzzy = false; break; case "auto": this.#allowFuzzy = session.settings.get("edit.fuzzyMatch"); break; default: throw new Error(`Invalid PI_EDIT_FUZZY: ${editFuzzy}`); } switch (editFuzzyThreshold) { case "auto": this.#fuzzyThreshold = session.settings.get("edit.fuzzyThreshold"); break; default: this.#fuzzyThreshold = parseFloat(editFuzzyThreshold); if (Number.isNaN(this.#fuzzyThreshold) || this.#fuzzyThreshold < 0 || this.#fuzzyThreshold > 1) { throw new Error(`Invalid PI_EDIT_FUZZY_THRESHOLD: ${editFuzzyThreshold}`); } break; } const enableLsp = session.enableLsp ?? true; const enableDiagnostics = enableLsp && session.settings.get("lsp.diagnosticsOnEdit"); const enableFormat = enableLsp && session.settings.get("lsp.formatOnWrite"); this.#writethrough = enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop; } /** * Determine edit mode dynamically based on current model. * This is re-evaluated on each access so tool definitions stay current when model changes. */ get mode(): EditMode { if (this.#editMode) return this.#editMode; return resolveEditMode(this.session); } /** * Dynamic description based on current edit mode (which depends on current model). */ get description(): string { switch (this.mode) { case "chunk": return renderPromptTemplate(chunkEditDescription, { anchorStyle: resolveAnchorStyle(this.session.settings), }); case "patch": return renderPromptTemplate(patchDescription); case "hashline": return renderPromptTemplate(hashlineDescription); default: return renderPromptTemplate(replaceDescription); } } /** * Dynamic parameters schema based on current edit mode (which depends on current model). */ get parameters(): TInput { switch (this.mode) { case "chunk": return chunkEditParamsSchema; case "patch": return patchEditSchema; case "hashline": return hashlineEditParamsSchema; default: return replaceEditSchema; } } async execute( _toolCallId: string, params: ReplaceParams | PatchParams | HashlineParams | ChunkParams, signal?: AbortSignal, _onUpdate?: AgentToolUpdateCallback, context?: AgentToolContext, ): Promise> { const batchRequest = getLspBatchRequest(context?.toolCall); // ───────────────────────────────────────────────────────────────── // Chunk mode execution // ───────────────────────────────────────────────────────────────── if (this.mode === "chunk") { if (!isChunkParams(params)) { throw new Error("Invalid edit parameters for chunk mode."); } const { path, edits } = params; const resolvedPath = resolvePlanPath(this.session, path); const sourceExists = await Bun.file(resolvedPath).exists(); enforcePlanModeWrite(this.session, path, { op: sourceExists ? "update" : "create" }); if (path.endsWith(".ipynb")) { throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); } let rawContent = ""; if (sourceExists) { rawContent = await Bun.file(resolvedPath).text(); await checkAutoGeneratedFileContent(rawContent, path); } const parentDir = nodePath.dirname(resolvedPath); if (parentDir && parentDir !== ".") { await fs.mkdir(parentDir, { recursive: true }); } const chunkLanguage = getLanguageFromPath(resolvedPath); const normalizedOperations: ChunkEditOperation[] = []; const assertChecksum = async (op: string, crc: string | undefined, selector: string): Promise => { if (crc) return crc.toUpperCase(); if (selector.length > 0 && sourceExists) { const resolved = await getChunkInfoForFile(resolvedPath, chunkLanguage, selector); if (resolved) { throw new Error( `Checksum required for ${op} on ${describeChunkTarget(selector)}. ` + `Re-read the chunk to get its checksum, then pass target: "${selector}#${resolved.checksum}".`, ); } throw new Error(`Chunk not found: "${selector}". Re-read the file to see available chunk paths.`); } throw new Error( `Checksum required for ${op} on ${describeChunkTarget(selector)}. ` + "Re-read the file first, then pass target with a #XXXX checksum suffix copied from the read output.", ); }; for (const edit of edits) { const { selector, crc } = parseChunkTarget(edit.target); const op = edit.op ?? "replace"; const hasContent = edit.content !== undefined; const content = flattenContent(edit.content); switch (op) { case "delete": { if (hasContent || edit.line !== undefined || edit.end_line !== undefined) { throw new Error( `Delete edit on ${describeChunkTarget(selector)} cannot include content or line ranges.`, ); } normalizedOperations.push({ op: "delete", sel: selector, crc: await assertChecksum("delete", crc, selector), }); break; } case "append": { if (!hasContent) throw new Error(`Content required for append on ${describeChunkTarget(selector)}.`); normalizedOperations.push({ op: "append_child", sel: selector, content }); break; } case "prepend": { if (!hasContent) throw new Error(`Content required for prepend on ${describeChunkTarget(selector)}.`); normalizedOperations.push({ op: "prepend_child", sel: selector, content }); break; } case "after": { if (!hasContent) throw new Error(`Content required for after-insert on ${describeChunkTarget(selector)}.`); if (!edit.anchor) throw new Error(`'anchor' required for op=after on ${describeChunkTarget(selector)}.`); normalizedOperations.push({ op: "append_sibling", sel: joinChunkPath(selector, edit.anchor), content, }); break; } case "before": { if (!hasContent) throw new Error(`Content required for before-insert on ${describeChunkTarget(selector)}.`); if (!edit.anchor) throw new Error(`'anchor' required for op=before on ${describeChunkTarget(selector)}.`); normalizedOperations.push({ op: "prepend_sibling", sel: joinChunkPath(selector, edit.anchor), content, }); break; } default: { if (!hasContent) { throw new Error(`Content required for replace edit on ${describeChunkTarget(selector)}.`); } normalizedOperations.push({ op: "replace", sel: selector, crc: await assertChecksum("replace", crc, selector), content, line: edit.line, endLine: edit.end_line, }); break; } } } const chunkResult = applyChunkEdits({ source: rawContent, language: chunkLanguage, cwd: this.session.cwd, filePath: resolvedPath, operations: normalizedOperations, anchorStyle: resolveAnchorStyle(this.session.settings), }); if (!chunkResult.changed) { const responseText = `[No changes needed — content already matches.]\n\n${chunkResult.responseText}`; return { content: [{ type: "text", text: responseText }], details: { diff: "", op: sourceExists ? "update" : "create", meta: outputMeta().get(), }, }; } const { bom, text } = stripBom(rawContent); const originalEnding = detectLineEnding(text); const finalContent = bom + restoreLineEndings(chunkResult.diffSourceAfter, originalEnding); const diagnostics = await this.#writethrough( resolvedPath, finalContent, signal, Bun.file(resolvedPath), batchRequest, ); invalidateFsScanAfterWrite(resolvedPath); const diffResult = generateUnifiedDiffString(chunkResult.diffSourceBefore, chunkResult.diffSourceAfter); const warningsBlock = chunkResult.warnings.length > 0 ? `\n\n${chunkResult.warnings.join("\n")}` : ""; const meta = outputMeta() .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) .get(); return { content: [{ type: "text", text: `${chunkResult.responseText}${warningsBlock}` }], details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine, diagnostics, op: sourceExists ? "update" : "create", meta, }, }; } // ───────────────────────────────────────────────────────────────── // Hashline mode execution // ───────────────────────────────────────────────────────────────── if (this.mode === "hashline") { if (!isHashlineParams(params)) { throw new Error("Invalid edit parameters for hashline mode."); } const { path, edits, delete: deleteFile, move } = params; enforcePlanModeWrite(this.session, path, { op: deleteFile ? "delete" : "update", move }); if (path.endsWith(".ipynb") && edits?.length > 0) { throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); } const absolutePath = resolvePlanPath(this.session, path); const resolvedMove = move ? resolvePlanPath(this.session, move) : undefined; if (resolvedMove === absolutePath) { throw new Error("move path is the same as source path"); } const sourceExists = await fs.exists(absolutePath); const isMoveOnly = Boolean(resolvedMove) && edits.length === 0; if (deleteFile) { if (sourceExists) { await fs.unlink(absolutePath); } invalidateFsScanAfterDelete(absolutePath); return { content: [{ type: "text", text: `Deleted ${path}` }], details: { diff: "", op: "delete", meta: outputMeta().get(), }, }; } if (isMoveOnly && resolvedMove) { if (!sourceExists) { throw new Error(`File not found: ${path}`); } const parentDir = nodePath.dirname(resolvedMove); if (parentDir && parentDir !== ".") { await fs.mkdir(parentDir, { recursive: true }); } // Preserve exact bytes for move-only operations, including binary files. await fs.rename(absolutePath, resolvedMove); invalidateFsScanAfterRename(absolutePath, resolvedMove); return { content: [{ type: "text", text: `Moved ${path} to ${move}` }], details: { diff: "", op: "update", move, meta: outputMeta().get(), }, }; } if (!sourceExists) { const lines: string[] = []; for (const edit of edits) { // For file creation, only anchorless appends/prepends are valid if (edit.loc === "append") { lines.push(...hashlineParseText(edit.content)); } else if (edit.loc === "prepend") { lines.unshift(...hashlineParseText(edit.content)); } else { throw new Error(`File not found: ${path}`); } } await fs.writeFile(absolutePath, lines.join("\n")); return { content: [{ type: "text", text: `Created ${path}` }], details: { diff: "", op: "create", meta: outputMeta().get(), }, }; } const anchorEdits = resolveEditAnchors(edits); const rawContent = await fs.readFile(absolutePath, "utf-8"); await checkAutoGeneratedFileContent(rawContent, path); const { bom, text } = stripBom(rawContent); const originalEnding = detectLineEnding(text); const originalNormalized = normalizeToLF(text); let normalizedText = originalNormalized; // Apply anchor-based edits first (replace, append_at, prepend_at) const anchorResult = applyHashlineEdits(normalizedText, anchorEdits); normalizedText = anchorResult.lines; const result = { text: normalizedText, firstChangedLine: anchorResult.firstChangedLine, warnings: anchorResult.warnings, noopEdits: anchorResult.noopEdits, }; if (originalNormalized === result.text && !move) { let diagnostic = `No changes made to ${path}. The edits produced identical content.`; if (result.noopEdits && result.noopEdits.length > 0) { const details = result.noopEdits .map( e => `Edit ${e.editIndex}: replacement for ${e.loc} is identical to current content:\n ${e.loc}| ${e.current}`, ) .join("\n"); diagnostic += `\n${details}`; if (result.noopEdits.length === 1 && result.noopEdits[0]?.current) { const preview = result.noopEdits[0].current.trimEnd(); if (preview.length > 0) { diagnostic += `\nThe file currently contains these lines:\n${preview}\nYour edits were normalized back to the original content (whitespace-only differences are preserved as-is). Ensure your replacement changes actual code, not just formatting.`; } } } throw new Error(diagnostic); } const finalContent = bom + restoreLineEndings(result.text, originalEnding); const writePath = resolvedMove ?? absolutePath; const diagnostics = await this.#writethrough( writePath, finalContent, signal, Bun.file(writePath), batchRequest, ); if (resolvedMove && resolvedMove !== absolutePath) { await fs.unlink(absolutePath); invalidateFsScanAfterRename(absolutePath, resolvedMove); } else { invalidateFsScanAfterWrite(absolutePath); } const diffResult = generateDiffString(originalNormalized, result.text); const meta = outputMeta() .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) .get(); const resultText = move ? `Moved ${path} to ${move}` : `Updated ${path}`; const preview = buildCompactHashlineDiffPreview(diffResult.diff); const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${preview.preview ? "" : " (no textual diff preview)"}`; const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : ""; const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : ""; return { content: [ { type: "text", text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`, }, ], details: { diff: diffResult.diff, firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine, diagnostics, op: "update", move, meta, }, }; } // ───────────────────────────────────────────────────────────────── // Patch mode execution // ───────────────────────────────────────────────────────────────── if (this.mode === "patch") { if (isHashlineParams(params) || isReplaceParams(params) || isChunkParams(params)) { throw new Error("Invalid edit parameters for patch mode."); } const { path, op: rawOp, rename, diff } = params; // Normalize unrecognized operations to "update" const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update"; enforcePlanModeWrite(this.session, path, { op, move: rename }); const resolvedPath = resolvePlanPath(this.session, path); const resolvedRename = rename ? resolvePlanPath(this.session, rename) : undefined; if (path.endsWith(".ipynb")) { throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); } if (rename?.endsWith(".ipynb")) { throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); } await checkAutoGeneratedFile(resolvedPath, path); const input: PatchInput = { path: resolvedPath, op, rename: resolvedRename, diff }; const fs = new LspFileSystem(this.#writethrough, signal, batchRequest); const result = await applyPatch(input, { cwd: this.session.cwd, fs, fuzzyThreshold: this.#fuzzyThreshold, allowFuzzy: this.#allowFuzzy, }); if (resolvedRename) { invalidateFsScanAfterRename(resolvedPath, resolvedRename); } else if (result.change.type === "delete") { invalidateFsScanAfterDelete(resolvedPath); } else { invalidateFsScanAfterWrite(resolvedPath); } const effRename = result.change.newPath ? rename : undefined; // Generate diff for display let diffResult: { diff: string; firstChangedLine: number | undefined } = { diff: "", firstChangedLine: undefined, }; if (result.change.type === "update" && result.change.oldContent && result.change.newContent) { const normalizedOld = normalizeToLF(stripBom(result.change.oldContent).text); const normalizedNew = normalizeToLF(stripBom(result.change.newContent).text); diffResult = generateUnifiedDiffString(normalizedOld, normalizedNew); } let resultText: string; switch (result.change.type) { case "create": resultText = `Created ${path}`; break; case "delete": resultText = `Deleted ${path}`; break; case "update": resultText = effRename ? `Updated and moved ${path} to ${effRename}` : `Updated ${path}`; break; } let diagnostics = fs.getDiagnostics(); if (op === "delete" && batchRequest?.flush) { const flushedDiagnostics = await flushLspWritethroughBatch(batchRequest.id, this.session.cwd, signal); diagnostics ??= flushedDiagnostics; } const patchWarnings = result.warnings ?? []; const mergedDiagnostics = mergeDiagnosticsWithWarnings(diagnostics, patchWarnings); const meta = outputMeta() .diagnostics(mergedDiagnostics?.summary ?? "", mergedDiagnostics?.messages ?? []) .get(); return { content: [{ type: "text", text: resultText }], details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine, diagnostics: mergedDiagnostics, op, move: effRename, meta, }, }; } // ───────────────────────────────────────────────────────────────── // Replace mode execution // ───────────────────────────────────────────────────────────────── if (!isReplaceParams(params)) { throw new Error("Invalid edit parameters for replace mode."); } const { path, old_text, new_text, all } = params; enforcePlanModeWrite(this.session, path); if (path.endsWith(".ipynb")) { throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead."); } if (old_text.length === 0) { throw new Error("old_text must not be empty."); } const absolutePath = resolvePlanPath(this.session, path); if (!(await fs.exists(absolutePath))) { throw new Error(`File not found: ${path}`); } const rawContent = await fs.readFile(absolutePath, "utf-8"); const { bom, text: content } = stripBom(rawContent); const originalEnding = detectLineEnding(content); const normalizedContent = normalizeToLF(content); const normalizedOldText = normalizeToLF(old_text); const normalizedNewText = normalizeToLF(new_text); const result = replaceText(normalizedContent, normalizedOldText, normalizedNewText, { fuzzy: this.#allowFuzzy, all: all ?? false, threshold: this.#fuzzyThreshold, }); if (result.count === 0) { // Get error details const matchOutcome = findMatch(normalizedContent, normalizedOldText, { allowFuzzy: this.#allowFuzzy, threshold: this.#fuzzyThreshold, }); if (matchOutcome.occurrences && matchOutcome.occurrences > 1) { const previews = matchOutcome.occurrencePreviews?.join("\n\n") ?? ""; const moreMsg = matchOutcome.occurrences > 5 ? ` (showing first 5 of ${matchOutcome.occurrences})` : ""; throw new Error( `Found ${matchOutcome.occurrences} occurrences in ${path}${moreMsg}:\n\n${previews}\n\n` + `Add more context lines to disambiguate.`, ); } throw new EditMatchError(path, normalizedOldText, matchOutcome.closest, { allowFuzzy: this.#allowFuzzy, threshold: this.#fuzzyThreshold, fuzzyMatches: matchOutcome.fuzzyMatches, }); } if (normalizedContent === result.content) { throw new Error( `No changes made to ${path}. The replacement produced identical content. This might indicate an issue with special characters or the text not existing as expected.`, ); } const finalContent = bom + restoreLineEndings(result.content, originalEnding); const diagnostics = await this.#writethrough( absolutePath, finalContent, signal, Bun.file(absolutePath), batchRequest, ); invalidateFsScanAfterWrite(absolutePath); const diffResult = generateDiffString(normalizedContent, result.content); const resultText = result.count > 1 ? `Successfully replaced ${result.count} occurrences in ${path}.` : `Successfully replaced text in ${path}.`; const meta = outputMeta() .diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? []) .get(); return { content: [{ type: "text", text: resultText }], details: { diff: diffResult.diff, firstChangedLine: diffResult.firstChangedLine, diagnostics, meta }, }; } }