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.
224 lines
8.2 KiB
TypeScript
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");
|
|
}
|
|
}
|