grep.ts

  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};