SPB Git

spb/khaelor Public

KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.

TypeScript 82.9% HTML 14.9% CSS 1.1% JavaScript 0.7%
11.1 KB · 341 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: src/tools/grep.ts4 * Description: The grep tool — ripgrep when available, pure-JS fallback, capped structured results (TOOL_PROTOCOL §5).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import * as path from "node:path";11import { isWorkspaceError } from "../workspace/index.js";12import { globToRegExp, walkFiles } from "../workspace/walk.js";13import { displayPath, resolveToolPath } from "./format.js";14import type { ToolDefinition } from "./registry.js";15import type { ToolContext, ToolResult } from "./types.js";1617export interface GrepInput {18  pattern: string;19  path?: string;20  include?: string;21}2223const DESCRIPTION =24  "Fast content search across the repository using ripgrep. pattern is a regular expression (Rust " +25  "regex syntax; escape literal dots, parens, brackets). Results are grouped by file as " +26  "'line_number: line text', files ordered by most recently modified. At most 100 matching lines are " +27  "returned — if truncated, narrow the pattern or scope with path/include. Respects .gitignore. Use " +28  "this to locate code; use read to view full context around a match.";2930const MAX_MATCH_LINES = 100;31const MAX_LINE_CHARS = 250;32const RG_TIMEOUT_MS = 10_000;3334interface GrepMatch {35  /** Absolute file path. */36  file: string;37  line: number;38  text: string;39}4041export interface GrepToolOptions {42  /** "auto" (default) probes PATH for rg; "never" forces the pure-JS fallback. */43  ripgrep?: "auto" | "never";44}4546function shellQuote(s: string): string {47  return `'${s.replace(/'/g, `'\\''`)}'`;48}4950function capLine(text: string): string {51  return text.length > MAX_LINE_CHARS ? `${text.slice(0, MAX_LINE_CHARS)}…` : text;52}5354function errorResult(content: string, title: string, startedAt: number): ToolResult {55  return { content, isError: true, metadata: { title, durationMs: Date.now() - startedAt } };56}5758async function ripgrepAvailable(ctx: ToolContext, cache: { value?: boolean }): Promise<boolean> {59  if (cache.value !== undefined) return cache.value;60  try {61    const result = await ctx.workspace.exec({ cmd: "command -v rg", timeoutMs: 5000 });62    cache.value = result.exitCode === 0;63  } catch {64    cache.value = false;65  }66  return cache.value;67}6869async function runRipgrep(70  ctx: ToolContext,71  pattern: string,72  target: string,73  include: string | undefined,74): Promise<{ matches: GrepMatch[]; error?: string }> {75  let khaelorignoreFlag = "";76  try {77    await ctx.workspace.readFile(path.join(ctx.workspace.cwd(), ".khaelorignore"));78    khaelorignoreFlag = `--ignore-file ${shellQuote(path.join(ctx.workspace.cwd(), ".khaelorignore"))} `;79  } catch {80    // No .khaelorignore.81  }82  const includeFlag = include !== undefined ? `--glob ${shellQuote(include)} ` : "";83  const cmd =84    `rg --no-config -n --with-filename --no-heading -S --sortr modified ` +85    `${khaelorignoreFlag}${includeFlag}-e ${shellQuote(pattern)} ${shellQuote(target)}`;86  const result = await ctx.workspace.exec({ cmd, timeoutMs: RG_TIMEOUT_MS, signal: ctx.signal });87  if (result.exitCode !== 0 && result.exitCode !== 1) {88    return { matches: [], error: result.stderr.trim() || "ripgrep failed" };89  }90  const matches: GrepMatch[] = [];91  for (const line of result.stdout.split("\n")) {92    if (line.length === 0) continue;93    const parsed = /^(.+?):(\d+):(.*)$/.exec(line);94    if (parsed === null) continue;95    matches.push({96      file: path.resolve(parsed[1] as string),97      line: Number(parsed[2]),98      text: parsed[3] as string,99    });100  }101  return { matches };102}103104async function runFallback(105  ctx: ToolContext,106  pattern: string,107  target: string,108  include: string | undefined,109): Promise<{ matches: GrepMatch[]; probe: (flags: string) => RegExp }> {110  // Smart-case: case-insensitive unless the pattern contains an uppercase letter.111  const flags = /[A-Z]/.test(pattern) ? "" : "i";112  const probe = (extra: string): RegExp => new RegExp(pattern, extra);113  const regex = new RegExp(pattern, flags);114  const includeRegex = include !== undefined ? globToRegExp(include) : undefined;115116  let files: { path: string; mtimeMs: number }[];117  let isFile = false;118  try {119    // A file target is searched directly.120    const content = await ctx.workspace.readFile(target);121    isFile = true;122    files = [{ path: target, mtimeMs: 0 }];123    void content;124  } catch (cause) {125    if (isWorkspaceError(cause) && cause.code === "file-is-directory") {126      files = await walkFiles(target, { signal: ctx.signal });127    } else if (isWorkspaceError(cause) && cause.code === "file-not-found") {128      throw cause;129    } else {130      files = [{ path: target, mtimeMs: 0 }];131      isFile = true;132    }133  }134135  if (!isFile) {136    files.sort((a, b) => b.mtimeMs - a.mtimeMs);137    if (includeRegex !== undefined) {138      files = files.filter((f) =>139        includeRegex.test(path.relative(target, f.path).split(path.sep).join("/")),140      );141    }142  }143144  const matches: GrepMatch[] = [];145  for (const file of files) {146    if (ctx.signal.aborted) break;147    let content: string;148    try {149      content = await ctx.workspace.readFile(file.path);150    } catch {151      continue; // Binary / oversized / vanished files are skipped.152    }153    const lines = content.split("\n");154    for (let i = 0; i < lines.length; i++) {155      if (regex.test(lines[i] as string)) {156        matches.push({ file: file.path, line: i + 1, text: lines[i] as string });157      }158    }159  }160  return { matches, probe };161}162163function formatMatches(164  cwd: string,165  pattern: string,166  matches: GrepMatch[],167  totalMatches: number,168): string {169  const shown = matches.slice(0, MAX_MATCH_LINES);170  const fileCount = new Set(shown.map((m) => m.file)).size;171  const header =172    totalMatches > MAX_MATCH_LINES173      ? `Showing first ${MAX_MATCH_LINES} of ${totalMatches} matching lines for "${pattern}":`174      : `${totalMatches} ${totalMatches === 1 ? "match" : "matches"} in ${fileCount} ${fileCount === 1 ? "file" : "files"} for "${pattern}":`;175176  const groups: string[] = [];177  let currentFile = "";178  for (const match of shown) {179    if (match.file !== currentFile) {180      currentFile = match.file;181      groups.push(`\n${displayPath(cwd, match.file)}`);182    }183    groups.push(`  ${match.line}: ${capLine(match.text)}`);184  }185  return `${header}\n${groups.join("\n")}`;186}187188async function executeGrep(189  input: GrepInput,190  ctx: ToolContext,191  options: GrepToolOptions,192  rgCache: { value?: boolean },193): Promise<ToolResult> {194  const startedAt = Date.now();195  const cwd = ctx.workspace.cwd();196  const target = input.path !== undefined ? resolveToolPath(cwd, input.path) : cwd;197  const title = (suffix: string): string => `Search "${input.pattern}" · ${suffix}`;198199  // Invalid regex is caught before execution (§5.2).200  try {201    new RegExp(input.pattern);202  } catch (cause) {203    return errorResult(204      `Invalid regular expression "${input.pattern}": ${cause instanceof Error ? cause.message : String(cause)}. Escape literal dots, parens, and brackets, then retry.`,205      title("invalid regex"),206      startedAt,207    );208  }209210  const useRg = options.ripgrep !== "never" && (await ripgrepAvailable(ctx, rgCache));211212  let matches: GrepMatch[];213  let ciProbeHint: string | undefined;214  try {215    if (useRg) {216      const result = await runRipgrep(ctx, input.pattern, target, input.include);217      if (result.error !== undefined) {218        return errorResult(219          `Search failed: ${result.error}. Check the pattern and path, then retry.`,220          title("error"),221          startedAt,222        );223      }224      matches = result.matches;225    } else {226      const result = await runFallback(ctx, input.pattern, target, input.include);227      matches = result.matches;228      if (matches.length === 0 && /[A-Z]/.test(input.pattern)) {229        // Cheap case-insensitive re-probe for the near-miss hint (§5.3).230        const ciRegex = new RegExp(input.pattern, "i");231        const files = await walkFiles(target, { signal: ctx.signal }).catch(() => []);232        outer: for (const file of files) {233          try {234            const content = await ctx.workspace.readFile(file.path);235            for (const line of content.split("\n")) {236              const hit = ciRegex.exec(line);237              if (hit !== null) {238                ciProbeHint = hit[0];239                break outer;240              }241            }242          } catch {243            continue;244          }245        }246      }247    }248  } catch (cause) {249    if (isWorkspaceError(cause) && cause.code === "file-not-found") {250      return errorResult(251        `Search path not found: ${target}. Check the path parameter.`,252        title("path not found"),253        startedAt,254      );255    }256    return errorResult(257      `Search failed: ${cause instanceof Error ? cause.message : String(cause)}.`,258      title("error"),259      startedAt,260    );261  }262263  if (matches.length === 0) {264    const hint =265      ciProbeHint !== undefined266        ? `Check the regex (did you mean "${ciProbeHint}"?) or broaden the scope.`267        : "Check the regex or broaden the scope with path/include.";268    return {269      content: `No matches for "${input.pattern}" in ${target}. ${hint}`,270      metadata: {271        title: title("0 matches"),272        matches: 0,273        files: 0,274        durationMs: Date.now() - startedAt,275      },276    };277  }278279  const totalMatches = matches.length;280  const fileCount = new Set(matches.map((m) => m.file)).size;281  let content = formatMatches(cwd, input.pattern, matches, totalMatches);282  let truncation;283  if (totalMatches > MAX_MATCH_LINES) {284    const fullListing = matches285      .map((m) => `${displayPath(cwd, m.file)}:${m.line}: ${m.text}`)286      .join("\n");287    const spillPath = await ctx.spill("grep", fullListing);288    content += `\n[Results truncated. Full results: ${spillPath} — or use a more specific pattern, path, or include filter.]`;289    truncation = {290      originalBytes: Buffer.byteLength(fullListing, "utf8"),291      originalLines: totalMatches,292      shownHeadLines: MAX_MATCH_LINES,293      shownTailLines: 0,294      omittedLines: totalMatches - MAX_MATCH_LINES,295      spillPath,296    };297  }298299  return {300    content,301    metadata: {302      title: title(303        `${totalMatches} ${totalMatches === 1 ? "match" : "matches"} in ${fileCount} ${fileCount === 1 ? "file" : "files"}`,304      ),305      matches: totalMatches,306      files: fileCount,307      durationMs: Date.now() - startedAt,308      ...(truncation !== undefined ? { truncation } : {}),309    },310  };311}312313/** Create the grep tool definition. */314export function createGrepTool(options: GrepToolOptions = {}): ToolDefinition<GrepInput> {315  const rgCache: { value?: boolean } = {};316  return {317    name: "grep",318    description: DESCRIPTION,319    capability: "file.read",320    inputSchema: {321      type: "object",322      properties: {323        pattern: {324          type: "string",325          description: "Regular expression to search for.",326        },327        path: {328          type: "string",329          description: "Directory or file to search in. Defaults to the working directory.",330        },331        include: {332          type: "string",333          description: 'Glob filter for file names, e.g. "*.ts" or "src/**/*.py".',334        },335      },336      required: ["pattern"],337    },338    execute: (input, ctx) => executeGrep(input, ctx, options, rgCache),339  };340}341