gadget/packages/ai-toolbox/src/plan/edit.ts
Rob Colbert c90e8ce0e8 plan tool updates
Plan tools extended to all modes; recent chat sessions added to
Authenticated Home view; drone locking and chat session startup factored
out of Project Manager into Chat Session view and made universal; update
for AGENTS.md.
2026-05-17 14:09:24 -04:00

224 lines
8.2 KiB
TypeScript

// src/plan/edit.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// 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<string> {
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");
}
}