// src/plan/edit.ts // Copyright (C) 2026 Rob Colbert // Licensed under the Apache License, Version 2.0 import fs from "node:fs/promises"; import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; import { GadgetTool } from "../tool.ts"; import { toolError } from "../system/common.ts"; import { getGadgetDir, resolvePlanPath } from "./common.ts"; export class PlanFileEditTool extends GadgetTool { get name(): string { return "plan_file_edit"; } get category(): string { return "plan"; } public definition: IToolDefinition = { type: "function", function: { name: this.name, description: "Perform an exact search-and-replace edit on a file in Gadget's .gadget directory. Replaces the first occurrence only and returns changed-line context. Operates exclusively in the .gadget directory for updating plans, todos, and other knowledge. Does not affect project files.", parameters: { type: "object", properties: { path: { type: "string", description: "Path to the file to edit, relative to the .gadget directory." }, search: { type: "string", description: "Exact text to search for. It must match whitespace and line endings exactly." }, replace: { type: "string", description: "Replacement text. Empty string is allowed to delete the match." }, }, required: ["path", "search", "replace"], }, }, }; public async execute(args: IToolArguments, logger: IAiLogger): Promise { const filePath = args.path; const search = args.search; const replace = args.replace; if (typeof filePath !== "string" || filePath.trim().length === 0) { return toolError({ code: "MISSING_PARAMETER", message: "File path must not be empty.", parameter: "path", recoveryHint: "Provide a valid file path within the .gadget directory.", }); } if (typeof search !== "string" || search.length === 0) { return toolError({ code: "MISSING_PARAMETER", message: "Search string must not be empty.", parameter: "search", recoveryHint: "Provide the exact text to search for.", }); } if (typeof replace !== "string") { return toolError({ code: "MISSING_PARAMETER", message: "Replace string must be a string and must not be undefined.", parameter: "replace", recoveryHint: "Provide the replacement text. Use an empty string to delete the match.", }); } const resolved = resolvePlanPath(this.toolbox, filePath); if (typeof resolved === "string") return resolved; try { const content = await fs.readFile(resolved.absolutePath, "utf-8"); const searchIdx = content.indexOf(search); if (searchIdx === -1) { const contextInfo = this.buildNotFoundContext(content, search); return toolError({ code: "NOT_FOUND", message: `Search string not found in ${resolved.displayPath}.${contextInfo}`, parameter: "search", recoveryHint: "Verify your search string matches exactly, including whitespace and line endings.", }); } const newContent = content.replace(search, replace); await fs.writeFile(resolved.absolutePath, newContent, "utf-8"); const diffContext = this.buildDiffContext(content, searchIdx, search, newContent); return [ `PATH: ${resolved.displayPath}`, "FILE OPERATION: edit", "SEARCH FOUND: true", "---", `File edited: ${resolved.displayPath}`, "", diffContext, ].join("\n"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); if (errorMessage.includes("ENOENT")) { const gadgetDir = getGadgetDir(this.toolbox); return toolError({ code: "NOT_FOUND", message: `File not found in .gadget: ${resolved.displayPath}${gadgetDir ? `. The .gadget directory exists but this file does not.` : ". Plan storage is empty. Create a file with plan_file_write first."}`, parameter: "path", recoveryHint: "Check the file path. Use plan_list to see available files, or plan_file_write to create the file first.", }); } logger.error("failed to edit plan file", { path: resolved.displayPath, error: errorMessage }); return toolError({ code: "OPERATION_FAILED", message: `Failed to edit file: ${errorMessage}`, }); } } private buildNotFoundContext(content: string, search: string): string { const lines = content.split("\n"); const searchWords = search.toLowerCase().split(/\s+/).filter((word) => word.length > 2); 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) { const preview = lines.slice(0, 5).map((line, index) => ` ${index + 1}: ${line}`).join("\n"); return `\n\nFile content (first 5 lines):\n${preview}`; } const contextStart = Math.max(0, bestMatchLine - 2); const contextEnd = Math.min(lines.length, bestMatchLine + 3); const context = lines .slice(contextStart, contextEnd) .map((line, index) => ` ${contextStart + index + 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"); 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; if (charOffset + lineLen > matchStart) { matchStartLine = i; break; } charOffset += lineLen; } const searchLines = search.split("\n"); const matchEndLine = matchStartLine + searchLines.length - 1; const affectedStartLine = Math.max(0, matchStartLine - contextLines); const diffLines: string[] = []; const numChangedLines = matchEndLine - matchStartLine + 1; if (numChangedLines === 1) { const lineNum = matchStartLine + 1; const oldLineText = oldLines[matchStartLine] ?? ""; const newLineText = newLines[matchStartLine] ?? ""; diffLines.push(`Changed line ${lineNum}:`); diffLines.push(` Removed (${oldLineText.length} chars): ${oldLineText}`); diffLines.push(` Added (${newLineText.length} 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 (let i = 0; i < searchLines.length; i++) { const newLineIdx = matchStartLine + i; const newLine = newLines[newLineIdx]; if (newLine !== undefined) diffLines.push(` ${newLineIdx + 1}: ${newLine}`); } } if (matchStartLine > affectedStartLine) { diffLines.push("", "Context before:"); for (let i = affectedStartLine; i < matchStartLine; i++) { const ctxLine = oldLines[i]; if (ctxLine !== undefined) diffLines.push(` ${i + 1}: ${ctxLine}`); } } const actualEndLine = Math.min(newLines.length, matchEndLine + contextLines + 1); if (matchEndLine + 1 < actualEndLine) { 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"); } }