9e824d9235
- Restructured hashline edit schema from flat op/pos/end/lines fields to nested loc/content objects with discriminated union types. - Replaced operation names (replace_line, replace_range, append_at, prepend_at) with unified loc object patterns supporting line, block, append, and prepend anchors. - Updated content field to accept array of strings or null instead of lines parameter in edit entries. - Refactored edit resolution logic to dispatch on loc shape instead of op enum, simplifying location handling.
897 lines
33 KiB
TypeScript
897 lines
33 KiB
TypeScript
/**
|
|
* Edit tool module.
|
|
*
|
|
* Supports three 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
|
|
*
|
|
* 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 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 {
|
|
invalidateFsScanAfterDelete,
|
|
invalidateFsScanAfterRename,
|
|
invalidateFsScanAfterWrite,
|
|
} from "../tools/fs-cache-invalidation";
|
|
import { outputMeta } from "../tools/output-meta";
|
|
import { enforcePlanModeWrite, resolvePlanPath } from "../tools/plan-mode-guard";
|
|
import { applyPatch } from "./applicator";
|
|
import { generateDiffString, generateUnifiedDiffString, replaceText } from "./diff";
|
|
import { findMatch } from "./fuzzy";
|
|
import {
|
|
type Anchor,
|
|
applyHashlineEdits,
|
|
buildCompactHashlineDiffPreview,
|
|
computeLineHash,
|
|
type HashlineEdit,
|
|
parseTag,
|
|
} from "./hashline";
|
|
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "./normalize";
|
|
import { type EditToolDetails, getLspBatchRequest } from "./shared";
|
|
// Internal imports
|
|
import type { FileSystem, Operation, PatchInput } from "./types";
|
|
import { EditMatchError } from "./types";
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Re-exports
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
// 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";
|
|
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)" })),
|
|
});
|
|
|
|
export type ReplaceParams = Static<typeof replaceEditSchema>;
|
|
export type PatchParams = Static<typeof patchEditSchema>;
|
|
|
|
/**
|
|
* Pattern matching hashline display format prefixes: `LINE#ID:CONTENT`, `#ID:CONTENT`, and `+ID:CONTENT`.
|
|
* A plus-prefixed form appears in diff-like output and should be treated as hashline metadata too.
|
|
*/
|
|
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*(?:\+?\s*(?:\d+\s*#\s*|#\s*)|\+)\s*[ZPMQVRWSNKTXJBYH]{2}:/;
|
|
const HASHLINE_PREFIX_PLUS_RE = /^\s*(?:>>>|>>)?\s*\+\s*(?:\d+\s*#\s*|#\s*)?[ZPMQVRWSNKTXJBYH]{2}:/;
|
|
|
|
/** Pattern matching a unified-diff added-line `+` prefix (but not `++`). Does NOT match `-` to avoid corrupting Markdown list items. */
|
|
const DIFF_PLUS_RE = /^[+](?![+])/;
|
|
|
|
/**
|
|
* Strip hashline display prefixes and diff `+` markers from replacement lines.
|
|
*
|
|
* Models frequently copy the `LINE#ID ` prefix from read output into their
|
|
* replacement content, or include unified-diff `+` prefixes. Both corrupt the
|
|
* output file. This strips them heuristically before application.
|
|
*/
|
|
export function stripNewLinePrefixes(lines: string[]): string[] {
|
|
// Hashline prefixes are highly specific to read output and are usually stripped only when
|
|
// *every* non-empty line carries one. If a line is prefixed as `+ID:`, strip that
|
|
// prefix while leaving other `+` lines untouched to avoid corrupting mixed snippets.
|
|
let hashPrefixCount = 0;
|
|
let diffPlusHashPrefixCount = 0;
|
|
let diffPlusCount = 0;
|
|
let nonEmpty = 0;
|
|
for (const l of lines) {
|
|
if (l.length === 0) continue;
|
|
nonEmpty++;
|
|
if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++;
|
|
if (HASHLINE_PREFIX_PLUS_RE.test(l)) diffPlusHashPrefixCount++;
|
|
if (DIFF_PLUS_RE.test(l)) diffPlusCount++;
|
|
}
|
|
if (nonEmpty === 0) return lines;
|
|
|
|
const stripHash = hashPrefixCount > 0 && hashPrefixCount === nonEmpty;
|
|
const stripPlus =
|
|
!stripHash && diffPlusHashPrefixCount === 0 && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5;
|
|
if (!stripHash && !stripPlus && diffPlusHashPrefixCount === 0) return lines;
|
|
|
|
return lines.map(l => {
|
|
if (stripHash) return l.replace(HASHLINE_PREFIX_RE, "");
|
|
if (stripPlus) return l.replace(DIFF_PLUS_RE, "");
|
|
if (diffPlusHashPrefixCount > 0 && HASHLINE_PREFIX_PLUS_RE.test(l)) return l.replace(HASHLINE_PREFIX_RE, "");
|
|
return l;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Strip hashline display prefixes only (no diff markers).
|
|
*
|
|
* Unlike {@link stripNewLinePrefixes} which also handles `+` diff markers,
|
|
* this only strips `LINE#ID:` / `#ID:` prefixes. Used by the write tool
|
|
* where diff markers are not applicable.
|
|
*
|
|
* Returns the original array reference when no stripping is needed.
|
|
*/
|
|
export function stripHashlinePrefixes(lines: string[]): string[] {
|
|
let hashPrefixCount = 0;
|
|
let nonEmpty = 0;
|
|
for (const l of lines) {
|
|
if (l.length === 0) continue;
|
|
nonEmpty++;
|
|
if (HASHLINE_PREFIX_RE.test(l)) hashPrefixCount++;
|
|
}
|
|
if (nonEmpty === 0 || hashPrefixCount !== nonEmpty) return lines;
|
|
return lines.map(l => l.replace(HASHLINE_PREFIX_RE, ""));
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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({
|
|
line: Type.String({ description: "anchor" }),
|
|
}),
|
|
Type.Object({
|
|
block: Type.Object({
|
|
pos: Type.String({ description: "anchor" }),
|
|
end: Type.String({ description: "limit position" }),
|
|
}),
|
|
}),
|
|
],
|
|
{ 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
|
|
* - { replace_line: anchor } — replace one line
|
|
* - { replace_block: { 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 ("line" in loc) {
|
|
const anchor = tryParseTag(loc.line);
|
|
if (!anchor) throw new Error("line requires a valid anchor.");
|
|
result.push({ op: "replace_line", pos: anchor, lines });
|
|
} else if ("block" in loc) {
|
|
const posAnchor = tryParseTag(loc.block.pos);
|
|
const endAnchor = tryParseTag(loc.block.end);
|
|
if (!posAnchor || !endAnchor) throw new Error("block 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, line, or block.");
|
|
}
|
|
} 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;
|
|
|
|
export type EditMode = "replace" | "patch" | "hashline";
|
|
|
|
export const DEFAULT_EDIT_MODE: EditMode = "hashline";
|
|
|
|
const EDIT_MODES = ["replace", "patch", "hashline"] as const satisfies readonly EditMode[];
|
|
const EDIT_ID = Object.fromEntries(EDIT_MODES.map(mode => [mode, mode])) satisfies Record<string, EditMode>;
|
|
export const normalizeEditMode = (mode?: string | null): EditMode | undefined => EDIT_ID[mode ?? ""];
|
|
|
|
function isHashlineParams(params: ReplaceParams | PatchParams | HashlineParams): params is HashlineParams {
|
|
return "edits" in params;
|
|
}
|
|
|
|
function isReplaceParams(params: ReplaceParams | PatchParams | HashlineParams): params is ReplaceParams {
|
|
return "old_text" in params && "new_text" in params;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
// 1. Check if edit mode is explicitly set for this model
|
|
const activeModel = this.session.getActiveModelString?.();
|
|
const modelVariant = this.session.settings.getEditVariantForModel(activeModel);
|
|
if (modelVariant) return modelVariant;
|
|
// 2. Check if model contains "-spark" substring (default to replace mode)
|
|
if (activeModel?.includes("-spark")) return "replace";
|
|
// 3. Check if edit mode is explicitly set in session settings
|
|
const settingsMode = normalizeEditMode(this.session.settings.get("edit.mode"));
|
|
if (settingsMode) return settingsMode;
|
|
// 4. Default to DEFAULT_EDIT_MODE
|
|
return DEFAULT_EDIT_MODE;
|
|
}
|
|
|
|
/**
|
|
* Dynamic description based on current edit mode (which depends on current model).
|
|
*/
|
|
get description(): string {
|
|
switch (this.mode) {
|
|
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 "patch":
|
|
return patchEditSchema;
|
|
case "hashline":
|
|
return hashlineEditParamsSchema;
|
|
default:
|
|
return replaceEditSchema;
|
|
}
|
|
}
|
|
|
|
async execute(
|
|
_toolCallId: string,
|
|
params: ReplaceParams | PatchParams | HashlineParams,
|
|
signal?: AbortSignal,
|
|
_onUpdate?: AgentToolUpdateCallback<EditToolDetails, TInput>,
|
|
context?: AgentToolContext,
|
|
): Promise<AgentToolResult<EditToolDetails, TInput>> {
|
|
const batchRequest = getLspBatchRequest(context?.toolCall);
|
|
|
|
// ─────────────────────────────────────────────────────────────────
|
|
// 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}`;
|
|
diagnostic +=
|
|
"\nYour content must differ from what the file already contains. Re-read the file to see the current state.";
|
|
} else {
|
|
// Edits were not literally identical but heuristics normalized them back
|
|
const lines = result.text.split("\n");
|
|
const targetLines: string[] = [];
|
|
const refs: Anchor[] = [];
|
|
for (const edit of anchorEdits) {
|
|
refs.length = 0;
|
|
switch (edit.op) {
|
|
case "replace_line":
|
|
refs.push(edit.pos);
|
|
break;
|
|
case "replace_range":
|
|
refs.push(edit.end, edit.pos);
|
|
break;
|
|
case "append_at":
|
|
case "prepend_at":
|
|
refs.push(edit.pos);
|
|
break;
|
|
case "append_file":
|
|
case "prepend_file":
|
|
break;
|
|
}
|
|
|
|
for (const ref of refs) {
|
|
try {
|
|
if (ref.line >= 1 && ref.line <= lines.length) {
|
|
const text = lines[ref.line - 1];
|
|
const hash = computeLineHash(ref.line, text);
|
|
targetLines.push(`${ref.line}#${hash}:${text}`);
|
|
}
|
|
} catch {
|
|
/* skip malformed refs */
|
|
}
|
|
}
|
|
}
|
|
if (targetLines.length > 0) {
|
|
const preview = [...new Set(targetLines)].slice(0, 5).join("\n");
|
|
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)) {
|
|
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 },
|
|
};
|
|
}
|
|
}
|