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