1import { spawn, spawnSync } from "node:child_process";
2import { createInterface } from "node:readline";
3import { readFileSync, statSync } from "node:fs";
4import { relative, basename } from "node:path";
5import { Type } from "@sinclair/typebox";
6import type { AgentTool } from "@mariozechner/pi-agent-core";
7import { resolveToCwd, ensureWorkspacePath } from "./path-utils.js";
8import {
9 DEFAULT_MAX_BYTES,
10 formatSize,
11 GREP_MAX_LINE_LENGTH,
12 truncateHead,
13 truncateLine,
14} from "../../util/truncate.js";
15import { ToolInputError } from "../../util/errors.js";
16
17const DEFAULT_LIMIT = 100;
18
19const GrepSchema = Type.Object({
20 pattern: Type.String({ description: "Search pattern (regex or literal string)" }),
21 path: Type.Optional(Type.String({ description: "Directory or file to search (default: current directory)" })),
22 glob: Type.Optional(Type.String({ description: "Filter files by glob pattern, e.g. '*.ts'" })),
23 ignoreCase: Type.Optional(Type.Boolean({ description: "Case-insensitive search (default: false)" })),
24 literal: Type.Optional(
25 Type.Boolean({ description: "Treat pattern as literal string instead of regex (default: false)" }),
26 ),
27 context: Type.Optional(Type.Number({ description: "Lines of context around each match (default: 0)" })),
28 limit: Type.Optional(Type.Number({ description: "Maximum matches to return (default: 100)" })),
29});
30
31export const createGrepTool = (workspacePath: string): AgentTool => {
32 const rgResult = spawnSync("which", ["rg"], { encoding: "utf-8" });
33 if (rgResult.status !== 0) {
34 throw new ToolInputError("grep requires ripgrep (rg) to be installed");
35 }
36 const rgPath = rgResult.stdout.trim();
37
38 return {
39 name: "grep",
40 label: "Grep",
41 description: `Search file contents for a pattern. Returns up to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB, whichever is reached first. Lines over ${GREP_MAX_LINE_LENGTH} chars are truncated. Respects .gitignore.`,
42 parameters: GrepSchema as any,
43 execute: async (_toolCallId: string, params: any) => {
44 const searchDir: string | undefined = params.path;
45 const searchPath = resolveToCwd(searchDir || ".", workspacePath);
46 ensureWorkspacePath(workspacePath, searchPath);
47 let isDirectory = false;
48 try {
49 isDirectory = statSync(searchPath).isDirectory();
50 } catch {
51 throw new ToolInputError(`Path does not exist or is not accessible: ${searchDir || "."}`);
52 }
53
54 const effectiveLimit: number = Math.max(1, Math.floor(params.limit ?? DEFAULT_LIMIT));
55 const contextLines: number = Math.max(0, Math.floor(params.context ?? 0));
56
57 const args: string[] = ["--json", "--line-number", "--color=never", "--hidden"];
58 if (params.ignoreCase) args.push("--ignore-case");
59 if (params.literal) args.push("--fixed-strings");
60 if (params.glob) args.push("--glob", params.glob);
61 args.push(params.pattern, searchPath);
62
63 return new Promise((resolve, reject) => {
64 const child = spawn(rgPath, args, { stdio: ["ignore", "pipe", "pipe"] });
65
66 const rl = createInterface({ input: child.stdout! });
67 const fileCache = new Map<string, string[]>();
68
69 interface MatchEntry {
70 filePath: string;
71 lineNumber: number;
72 }
73
74 const matches: MatchEntry[] = [];
75 let stderrData = "";
76 let limitReached = false;
77
78 child.stderr!.on("data", (chunk: Buffer) => {
79 stderrData += chunk.toString();
80 });
81
82 rl.on("line", (line: string) => {
83 if (matches.length >= effectiveLimit) {
84 if (!limitReached) {
85 limitReached = true;
86 child.kill();
87 }
88 return;
89 }
90
91 try {
92 const parsed = JSON.parse(line);
93 if (parsed.type === "match") {
94 const filePath: string = parsed.data.path.text;
95 const lineNumber: number = parsed.data.line_number;
96 matches.push({ filePath, lineNumber });
97 }
98 } catch {
99 // ignore malformed JSON lines
100 }
101 });
102
103 child.on("close", (code: number | null) => {
104 if (code !== 0 && code !== 1 && !limitReached) {
105 reject(new Error(`rg exited with code ${code}: ${stderrData.trim()}`));
106 return;
107 }
108
109 if (matches.length === 0) {
110 resolve({
111 content: [{ type: "text", text: "No matches found" }],
112 details: { matches: 0 },
113 });
114 return;
115 }
116
117 const outputLines: string[] = [];
118 let anyLineTruncated = false;
119
120 for (const match of matches) {
121 let lines: string[];
122 if (fileCache.has(match.filePath)) {
123 lines = fileCache.get(match.filePath)!;
124 } else {
125 try {
126 lines = readFileSync(match.filePath, "utf-8").split(/\r?\n/);
127 fileCache.set(match.filePath, lines);
128 } catch {
129 lines = [];
130 }
131 }
132
133 const relPath = isDirectory
134 ? relative(searchPath, match.filePath)
135 : basename(match.filePath);
136
137 const startLine = Math.max(0, match.lineNumber - 1 - contextLines);
138 const endLine = Math.min(lines.length, match.lineNumber + contextLines);
139
140 for (let i = startLine; i < endLine; i++) {
141 const lineContent = lines[i] ?? "";
142 const { text: truncatedContent, wasTruncated } = truncateLine(lineContent);
143 if (wasTruncated) anyLineTruncated = true;
144
145 const lineNum = i + 1;
146 if (lineNum === match.lineNumber) {
147 outputLines.push(`${relPath}:${lineNum}: ${truncatedContent}`);
148 } else {
149 outputLines.push(`${relPath}-${lineNum}- ${truncatedContent}`);
150 }
151 }
152 }
153
154 const rawOutput = outputLines.join("\n");
155 const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
156
157 const notices: string[] = [];
158 if (limitReached) {
159 notices.push(
160 `${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
161 );
162 }
163 if (truncation.truncated) {
164 notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
165 }
166 if (anyLineTruncated) {
167 notices.push(
168 `Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`,
169 );
170 }
171
172 let output = truncation.content;
173 if (notices.length > 0) {
174 output += `\n\n[${notices.join(". ")}]`;
175 }
176
177 resolve({
178 content: [{ type: "text", text: output }],
179 details: {
180 matches: matches.length,
181 ...(limitReached ? { matchLimitReached: effectiveLimit } : {}),
182 ...(truncation.truncated ? { truncation } : {}),
183 ...(anyLineTruncated ? { linesTruncated: true } : {}),
184 },
185 });
186 });
187 });
188 },
189 };
190};