read.ts

 1import { readFile, stat } from "node:fs/promises";
 2import { Type } from "@sinclair/typebox";
 3import type { AgentTool } from "@mariozechner/pi-agent-core";
 4import { resolveReadPath, ensureWorkspacePath } from "./path-utils.js";
 5import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "../../util/truncate.js";
 6import { ToolInputError } from "../../util/errors.js";
 7
 8const MAX_READ_BYTES = 5 * 1024 * 1024;
 9
10const ReadSchema = Type.Object({
11	path: Type.String({ description: "File path relative to workspace root" }),
12	offset: Type.Optional(Type.Number({ description: "1-based starting line (default: 1)" })),
13	limit: Type.Optional(Type.Number({ description: "Maximum lines to return" })),
14});
15
16export const createReadTool = (workspacePath: string): AgentTool => ({
17	name: "read",
18	label: "Read File",
19	description: `Read a file's contents. Returns up to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB, whichever is reached first. Use offset and limit to paginate large files.`,
20	parameters: ReadSchema as any,
21	execute: async (_toolCallId: string, params: any) => {
22		const absolutePath = resolveReadPath(params.path, workspacePath);
23		ensureWorkspacePath(workspacePath, absolutePath);
24		const fileStats = await stat(absolutePath);
25
26		if (fileStats.size > MAX_READ_BYTES) {
27			throw new ToolInputError(`File too large (>5MB): ${params.path}`);
28		}
29
30		const raw = await readFile(absolutePath, "utf8");
31		const allLines = raw.split("\n");
32		const totalFileLines = allLines.length;
33
34		const startLine = params.offset ? Math.max(0, params.offset - 1) : 0;
35		const startLineDisplay = startLine + 1;
36
37		if (startLine >= allLines.length) {
38			throw new Error(`Offset ${params.offset} is beyond end of file (${allLines.length} lines total)`);
39		}
40
41		let selectedContent: string;
42		let userLimitedLines: number | undefined;
43		if (params.limit !== undefined) {
44			const endLine = Math.min(startLine + params.limit, allLines.length);
45			selectedContent = allLines.slice(startLine, endLine).join("\n");
46			userLimitedLines = endLine - startLine;
47		} else {
48			selectedContent = allLines.slice(startLine).join("\n");
49		}
50
51		const truncation = truncateHead(selectedContent);
52
53		let output: string;
54
55		if (truncation.firstLineExceedsLimit) {
56			const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine] ?? "", "utf-8"));
57			output = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use a local file viewer to extract that single line by number.]`;
58		} else if (truncation.truncated) {
59			const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
60			const nextOffset = endLineDisplay + 1;
61
62			output = truncation.content;
63
64			if (truncation.truncatedBy === "lines") {
65				output += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
66			} else {
67				output += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
68			}
69		} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
70			const remaining = allLines.length - (startLine + userLimitedLines);
71			const nextOffset = startLine + userLimitedLines + 1;
72
73			output = truncation.content;
74			output += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
75		} else {
76			output = truncation.content;
77		}
78
79		return {
80			content: [{ type: "text", text: output }],
81			details: { truncation },
82		};
83	},
84});