grep.ts

  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("ripgrep (rg) is not available");
 35  }
 36  const rgPath = rgResult.stdout.trim();
 37
 38  return {
 39    name: "grep",
 40    label: "Grep",
 41    description: `Search file contents for a pattern using ripgrep. Returns up to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Lines truncated to ${GREP_MAX_LINE_LENGTH} chars. 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};