1import { readFile, stat } from "node:fs/promises";
2import { Type } from "@sinclair/typebox";
3import type { AgentTool } from "@mariozechner/pi-ai";
4import { resolveReadPath } 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: "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. Output is truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Use offset/limit for large files.`,
20 parameters: ReadSchema as any,
21 execute: async (_toolCallId: string, params: any) => {
22 const absolutePath = resolveReadPath(params.path, workspacePath);
23 const fileStats = await stat(absolutePath);
24
25 if (fileStats.size > MAX_READ_BYTES) {
26 throw new ToolInputError(`File exceeds 5MB limit: ${params.path}`);
27 }
28
29 const raw = await readFile(absolutePath, "utf8");
30 const allLines = raw.split("\n");
31 const totalFileLines = allLines.length;
32
33 const startLine = params.offset ? Math.max(0, params.offset - 1) : 0;
34 const startLineDisplay = startLine + 1;
35
36 if (startLine >= allLines.length) {
37 throw new Error(`Offset ${params.offset} is beyond end of file (${allLines.length} lines total)`);
38 }
39
40 let selectedContent: string;
41 let userLimitedLines: number | undefined;
42 if (params.limit !== undefined) {
43 const endLine = Math.min(startLine + params.limit, allLines.length);
44 selectedContent = allLines.slice(startLine, endLine).join("\n");
45 userLimitedLines = endLine - startLine;
46 } else {
47 selectedContent = allLines.slice(startLine).join("\n");
48 }
49
50 const truncation = truncateHead(selectedContent);
51
52 let output: string;
53
54 if (truncation.firstLineExceedsLimit) {
55 const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine] ?? "", "utf-8"));
56 output = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use a local file viewer to extract that single line by number.]`;
57 } else if (truncation.truncated) {
58 const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
59 const nextOffset = endLineDisplay + 1;
60
61 output = truncation.content;
62
63 if (truncation.truncatedBy === "lines") {
64 output += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
65 } else {
66 output += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
67 }
68 } else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
69 const remaining = allLines.length - (startLine + userLimitedLines);
70 const nextOffset = startLine + userLimitedLines + 1;
71
72 output = truncation.content;
73 output += `\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
74 } else {
75 output = truncation.content;
76 }
77
78 return {
79 content: [{ type: "text", text: output }],
80 details: { truncation },
81 };
82 },
83});