// 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 { 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();