/** * KHAELOR * File: src/tools/read.ts * Description: The read tool — line-numbered file paging with binary/size/directory guards (TOOL_PROTOCOL §2). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { isWorkspaceError } from "../workspace/index.js"; import { listDirectory } from "../workspace/walk.js"; import { displayPath, numberedLine, resolveToolPath } from "./format.js"; import type { ToolDefinition } from "./registry.js"; import type { ToolContext, ToolResult } from "./types.js"; export interface ReadInput { file_path: string; offset?: number; limit?: number; } const DEFAULT_LIMIT = 2000; const MAX_LINE_CHARS = 2000; const MAX_RESULT_BYTES = 50 * 1024; const LINE_TRUNCATED_MARKER = "… [line truncated]"; const DESCRIPTION = "Read a file from the workspace. Returns the file content with line numbers, in the format " + "'LINE_NUMBER→CONTENT'. By default reads up to 2000 lines from the beginning. For larger files, " + "use offset and limit to page through content — the output tells you the total line count and how " + "to continue. Prefer reading only the region you need on large files. Binary files are detected " + "and described instead of dumped. If the path is a directory, its entries are listed. File paths " + "must be absolute or relative to the working directory."; function errorResult(content: string, title: string, startedAt: number): ToolResult { return { content, isError: true, metadata: { title, durationMs: Date.now() - startedAt }, }; } async function executeRead(input: ReadInput, ctx: ToolContext): Promise { const startedAt = Date.now(); const cwd = ctx.workspace.cwd(); const abs = resolveToolPath(cwd, input.file_path); const rel = displayPath(cwd, abs); let content: string; try { content = await ctx.workspace.readFile(abs); } catch (cause) { if (isWorkspaceError(cause)) { if (cause.code === "file-not-found") { return errorResult( `File not found: ${abs}. Check the path, or use glob to locate the file by name.`, `Read ${rel} · not found`, startedAt, ); } if (cause.code === "file-is-directory") { const listing = await listDirectory(abs); const hidden = listing.hiddenCount > 0 ? `\n(${listing.hiddenCount} hidden entries not shown)` : ""; const more = listing.truncated ? "\n(listing truncated)" : ""; return { content: `${abs} is a directory. Entries (2 levels):\n${listing.lines.join("\n")}${hidden}${more}`, metadata: { title: `Read ${rel} · directory · ${listing.lines.length} entries`, durationMs: Date.now() - startedAt, extra: { path: abs, directory: true }, }, }; } if (cause.code === "file-too-large") { const size = cause.details["size"]; return errorResult( `File is too large to read (${String(size)} bytes): ${abs}. Use grep to search its content instead of reading it whole.`, `Read ${rel} · too large`, startedAt, ); } if (cause.code === "file-binary") { const size = cause.details["size"]; return errorResult( `File appears to be binary: ${abs} (${String(size)} bytes). Binary files are not displayed. Use bash (e.g. \`file\`, \`strings\`) if you need to inspect it.`, `Read ${rel} · binary`, startedAt, ); } } return errorResult( `Failed to read ${abs}: ${cause instanceof Error ? cause.message : String(cause)}`, `Read ${rel} · error`, startedAt, ); } const lines = content.split("\n"); if (lines[lines.length - 1] === "") lines.pop(); const totalLines = lines.length; if (totalLines === 0) { ctx.fileTimes.stamp(abs, content); ctx.emit({ type: "file.read", payload: { path: rel, bytes: 0, mtimeMs: ctx.fileTimes.get(abs)?.mtimeMs ?? Date.now(), toolUseId: ctx.callId, }, }); return { content: `${abs}\n(empty file — 0 lines)`, metadata: { title: `Read ${rel} · empty`, durationMs: Date.now() - startedAt, extra: { path: abs, lines: 0, totalLines: 0 }, }, }; } const offset = input.offset ?? 1; if (offset < 1) { return errorResult( `Offset must be a 1-based line number (got ${offset}).`, `Read ${rel} · error`, startedAt, ); } if (offset > totalLines) { return errorResult( `Offset ${offset} is beyond the end of the file (${totalLines} lines). Use offset ≤ ${totalLines}.`, `Read ${rel} · error`, startedAt, ); } const limit = input.limit !== undefined && input.limit > 0 ? input.limit : DEFAULT_LIMIT; const rendered: string[] = [abs]; let bytes = abs.length + 1; let shown = 0; for (let i = offset - 1; i < Math.min(totalLines, offset - 1 + limit); i++) { let line = lines[i] as string; if (line.length > MAX_LINE_CHARS) { line = line.slice(0, MAX_LINE_CHARS) + LINE_TRUNCATED_MARKER; } const row = numberedLine(i + 1, line); bytes += row.length + 1; if (shown > 0 && bytes > MAX_RESULT_BYTES) break; // byte cap ends the page rendered.push(row); shown += 1; } const start = offset; const end = offset + shown - 1; if (end < totalLines || start > 1) { const continuation = end < totalLines ? ` Use offset=${end + 1} to continue.` : ""; rendered.push(`(Showing lines ${start}–${end} of ${totalLines}.${continuation})`); } ctx.fileTimes.stamp(abs, content); ctx.emit({ type: "file.read", payload: { path: rel, range: { start, end }, bytes: Buffer.byteLength(content, "utf8"), mtimeMs: ctx.fileTimes.get(abs)?.mtimeMs ?? Date.now(), toolUseId: ctx.callId, }, }); return { content: rendered.join("\n"), metadata: { title: `Read ${rel} · lines ${start}–${end} of ${totalLines}`, durationMs: Date.now() - startedAt, extra: { path: abs, lines: shown, totalLines }, }, }; } /** Create the read tool definition. */ export function createReadTool(): ToolDefinition { return { name: "read", description: DESCRIPTION, capability: "file.read", inputSchema: { type: "object", properties: { file_path: { type: "string", description: "Path to the file to read (absolute preferred).", }, offset: { type: "integer", description: "1-based line number to start reading from. Omit to start at line 1.", }, limit: { type: "integer", description: "Maximum number of lines to return. Omit for the default of 2000.", }, }, required: ["file_path"], }, execute: executeRead, }; }