/** * KHAELOR * File: src/tools/grep.ts * Description: The grep tool — ripgrep when available, pure-JS fallback, capped structured results (TOOL_PROTOCOL §5). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import * as path from "node:path"; import { isWorkspaceError } from "../workspace/index.js"; import { globToRegExp, walkFiles } from "../workspace/walk.js"; import { displayPath, resolveToolPath } from "./format.js"; import type { ToolDefinition } from "./registry.js"; import type { ToolContext, ToolResult } from "./types.js"; export interface GrepInput { pattern: string; path?: string; include?: string; } const DESCRIPTION = "Fast content search across the repository using ripgrep. pattern is a regular expression (Rust " + "regex syntax; escape literal dots, parens, brackets). Results are grouped by file as " + "'line_number: line text', files ordered by most recently modified. At most 100 matching lines are " + "returned — if truncated, narrow the pattern or scope with path/include. Respects .gitignore. Use " + "this to locate code; use read to view full context around a match."; const MAX_MATCH_LINES = 100; const MAX_LINE_CHARS = 250; const RG_TIMEOUT_MS = 10_000; interface GrepMatch { /** Absolute file path. */ file: string; line: number; text: string; } export interface GrepToolOptions { /** "auto" (default) probes PATH for rg; "never" forces the pure-JS fallback. */ ripgrep?: "auto" | "never"; } function shellQuote(s: string): string { return `'${s.replace(/'/g, `'\\''`)}'`; } function capLine(text: string): string { return text.length > MAX_LINE_CHARS ? `${text.slice(0, MAX_LINE_CHARS)}…` : text; } function errorResult(content: string, title: string, startedAt: number): ToolResult { return { content, isError: true, metadata: { title, durationMs: Date.now() - startedAt } }; } async function ripgrepAvailable(ctx: ToolContext, cache: { value?: boolean }): Promise { if (cache.value !== undefined) return cache.value; try { const result = await ctx.workspace.exec({ cmd: "command -v rg", timeoutMs: 5000 }); cache.value = result.exitCode === 0; } catch { cache.value = false; } return cache.value; } async function runRipgrep( ctx: ToolContext, pattern: string, target: string, include: string | undefined, ): Promise<{ matches: GrepMatch[]; error?: string }> { let khaelorignoreFlag = ""; try { await ctx.workspace.readFile(path.join(ctx.workspace.cwd(), ".khaelorignore")); khaelorignoreFlag = `--ignore-file ${shellQuote(path.join(ctx.workspace.cwd(), ".khaelorignore"))} `; } catch { // No .khaelorignore. } const includeFlag = include !== undefined ? `--glob ${shellQuote(include)} ` : ""; const cmd = `rg --no-config -n --with-filename --no-heading -S --sortr modified ` + `${khaelorignoreFlag}${includeFlag}-e ${shellQuote(pattern)} ${shellQuote(target)}`; const result = await ctx.workspace.exec({ cmd, timeoutMs: RG_TIMEOUT_MS, signal: ctx.signal }); if (result.exitCode !== 0 && result.exitCode !== 1) { return { matches: [], error: result.stderr.trim() || "ripgrep failed" }; } const matches: GrepMatch[] = []; for (const line of result.stdout.split("\n")) { if (line.length === 0) continue; const parsed = /^(.+?):(\d+):(.*)$/.exec(line); if (parsed === null) continue; matches.push({ file: path.resolve(parsed[1] as string), line: Number(parsed[2]), text: parsed[3] as string, }); } return { matches }; } async function runFallback( ctx: ToolContext, pattern: string, target: string, include: string | undefined, ): Promise<{ matches: GrepMatch[]; probe: (flags: string) => RegExp }> { // Smart-case: case-insensitive unless the pattern contains an uppercase letter. const flags = /[A-Z]/.test(pattern) ? "" : "i"; const probe = (extra: string): RegExp => new RegExp(pattern, extra); const regex = new RegExp(pattern, flags); const includeRegex = include !== undefined ? globToRegExp(include) : undefined; let files: { path: string; mtimeMs: number }[]; let isFile = false; try { // A file target is searched directly. const content = await ctx.workspace.readFile(target); isFile = true; files = [{ path: target, mtimeMs: 0 }]; void content; } catch (cause) { if (isWorkspaceError(cause) && cause.code === "file-is-directory") { files = await walkFiles(target, { signal: ctx.signal }); } else if (isWorkspaceError(cause) && cause.code === "file-not-found") { throw cause; } else { files = [{ path: target, mtimeMs: 0 }]; isFile = true; } } if (!isFile) { files.sort((a, b) => b.mtimeMs - a.mtimeMs); if (includeRegex !== undefined) { files = files.filter((f) => includeRegex.test(path.relative(target, f.path).split(path.sep).join("/")), ); } } const matches: GrepMatch[] = []; for (const file of files) { if (ctx.signal.aborted) break; let content: string; try { content = await ctx.workspace.readFile(file.path); } catch { continue; // Binary / oversized / vanished files are skipped. } const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { if (regex.test(lines[i] as string)) { matches.push({ file: file.path, line: i + 1, text: lines[i] as string }); } } } return { matches, probe }; } function formatMatches( cwd: string, pattern: string, matches: GrepMatch[], totalMatches: number, ): string { const shown = matches.slice(0, MAX_MATCH_LINES); const fileCount = new Set(shown.map((m) => m.file)).size; const header = totalMatches > MAX_MATCH_LINES ? `Showing first ${MAX_MATCH_LINES} of ${totalMatches} matching lines for "${pattern}":` : `${totalMatches} ${totalMatches === 1 ? "match" : "matches"} in ${fileCount} ${fileCount === 1 ? "file" : "files"} for "${pattern}":`; const groups: string[] = []; let currentFile = ""; for (const match of shown) { if (match.file !== currentFile) { currentFile = match.file; groups.push(`\n${displayPath(cwd, match.file)}`); } groups.push(` ${match.line}: ${capLine(match.text)}`); } return `${header}\n${groups.join("\n")}`; } async function executeGrep( input: GrepInput, ctx: ToolContext, options: GrepToolOptions, rgCache: { value?: boolean }, ): Promise { const startedAt = Date.now(); const cwd = ctx.workspace.cwd(); const target = input.path !== undefined ? resolveToolPath(cwd, input.path) : cwd; const title = (suffix: string): string => `Search "${input.pattern}" · ${suffix}`; // Invalid regex is caught before execution (§5.2). try { new RegExp(input.pattern); } catch (cause) { return errorResult( `Invalid regular expression "${input.pattern}": ${cause instanceof Error ? cause.message : String(cause)}. Escape literal dots, parens, and brackets, then retry.`, title("invalid regex"), startedAt, ); } const useRg = options.ripgrep !== "never" && (await ripgrepAvailable(ctx, rgCache)); let matches: GrepMatch[]; let ciProbeHint: string | undefined; try { if (useRg) { const result = await runRipgrep(ctx, input.pattern, target, input.include); if (result.error !== undefined) { return errorResult( `Search failed: ${result.error}. Check the pattern and path, then retry.`, title("error"), startedAt, ); } matches = result.matches; } else { const result = await runFallback(ctx, input.pattern, target, input.include); matches = result.matches; if (matches.length === 0 && /[A-Z]/.test(input.pattern)) { // Cheap case-insensitive re-probe for the near-miss hint (§5.3). const ciRegex = new RegExp(input.pattern, "i"); const files = await walkFiles(target, { signal: ctx.signal }).catch(() => []); outer: for (const file of files) { try { const content = await ctx.workspace.readFile(file.path); for (const line of content.split("\n")) { const hit = ciRegex.exec(line); if (hit !== null) { ciProbeHint = hit[0]; break outer; } } } catch { continue; } } } } } catch (cause) { if (isWorkspaceError(cause) && cause.code === "file-not-found") { return errorResult( `Search path not found: ${target}. Check the path parameter.`, title("path not found"), startedAt, ); } return errorResult( `Search failed: ${cause instanceof Error ? cause.message : String(cause)}.`, title("error"), startedAt, ); } if (matches.length === 0) { const hint = ciProbeHint !== undefined ? `Check the regex (did you mean "${ciProbeHint}"?) or broaden the scope.` : "Check the regex or broaden the scope with path/include."; return { content: `No matches for "${input.pattern}" in ${target}. ${hint}`, metadata: { title: title("0 matches"), matches: 0, files: 0, durationMs: Date.now() - startedAt, }, }; } const totalMatches = matches.length; const fileCount = new Set(matches.map((m) => m.file)).size; let content = formatMatches(cwd, input.pattern, matches, totalMatches); let truncation; if (totalMatches > MAX_MATCH_LINES) { const fullListing = matches .map((m) => `${displayPath(cwd, m.file)}:${m.line}: ${m.text}`) .join("\n"); const spillPath = await ctx.spill("grep", fullListing); content += `\n[Results truncated. Full results: ${spillPath} — or use a more specific pattern, path, or include filter.]`; truncation = { originalBytes: Buffer.byteLength(fullListing, "utf8"), originalLines: totalMatches, shownHeadLines: MAX_MATCH_LINES, shownTailLines: 0, omittedLines: totalMatches - MAX_MATCH_LINES, spillPath, }; } return { content, metadata: { title: title( `${totalMatches} ${totalMatches === 1 ? "match" : "matches"} in ${fileCount} ${fileCount === 1 ? "file" : "files"}`, ), matches: totalMatches, files: fileCount, durationMs: Date.now() - startedAt, ...(truncation !== undefined ? { truncation } : {}), }, }; } /** Create the grep tool definition. */ export function createGrepTool(options: GrepToolOptions = {}): ToolDefinition { const rgCache: { value?: boolean } = {}; return { name: "grep", description: DESCRIPTION, capability: "file.read", inputSchema: { type: "object", properties: { pattern: { type: "string", description: "Regular expression to search for.", }, path: { type: "string", description: "Directory or file to search in. Defaults to the working directory.", }, include: { type: "string", description: 'Glob filter for file names, e.g. "*.ts" or "src/**/*.py".', }, }, required: ["pattern"], }, execute: (input, ctx) => executeGrep(input, ctx, options, rgCache), }; }