import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { createGrepToolDefinition, type GrepToolDetails, type GrepToolInput } from "@earendil-works/pi-coding-agent"; const DEFAULT_LIMIT = 20; const MAX_BROAD_CONTEXT_LIMIT = 20; const SUMMARY_TOP_FILES = 5; const SUMMARY_SAMPLE_LINES = 5; function isWidePath(searchPath?: string): boolean { const value = (searchPath ?? ".").trim(); return value === "" || value === "." || value === "./" || value === "/"; } function isBroadContextSearch(input: GrepToolInput): boolean { const context = input.context ?? 0; if (context <= 0) return false; const effectiveLimit = Math.max(1, input.limit ?? DEFAULT_LIMIT); return effectiveLimit > MAX_BROAD_CONTEXT_LIMIT || isWidePath(input.path) || !input.glob; } function buildBroadContextMessage(input: GrepToolInput): string { const effectiveLimit = Math.max(1, input.limit ?? DEFAULT_LIMIT); const path = input.path ?? "."; const glob = input.glob ?? "(none)"; const context = input.context ?? 0; return [ "Search blocked: grep context is too expensive for this broad search.", `Current settings: path=${path}, glob=${glob}, limit=${effectiveLimit}, context=${context}.`, `Retry with context removed, or narrow the search before using context (for example: add a tighter glob, search a smaller path, or keep limit at ${MAX_BROAD_CONTEXT_LIMIT} or below).`, "Use grep to locate candidates first, then use read on the most relevant file(s) for surrounding code.", ].join(" "); } function getMatchLines(text: string): string[] { return text .split("\n") .map((line) => line.trimEnd()) .filter((line) => /:\d+:/.test(line)); } function buildSummary(text: string, details: GrepToolDetails | undefined): string { const matchLines = getMatchLines(text); const counts = new Map(); for (const line of matchLines) { const match = line.match(/^(.*?):\d+:/); if (!match) continue; const file = match[1] ?? "(unknown)"; counts.set(file, (counts.get(file) ?? 0) + 1); } const topFiles = [...counts.entries()] .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .slice(0, SUMMARY_TOP_FILES) .map(([file, count]) => `- ${file}: ${count}`); const sampleLines = matchLines.slice(0, SUMMARY_SAMPLE_LINES).map((line) => `- ${line}`); const notices: string[] = []; if (details?.matchLimitReached) notices.push(`hit match limit ${details.matchLimitReached}`); if (details?.truncation?.truncated) notices.push("hit byte truncation"); if (details?.linesTruncated) notices.push("some lines were shortened"); const parts = [ `Broad result summary: ${matchLines.length} match lines shown${notices.length ? `; ${notices.join(", ")}` : ""}.`, ]; if (topFiles.length > 0) { parts.push("Top files:"); parts.push(...topFiles); } if (sampleLines.length > 0) { parts.push("Sample matches:"); parts.push(...sampleLines); } parts.push("Refine pattern/path/glob or raise limit only if more breadth is worth the extra context cost."); return parts.join("\n"); } export default function safeGrep(pi: ExtensionAPI) { const base = createGrepToolDefinition(process.cwd()); pi.registerTool({ name: "grep", label: "grep", description: "Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Large results may be limited or summarized, and broad searches with context may be refused to avoid large noisy result sets.", promptSnippet: "Search file contents for patterns (respects .gitignore)", promptGuidelines: [ "Use grep to locate candidate files or lines first. Use read when surrounding code is needed. Avoid broad grep searches with context.", ], parameters: base.parameters, async execute(toolCallId, rawInput, signal, onUpdate, ctx) { const input: GrepToolInput = { ...rawInput, limit: Math.max(1, rawInput.limit ?? DEFAULT_LIMIT), }; if (isBroadContextSearch(input)) { return { content: [{ type: "text", text: buildBroadContextMessage(input) }], details: undefined, }; } const result = await base.execute(toolCallId, input, signal, onUpdate, ctx); const text = result.content.find((item) => item.type === "text")?.text ?? ""; const shouldSummarize = !!(result.details?.matchLimitReached || result.details?.truncation?.truncated); if (!shouldSummarize) return result; return { content: [{ type: "text", text: buildSummary(text, result.details) }], details: result.details, }; }, }); }