// src/tools/search/grep.ts // Copyright (C) 2025 DTP Technologies, LLC // All Rights Reserved import fs from "node:fs/promises"; import path from "node:path"; import type { ToolDefinition } from "../../lib/ai-client.js"; import { DtpTool, type ToolArguments, type ToolContext, } from "../../lib/tool.js"; import { ChatSessionMode } from "../../models/chat-session.js"; import HostMonitorService from "../../services/host-monitor.js"; const MAX_MATCHES = 500; const MAX_FILE_SIZE = 1024 * 1024; // 1MB class GrepTool extends DtpTool { get name(): string { return "GrepTool"; } get slug(): string { return "grep"; } get metadata() { return { name: this.definition.function.name || "grep", category: "search", tags: ["search", "find", "regex", "content", "grep"], modes: [ ChatSessionMode.Plan, ChatSessionMode.Build, ChatSessionMode.Test, ChatSessionMode.Ship, ChatSessionMode.Develop, ], }; } public definition: ToolDefinition = { type: "function", function: { name: "grep", description: "Search file contents using regular expressions. Returns matching lines with file paths and line numbers. Supports case-insensitive matching and context lines.", parameters: { type: "object", properties: { pattern: { type: "string", description: "Regular expression pattern to search for. Use standard JavaScript regex syntax.", }, path: { type: "string", description: "File or directory path to search in. Can be a specific file or directory.", }, caseInsensitive: { type: "boolean", description: "Case insensitive search (default: false).", }, contextBefore: { type: "number", description: "Number of lines to show before each match (default: 0).", }, contextAfter: { type: "number", description: "Number of lines to show after each match (default: 0).", }, }, required: ["pattern", "path"], }, }, }; public async execute( _context: ToolContext, args: ToolArguments, ): Promise { const pattern = args.pattern as string | undefined; const searchPath = args.path as string | undefined; const caseInsensitive = (args.caseInsensitive as boolean) || false; const contextBefore = (args.contextBefore as number) || 0; const contextAfter = (args.contextAfter as number) || 0; if (!pattern || pattern.trim().length === 0) { return this.error("MISSING_PARAMETER", "pattern is required.", { parameter: "pattern", recoveryHint: "Provide a regex pattern to search for.", }); } if (!searchPath || searchPath.trim().length === 0) { return this.error("MISSING_PARAMETER", "path is required.", { parameter: "path", recoveryHint: "Provide a file or directory path to search in.", }); } let regex: RegExp; try { regex = new RegExp(pattern, caseInsensitive ? "i" : ""); } catch (error) { return this.error( "INVALID_PARAMETER", `Invalid regex pattern: ${error instanceof Error ? error.message : String(error)}`, { parameter: "pattern" }, ); } try { const stat = await fs.stat(searchPath); const matches: Array<{ file: string; line: number; content: string; }> = []; if (stat.isFile()) { const fileMatches = await this.searchFile( searchPath, regex, contextBefore, contextAfter, ); matches.push(...fileMatches); } else if (stat.isDirectory()) { await this.searchDirectory( searchPath, regex, matches, contextBefore, contextAfter, ); } const limited = matches.slice(0, MAX_MATCHES); let output = ""; if (matches.length === 0) { output = `No matches found for "${pattern}" in ${searchPath}`; } else { const truncated = matches.length > MAX_MATCHES; output = `Found ${matches.length} match(es) for "${pattern}" in ${searchPath}${ truncated ? ` (showing first ${MAX_MATCHES})` : "" }:\n\n`; for (const match of limited) { output += `${match.file}:${match.line}: ${match.content}\n`; } if (truncated) { output += `\n... and ${matches.length - MAX_MATCHES} more matches`; } } const byteCount = Buffer.byteLength(output, "utf-8"); HostMonitorService.toolCall(byteCount); return this.success( { matches: limited, total: matches.length, truncated: matches.length > MAX_MATCHES, }, output, ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); return this.error( "OPERATION_FAILED", `Failed to search: ${errorMessage}`, ); } } private async searchFile( filePath: string, regex: RegExp, contextBefore: number, contextAfter: number, ): Promise> { const matches: Array<{ file: string; line: number; content: string }> = []; let stat; try { stat = await fs.stat(filePath); } catch { return matches; } if (stat.size > MAX_FILE_SIZE) { return matches; } let content: string; try { content = await fs.readFile(filePath, "utf-8"); } catch { return matches; } const lines = content.split("\n"); for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line !== undefined && regex.test(line)) { const startLine = Math.max(0, i - contextBefore); const endLine = Math.min(lines.length - 1, i + contextAfter); for (let j = startLine; j <= endLine; j++) { const contextLine = lines[j]; if (contextLine !== undefined) { matches.push({ file: filePath, line: j + 1, content: contextLine, }); } } regex.lastIndex = 0; } } return matches; } private async searchDirectory( dir: string, regex: RegExp, matches: Array<{ file: string; line: number; content: string }>, contextBefore: number, contextAfter: number, ): Promise { let entries; try { entries = await fs.readdir(dir, { withFileTypes: true }); } catch { return; } for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { if ( entry.name !== "node_modules" && entry.name !== ".git" && entry.name !== "dist" && entry.name !== "build" ) { await this.searchDirectory( fullPath, regex, matches, contextBefore, contextAfter, ); } } else if (entry.isFile()) { const ext = path.extname(entry.name); if ( ext === ".ts" || ext === ".tsx" || ext === ".js" || ext === ".jsx" || ext === ".json" || ext === ".md" || ext === ".txt" ) { const fileMatches = await this.searchFile( fullPath, regex, contextBefore, contextAfter, ); matches.push(...fileMatches); } } if (matches.length >= MAX_MATCHES * 2) { return; } } } } export default new GrepTool();