// 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 { 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();