SPB Git

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%
6.8 KB · 212 lines typescript
Raw Blame History
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