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%
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