gadget/docs/archive/tools/file/read.ts

231 lines
6.8 KiB
TypeScript

// src/tools/file/read.ts
// Copyright (C) 2025 DTP Technologies, LLC
// All Rights Reserved
import fs from "node:fs/promises";
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 { validateProjectPath } from "../../lib/path-security.js";
import { PROJECT_ROOT } from "../../config/env.js";
import HostMonitorService from "../../services/host-monitor.js";
const MAX_FILE_SIZE = 50 * 1024; // 50KB
export class FileReadTool extends DtpTool {
get name(): string {
return "FileReadTool";
}
get slug(): string {
return "file-read";
}
get metadata() {
return {
name: this.definition.function.name || "file_read",
category: "file",
tags: ["file", "read", "view", "display"],
modes: [
ChatSessionMode.Plan,
ChatSessionMode.Build,
ChatSessionMode.Test,
ChatSessionMode.Ship,
ChatSessionMode.Develop,
],
};
}
public definition: ToolDefinition = {
type: "function",
function: {
name: "file_read",
description:
"Read the contents of a file with line numbers. Returns text with numbered lines. Supports reading a specific line range. Binary files cannot be displayed.",
parameters: {
type: "object",
properties: {
path: {
type: "string",
description: "Path to the file to read (relative or absolute).",
},
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(
_context: ToolContext,
args: ToolArguments,
): Promise<string> {
const filePath = args.path 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.",
});
}
const startLine = (args.startLine as number | undefined) ?? 1;
const endLine = args.endLine as number | undefined;
if (startLine < 1) {
return this.error("INVALID_PARAMETER", "startLine must be >= 1.", {
parameter: "startLine",
expected: "A positive integer >= 1",
});
}
if (endLine !== undefined && endLine < startLine) {
return this.error("INVALID_PARAMETER", "endLine must be >= startLine.", {
parameter: "endLine",
expected: "An integer >= startLine",
});
}
// 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 stat = await fs.stat(resolvedPath);
if (!stat.isFile()) {
return this.error("INVALID_PARAMETER", `"${filePath}" is not a file.`, {
parameter: "path",
recoveryHint: "Provide a path to a regular file, not a directory.",
});
}
if (stat.size > MAX_FILE_SIZE) {
return this.error(
"LIMIT_EXCEEDED",
`File is too large to read (${(stat.size / 1024).toFixed(1)}KB). Maximum file size is ${(MAX_FILE_SIZE / 1024).toFixed(0)}KB.`,
{
parameter: "path",
recoveryHint: `Use startLine and endLine to read a smaller portion of the file, or use shell_exec with head/tail commands.`,
},
);
}
const raw = await fs.readFile(resolvedPath);
if (this.isBinary(raw)) {
const output = `Binary file, cannot display: ${filePath}`;
const byteCount = Buffer.byteLength(output, "utf-8");
HostMonitorService.fileOperation(byteCount);
HostMonitorService.toolCall(byteCount);
// Return plain text response for binary files
const plainTextResponse = `PATH: ${filePath}
TOTAL LINES: 0
LINES SHOWN: 0
FILE OPERATION: read
---
${output}`;
return plainTextResponse;
}
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 = selectedLines
.map((line, i) => `${startIdx + i + 1}: ${line}`)
.join("\n");
const totalLines = lines.length;
const output =
endLine !== undefined || startLine > 1
? `File: ${filePath} (lines ${startIdx + 1}-${endIdx} of ${totalLines})\n\n${numberedLines}`
: `File: ${filePath} (${totalLines} lines)\n\n${numberedLines}`;
const fileOperation = {
type: "read" as const,
path: filePath,
isBinary: false,
linesAdded: 0,
linesRemoved: 0,
};
const byteCount = Buffer.byteLength(output, "utf-8");
HostMonitorService.fileOperation(byteCount);
HostMonitorService.toolCall(byteCount);
// Return plain text response instead of JSON
const plainTextResponse = `PATH: ${filePath}
TOTAL LINES: ${totalLines}
LINES SHOWN: ${selectedLines.length}
FILE OPERATION: ${fileOperation.type}
---
${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.",
});
}
this.log.error("Failed to read file", {
path: filePath,
error: errorMessage,
});
return this.error(
"OPERATION_FAILED",
`Failed to read file: ${errorMessage}`,
);
}
}
private isBinary(buffer: Buffer): boolean {
const sampleSize = Math.min(buffer.length, 8192);
for (let i = 0; i < sampleSize; i++) {
const byte = buffer[i];
if (byte === undefined) continue;
if (byte === 0) return true;
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) return true;
}
return false;
}
}
export default new FileReadTool();