231 lines
6.8 KiB
TypeScript
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();
|