gadget/docs/archive/tools/file/edit.ts

440 lines
13 KiB
TypeScript

// src/tools/file/edit.ts
// Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved
import fs from "node:fs/promises";
import path from "node:path";
import type { ToolDefinition } from "../../lib/ai-client.js";
import {
DtpTool,
type ToolArguments,
type ToolContext,
} from "../../lib/tool.js";
import { ChatSessionMode } from "@/models/chat-session.js";
import type { IChatFileOperation } from "../../models/chat-history.js";
import { validateProjectPath } from "../../lib/path-security.js";
import { PROJECT_ROOT } from "../../config/env.js";
import HostMonitorService from "../../services/host-monitor.js";
// Binary file extensions that should not be diffed or stored
const BINARY_EXTENSIONS = new Set([
".o",
".obj",
".a",
".lib",
".so",
".dll",
".exe",
".bin",
".png",
".jpg",
".jpeg",
".gif",
".bmp",
".ico",
".webp",
".pdf",
".zip",
".tar",
".gz",
".bz2",
".7z",
".wasm",
".pyc",
".class",
]);
function isBinaryPath(filePath: string): boolean {
return BINARY_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
function countLineDelta(
oldContent: string,
newContent: string,
): { added: number; removed: number } {
const oldLines = oldContent.split("\n");
const newLines = newContent.split("\n");
// Simple unified diff line count approximation
let added = 0;
let removed = 0;
const maxLen = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLen; i++) {
const o = oldLines[i];
const n = newLines[i];
if (o === undefined && n !== undefined) {
added++;
} else if (n === undefined && o !== undefined) {
removed++;
} else if (o !== n) {
added++;
removed++;
}
}
return { added, removed };
}
function buildUnifiedDiff(
oldContent: string,
newContent: string,
filePath: string,
): string {
const oldLines = oldContent.split("\n");
const newLines = newContent.split("\n");
const lines: string[] = [];
lines.push(`--- a/${filePath}`);
lines.push(`+++ b/${filePath}`);
const maxLen = Math.max(oldLines.length, newLines.length);
for (let i = 0; i < maxLen; i++) {
const o = oldLines[i];
const n = newLines[i];
if (o === undefined) {
lines.push(`+${n ?? ""}`);
} else if (n === undefined) {
lines.push(`-${o}`);
} else if (o !== n) {
lines.push(`-${o}`);
lines.push(`+${n}`);
} else {
lines.push(` ${o}`);
}
}
return lines.join("\n");
}
export class FileEditTool extends DtpTool {
get name(): string {
return "FileEditTool";
}
get slug(): string {
return "file-edit";
}
get metadata() {
return {
name: this.definition.function.name || "file_edit",
category: "file",
tags: ["file", "edit", "replace", "modify", "search"],
modes: [
ChatSessionMode.Plan,
ChatSessionMode.Build,
ChatSessionMode.Test,
ChatSessionMode.Ship,
ChatSessionMode.Develop,
],
};
}
public definition: ToolDefinition = {
type: "function",
function: {
name: "file_edit",
description:
"Perform a search-and-replace edit on an existing file. Replaces the first occurrence of the search string with the replace string. Returns a diff context showing what changed.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the file to edit (relative or absolute).",
},
search: {
type: "string",
description:
"The exact text to search for (first occurrence will be replaced).",
},
replace: {
type: "string",
description: "The text to replace the search string with.",
},
},
required: ["path", "search", "replace"],
},
},
};
public async execute(
_context: ToolContext,
args: ToolArguments,
): Promise<string> {
const filePath = args.path as string | undefined;
const search = args.search as string | undefined;
const replace = args.replace as string | undefined;
if (!filePath || filePath.trim().length === 0) {
return this.error("MISSING_PARAMETER", "File path must not be empty.", {
parameter: "path",
recoveryHint: "Provide a valid file path.",
});
}
if (search === undefined || search.length === 0) {
return this.error(
"MISSING_PARAMETER",
"Search string must not be empty.",
{
parameter: "search",
recoveryHint: "Provide the exact text to search for.",
},
);
}
if (replace === undefined) {
return this.error(
"MISSING_PARAMETER",
"Replace string must not be undefined.",
{
parameter: "replace",
recoveryHint:
"Provide the replacement text (use empty string to delete).",
},
);
}
// Validate path security - prevent path traversal attacks
const pathValidation = validateProjectPath(filePath, PROJECT_ROOT);
if (!pathValidation.valid || !pathValidation.resolvedPath) {
return this.error(
"SECURITY_VIOLATION",
pathValidation.error || "Invalid path",
{
parameter: "path",
recoveryHint:
"Provide a valid relative path within the project directory.",
},
);
}
const resolvedPath = pathValidation.resolvedPath;
try {
const content = await fs.readFile(resolvedPath, "utf-8");
const searchIdx = content.indexOf(search);
if (searchIdx === -1) {
// Enhancement B: Show file content context on NOT_FOUND
const contextInfo = this.buildNotFoundContext(content, search);
return this.error(
"NOT_FOUND",
`Search string not found in ${filePath}.${contextInfo}`,
{
parameter: "search",
recoveryHint:
"Verify your search string matches exactly, including whitespace and line endings.",
},
);
}
const newContent = content.replace(search, replace);
await fs.writeFile(resolvedPath, newContent, "utf-8");
const diffContext = this.buildDiffContext(
content,
searchIdx,
search,
newContent,
);
const output = `File edited: ${filePath}\n\n${diffContext}`;
// Build file operation metadata for the session panel
const binary = isBinaryPath(filePath);
const fileOperation: IChatFileOperation = {
type: "edit",
path: filePath,
isBinary: binary,
};
if (!binary) {
const delta = countLineDelta(content, newContent);
fileOperation.linesAdded = delta.added;
fileOperation.linesRemoved = delta.removed;
fileOperation.diff = buildUnifiedDiff(content, newContent, filePath);
}
const byteCount = Buffer.byteLength(output, "utf-8");
HostMonitorService.fileOperation(byteCount);
HostMonitorService.toolCall(byteCount);
// Return plain text format instead of JSON
const plainTextResponse = `PATH: ${filePath}
FILE OPERATION: edit
SEARCH FOUND: true
---
${output}`;
return plainTextResponse;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes("ENOENT")) {
return this.error("NOT_FOUND", `File not found: ${filePath}`, {
parameter: "path",
recoveryHint:
"Check the file path and ensure the file exists. Use file_write to create it.",
});
}
this.log.error("Failed to edit file", {
path: filePath,
error: errorMessage,
});
return this.error(
"OPERATION_FAILED",
`Failed to edit file: ${errorMessage}`,
);
}
}
/**
* Build context information when search string is not found.
* Shows file content around the approximate location where the search might have been expected.
*/
private buildNotFoundContext(content: string, search: string): string {
const lines = content.split("\n");
const searchLower = search.toLowerCase();
const searchWords = searchLower.split(/\s+/).filter((w) => w.length > 2);
// Try to find a line that contains some words from the search
let bestMatchLine = -1;
let bestMatchScore = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line === undefined) continue;
const lineLower = line.toLowerCase();
let score = 0;
for (const word of searchWords) {
if (lineLower.includes(word)) {
score++;
}
}
if (score > bestMatchScore) {
bestMatchScore = score;
bestMatchLine = i;
}
}
if (bestMatchLine === -1) {
// No similar content found, show first few lines
const previewLines = lines.slice(0, 5);
const preview = previewLines
.map((line, i) => ` ${i + 1}: ${line ?? ""}`)
.join("\n");
return `\n\nFile content (first 5 lines):\n${preview}`;
}
// Show context around the best match
const contextStart = Math.max(0, bestMatchLine - 2);
const contextEnd = Math.min(lines.length, bestMatchLine + 3);
const contextLines = lines.slice(contextStart, contextEnd);
const context = contextLines
.map((line, i) => ` ${contextStart + i + 1}: ${line ?? ""}`)
.join("\n");
return `\n\nFile content around line ${bestMatchLine + 1} (possible match location):\n${context}`;
}
private buildDiffContext(
original: string,
matchStart: number,
search: string,
newContent: string,
): string {
const contextLines = 2;
const oldLines = original.split("\n");
const newLines = newContent.split("\n");
// Find the starting line of the match
let charOffset = 0;
let matchStartLine = 0;
for (let i = 0; i < oldLines.length; i++) {
const line = oldLines[i];
if (line === undefined) break;
const lineLen = line.length + 1; // +1 for newline
if (charOffset + lineLen > matchStart) {
matchStartLine = i;
break;
}
charOffset += lineLen;
}
// Calculate how many lines the search string spans
const searchLines = search.split("\n");
const matchEndLine = matchStartLine + searchLines.length - 1;
// Enhancement A: Show all affected lines for multi-line changes
const affectedStartLine = Math.max(0, matchStartLine - contextLines);
const diffLines: string[] = [];
// Add summary header
const numChangedLines = matchEndLine - matchStartLine + 1;
if (numChangedLines === 1) {
const lineNum = matchStartLine + 1;
const oldLineText = oldLines[matchStartLine] ?? "";
const newLineText = newLines[matchStartLine] ?? "";
const oldLen = oldLineText.length;
const newLen = newLineText.length;
diffLines.push(`Changed line ${lineNum}:`);
diffLines.push(` Removed (${oldLen} chars): ${oldLineText}`);
diffLines.push(` Added (${newLen} chars): ${newLineText}`);
} else {
diffLines.push(
`Changed lines ${matchStartLine + 1}-${matchEndLine + 1}:`,
);
diffLines.push(` Search spanned ${numChangedLines} lines`);
diffLines.push(` --- Old:`);
for (let i = matchStartLine; i <= matchEndLine; i++) {
const oldLine = oldLines[i];
if (oldLine !== undefined) {
diffLines.push(` ${i + 1}: ${oldLine}`);
}
}
diffLines.push(` --- New:`);
// For multi-line replacements, show the corresponding new lines
const numReplaceLines = searchLines.length;
for (let i = 0; i < numReplaceLines; i++) {
const newLineIdx = matchStartLine + i;
const newLine = newLines[newLineIdx];
if (newLine !== undefined) {
diffLines.push(` ${newLineIdx + 1}: ${newLine}`);
}
}
}
// Add context before (if any)
if (matchStartLine > affectedStartLine) {
diffLines.push("");
diffLines.push("Context before:");
for (let i = affectedStartLine; i < matchStartLine; i++) {
const ctxLine = oldLines[i];
if (ctxLine !== undefined) {
diffLines.push(` ${i + 1}: ${ctxLine}`);
}
}
}
// Add context after (if any)
const actualEndLine = Math.min(
newLines.length,
matchEndLine + contextLines + 1,
);
if (matchEndLine + 1 < actualEndLine) {
diffLines.push("");
diffLines.push("Context after:");
for (let i = matchEndLine + 1; i < actualEndLine; i++) {
const ctxLine = newLines[i];
if (ctxLine !== undefined) {
diffLines.push(` ${i + 1}: ${ctxLine}`);
}
}
}
return diffLines.join("\n");
}
}
export default new FileEditTool();