u
This commit is contained in:
@@ -81,3 +81,35 @@ export function listDir(dir, { seek = 0, limit = 50, recursive = false } = {}) {
|
|||||||
const truncated = total - (seek + sliced.length);
|
const truncated = total - (seek + sliced.length);
|
||||||
return { entries: sliced, total, truncated: Math.max(0, truncated) };
|
return { entries: sliced, total, truncated: Math.max(0, truncated) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively walk the *files* under `dir`, honoring .gitignore rules
|
||||||
|
* (including nested .gitignore files) plus the hardcoded node_modules rule.
|
||||||
|
* Returns an array of { abs, rel } where `rel` is relative to ROOT_DIR using
|
||||||
|
* forward slashes. Symlinks are skipped to avoid loops.
|
||||||
|
*/
|
||||||
|
export function walkFiles(dir) {
|
||||||
|
const results = [];
|
||||||
|
const walk = (current) => {
|
||||||
|
// Rebuild per directory so nested .gitignore files apply to their subtree.
|
||||||
|
const ig = buildIgnoreFilter(current);
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = path.join(current, e.name);
|
||||||
|
const rel = path.relative(ROOT_DIR, full).replace(/\\/g, '/');
|
||||||
|
if (ig.ignores(e.isDirectory() ? rel + '/' : rel)) continue;
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
walk(full);
|
||||||
|
} else if (e.isFile()) {
|
||||||
|
results.push({ abs: full, rel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(dir);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ ${dirSection}
|
|||||||
## Available Tools & Guidelines
|
## Available Tools & Guidelines
|
||||||
- \`read_file(filename, seek, limit)\`: Inspect file contents and line numbers before modifying them.
|
- \`read_file(filename, seek, limit)\`: Inspect file contents and line numbers before modifying them.
|
||||||
- \`list_files(dirname, recursive, seek, limit)\`: Explore workspace folders (respects .gitignore).
|
- \`list_files(dirname, recursive, seek, limit)\`: Explore workspace folders (respects .gitignore).
|
||||||
|
- \`ripgrep(pattern, path, glob, ignore_case, literal, max_results)\`: Search file contents (regex by default) across the workspace; respects .gitignore and skips binary files. Returns matches with 0-indexed line numbers (usable directly as read_file's seek).
|
||||||
- \`patch(operations)\`: Apply file updates or create files.
|
- \`patch(operations)\`: Apply file updates or create files.
|
||||||
- Create or overwrite: \`{"file": "path", "search": null, "replace": "content"}\` (parent directories auto-created).
|
- Create or overwrite: \`{"file": "path", "search": null, "replace": "content"}\` (parent directories auto-created).
|
||||||
- Search & replace: \`{"file": "path", "search": "exact old text", "replace": "new text"}\`. Replaces the FIRST match.
|
- Search & replace: \`{"file": "path", "search": "exact old text", "replace": "new text"}\`. Replaces the FIRST match.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { listFilesTool } from './list-files.js';
|
import { listFilesTool } from './list-files.js';
|
||||||
import { readFileTool } from './read-file.js';
|
import { readFileTool } from './read-file.js';
|
||||||
|
import { ripgrepTool } from './ripgrep.js';
|
||||||
import { patchTool } from './patch.js';
|
import { patchTool } from './patch.js';
|
||||||
import { mkdirTool } from './mkdir.js';
|
import { mkdirTool } from './mkdir.js';
|
||||||
import { rmdirTool } from './rmdir.js';
|
import { rmdirTool } from './rmdir.js';
|
||||||
@@ -15,6 +16,7 @@ import { askUserTool } from './ask-user.js';
|
|||||||
export {
|
export {
|
||||||
listFilesTool,
|
listFilesTool,
|
||||||
readFileTool,
|
readFileTool,
|
||||||
|
ripgrepTool,
|
||||||
patchTool,
|
patchTool,
|
||||||
mkdirTool,
|
mkdirTool,
|
||||||
rmdirTool,
|
rmdirTool,
|
||||||
@@ -25,6 +27,7 @@ export {
|
|||||||
export const tools = [
|
export const tools = [
|
||||||
listFilesTool,
|
listFilesTool,
|
||||||
readFileTool,
|
readFileTool,
|
||||||
|
ripgrepTool,
|
||||||
patchTool,
|
patchTool,
|
||||||
mkdirTool,
|
mkdirTool,
|
||||||
rmdirTool,
|
rmdirTool,
|
||||||
|
|||||||
274
src/tools/ripgrep.js
Normal file
274
src/tools/ripgrep.js
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ripgrep — recursive, .gitignore-aware content search (pure JS, no binaries).
|
||||||
|
*
|
||||||
|
* One tool per file: this module owns the search tool plus its small helpers.
|
||||||
|
* Directory traversal lives in src/dir-listing.js (walkFiles).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { defineTool, ToolError } from 'llmorbiter';
|
||||||
|
import { resolveSafe } from '../sandbox.js';
|
||||||
|
import { ROOT_DIR } from '../config.js';
|
||||||
|
import { walkFiles } from '../dir-listing.js';
|
||||||
|
|
||||||
|
// Files larger than this are skipped during a search to keep things responsive.
|
||||||
|
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
// Longest slice of a matching line echoed back in `text`. Longer lines are capped
|
||||||
|
// (non-silently: see `textTruncated` on each match and `linesTruncated` in the result).
|
||||||
|
const MAX_MATCH_TEXT_LEN = 300;
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Escape a string so it can be matched literally inside a RegExp. */
|
||||||
|
function escapeRegExp(str) {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a simple glob into a RegExp anchored at both ends.
|
||||||
|
* ** -> any characters (including "/")
|
||||||
|
* * -> any characters except "/"
|
||||||
|
* ? -> a single character except "/"
|
||||||
|
* \\ -> escapes the next character
|
||||||
|
* All other characters are matched literally (no brace/char-class expansion).
|
||||||
|
*/
|
||||||
|
function globToRegExp(pattern) {
|
||||||
|
let re = '';
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
const c = pattern[i];
|
||||||
|
if (c === '*') {
|
||||||
|
if (pattern[i + 1] === '*') {
|
||||||
|
re += '.*';
|
||||||
|
i++;
|
||||||
|
// Let "**/" also match zero directories.
|
||||||
|
if (pattern[i + 1] === '/') i++;
|
||||||
|
} else {
|
||||||
|
re += '[^/]*';
|
||||||
|
}
|
||||||
|
} else if (c === '?') {
|
||||||
|
re += '[^/]';
|
||||||
|
} else if ('\\^$.|+()[]{}'.includes(c)) {
|
||||||
|
re += '\\' + c;
|
||||||
|
} else {
|
||||||
|
re += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new RegExp('^' + re + '$');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a predicate testing a workspace-relative path (forward slashes) against
|
||||||
|
* a glob. A glob without "/" is also matched against the basename, mirroring
|
||||||
|
* ripgrep's `-g '*.ext'` behaviour.
|
||||||
|
*/
|
||||||
|
function makeGlobMatcher(pattern) {
|
||||||
|
const rx = globToRegExp(pattern);
|
||||||
|
const basenameOnly = !pattern.includes('/');
|
||||||
|
return (rel) => {
|
||||||
|
if (rx.test(rel)) return true;
|
||||||
|
if (basenameOnly) {
|
||||||
|
const base = rel.slice(rel.lastIndexOf('/') + 1);
|
||||||
|
return rx.test(base);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a workspace-relative path (forward slashes) lives inside a
|
||||||
|
* dot-directory — i.e. any *directory* segment starts with ".". Traversal into
|
||||||
|
* such directories (".git", ".cache", …) is suppressed by default so their
|
||||||
|
* internals never appear in search results. The final segment is the file name
|
||||||
|
* and is not checked, so dot-files like ".env" remain searchable.
|
||||||
|
*/
|
||||||
|
function isInDotDirectory(rel) {
|
||||||
|
const segments = rel.split('/');
|
||||||
|
for (let i = 0; i < segments.length - 1; i++) {
|
||||||
|
if (segments[i].startsWith('.')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tool definition ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const ripgrepTool = defineTool({
|
||||||
|
name: 'ripgrep',
|
||||||
|
description:
|
||||||
|
'Search file contents across the workspace for a pattern (regex by default). ' +
|
||||||
|
'Recurses into directories, honors .gitignore rules, skips binary/large files, ' +
|
||||||
|
'and ignores dot-directories (e.g. ".git") by default. ' +
|
||||||
|
'Returns matches as { file, line, column, text }, where "line" is 0-indexed so it can be ' +
|
||||||
|
'passed straight to read_file\'s seek. Use glob to restrict which files are searched. ' +
|
||||||
|
'Long matching lines are capped in "text" (surfaced via each match\'s textTruncated flag ' +
|
||||||
|
'and the result\'s linesTruncated count).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ['pattern'],
|
||||||
|
properties: {
|
||||||
|
pattern: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Regex (or literal string when literal=true) to search for.',
|
||||||
|
},
|
||||||
|
path: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'File or directory to search, relative to the workspace root. Default: "."',
|
||||||
|
},
|
||||||
|
glob: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Only search files whose path matches this glob, e.g. "*.js" or "src/**/*.ts".',
|
||||||
|
},
|
||||||
|
ignore_case: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Case-insensitive search. Default: false.',
|
||||||
|
},
|
||||||
|
literal: {
|
||||||
|
type: 'boolean',
|
||||||
|
description: 'Treat "pattern" as a literal string instead of a regular expression. Default: false.',
|
||||||
|
},
|
||||||
|
max_results: {
|
||||||
|
type: 'number',
|
||||||
|
description: 'Maximum number of matches to return. Default: 100.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
invoke: async ({
|
||||||
|
pattern,
|
||||||
|
path: searchPath = '.',
|
||||||
|
glob,
|
||||||
|
ignore_case = false,
|
||||||
|
literal = false,
|
||||||
|
max_results = 100,
|
||||||
|
}) => {
|
||||||
|
if (typeof pattern !== 'string' || pattern.length === 0) {
|
||||||
|
throw new ToolError('"pattern" must be a non-empty string.');
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(max_results) || max_results < 1) {
|
||||||
|
throw new ToolError(`"max_results" must be a positive integer (received ${max_results}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const absTarget = resolveSafe(searchPath);
|
||||||
|
if (!fs.existsSync(absTarget)) {
|
||||||
|
throw new ToolError(`Path not found: "${searchPath}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile the search pattern once (global so matchAll can iterate hits).
|
||||||
|
let regex;
|
||||||
|
try {
|
||||||
|
regex = new RegExp(literal ? escapeRegExp(pattern) : pattern, ignore_case ? 'gi' : 'g');
|
||||||
|
} catch (err) {
|
||||||
|
throw new ToolError(`Invalid pattern: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const globMatch = glob ? makeGlobMatcher(glob) : null;
|
||||||
|
|
||||||
|
// Gather candidate files: a single file, or a recursive walk of a directory.
|
||||||
|
const targetStat = fs.statSync(absTarget);
|
||||||
|
const candidates = targetStat.isFile()
|
||||||
|
? [{ abs: absTarget, rel: path.relative(ROOT_DIR, absTarget).replace(/\\/g, '/') }]
|
||||||
|
: walkFiles(absTarget);
|
||||||
|
|
||||||
|
// Drop anything inside a dot-directory (e.g. ".git/…") by default, then apply
|
||||||
|
// the glob up front so the eligible-file count is known even when the scan
|
||||||
|
// stops early on max_results. Dot-files (".env", ".gitignore") are kept.
|
||||||
|
const scoped = candidates.filter(
|
||||||
|
({ rel }) => !isInDotDirectory(rel) && (globMatch ? globMatch(rel) : true),
|
||||||
|
);
|
||||||
|
|
||||||
|
const matches = [];
|
||||||
|
let filesSearched = 0;
|
||||||
|
let filesSkipped = 0;
|
||||||
|
let linesTruncated = 0;
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
search:
|
||||||
|
for (const { abs, rel } of scoped) {
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
size = fs.statSync(abs).size;
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (size > MAX_FILE_BYTES) {
|
||||||
|
filesSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = fs.readFileSync(abs, 'utf8');
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Skip binary files (a NUL byte is a good heuristic).
|
||||||
|
if (content.includes('\u0000')) {
|
||||||
|
filesSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
filesSearched++;
|
||||||
|
const lines = content.split('\n');
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const rawLine = lines[i];
|
||||||
|
const lineText = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
|
||||||
|
let shown;
|
||||||
|
let capped = false;
|
||||||
|
for (const m of lineText.matchAll(regex)) {
|
||||||
|
if (matches.length >= max_results) {
|
||||||
|
// We found one more than requested: stop and flag truncation.
|
||||||
|
truncated = true;
|
||||||
|
break search;
|
||||||
|
}
|
||||||
|
// Cap long lines once per line, only when a match actually needs it.
|
||||||
|
if (shown === undefined) {
|
||||||
|
capped = lineText.length > MAX_MATCH_TEXT_LEN;
|
||||||
|
shown = capped ? lineText.slice(0, MAX_MATCH_TEXT_LEN) + '…' : lineText;
|
||||||
|
}
|
||||||
|
const entry = { file: rel, line: i, column: m.index, text: shown };
|
||||||
|
if (capped) {
|
||||||
|
entry.textTruncated = true;
|
||||||
|
entry.lineLength = lineText.length;
|
||||||
|
}
|
||||||
|
matches.push(entry);
|
||||||
|
}
|
||||||
|
if (capped) linesTruncated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
pattern,
|
||||||
|
path: searchPath,
|
||||||
|
glob: glob ?? null,
|
||||||
|
ignoreCase: ignore_case,
|
||||||
|
literal,
|
||||||
|
filesSearched,
|
||||||
|
filesEligible: scoped.length,
|
||||||
|
matches,
|
||||||
|
returned: matches.length,
|
||||||
|
};
|
||||||
|
if (filesSkipped > 0) {
|
||||||
|
result.skippedFiles = filesSkipped;
|
||||||
|
result.skippedNote = `${filesSkipped} file(s) skipped (binary or larger than ${MAX_FILE_BYTES} bytes).`;
|
||||||
|
}
|
||||||
|
if (linesTruncated > 0) {
|
||||||
|
result.linesTruncated = linesTruncated;
|
||||||
|
result.textTruncationNote =
|
||||||
|
`${linesTruncated} matching line(s) longer than ${MAX_MATCH_TEXT_LEN} chars were capped in "text" ` +
|
||||||
|
`(marked with a trailing "…" and textTruncated: true; "column" still refers to the full line).`;
|
||||||
|
}
|
||||||
|
if (truncated) {
|
||||||
|
result.truncated = true;
|
||||||
|
result.truncationNote =
|
||||||
|
`Showing the first ${max_results} matches. Refine the pattern or raise max_results to see more. ` +
|
||||||
|
`(Search stopped early: filesSearched counts only files scanned so far; ` +
|
||||||
|
`${scoped.length} file(s) were eligible before scanning.)`;
|
||||||
|
} else {
|
||||||
|
result.totalMatches = matches.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user