140 lines
4.9 KiB
TypeScript
140 lines
4.9 KiB
TypeScript
// 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 { DroneTool } from "../tool.ts";
|
|
import {
|
|
asPositiveInteger,
|
|
formatNumberedLines,
|
|
isBinaryBuffer,
|
|
toolError,
|
|
} from "../system/common.ts";
|
|
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
|
|
|
export class PlanFileReadTool extends DroneTool {
|
|
get name(): string {
|
|
return "plan_file_read";
|
|
}
|
|
|
|
get category(): string {
|
|
return "plan";
|
|
}
|
|
|
|
public definition: IToolDefinition = {
|
|
type: "function",
|
|
function: {
|
|
name: this.name,
|
|
description:
|
|
"Read a file from Gadget's .gadget directory with line numbers. This Plan-mode tool works in Gadget's own directory (.gadget) for storing and reading plans, todos, and other knowledge. It does not affect project files.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
path: { type: "string", description: "Path to the file to read, relative to the .gadget directory." },
|
|
startLine: { type: "number", description: "Starting line number (1-indexed). Defaults to 1." },
|
|
endLine: { type: "number", description: "Ending line number (inclusive). Defaults to end of file." },
|
|
},
|
|
required: ["path"],
|
|
},
|
|
},
|
|
};
|
|
|
|
public async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
|
|
const filePath = args.path;
|
|
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.",
|
|
});
|
|
}
|
|
|
|
const startLine = asPositiveInteger(args.startLine) ?? 1;
|
|
const endLine = args.endLine === undefined ? undefined : asPositiveInteger(args.endLine);
|
|
|
|
if (startLine < 1) {
|
|
return toolError({
|
|
code: "INVALID_PARAMETER",
|
|
message: "startLine must be >= 1.",
|
|
parameter: "startLine",
|
|
expected: "A positive integer >= 1",
|
|
});
|
|
}
|
|
if (endLine !== undefined && endLine < startLine) {
|
|
return toolError({
|
|
code: "INVALID_PARAMETER",
|
|
message: "endLine must be >= startLine.",
|
|
parameter: "endLine",
|
|
expected: "An integer >= startLine",
|
|
});
|
|
}
|
|
|
|
const resolved = resolvePlanPath(this.toolbox, filePath);
|
|
if (typeof resolved === "string") return resolved;
|
|
|
|
try {
|
|
const stat = await fs.stat(resolved.absolutePath);
|
|
if (!stat.isFile()) {
|
|
return toolError({
|
|
code: "INVALID_PARAMETER",
|
|
message: `"${resolved.displayPath}" is not a file.`,
|
|
parameter: "path",
|
|
recoveryHint: "Provide a path to a regular file, not a directory.",
|
|
});
|
|
}
|
|
|
|
const raw = await fs.readFile(resolved.absolutePath);
|
|
if (isBinaryBuffer(raw)) {
|
|
return [
|
|
`PATH: ${resolved.displayPath}`,
|
|
"TOTAL LINES: 0",
|
|
"LINES SHOWN: 0",
|
|
"FILE OPERATION: read",
|
|
"---",
|
|
`Binary file, cannot display: ${resolved.displayPath}`,
|
|
].join("\n");
|
|
}
|
|
|
|
const content = raw.toString("utf-8");
|
|
const lines = content.split("\n");
|
|
const startIdx = Math.max(0, startLine - 1);
|
|
const endIdx = endLine !== undefined ? Math.min(endLine, lines.length) : lines.length;
|
|
const selectedLines = lines.slice(startIdx, endIdx);
|
|
const numberedLines = formatNumberedLines(selectedLines, startIdx);
|
|
const totalLines = lines.length;
|
|
const rangeLabel = endLine !== undefined || startLine > 1
|
|
? `lines ${startIdx + 1}-${endIdx} of ${totalLines}`
|
|
: `${totalLines} lines`;
|
|
|
|
return [
|
|
`PATH: ${resolved.displayPath}`,
|
|
`TOTAL LINES: ${totalLines}`,
|
|
`LINES SHOWN: ${selectedLines.length}`,
|
|
"FILE OPERATION: read",
|
|
"---",
|
|
`File: ${resolved.displayPath} (${rangeLabel})`,
|
|
"",
|
|
numberedLines,
|
|
].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.",
|
|
});
|
|
}
|
|
logger.error("failed to read plan file", { path: resolved.displayPath, error: errorMessage });
|
|
return toolError({
|
|
code: "OPERATION_FAILED",
|
|
message: `Failed to read file: ${errorMessage}`,
|
|
});
|
|
}
|
|
}
|
|
}
|