294 lines
7.7 KiB
TypeScript
294 lines
7.7 KiB
TypeScript
// 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<string> {
|
|
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<Array<{ file: string; line: number; content: string }>> {
|
|
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<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 (
|
|
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();
|