src / tools / read.ts
import { promises as fs } from "fs";
import { fsError, looksBinary, MAX_FILE_BYTES } from "../fsutil";
import { resolveInScope } from "../paths";
const DEFAULT_LINES = 100;
const MAX_LINES = 1000;
const TOKEN_BUDGET = 1000;
const TOKENS_PER_WORD = 1.3;
function splitLines(text: string): string[] {
if (text === "") return [];
const lines = text.split(/\r\n|\n|\r/);
if (/(\r\n|\n|\r)$/.test(text)) lines.pop();
return lines;
}
const wordCount = (s: string) => s.split(/\s+/).filter(Boolean).length;
/** How many of `lines` fit the token budget (always at least 1). */
function fitCount(lines: string[]): number {
let words = 0;
let n = 0;
for (const line of lines) {
words += wordCount(line);
if (n > 0 && Math.round(words * TOKENS_PER_WORD) > TOKEN_BUDGET) break;
n++;
}
return n;
}
export async function runRead(
root: string,
args: { path: string; offset?: number; limit?: number },
): Promise<string> {
const r = await resolveInScope(root, args.path);
if (!r.ok) return r.error;
let buf: Buffer;
try {
const st = await fs.stat(r.abs);
if (st.isDirectory()) return `Error: is a directory, not a file: ${args.path}`;
if (!st.isFile()) return `Error: not a regular file: ${args.path}`;
if (st.size > MAX_FILE_BYTES) {
return `Error: file is too large to read (${st.size} bytes, limit ${MAX_FILE_BYTES})`;
}
buf = await fs.readFile(r.abs);
} catch (e) {
return fsError(e, args.path);
}
if (looksBinary(buf.subarray(0, 8192))) {
return `Error: binary file, cannot read as text: ${args.path}`;
}
const lines = splitLines(buf.toString("utf8"));
const total = lines.length;
if (total === 0) return "(empty file)";
const offset = Math.max(1, Math.trunc(args.offset ?? 1));
const limit = Math.min(MAX_LINES, Math.max(1, Math.trunc(args.limit ?? DEFAULT_LINES)));
const start = offset - 1;
if (start >= total) return `Error: offset ${offset} past end of file (${total} lines)`;
const window = lines.slice(start, Math.min(start + limit, total));
const shown = fitCount(window);
const truncated = shown < window.length;
const end = start + shown;
const width = String(end).length;
const body = window
.slice(0, shown)
.map((line, i) => `${String(start + i + 1).padStart(width)} ${line}`)
.join("\n");
let footer = `--- lines ${offset}-${end} of ${total}`;
if (truncated) footer += ` | truncated at ~${TOKEN_BUDGET} tokens`;
if (end < total) footer += ` | continue: offset=${end + 1}`;
return `${body}\n${footer}`;
}
src / tools / read.ts
import { promises as fs } from "fs";
import { fsError, looksBinary, MAX_FILE_BYTES } from "../fsutil";
import { resolveInScope } from "../paths";
const DEFAULT_LINES = 100;
const MAX_LINES = 1000;
const TOKEN_BUDGET = 1000;
const TOKENS_PER_WORD = 1.3;
function splitLines(text: string): string[] {
if (text === "") return [];
const lines = text.split(/\r\n|\n|\r/);
if (/(\r\n|\n|\r)$/.test(text)) lines.pop();
return lines;
}
const wordCount = (s: string) => s.split(/\s+/).filter(Boolean).length;
/** How many of `lines` fit the token budget (always at least 1). */
function fitCount(lines: string[]): number {
let words = 0;
let n = 0;
for (const line of lines) {
words += wordCount(line);
if (n > 0 && Math.round(words * TOKENS_PER_WORD) > TOKEN_BUDGET) break;
n++;
}
return n;
}
export async function runRead(
root: string,
args: { path: string; offset?: number; limit?: number },
): Promise<string> {
const r = await resolveInScope(root, args.path);
if (!r.ok) return r.error;
let buf: Buffer;
try {
const st = await fs.stat(r.abs);
if (st.isDirectory()) return `Error: is a directory, not a file: ${args.path}`;
if (!st.isFile()) return `Error: not a regular file: ${args.path}`;
if (st.size > MAX_FILE_BYTES) {
return `Error: file is too large to read (${st.size} bytes, limit ${MAX_FILE_BYTES})`;
}
buf = await fs.readFile(r.abs);
} catch (e) {
return fsError(e, args.path);
}
if (looksBinary(buf.subarray(0, 8192))) {
return `Error: binary file, cannot read as text: ${args.path}`;
}
const lines = splitLines(buf.toString("utf8"));
const total = lines.length;
if (total === 0) return "(empty file)";
const offset = Math.max(1, Math.trunc(args.offset ?? 1));
const limit = Math.min(MAX_LINES, Math.max(1, Math.trunc(args.limit ?? DEFAULT_LINES)));
const start = offset - 1;
if (start >= total) return `Error: offset ${offset} past end of file (${total} lines)`;
const window = lines.slice(start, Math.min(start + limit, total));
const shown = fitCount(window);
const truncated = shown < window.length;
const end = start + shown;
const width = String(end).length;
const body = window
.slice(0, shown)
.map((line, i) => `${String(start + i + 1).padStart(width)} ${line}`)
.join("\n");
let footer = `--- lines ${offset}-${end} of ${total}`;
if (truncated) footer += ` | truncated at ~${TOKEN_BUDGET} tokens`;
if (end < total) footer += ` | continue: offset=${end + 1}`;
return `${body}\n${footer}`;
}