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