Files
oh-my-pi/packages/coding-agent/src/patch/index.ts
T
can1357 87a3b6ca03 refactor(patch): restructured chunk edit schema to use explicit op enum and anchor field
- Refactored chunk edit schema to use explicit `op` enum field (replace, delete, append, prepend, after, before) instead of multiple boolean/string flags.
- Renamed chunk edit parameters from `after`/`before` field names to `anchor` for sibling-relative insert operations.
- Simplified chunk edit validation logic by consolidating nested conditionals into switch statement on operation type.
- Fixed `log_experiment` to correctly identify run-modified files by removing pre-run dirty path filtering.
- Simplified chunk edit and read prompt documentation to emphasize read-first workflow with consolidated examples.
2026-04-07 05:53:40 +02:00

1041 lines
37 KiB
TypeScript

/**
* 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<typeof replaceEditSchema>;
export type PatchParams = Static<typeof patchEditSchema>;
export type ChunkToolEdit = Static<typeof chunkToolEditSchema>;
export type ChunkParams = Static<typeof chunkEditParamsSchema>;
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<typeof hashlineEditSchema>;
export type HashlineParams = Static<typeof hashlineEditParamsSchema>;
// ═══════════════════════════════════════════════════════════════════════════
// 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<string, Bun.BunFile> = {};
constructor(
private readonly writethrough: (
dst: string,
content: string,
signal?: AbortSignal,
file?: import("bun").BunFile,
batch?: { id: string; flush: boolean },
) => Promise<FileDiagnosticsResult | undefined>,
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<boolean> {
return this.#getFile(path).exists();
}
async read(path: string): Promise<string> {
return this.#getFile(path).text();
}
async readBinary(path: string): Promise<Uint8Array> {
const buffer = await this.#getFile(path).arrayBuffer();
return new Uint8Array(buffer);
}
async write(path: string, content: string): Promise<void> {
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<void> {
await this.#getFile(path).unlink();
}
async mkdir(path: string): Promise<void> {
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<TInput> {
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<EditToolDetails, TInput>,
context?: AgentToolContext,
): Promise<AgentToolResult<EditToolDetails, TInput>> {
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<string> => {
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 },
};
}
}