440 lines
13 KiB
TypeScript
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();
|