src / tools / edit.ts
src / tools / edit.ts
import { tool, type Tool } from "@lmstudio/sdk";
import { existsSync } from "fs";
import { readFile, stat, writeFile } from "fs/promises";
import { basename } from "path";
import { z } from "zod";
import { listBackups, restoreBackup, snapshot } from "../backup";
import { validateSyntax } from "../validate";
import { clamp, firstText, formatBytes, type Workspace } from "../workspace";
const CONTEXT_LINES = 3;
const MAX_DIFF_CHARS = 3000;
const MAX_HUNKS = 12;
const MAX_DIFF_LINE_CHARS = 240;
const MAX_EDITS = 20;
const MAX_BACKUPS_LISTED = 25;
/** Ceiling on LCS table cells, so a big rewrite can never stall the event loop. */
const LCS_CELL_LIMIT = 60000;
export function editTools(ws: Workspace): Tool[] {
if (!ws.allowWrite) return [];
const tools: Tool[] = [];
tools.push(
tool({
name: "append_file",
description:
"Add text to the END of a file, creating it if it does not exist. Use this to build a " +
"large file in several smaller pieces: write the first part with write_file, then append " +
"the rest a few hundred lines at a time. That is far more reliable than trying to emit a " +
"whole large file in one call, which often produces broken output.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
content: z.string().default("").describe("Text to add at the end of the file."),
text: z.string().optional().describe("Alias for content."),
},
implementation: async ({ path, file_path, content, text }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.js\".";
const body = firstText(content, text);
if (body === "") return "Error: content is empty. Pass the text to append.";
const absPath = ws.resolveInRoot(target);
const display = ws.rel(absPath);
try {
const existed = existsSync(absPath);
ctx.status(`Appending to ${display}`);
if (existed) await snapshot(ws, absPath);
const { appendFile, mkdir } = await import("fs/promises");
await mkdir(absPath.slice(0, absPath.lastIndexOf(basename(absPath))), { recursive: true });
// Keep a newline between chunks so appended blocks never fuse.
const previous = existed ? await readFile(absPath, "utf-8") : "";
const separator = previous === "" || previous.endsWith("\n") ? "" : "\n";
await appendFile(absPath, `${separator}${body}`, "utf-8");
const complaint = await validateSyntax(ws, absPath);
const total = (await stat(absPath)).size;
const lines = (previous + separator + body).split(/\r?\n/).length;
return (
`${existed ? "Appended to" : "Created"} ${display}: added ${formatBytes(
Buffer.byteLength(body, "utf-8"),
)}, file is now ${formatBytes(total)} (${lines} lines). ` +
`Append the next piece, or run verify when the file is complete.${complaint}`
);
} catch (caught) {
return failure(caught, `append to ${display}`, FILE_HINT);
}
},
}),
);
tools.push(
tool({
name: "edit_file",
description:
"Change one exact piece of text inside an existing file, leaving everything else " +
"untouched. This is the correct way to modify a file -- never rewrite a whole file to " +
"change a few lines. old_text must match the file character for character, including " +
"indentation. Returns a diff of what changed. Read the file first so you can copy the " +
"text exactly.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
old_text: z
.string()
.default("")
.describe(
"The exact text to find, copied from the file including its indentation and line breaks.",
),
new_text: z.string().default("").describe("The text to put in its place. Empty string deletes it."),
old_string: z.string().optional().describe("Alias for old_text."),
new_string: z.string().optional().describe("Alias for new_text."),
replace_all: z
.boolean()
.default(false)
.describe("Set true to change every occurrence instead of requiring exactly one."),
},
implementation: async ({ path, file_path, old_text, new_text, old_string, new_string, replace_all }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.ts\".";
old_text = firstText(old_text, old_string);
new_text = firstText(new_text, new_string);
if (old_text === "") {
return "Error: old_text is empty. Pass the exact text to replace, copied from the file.";
}
const absPath = ws.resolveInRoot(target);
const display = ws.rel(absPath);
ctx.status(`Editing ${display}`);
try {
const loaded = await loadFile(ws, absPath, display);
if (!loaded.ok) return loaded.message;
const applied = applyReplacement(loaded.file.content, old_text, new_text, replace_all, display);
if (applied.status === "error") return applied.message;
if (applied.status === "noop") return applied.reason;
const backup = await snapshot(ws, absPath);
await writeFile(absPath, applied.content, "utf-8");
const complaint = await validateSyntax(ws, absPath);
if (applied.count > 1) {
ctx.warn(`Replaced ${applied.count} occurrences in ${display}.`);
}
return (
renderEditReport({
display,
before: loaded.file.content,
after: applied.content,
headline:
`Edited ${display} (${applied.count} replacement${applied.count === 1 ? "" : "s"}` +
`${applied.crlfAdjusted ? ", matched after adapting line endings to CRLF" : ""})`,
backup,
}) + complaint
);
} catch (caught) {
return failure(caught, `edit ${display}`, FILE_HINT);
}
},
}),
);
tools.push(
tool({
name: "preview_edit",
description:
"Dry run of edit_file: show the diff an edit would produce WITHOUT changing the file. " +
"Use it when you are unsure old_text matches, before committing to the real edit. " +
"Nothing is written and nothing is backed up.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
old_text: z.string().default("").describe("The exact text to find, copied from the file."),
new_text: z.string().default("").describe("The text that would replace it."),
old_string: z.string().optional().describe("Alias for old_text."),
new_string: z.string().optional().describe("Alias for new_text."),
},
implementation: async ({ path, file_path, old_text, new_text, old_string, new_string }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.ts\".";
old_text = firstText(old_text, old_string);
new_text = firstText(new_text, new_string);
const absPath = ws.resolveInRoot(target);
const display = ws.rel(absPath);
ctx.status(`Previewing edit to ${display}`);
try {
const loaded = await loadFile(ws, absPath, display);
if (!loaded.ok) return loaded.message;
const applied = applyReplacement(loaded.file.content, old_text, new_text, false, display);
if (applied.status === "error") return applied.message;
if (applied.status === "noop") return applied.reason;
return renderEditReport({
display,
before: loaded.file.content,
after: applied.content,
headline: `Preview only -- ${display} was NOT modified. This is what edit_file would do`,
backup: undefined,
});
} catch (caught) {
return failure(caught, `preview an edit to ${display}`, FILE_HINT);
}
},
}),
);
tools.push(
tool({
name: "multi_edit",
description:
"Make several exact-text edits to one file in a single step, applied in order. " +
"All or nothing: if any edit fails to match, the file is left completely untouched and " +
"you are told which one failed. Use this instead of many edit_file calls on the same file.",
parameters: {
path: z
.string()
.default("")
.describe("File path relative to the workspace root. All edits apply to this one file."),
edits: z
.array(
z.object({
old_text: z.string().default("").describe("Exact text to find, copied from the file."),
new_text: z.string().default("").describe("Text to put in its place."),
old_string: z.string().optional().describe("Alias for old_text."),
new_string: z.string().optional().describe("Alias for new_text."),
// Models routinely repeat the path on each edit. Accepting it here
// costs nothing and avoids the whole call being rejected.
path: z
.string()
.optional()
.describe("Optional; ignored if the top-level path is set. Must be the same file."),
}),
)
.describe(
"List of edits, applied top to bottom. Each old_text must match exactly once at the " +
"time its turn comes.",
),
},
implementation: async ({ path, edits }, ctx) => {
// Accept the path at the top level, on the edits, or both.
const normalised = edits.map((e) => ({
old_text: firstText(e.old_text, e.old_string),
new_text: firstText(e.new_text, e.new_string),
path: e.path,
}));
edits = normalised;
const perEdit = [...new Set(normalised.map((e) => e.path).filter((p): p is string => !!p?.trim()))];
const chosen = path.trim() !== "" ? path.trim() : perEdit[0];
if (chosen === undefined) {
return "Error: no file path given. Pass path=\"src/thing.ts\" alongside the edits.";
}
if (path.trim() === "" && perEdit.length > 1) {
return (
`Error: the edits name ${perEdit.length} different files (${perEdit.join(", ")}). ` +
`multi_edit changes one file per call -- send a separate call for each file.`
);
}
const absPath = ws.resolveInRoot(chosen);
const display = ws.rel(absPath);
ctx.status(`Applying ${edits.length} edit(s) to ${display}`);
try {
if (edits.length === 0) {
return "Error: edits was empty. Pass at least one {old_text, new_text} pair.";
}
if (edits.length > MAX_EDITS) {
return `Error: ${edits.length} edits is too many in one call. Send at most ${MAX_EDITS} at a time.`;
}
const loaded = await loadFile(ws, absPath, display);
if (!loaded.ok) return loaded.message;
// Validate every edit against the evolving buffer before anything is written.
let buffer = loaded.file.content;
let changes = 0;
const skipped: number[] = [];
for (let i = 0; i < edits.length; i++) {
const step = applyReplacement(buffer, edits[i].old_text, edits[i].new_text, false, display);
if (step.status === "error") {
return (
`Error: edit ${i + 1} of ${edits.length} failed, so NOTHING was written to ${display}. ` +
`${step.message.replace(/^Error: /, "")} Fix that edit and send the whole list again.`
);
}
if (step.status === "noop") {
skipped.push(i + 1);
continue;
}
buffer = step.content;
changes++;
}
if (changes === 0) {
return `No change: every edit was already applied to ${display}. The file is left as is.`;
}
const backup = await snapshot(ws, absPath);
await writeFile(absPath, buffer, "utf-8");
const skipNote =
skipped.length === 0 ? "" : `, skipped ${skipped.length} already-applied (#${skipped.join(", #")})`;
return renderEditReport({
display,
before: loaded.file.content,
after: buffer,
headline: `Applied ${changes} of ${edits.length} edit(s) to ${display}${skipNote}`,
backup,
});
} catch (caught) {
return failure(caught, `apply multiple edits to ${display}`, FILE_HINT);
}
},
}),
);
tools.push(
tool({
name: "insert_lines",
description:
"Insert new lines into a file after a given line number, pushing the rest down. " +
"Use after_line 0 to put the content at the very top. Nothing existing is removed. " +
"Returns a diff of what was added.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
after_line: z
.number()
.int()
.min(0)
.describe("1-based line number to insert after. 0 means insert at the top of the file."),
content: z.string().describe("The line or lines to insert. Use \\n between lines."),
},
implementation: async ({ path, file_path, after_line, content }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.ts\".";
const absPath = ws.resolveInRoot(target);
const display = ws.rel(absPath);
ctx.status(`Inserting into ${display} after line ${after_line}`);
try {
if (content === "") {
return "Error: content was empty, so there is nothing to insert.";
}
const loaded = await loadFile(ws, absPath, display);
if (!loaded.ok) return loaded.message;
const original = loaded.file.lines;
const at = Math.min(after_line, original.length);
const clampNote =
at < after_line
? ` (after_line ${after_line} was past the end of the ${original.length}-line file, so it was appended)`
: "";
const incoming = splitLines(content);
// A repeated identical call must not duplicate the block.
if (sameSlice(original, at, incoming)) {
return `No change: those ${incoming.length} line(s) are already at line ${at + 1} of ${display}.`;
}
const next = [...original.slice(0, at), ...incoming, ...original.slice(at)];
const after = joinLines(next, loaded.file);
const backup = await snapshot(ws, absPath);
await writeFile(absPath, after, "utf-8");
return renderEditReport({
display,
before: loaded.file.content,
after,
headline: `Inserted ${incoming.length} line(s) into ${display} after line ${at}${clampNote}`,
backup,
});
} catch (caught) {
return failure(caught, `insert lines into ${display}`, FILE_HINT);
}
},
}),
);
tools.push(
tool({
name: "replace_lines",
description:
"Replace a range of lines in a file with new content. Line numbers are 1-based and both " +
"ends are included, so start_line 4 end_line 6 replaces lines 4, 5 and 6. Pass empty " +
"content to delete the range. Prefer edit_file when you can quote the exact text; use " +
"this when you know the line numbers.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
start_line: z.number().int().min(1).describe("First line to replace, 1-based, included."),
end_line: z.number().int().min(1).describe("Last line to replace, 1-based, included."),
content: z
.string()
.default("")
.describe("Replacement text. Use \\n between lines. Empty deletes the range."),
},
implementation: async ({ path, file_path, start_line, end_line, content }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.ts\".";
const absPath = ws.resolveInRoot(target);
const display = ws.rel(absPath);
ctx.status(`Replacing lines ${start_line}-${end_line} of ${display}`);
try {
const loaded = await loadFile(ws, absPath, display);
if (!loaded.ok) return loaded.message;
const original = loaded.file.lines;
if (end_line < start_line) {
return `Error: end_line (${end_line}) is before start_line (${start_line}). The range is inclusive, so end_line must be >= start_line.`;
}
if (start_line > original.length) {
return `Error: ${display} has only ${original.length} line(s), so line ${start_line} does not exist. Use insert_lines to add content at the end.`;
}
const last = Math.min(end_line, original.length);
const incoming = splitLines(content);
if (sameRange(original, start_line - 1, last, incoming)) {
return `No change: lines ${start_line}-${last} of ${display} already contain exactly that content.`;
}
const next = [...original.slice(0, start_line - 1), ...incoming, ...original.slice(last)];
const after = joinLines(next, loaded.file);
const backup = await snapshot(ws, absPath);
await writeFile(absPath, after, "utf-8");
if (incoming.length === 0) {
ctx.warn(`Deleted lines ${start_line}-${last} of ${display}.`);
}
const verb =
incoming.length === 0
? `Deleted lines ${start_line}-${last} of ${display}`
: `Replaced lines ${start_line}-${last} of ${display} with ${incoming.length} line(s)`;
return renderEditReport({ display, before: loaded.file.content, after, headline: verb, backup });
} catch (caught) {
return failure(caught, `replace lines in ${display}`, FILE_HINT);
}
},
}),
);
tools.push(
tool({
name: "list_backups",
description:
"List the automatic backups taken before files were edited, newest first. Use this to " +
"find the backup name to hand to restore_file when an edit went wrong.",
parameters: {},
implementation: async (_params, ctx) => {
ctx.status("Listing backups");
try {
const entries = await listBackups(ws);
if (entries.length === 0) {
return "No backups yet. One is taken automatically before each edit.";
}
const shown = entries.slice(0, MAX_BACKUPS_LISTED);
const lines = shown.map(
(entry) => `${entry.fileName} <- ${entry.originalPath} (${formatBytes(entry.size)})`,
);
const note =
entries.length > shown.length
? `\n\n[truncated: showing the ${shown.length} newest of ${entries.length} backups]`
: "";
return `${shown.length} backup(s), newest first:\n${lines.join("\n")}${note}`;
} catch (caught) {
return failure(caught, "list backups");
}
},
}),
);
tools.push(
tool({
name: "restore_file",
description:
"Undo a bad edit by restoring a file from one of the automatic backups. Call " +
"list_backups first to get the exact backup name. The current contents are themselves " +
"backed up first, so a restore can be undone.",
parameters: {
backup_name: z
.string()
.describe("Exact backup file name from list_backups, e.g. 'src__app.ts.2026-01-01T00-00-00-000Z.bak'."),
},
implementation: async ({ backup_name }, ctx) => {
ctx.status(`Restoring from ${backup_name}`);
try {
const restored = await restoreBackup(ws, backup_name);
ctx.warn(`Restored ${restored} from backup ${basename(backup_name)}.`);
return `Restored ${restored} from backup ${basename(backup_name)}. The previous contents were backed up first.`;
} catch (caught) {
return failure(caught, `restore from backup "${backup_name}"`);
}
},
}),
);
return tools;
}
interface LoadedFile {
content: string;
lines: string[];
eol: string;
hadTrailingNewline: boolean;
}
type LoadResult = { ok: true; file: LoadedFile } | { ok: false; message: string };
async function loadFile(ws: Workspace, absPath: string, display: string): Promise<LoadResult> {
if (!existsSync(absPath)) {
return {
ok: false,
message: `Error: "${display}" does not exist. Check the path with list_directory, or use write_file to create the file first.`,
};
}
const info = await stat(absPath);
if (info.isDirectory()) {
return { ok: false, message: `Error: "${display}" is a directory, not a file.` };
}
if (info.size > ws.maxBytes) {
return {
ok: false,
message: `Error: "${display}" is ${formatBytes(info.size)}, over the ${ws.maxFileSizeKb} KB limit for safe editing. Edit a smaller file instead.`,
};
}
const content = await readFile(absPath, "utf-8");
if (content.includes("\u0000")) {
return {
ok: false,
message: `Error: "${display}" looks like a binary file, so text editing would corrupt it.`,
};
}
return {
ok: true,
file: {
content,
lines: splitLines(content),
eol: content.includes("\r\n") ? "\r\n" : "\n",
hadTrailingNewline: content.endsWith("\n"),
},
};
}
type ApplyResult =
| { status: "ok"; content: string; count: number; crlfAdjusted: boolean }
| { status: "noop"; reason: string }
| { status: "error"; message: string };
/**
* Shared matching rules for every text-replacement tool. Treating an
* already-applied edit as a no-op keeps Gemma's duplicated tool calls harmless.
*/
function applyReplacement(
content: string,
oldText: string,
newText: string,
replaceAll: boolean,
display: string,
): ApplyResult {
if (oldText === "") {
return {
status: "error",
message:
"Error: old_text was empty. Give the exact existing text to replace, or use write_file to create a new file.",
};
}
if (oldText === newText) {
return { status: "noop", reason: "No change: old_text and new_text are identical." };
}
let needle = oldText;
let replacement = newText;
let crlfAdjusted = false;
let count = countOccurrences(content, needle);
// A model quoting a CRLF file almost always types plain \n; retry rather than fail.
if (count === 0 && content.includes("\r\n") && oldText.includes("\n") && !oldText.includes("\r")) {
const crlfNeedle = oldText.replace(/\n/g, "\r\n");
const crlfCount = countOccurrences(content, crlfNeedle);
if (crlfCount > 0) {
needle = crlfNeedle;
replacement = newText.replace(/\n/g, "\r\n");
crlfAdjusted = true;
count = crlfCount;
}
}
if (count === 0) {
if (newText !== "" && content.includes(newText)) {
return {
status: "noop",
reason: `No change needed: "${display}" already contains new_text, so this edit was already applied.`,
};
}
return {
status: "error",
message:
`Error: old_text was not found in "${display}". It must match the file EXACTLY, including ` +
"indentation, spacing and line breaks. Call read_file first and copy the text straight out of it.",
};
}
if (count > 1 && !replaceAll) {
return {
status: "error",
message:
`Error: old_text appears ${count} times in "${display}", so it is ambiguous. Add more ` +
"surrounding lines to old_text so it matches only the place you mean, or set replace_all to true " +
`to change all ${count}.`,
};
}
const next = replaceAll
? content.split(needle).join(replacement)
: replaceFirst(content, needle, replacement);
return { status: "ok", content: next, count: replaceAll ? count : 1, crlfAdjusted };
}
function replaceFirst(content: string, needle: string, replacement: string): string {
const at = content.indexOf(needle);
return content.slice(0, at) + replacement + content.slice(at + needle.length);
}
function countOccurrences(haystack: string, needle: string): number {
if (needle === "") return 0;
let count = 0;
let at = haystack.indexOf(needle);
while (at !== -1) {
count++;
at = haystack.indexOf(needle, at + needle.length);
}
return count;
}
function splitLines(text: string): string[] {
const lines = text.split(/\r?\n/);
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines;
}
function joinLines(lines: string[], file: LoadedFile): string {
if (lines.length === 0) return "";
return lines.join(file.eol) + (file.hadTrailingNewline ? file.eol : "");
}
function sameSlice(lines: string[], at: number, incoming: string[]): boolean {
return sameRange(lines, at, at + incoming.length, incoming);
}
function sameRange(lines: string[], from: number, toExclusive: number, incoming: string[]): boolean {
if (toExclusive - from !== incoming.length) return false;
for (let i = 0; i < incoming.length; i++) {
if (lines[from + i] !== incoming[i]) return false;
}
return true;
}
interface EditReport {
display: string;
before: string;
after: string;
headline: string;
backup: string | undefined;
}
function renderEditReport(report: EditReport): string {
const { diff, touched } = renderDiff(report.display, report.before, report.after);
const backupNote =
report.backup === undefined
? ""
: `\n\nBackup: ${basename(report.backup)} (undo with restore_file).`;
return `${report.headline}. Lines touched: ${touched}.\n\n${diff}${backupNote}`;
}
type DiffKind = "equal" | "del" | "add";
interface DiffOp {
kind: DiffKind;
line: string;
}
/** Small hand-rolled unified-style diff -- no dependencies, output always bounded. */
function renderDiff(display: string, before: string, after: string): { diff: string; touched: string } {
const oldLines = splitLines(before);
const newLines = splitLines(after);
const ops = diffLines(oldLines, newLines);
const oldNo: number[] = new Array(ops.length).fill(0);
const newNo: number[] = new Array(ops.length).fill(0);
let o = 0;
let n = 0;
for (let i = 0; i < ops.length; i++) {
if (ops[i].kind !== "add") o++;
if (ops[i].kind !== "del") n++;
oldNo[i] = o;
newNo[i] = n;
}
const changed = ops.map((op) => op.kind !== "equal");
if (!changed.includes(true)) {
return { diff: "(no lines differ)", touched: "none" };
}
const hunks: Array<{ start: number; end: number }> = [];
let i = 0;
while (i < ops.length) {
if (!changed[i]) {
i++;
continue;
}
const start = Math.max(0, i - CONTEXT_LINES);
let lastChanged = i;
let j = i + 1;
while (j < ops.length) {
if (changed[j]) {
lastChanged = j;
j++;
continue;
}
let k = j;
while (k < ops.length && !changed[k]) k++;
// Merge hunks separated by only a little unchanged code.
if (k < ops.length && k - j <= CONTEXT_LINES * 2) {
j = k;
continue;
}
break;
}
const end = Math.min(ops.length - 1, lastChanged + CONTEXT_LINES);
hunks.push({ start, end });
i = end + 1;
}
const out: string[] = [`--- ${display} (before)`, `+++ ${display} (after)`];
for (const hunk of hunks.slice(0, MAX_HUNKS)) {
let oldCount = 0;
let newCount = 0;
for (let idx = hunk.start; idx <= hunk.end; idx++) {
if (ops[idx].kind !== "add") oldCount++;
if (ops[idx].kind !== "del") newCount++;
}
const oldStart = ops[hunk.start].kind === "add" ? oldNo[hunk.start] + 1 : oldNo[hunk.start];
const newStart = ops[hunk.start].kind === "del" ? newNo[hunk.start] + 1 : newNo[hunk.start];
out.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
for (let idx = hunk.start; idx <= hunk.end; idx++) {
const op = ops[idx];
const marker = op.kind === "add" ? "+" : op.kind === "del" ? "-" : " ";
out.push(marker + trimLine(op.line));
}
}
if (hunks.length > MAX_HUNKS) {
out.push(`[truncated: ${hunks.length - MAX_HUNKS} more changed section(s) not shown]`);
}
return { diff: clamp(out.join("\n"), MAX_DIFF_CHARS, "diff"), touched: describeTouched(ops, oldNo, newNo) };
}
function trimLine(line: string): string {
return line.length <= MAX_DIFF_LINE_CHARS ? line : `${line.slice(0, MAX_DIFF_LINE_CHARS)} [line cut short]`;
}
function describeTouched(ops: DiffOp[], oldNo: number[], newNo: number[]): string {
let minOld = Number.POSITIVE_INFINITY;
let maxOld = 0;
let minNew = Number.POSITIVE_INFINITY;
let maxNew = 0;
for (let i = 0; i < ops.length; i++) {
if (ops[i].kind === "del") {
minOld = Math.min(minOld, oldNo[i]);
maxOld = Math.max(maxOld, oldNo[i]);
} else if (ops[i].kind === "add") {
minNew = Math.min(minNew, newNo[i]);
maxNew = Math.max(maxNew, newNo[i]);
}
}
const removed = maxOld > 0;
const added = maxNew > 0;
if (removed && added) return `old ${range(minOld, maxOld)} -> new ${range(minNew, maxNew)}`;
if (removed) return `removed old ${range(minOld, maxOld)}`;
if (added) return `added at new ${range(minNew, maxNew)}`;
return "none";
}
function range(from: number, to: number): string {
return from === to ? `line ${from}` : `lines ${from}-${to}`;
}
function diffLines(oldLines: string[], newLines: string[]): DiffOp[] {
let start = 0;
const shared = Math.min(oldLines.length, newLines.length);
while (start < shared && oldLines[start] === newLines[start]) start++;
let endOld = oldLines.length - 1;
let endNew = newLines.length - 1;
while (endOld >= start && endNew >= start && oldLines[endOld] === newLines[endNew]) {
endOld--;
endNew--;
}
const ops: DiffOp[] = [];
for (let i = 0; i < start; i++) ops.push({ kind: "equal", line: oldLines[i] });
ops.push(...diffMiddle(oldLines.slice(start, endOld + 1), newLines.slice(start, endNew + 1)));
for (let i = endOld + 1; i < oldLines.length; i++) ops.push({ kind: "equal", line: oldLines[i] });
return ops;
}
function diffMiddle(a: string[], b: string[]): DiffOp[] {
if (a.length === 0) return b.map((line) => ({ kind: "add" as const, line }));
if (b.length === 0) return a.map((line) => ({ kind: "del" as const, line }));
if (a.length * b.length > LCS_CELL_LIMIT) {
// Too big to align line by line; report it as one wholesale block swap.
return [
...a.map((line) => ({ kind: "del" as const, line })),
...b.map((line) => ({ kind: "add" as const, line })),
];
}
const n = a.length;
const m = b.length;
const width = m + 1;
const table = new Int32Array((n + 1) * width);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
table[i * width + j] =
a[i] === b[j]
? table[(i + 1) * width + j + 1] + 1
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
}
}
const ops: DiffOp[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
ops.push({ kind: "equal", line: a[i] });
i++;
j++;
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
ops.push({ kind: "del", line: a[i] });
i++;
} else {
ops.push({ kind: "add", line: b[j] });
j++;
}
}
while (i < n) ops.push({ kind: "del", line: a[i++] });
while (j < m) ops.push({ kind: "add", line: b[j++] });
return ops;
}
/** Path-containment failures must reach the caller unchanged; everything else becomes text. */
function failure(caught: unknown, what: string, hint = ""): string {
if (caught instanceof Error && caught.message.includes("outside the workspace root")) {
throw caught;
}
const detail = (caught instanceof Error ? caught.message : String(caught)).trim().replace(/\.$/, "");
return `Error: could not ${what}: ${detail}.${hint === "" ? "" : ` ${hint}`}`;
}
const FILE_HINT = "Check the path with list_directory and make sure it is a writable text file.";
import { tool, type Tool } from "@lmstudio/sdk";
import { existsSync } from "fs";
import { readFile, stat, writeFile } from "fs/promises";
import { basename } from "path";
import { z } from "zod";
import { listBackups, restoreBackup, snapshot } from "../backup";
import { validateSyntax } from "../validate";
import { clamp, firstText, formatBytes, type Workspace } from "../workspace";
const CONTEXT_LINES = 3;
const MAX_DIFF_CHARS = 3000;
const MAX_HUNKS = 12;
const MAX_DIFF_LINE_CHARS = 240;
const MAX_EDITS = 20;
const MAX_BACKUPS_LISTED = 25;
/** Ceiling on LCS table cells, so a big rewrite can never stall the event loop. */
const LCS_CELL_LIMIT = 60000;
export function editTools(ws: Workspace): Tool[] {
if (!ws.allowWrite) return [];
const tools: Tool[] = [];
tools.push(
tool({
name: "append_file",
description:
"Add text to the END of a file, creating it if it does not exist. Use this to build a " +
"large file in several smaller pieces: write the first part with write_file, then append " +
"the rest a few hundred lines at a time. That is far more reliable than trying to emit a " +
"whole large file in one call, which often produces broken output.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
content: z.string().default("").describe("Text to add at the end of the file."),
text: z.string().optional().describe("Alias for content."),
},
implementation: async ({ path, file_path, content, text }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.js\".";
const body = firstText(content, text);
if (body === "") return "Error: content is empty. Pass the text to append.";
const absPath = ws.resolveInRoot(target);
const display = ws.rel(absPath);
try {
const existed = existsSync(absPath);
ctx.status(`Appending to ${display}`);
if (existed) await snapshot(ws, absPath);
const { appendFile, mkdir } = await import("fs/promises");
await mkdir(absPath.slice(0, absPath.lastIndexOf(basename(absPath))), { recursive: true });
// Keep a newline between chunks so appended blocks never fuse.
const previous = existed ? await readFile(absPath, "utf-8") : "";
const separator = previous === "" || previous.endsWith("\n") ? "" : "\n";
await appendFile(absPath, `${separator}${body}`, "utf-8");
const complaint = await validateSyntax(ws, absPath);
const total = (await stat(absPath)).size;
const lines = (previous + separator + body).split(/\r?\n/).length;
return (
`${existed ? "Appended to" : "Created"} ${display}: added ${formatBytes(
Buffer.byteLength(body, "utf-8"),
)}, file is now ${formatBytes(total)} (${lines} lines). ` +
`Append the next piece, or run verify when the file is complete.${complaint}`
);
} catch (caught) {
return failure(caught, `append to ${display}`, FILE_HINT);
}
},
}),
);
tools.push(
tool({
name: "edit_file",
description:
"Change one exact piece of text inside an existing file, leaving everything else " +
"untouched. This is the correct way to modify a file -- never rewrite a whole file to " +
"change a few lines. old_text must match the file character for character, including " +
"indentation. Returns a diff of what changed. Read the file first so you can copy the " +
"text exactly.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
old_text: z
.string()
.default("")
.describe(
"The exact text to find, copied from the file including its indentation and line breaks.",
),
new_text: z.string().default("").describe("The text to put in its place. Empty string deletes it."),
old_string: z.string().optional().describe("Alias for old_text."),
new_string: z.string().optional().describe("Alias for new_text."),
replace_all: z
.boolean()
.default(false)
.describe("Set true to change every occurrence instead of requiring exactly one."),
},
implementation: async ({ path, file_path, old_text, new_text, old_string, new_string, replace_all }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.ts\".";
old_text = firstText(old_text, old_string);
new_text = firstText(new_text, new_string);
if (old_text === "") {
return "Error: old_text is empty. Pass the exact text to replace, copied from the file.";
}
const absPath = ws.resolveInRoot(target);
const display = ws.rel(absPath);
ctx.status(`Editing ${display}`);
try {
const loaded = await loadFile(ws, absPath, display);
if (!loaded.ok) return loaded.message;
const applied = applyReplacement(loaded.file.content, old_text, new_text, replace_all, display);
if (applied.status === "error") return applied.message;
if (applied.status === "noop") return applied.reason;
const backup = await snapshot(ws, absPath);
await writeFile(absPath, applied.content, "utf-8");
const complaint = await validateSyntax(ws, absPath);
if (applied.count > 1) {
ctx.warn(`Replaced ${applied.count} occurrences in ${display}.`);
}
return (
renderEditReport({
display,
before: loaded.file.content,
after: applied.content,
headline:
`Edited ${display} (${applied.count} replacement${applied.count === 1 ? "" : "s"}` +
`${applied.crlfAdjusted ? ", matched after adapting line endings to CRLF" : ""})`,
backup,
}) + complaint
);
} catch (caught) {
return failure(caught, `edit ${display}`, FILE_HINT);
}
},
}),
);
tools.push(
tool({
name: "preview_edit",
description:
"Dry run of edit_file: show the diff an edit would produce WITHOUT changing the file. " +
"Use it when you are unsure old_text matches, before committing to the real edit. " +
"Nothing is written and nothing is backed up.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
old_text: z.string().default("").describe("The exact text to find, copied from the file."),
new_text: z.string().default("").describe("The text that would replace it."),
old_string: z.string().optional().describe("Alias for old_text."),
new_string: z.string().optional().describe("Alias for new_text."),
},
implementation: async ({ path, file_path, old_text, new_text, old_string, new_string }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.ts\".";
old_text = firstText(old_text, old_string);
new_text = firstText(new_text, new_string);
const absPath = ws.resolveInRoot(target);
const display = ws.rel(absPath);
ctx.status(`Previewing edit to ${display}`);
try {
const loaded = await loadFile(ws, absPath, display);
if (!loaded.ok) return loaded.message;
const applied = applyReplacement(loaded.file.content, old_text, new_text, false, display);
if (applied.status === "error") return applied.message;
if (applied.status === "noop") return applied.reason;
return renderEditReport({
display,
before: loaded.file.content,
after: applied.content,
headline: `Preview only -- ${display} was NOT modified. This is what edit_file would do`,
backup: undefined,
});
} catch (caught) {
return failure(caught, `preview an edit to ${display}`, FILE_HINT);
}
},
}),
);
tools.push(
tool({
name: "multi_edit",
description:
"Make several exact-text edits to one file in a single step, applied in order. " +
"All or nothing: if any edit fails to match, the file is left completely untouched and " +
"you are told which one failed. Use this instead of many edit_file calls on the same file.",
parameters: {
path: z
.string()
.default("")
.describe("File path relative to the workspace root. All edits apply to this one file."),
edits: z
.array(
z.object({
old_text: z.string().default("").describe("Exact text to find, copied from the file."),
new_text: z.string().default("").describe("Text to put in its place."),
old_string: z.string().optional().describe("Alias for old_text."),
new_string: z.string().optional().describe("Alias for new_text."),
// Models routinely repeat the path on each edit. Accepting it here
// costs nothing and avoids the whole call being rejected.
path: z
.string()
.optional()
.describe("Optional; ignored if the top-level path is set. Must be the same file."),
}),
)
.describe(
"List of edits, applied top to bottom. Each old_text must match exactly once at the " +
"time its turn comes.",
),
},
implementation: async ({ path, edits }, ctx) => {
// Accept the path at the top level, on the edits, or both.
const normalised = edits.map((e) => ({
old_text: firstText(e.old_text, e.old_string),
new_text: firstText(e.new_text, e.new_string),
path: e.path,
}));
edits = normalised;
const perEdit = [...new Set(normalised.map((e) => e.path).filter((p): p is string => !!p?.trim()))];
const chosen = path.trim() !== "" ? path.trim() : perEdit[0];
if (chosen === undefined) {
return "Error: no file path given. Pass path=\"src/thing.ts\" alongside the edits.";
}
if (path.trim() === "" && perEdit.length > 1) {
return (
`Error: the edits name ${perEdit.length} different files (${perEdit.join(", ")}). ` +
`multi_edit changes one file per call -- send a separate call for each file.`
);
}
const absPath = ws.resolveInRoot(chosen);
const display = ws.rel(absPath);
ctx.status(`Applying ${edits.length} edit(s) to ${display}`);
try {
if (edits.length === 0) {
return "Error: edits was empty. Pass at least one {old_text, new_text} pair.";
}
if (edits.length > MAX_EDITS) {
return `Error: ${edits.length} edits is too many in one call. Send at most ${MAX_EDITS} at a time.`;
}
const loaded = await loadFile(ws, absPath, display);
if (!loaded.ok) return loaded.message;
// Validate every edit against the evolving buffer before anything is written.
let buffer = loaded.file.content;
let changes = 0;
const skipped: number[] = [];
for (let i = 0; i < edits.length; i++) {
const step = applyReplacement(buffer, edits[i].old_text, edits[i].new_text, false, display);
if (step.status === "error") {
return (
`Error: edit ${i + 1} of ${edits.length} failed, so NOTHING was written to ${display}. ` +
`${step.message.replace(/^Error: /, "")} Fix that edit and send the whole list again.`
);
}
if (step.status === "noop") {
skipped.push(i + 1);
continue;
}
buffer = step.content;
changes++;
}
if (changes === 0) {
return `No change: every edit was already applied to ${display}. The file is left as is.`;
}
const backup = await snapshot(ws, absPath);
await writeFile(absPath, buffer, "utf-8");
const skipNote =
skipped.length === 0 ? "" : `, skipped ${skipped.length} already-applied (#${skipped.join(", #")})`;
return renderEditReport({
display,
before: loaded.file.content,
after: buffer,
headline: `Applied ${changes} of ${edits.length} edit(s) to ${display}${skipNote}`,
backup,
});
} catch (caught) {
return failure(caught, `apply multiple edits to ${display}`, FILE_HINT);
}
},
}),
);
tools.push(
tool({
name: "insert_lines",
description:
"Insert new lines into a file after a given line number, pushing the rest down. " +
"Use after_line 0 to put the content at the very top. Nothing existing is removed. " +
"Returns a diff of what was added.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
after_line: z
.number()
.int()
.min(0)
.describe("1-based line number to insert after. 0 means insert at the top of the file."),
content: z.string().describe("The line or lines to insert. Use \\n between lines."),
},
implementation: async ({ path, file_path, after_line, content }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.ts\".";
const absPath = ws.resolveInRoot(target);
const display = ws.rel(absPath);
ctx.status(`Inserting into ${display} after line ${after_line}`);
try {
if (content === "") {
return "Error: content was empty, so there is nothing to insert.";
}
const loaded = await loadFile(ws, absPath, display);
if (!loaded.ok) return loaded.message;
const original = loaded.file.lines;
const at = Math.min(after_line, original.length);
const clampNote =
at < after_line
? ` (after_line ${after_line} was past the end of the ${original.length}-line file, so it was appended)`
: "";
const incoming = splitLines(content);
// A repeated identical call must not duplicate the block.
if (sameSlice(original, at, incoming)) {
return `No change: those ${incoming.length} line(s) are already at line ${at + 1} of ${display}.`;
}
const next = [...original.slice(0, at), ...incoming, ...original.slice(at)];
const after = joinLines(next, loaded.file);
const backup = await snapshot(ws, absPath);
await writeFile(absPath, after, "utf-8");
return renderEditReport({
display,
before: loaded.file.content,
after,
headline: `Inserted ${incoming.length} line(s) into ${display} after line ${at}${clampNote}`,
backup,
});
} catch (caught) {
return failure(caught, `insert lines into ${display}`, FILE_HINT);
}
},
}),
);
tools.push(
tool({
name: "replace_lines",
description:
"Replace a range of lines in a file with new content. Line numbers are 1-based and both " +
"ends are included, so start_line 4 end_line 6 replaces lines 4, 5 and 6. Pass empty " +
"content to delete the range. Prefer edit_file when you can quote the exact text; use " +
"this when you know the line numbers.",
parameters: {
path: z.string().default("").describe("File path relative to the workspace root."),
file_path: z.string().optional().describe("Alias for path."),
start_line: z.number().int().min(1).describe("First line to replace, 1-based, included."),
end_line: z.number().int().min(1).describe("Last line to replace, 1-based, included."),
content: z
.string()
.default("")
.describe("Replacement text. Use \\n between lines. Empty deletes the range."),
},
implementation: async ({ path, file_path, start_line, end_line, content }, ctx) => {
const target = firstText(path, file_path);
if (target === "") return "Error: no file path given. Pass path=\"src/thing.ts\".";
const absPath = ws.resolveInRoot(target);
const display = ws.rel(absPath);
ctx.status(`Replacing lines ${start_line}-${end_line} of ${display}`);
try {
const loaded = await loadFile(ws, absPath, display);
if (!loaded.ok) return loaded.message;
const original = loaded.file.lines;
if (end_line < start_line) {
return `Error: end_line (${end_line}) is before start_line (${start_line}). The range is inclusive, so end_line must be >= start_line.`;
}
if (start_line > original.length) {
return `Error: ${display} has only ${original.length} line(s), so line ${start_line} does not exist. Use insert_lines to add content at the end.`;
}
const last = Math.min(end_line, original.length);
const incoming = splitLines(content);
if (sameRange(original, start_line - 1, last, incoming)) {
return `No change: lines ${start_line}-${last} of ${display} already contain exactly that content.`;
}
const next = [...original.slice(0, start_line - 1), ...incoming, ...original.slice(last)];
const after = joinLines(next, loaded.file);
const backup = await snapshot(ws, absPath);
await writeFile(absPath, after, "utf-8");
if (incoming.length === 0) {
ctx.warn(`Deleted lines ${start_line}-${last} of ${display}.`);
}
const verb =
incoming.length === 0
? `Deleted lines ${start_line}-${last} of ${display}`
: `Replaced lines ${start_line}-${last} of ${display} with ${incoming.length} line(s)`;
return renderEditReport({ display, before: loaded.file.content, after, headline: verb, backup });
} catch (caught) {
return failure(caught, `replace lines in ${display}`, FILE_HINT);
}
},
}),
);
tools.push(
tool({
name: "list_backups",
description:
"List the automatic backups taken before files were edited, newest first. Use this to " +
"find the backup name to hand to restore_file when an edit went wrong.",
parameters: {},
implementation: async (_params, ctx) => {
ctx.status("Listing backups");
try {
const entries = await listBackups(ws);
if (entries.length === 0) {
return "No backups yet. One is taken automatically before each edit.";
}
const shown = entries.slice(0, MAX_BACKUPS_LISTED);
const lines = shown.map(
(entry) => `${entry.fileName} <- ${entry.originalPath} (${formatBytes(entry.size)})`,
);
const note =
entries.length > shown.length
? `\n\n[truncated: showing the ${shown.length} newest of ${entries.length} backups]`
: "";
return `${shown.length} backup(s), newest first:\n${lines.join("\n")}${note}`;
} catch (caught) {
return failure(caught, "list backups");
}
},
}),
);
tools.push(
tool({
name: "restore_file",
description:
"Undo a bad edit by restoring a file from one of the automatic backups. Call " +
"list_backups first to get the exact backup name. The current contents are themselves " +
"backed up first, so a restore can be undone.",
parameters: {
backup_name: z
.string()
.describe("Exact backup file name from list_backups, e.g. 'src__app.ts.2026-01-01T00-00-00-000Z.bak'."),
},
implementation: async ({ backup_name }, ctx) => {
ctx.status(`Restoring from ${backup_name}`);
try {
const restored = await restoreBackup(ws, backup_name);
ctx.warn(`Restored ${restored} from backup ${basename(backup_name)}.`);
return `Restored ${restored} from backup ${basename(backup_name)}. The previous contents were backed up first.`;
} catch (caught) {
return failure(caught, `restore from backup "${backup_name}"`);
}
},
}),
);
return tools;
}
interface LoadedFile {
content: string;
lines: string[];
eol: string;
hadTrailingNewline: boolean;
}
type LoadResult = { ok: true; file: LoadedFile } | { ok: false; message: string };
async function loadFile(ws: Workspace, absPath: string, display: string): Promise<LoadResult> {
if (!existsSync(absPath)) {
return {
ok: false,
message: `Error: "${display}" does not exist. Check the path with list_directory, or use write_file to create the file first.`,
};
}
const info = await stat(absPath);
if (info.isDirectory()) {
return { ok: false, message: `Error: "${display}" is a directory, not a file.` };
}
if (info.size > ws.maxBytes) {
return {
ok: false,
message: `Error: "${display}" is ${formatBytes(info.size)}, over the ${ws.maxFileSizeKb} KB limit for safe editing. Edit a smaller file instead.`,
};
}
const content = await readFile(absPath, "utf-8");
if (content.includes("\u0000")) {
return {
ok: false,
message: `Error: "${display}" looks like a binary file, so text editing would corrupt it.`,
};
}
return {
ok: true,
file: {
content,
lines: splitLines(content),
eol: content.includes("\r\n") ? "\r\n" : "\n",
hadTrailingNewline: content.endsWith("\n"),
},
};
}
type ApplyResult =
| { status: "ok"; content: string; count: number; crlfAdjusted: boolean }
| { status: "noop"; reason: string }
| { status: "error"; message: string };
/**
* Shared matching rules for every text-replacement tool. Treating an
* already-applied edit as a no-op keeps Gemma's duplicated tool calls harmless.
*/
function applyReplacement(
content: string,
oldText: string,
newText: string,
replaceAll: boolean,
display: string,
): ApplyResult {
if (oldText === "") {
return {
status: "error",
message:
"Error: old_text was empty. Give the exact existing text to replace, or use write_file to create a new file.",
};
}
if (oldText === newText) {
return { status: "noop", reason: "No change: old_text and new_text are identical." };
}
let needle = oldText;
let replacement = newText;
let crlfAdjusted = false;
let count = countOccurrences(content, needle);
// A model quoting a CRLF file almost always types plain \n; retry rather than fail.
if (count === 0 && content.includes("\r\n") && oldText.includes("\n") && !oldText.includes("\r")) {
const crlfNeedle = oldText.replace(/\n/g, "\r\n");
const crlfCount = countOccurrences(content, crlfNeedle);
if (crlfCount > 0) {
needle = crlfNeedle;
replacement = newText.replace(/\n/g, "\r\n");
crlfAdjusted = true;
count = crlfCount;
}
}
if (count === 0) {
if (newText !== "" && content.includes(newText)) {
return {
status: "noop",
reason: `No change needed: "${display}" already contains new_text, so this edit was already applied.`,
};
}
return {
status: "error",
message:
`Error: old_text was not found in "${display}". It must match the file EXACTLY, including ` +
"indentation, spacing and line breaks. Call read_file first and copy the text straight out of it.",
};
}
if (count > 1 && !replaceAll) {
return {
status: "error",
message:
`Error: old_text appears ${count} times in "${display}", so it is ambiguous. Add more ` +
"surrounding lines to old_text so it matches only the place you mean, or set replace_all to true " +
`to change all ${count}.`,
};
}
const next = replaceAll
? content.split(needle).join(replacement)
: replaceFirst(content, needle, replacement);
return { status: "ok", content: next, count: replaceAll ? count : 1, crlfAdjusted };
}
function replaceFirst(content: string, needle: string, replacement: string): string {
const at = content.indexOf(needle);
return content.slice(0, at) + replacement + content.slice(at + needle.length);
}
function countOccurrences(haystack: string, needle: string): number {
if (needle === "") return 0;
let count = 0;
let at = haystack.indexOf(needle);
while (at !== -1) {
count++;
at = haystack.indexOf(needle, at + needle.length);
}
return count;
}
function splitLines(text: string): string[] {
const lines = text.split(/\r?\n/);
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
return lines;
}
function joinLines(lines: string[], file: LoadedFile): string {
if (lines.length === 0) return "";
return lines.join(file.eol) + (file.hadTrailingNewline ? file.eol : "");
}
function sameSlice(lines: string[], at: number, incoming: string[]): boolean {
return sameRange(lines, at, at + incoming.length, incoming);
}
function sameRange(lines: string[], from: number, toExclusive: number, incoming: string[]): boolean {
if (toExclusive - from !== incoming.length) return false;
for (let i = 0; i < incoming.length; i++) {
if (lines[from + i] !== incoming[i]) return false;
}
return true;
}
interface EditReport {
display: string;
before: string;
after: string;
headline: string;
backup: string | undefined;
}
function renderEditReport(report: EditReport): string {
const { diff, touched } = renderDiff(report.display, report.before, report.after);
const backupNote =
report.backup === undefined
? ""
: `\n\nBackup: ${basename(report.backup)} (undo with restore_file).`;
return `${report.headline}. Lines touched: ${touched}.\n\n${diff}${backupNote}`;
}
type DiffKind = "equal" | "del" | "add";
interface DiffOp {
kind: DiffKind;
line: string;
}
/** Small hand-rolled unified-style diff -- no dependencies, output always bounded. */
function renderDiff(display: string, before: string, after: string): { diff: string; touched: string } {
const oldLines = splitLines(before);
const newLines = splitLines(after);
const ops = diffLines(oldLines, newLines);
const oldNo: number[] = new Array(ops.length).fill(0);
const newNo: number[] = new Array(ops.length).fill(0);
let o = 0;
let n = 0;
for (let i = 0; i < ops.length; i++) {
if (ops[i].kind !== "add") o++;
if (ops[i].kind !== "del") n++;
oldNo[i] = o;
newNo[i] = n;
}
const changed = ops.map((op) => op.kind !== "equal");
if (!changed.includes(true)) {
return { diff: "(no lines differ)", touched: "none" };
}
const hunks: Array<{ start: number; end: number }> = [];
let i = 0;
while (i < ops.length) {
if (!changed[i]) {
i++;
continue;
}
const start = Math.max(0, i - CONTEXT_LINES);
let lastChanged = i;
let j = i + 1;
while (j < ops.length) {
if (changed[j]) {
lastChanged = j;
j++;
continue;
}
let k = j;
while (k < ops.length && !changed[k]) k++;
// Merge hunks separated by only a little unchanged code.
if (k < ops.length && k - j <= CONTEXT_LINES * 2) {
j = k;
continue;
}
break;
}
const end = Math.min(ops.length - 1, lastChanged + CONTEXT_LINES);
hunks.push({ start, end });
i = end + 1;
}
const out: string[] = [`--- ${display} (before)`, `+++ ${display} (after)`];
for (const hunk of hunks.slice(0, MAX_HUNKS)) {
let oldCount = 0;
let newCount = 0;
for (let idx = hunk.start; idx <= hunk.end; idx++) {
if (ops[idx].kind !== "add") oldCount++;
if (ops[idx].kind !== "del") newCount++;
}
const oldStart = ops[hunk.start].kind === "add" ? oldNo[hunk.start] + 1 : oldNo[hunk.start];
const newStart = ops[hunk.start].kind === "del" ? newNo[hunk.start] + 1 : newNo[hunk.start];
out.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
for (let idx = hunk.start; idx <= hunk.end; idx++) {
const op = ops[idx];
const marker = op.kind === "add" ? "+" : op.kind === "del" ? "-" : " ";
out.push(marker + trimLine(op.line));
}
}
if (hunks.length > MAX_HUNKS) {
out.push(`[truncated: ${hunks.length - MAX_HUNKS} more changed section(s) not shown]`);
}
return { diff: clamp(out.join("\n"), MAX_DIFF_CHARS, "diff"), touched: describeTouched(ops, oldNo, newNo) };
}
function trimLine(line: string): string {
return line.length <= MAX_DIFF_LINE_CHARS ? line : `${line.slice(0, MAX_DIFF_LINE_CHARS)} [line cut short]`;
}
function describeTouched(ops: DiffOp[], oldNo: number[], newNo: number[]): string {
let minOld = Number.POSITIVE_INFINITY;
let maxOld = 0;
let minNew = Number.POSITIVE_INFINITY;
let maxNew = 0;
for (let i = 0; i < ops.length; i++) {
if (ops[i].kind === "del") {
minOld = Math.min(minOld, oldNo[i]);
maxOld = Math.max(maxOld, oldNo[i]);
} else if (ops[i].kind === "add") {
minNew = Math.min(minNew, newNo[i]);
maxNew = Math.max(maxNew, newNo[i]);
}
}
const removed = maxOld > 0;
const added = maxNew > 0;
if (removed && added) return `old ${range(minOld, maxOld)} -> new ${range(minNew, maxNew)}`;
if (removed) return `removed old ${range(minOld, maxOld)}`;
if (added) return `added at new ${range(minNew, maxNew)}`;
return "none";
}
function range(from: number, to: number): string {
return from === to ? `line ${from}` : `lines ${from}-${to}`;
}
function diffLines(oldLines: string[], newLines: string[]): DiffOp[] {
let start = 0;
const shared = Math.min(oldLines.length, newLines.length);
while (start < shared && oldLines[start] === newLines[start]) start++;
let endOld = oldLines.length - 1;
let endNew = newLines.length - 1;
while (endOld >= start && endNew >= start && oldLines[endOld] === newLines[endNew]) {
endOld--;
endNew--;
}
const ops: DiffOp[] = [];
for (let i = 0; i < start; i++) ops.push({ kind: "equal", line: oldLines[i] });
ops.push(...diffMiddle(oldLines.slice(start, endOld + 1), newLines.slice(start, endNew + 1)));
for (let i = endOld + 1; i < oldLines.length; i++) ops.push({ kind: "equal", line: oldLines[i] });
return ops;
}
function diffMiddle(a: string[], b: string[]): DiffOp[] {
if (a.length === 0) return b.map((line) => ({ kind: "add" as const, line }));
if (b.length === 0) return a.map((line) => ({ kind: "del" as const, line }));
if (a.length * b.length > LCS_CELL_LIMIT) {
// Too big to align line by line; report it as one wholesale block swap.
return [
...a.map((line) => ({ kind: "del" as const, line })),
...b.map((line) => ({ kind: "add" as const, line })),
];
}
const n = a.length;
const m = b.length;
const width = m + 1;
const table = new Int32Array((n + 1) * width);
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
table[i * width + j] =
a[i] === b[j]
? table[(i + 1) * width + j + 1] + 1
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
}
}
const ops: DiffOp[] = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
ops.push({ kind: "equal", line: a[i] });
i++;
j++;
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
ops.push({ kind: "del", line: a[i] });
i++;
} else {
ops.push({ kind: "add", line: b[j] });
j++;
}
}
while (i < n) ops.push({ kind: "del", line: a[i++] });
while (j < m) ops.push({ kind: "add", line: b[j++] });
return ops;
}
/** Path-containment failures must reach the caller unchanged; everything else becomes text. */
function failure(caught: unknown, what: string, hint = ""): string {
if (caught instanceof Error && caught.message.includes("outside the workspace root")) {
throw caught;
}
const detail = (caught instanceof Error ? caught.message : String(caught)).trim().replace(/\.$/, "");
return `Error: could not ${what}: ${detail}.${hint === "" ? "" : ` ${hint}`}`;
}
const FILE_HINT = "Check the path with list_directory and make sure it is a writable text file.";