co-mono/packages/mom/src/tools/edit.ts
Mario Zechner 6ddc7418da WIP: Major cleanup - move Attachment to consumers, simplify agent API
- Removed Attachment from agent package (now in web-ui/coding-agent)
- Agent.prompt now takes (text, images?: ImageContent[])
- Removed transports from web-ui (duplicate of agent package)
- Updated coding-agent to use local message types
- Updated mom package for new agent API

Remaining: Fix AgentInterface.ts to compose UserMessageWithAttachments
2025-12-30 22:42:20 +01:00

165 lines
4.9 KiB
TypeScript

import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import * as Diff from "diff";
import type { Executor } from "../sandbox.js";
/**
* Generate a unified diff string with line numbers and context
*/
function generateDiffString(oldContent: string, newContent: string, contextLines = 4): string {
const parts = Diff.diffLines(oldContent, newContent);
const output: string[] = [];
const oldLines = oldContent.split("\n");
const newLines = newContent.split("\n");
const maxLineNum = Math.max(oldLines.length, newLines.length);
const lineNumWidth = String(maxLineNum).length;
let oldLineNum = 1;
let newLineNum = 1;
let lastWasChange = false;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const raw = part.value.split("\n");
if (raw[raw.length - 1] === "") {
raw.pop();
}
if (part.added || part.removed) {
for (const line of raw) {
if (part.added) {
const lineNum = String(newLineNum).padStart(lineNumWidth, " ");
output.push(`+${lineNum} ${line}`);
newLineNum++;
} else {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(`-${lineNum} ${line}`);
oldLineNum++;
}
}
lastWasChange = true;
} else {
const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed);
if (lastWasChange || nextPartIsChange) {
let linesToShow = raw;
let skipStart = 0;
let skipEnd = 0;
if (!lastWasChange) {
skipStart = Math.max(0, raw.length - contextLines);
linesToShow = raw.slice(skipStart);
}
if (!nextPartIsChange && linesToShow.length > contextLines) {
skipEnd = linesToShow.length - contextLines;
linesToShow = linesToShow.slice(0, contextLines);
}
if (skipStart > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
}
for (const line of linesToShow) {
const lineNum = String(oldLineNum).padStart(lineNumWidth, " ");
output.push(` ${lineNum} ${line}`);
oldLineNum++;
newLineNum++;
}
if (skipEnd > 0) {
output.push(` ${"".padStart(lineNumWidth, " ")} ...`);
}
oldLineNum += skipStart + skipEnd;
newLineNum += skipStart + skipEnd;
} else {
oldLineNum += raw.length;
newLineNum += raw.length;
}
lastWasChange = false;
}
}
return output.join("\n");
}
const editSchema = Type.Object({
label: Type.String({ description: "Brief description of the edit you're making (shown to user)" }),
path: Type.String({ description: "Path to the file to edit (relative or absolute)" }),
oldText: Type.String({ description: "Exact text to find and replace (must match exactly)" }),
newText: Type.String({ description: "New text to replace the old text with" }),
});
export function createEditTool(executor: Executor): AgentTool<typeof editSchema> {
return {
name: "edit",
label: "edit",
description:
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
parameters: editSchema,
execute: async (
_toolCallId: string,
{ path, oldText, newText }: { label: string; path: string; oldText: string; newText: string },
signal?: AbortSignal,
) => {
// Read the file
const readResult = await executor.exec(`cat ${shellEscape(path)}`, { signal });
if (readResult.code !== 0) {
throw new Error(readResult.stderr || `File not found: ${path}`);
}
const content = readResult.stdout;
// Check if old text exists
if (!content.includes(oldText)) {
throw new Error(
`Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`,
);
}
// Count occurrences
const occurrences = content.split(oldText).length - 1;
if (occurrences > 1) {
throw new Error(
`Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`,
);
}
// Perform replacement
const index = content.indexOf(oldText);
const newContent = content.substring(0, index) + newText + content.substring(index + oldText.length);
if (content === newContent) {
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.`,
);
}
// Write the file back
const writeResult = await executor.exec(`printf '%s' ${shellEscape(newContent)} > ${shellEscape(path)}`, {
signal,
});
if (writeResult.code !== 0) {
throw new Error(writeResult.stderr || `Failed to write file: ${path}`);
}
return {
content: [
{
type: "text",
text: `Successfully replaced text in ${path}. Changed ${oldText.length} characters to ${newText.length} characters.`,
},
],
details: { diff: generateDiffString(content, newContent) },
};
},
};
}
function shellEscape(s: string): string {
return `'${s.replace(/'/g, "'\\''")}'`;
}