spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/tools/read.ts4 * Description: The read tool — line-numbered file paging with binary/size/directory guards (TOOL_PROTOCOL §2).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { isWorkspaceError } from "../workspace/index.js";11import { listDirectory } from "../workspace/walk.js";12import { displayPath, numberedLine, resolveToolPath } from "./format.js";13import type { ToolDefinition } from "./registry.js";14import type { ToolContext, ToolResult } from "./types.js";1516export interface ReadInput {17 file_path: string;18 offset?: number;19 limit?: number;20}2122const DEFAULT_LIMIT = 2000;23const MAX_LINE_CHARS = 2000;24const MAX_RESULT_BYTES = 50 * 1024;25const LINE_TRUNCATED_MARKER = "… [line truncated]";2627const DESCRIPTION =28 "Read a file from the workspace. Returns the file content with line numbers, in the format " +29 "'LINE_NUMBER→CONTENT'. By default reads up to 2000 lines from the beginning. For larger files, " +30 "use offset and limit to page through content — the output tells you the total line count and how " +31 "to continue. Prefer reading only the region you need on large files. Binary files are detected " +32 "and described instead of dumped. If the path is a directory, its entries are listed. File paths " +33 "must be absolute or relative to the working directory.";3435function errorResult(content: string, title: string, startedAt: number): ToolResult {36 return {37 content,38 isError: true,39 metadata: { title, durationMs: Date.now() - startedAt },40 };41}4243async function executeRead(input: ReadInput, ctx: ToolContext): Promise<ToolResult> {44 const startedAt = Date.now();45 const cwd = ctx.workspace.cwd();46 const abs = resolveToolPath(cwd, input.file_path);47 const rel = displayPath(cwd, abs);4849 let content: string;50 try {51 content = await ctx.workspace.readFile(abs);52 } catch (cause) {53 if (isWorkspaceError(cause)) {54 if (cause.code === "file-not-found") {55 return errorResult(56 `File not found: ${abs}. Check the path, or use glob to locate the file by name.`,57 `Read ${rel} · not found`,58 startedAt,59 );60 }61 if (cause.code === "file-is-directory") {62 const listing = await listDirectory(abs);63 const hidden =64 listing.hiddenCount > 0 ? `\n(${listing.hiddenCount} hidden entries not shown)` : "";65 const more = listing.truncated ? "\n(listing truncated)" : "";66 return {67 content: `${abs} is a directory. Entries (2 levels):\n${listing.lines.join("\n")}${hidden}${more}`,68 metadata: {69 title: `Read ${rel} · directory · ${listing.lines.length} entries`,70 durationMs: Date.now() - startedAt,71 extra: { path: abs, directory: true },72 },73 };74 }75 if (cause.code === "file-too-large") {76 const size = cause.details["size"];77 return errorResult(78 `File is too large to read (${String(size)} bytes): ${abs}. Use grep to search its content instead of reading it whole.`,79 `Read ${rel} · too large`,80 startedAt,81 );82 }83 if (cause.code === "file-binary") {84 const size = cause.details["size"];85 return errorResult(86 `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.`,87 `Read ${rel} · binary`,88 startedAt,89 );90 }91 }92 return errorResult(93 `Failed to read ${abs}: ${cause instanceof Error ? cause.message : String(cause)}`,94 `Read ${rel} · error`,95 startedAt,96 );97 }9899 const lines = content.split("\n");100 if (lines[lines.length - 1] === "") lines.pop();101 const totalLines = lines.length;102103 if (totalLines === 0) {104 ctx.fileTimes.stamp(abs, content);105 ctx.emit({106 type: "file.read",107 payload: {108 path: rel,109 bytes: 0,110 mtimeMs: ctx.fileTimes.get(abs)?.mtimeMs ?? Date.now(),111 toolUseId: ctx.callId,112 },113 });114 return {115 content: `${abs}\n(empty file — 0 lines)`,116 metadata: {117 title: `Read ${rel} · empty`,118 durationMs: Date.now() - startedAt,119 extra: { path: abs, lines: 0, totalLines: 0 },120 },121 };122 }123124 const offset = input.offset ?? 1;125 if (offset < 1) {126 return errorResult(127 `Offset must be a 1-based line number (got ${offset}).`,128 `Read ${rel} · error`,129 startedAt,130 );131 }132 if (offset > totalLines) {133 return errorResult(134 `Offset ${offset} is beyond the end of the file (${totalLines} lines). Use offset ≤ ${totalLines}.`,135 `Read ${rel} · error`,136 startedAt,137 );138 }139 const limit = input.limit !== undefined && input.limit > 0 ? input.limit : DEFAULT_LIMIT;140141 const rendered: string[] = [abs];142 let bytes = abs.length + 1;143 let shown = 0;144 for (let i = offset - 1; i < Math.min(totalLines, offset - 1 + limit); i++) {145 let line = lines[i] as string;146 if (line.length > MAX_LINE_CHARS) {147 line = line.slice(0, MAX_LINE_CHARS) + LINE_TRUNCATED_MARKER;148 }149 const row = numberedLine(i + 1, line);150 bytes += row.length + 1;151 if (shown > 0 && bytes > MAX_RESULT_BYTES) break; // byte cap ends the page152 rendered.push(row);153 shown += 1;154 }155156 const start = offset;157 const end = offset + shown - 1;158 if (end < totalLines || start > 1) {159 const continuation = end < totalLines ? ` Use offset=${end + 1} to continue.` : "";160 rendered.push(`(Showing lines ${start}–${end} of ${totalLines}.${continuation})`);161 }162163 ctx.fileTimes.stamp(abs, content);164 ctx.emit({165 type: "file.read",166 payload: {167 path: rel,168 range: { start, end },169 bytes: Buffer.byteLength(content, "utf8"),170 mtimeMs: ctx.fileTimes.get(abs)?.mtimeMs ?? Date.now(),171 toolUseId: ctx.callId,172 },173 });174175 return {176 content: rendered.join("\n"),177 metadata: {178 title: `Read ${rel} · lines ${start}–${end} of ${totalLines}`,179 durationMs: Date.now() - startedAt,180 extra: { path: abs, lines: shown, totalLines },181 },182 };183}184185/** Create the read tool definition. */186export function createReadTool(): ToolDefinition<ReadInput> {187 return {188 name: "read",189 description: DESCRIPTION,190 capability: "file.read",191 inputSchema: {192 type: "object",193 properties: {194 file_path: {195 type: "string",196 description: "Path to the file to read (absolute preferred).",197 },198 offset: {199 type: "integer",200 description: "1-based line number to start reading from. Omit to start at line 1.",201 },202 limit: {203 type: "integer",204 description: "Maximum number of lines to return. Omit for the default of 2000.",205 },206 },207 required: ["file_path"],208 },209 execute: executeRead,210 };211}212