gadget/gadget-drone/src/tools/system/grep.ts
Rob Colbert a4cff5be69 agent toolbox refactor and updates
- reorganized tools into better named directories
- added the file & shell tools
2026-05-10 07:35:09 -04:00

221 lines
7.1 KiB
TypeScript

// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import fs from "node:fs/promises";
import path from "node:path";
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
const MAX_MATCHES = 500;
const MAX_FILE_SIZE = 1024 * 1024;
// TODO: Consider broadening this list or making it user-configurable
// Currently limited to common source/doc extensions for performance.
const SEARCH_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt"]);
export class GrepTool extends DroneTool {
get name(): string {
return "grep";
}
get category(): string {
return "system";
}
public definition: IToolDefinition = {
type: "function",
function: {
name: this.name,
description: "Search project 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, relative to the project root.",
},
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(args: IToolArguments, logger: IAiLogger): Promise<string> {
const pattern = typeof args.pattern === "string" ? args.pattern : undefined;
const searchPath = typeof args.path === "string" ? args.path : undefined;
const caseInsensitive = args.caseInsensitive === true;
const contextBefore = typeof args.contextBefore === "number" ? args.contextBefore : 0;
const contextAfter = typeof args.contextAfter === "number" ? args.contextAfter : 0;
if (!pattern || pattern.trim().length === 0) {
return toolError({
code: "MISSING_PARAMETER",
message: "pattern is required.",
parameter: "pattern",
recoveryHint: "Provide a regex pattern to search for.",
});
}
if (!searchPath || searchPath.trim().length === 0) {
return toolError({
code: "MISSING_PARAMETER",
message: "path is required.",
parameter: "path",
recoveryHint: "Provide a file or directory path to search in.",
});
}
const resolved = resolveProjectPath(this.toolbox, searchPath);
if (typeof resolved === "string") return resolved;
let regex: RegExp;
try {
regex = new RegExp(pattern, caseInsensitive ? "i" : "");
} catch (error) {
return toolError({
code: "INVALID_PARAMETER",
message: `Invalid regex pattern: ${error instanceof Error ? error.message : String(error)}`,
parameter: "pattern",
});
}
const projectRoot = getProjectRoot(this.toolbox);
try {
const stat = await fs.stat(resolved.absolutePath);
const matches: Array<{ file: string; line: number; content: string }> = [];
if (stat.isFile()) {
const fileMatches = await this.searchFile(resolved.absolutePath, regex, contextBefore, contextAfter);
matches.push(...fileMatches);
} else if (stat.isDirectory()) {
await this.searchDirectory(resolved.absolutePath, regex, matches, contextBefore, contextAfter);
}
const limited = matches.slice(0, MAX_MATCHES);
if (matches.length === 0) {
return `No matches found for "${pattern}" in ${resolved.displayPath}`;
}
const truncated = matches.length > MAX_MATCHES;
const lines: string[] = [
`Found ${matches.length} match(es) for "${pattern}" in ${resolved.displayPath}${truncated ? ` (showing first ${MAX_MATCHES})` : ""}:`,
"",
];
for (const match of limited) {
const displayFile = projectRoot ? path.relative(projectRoot, match.file) : match.file;
lines.push(`${displayFile}:${match.line}: ${match.content}`);
}
if (truncated) {
lines.push("", `... and ${matches.length - MAX_MATCHES} more matches`);
}
return lines.join("\n");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error("failed to search", { path: resolved.displayPath, pattern, error: errorMessage });
return toolError({
code: "OPERATION_FAILED",
message: `Failed to search: ${errorMessage}`,
});
}
}
private async searchFile(
filePath: string,
regex: RegExp,
contextBefore: number,
contextAfter: number,
): Promise<Array<{ file: string; line: number; content: string }>> {
const matches: Array<{ file: string; line: number; content: string }> = [];
try {
const stat = await fs.stat(filePath);
if (stat.size > MAX_FILE_SIZE) return matches;
} catch {
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<void> {
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 (SEARCH_EXTENSIONS.has(ext)) {
const fileMatches = await this.searchFile(fullPath, regex, contextBefore, contextAfter);
matches.push(...fileMatches);
}
}
if (matches.length >= MAX_MATCHES * 2) return;
}
}
}