src / tools / search.ts
src / tools / search.ts
import { tool, type Tool } from "@lmstudio/sdk";
import { existsSync } from "fs";
import { readdir, readFile, stat } from "fs/promises";
import { join, relative, sep } from "path";
import { z } from "zod";
import { clamp, firstText, formatBytes, IGNORED_DIRS, type Workspace } from "../workspace";
/** Walk limits. These exist so a monorepo or a symlink farm cannot hang the plugin. */
const MAX_DEPTH = 12;
const MAX_DIR_ENTRIES = 20000;
const MAX_FILES_READ = 3000;
const MATCH_COLLECT_CAP = 5000;
const RESULT_CEILING = 500;
const MAX_OUTPUT_CHARS = 8000;
const MAX_DISPLAY_CHARS = 300;
/** Matching is done against at most this many chars of a line, so a minified bundle cannot stall a regex. */
const MAX_SCAN_LINE_CHARS = 5000;
interface WalkStats {
entriesSeen: number;
hitEntryBudget: boolean;
hitDepthLimit: boolean;
stoppedEarly: boolean;
}
/**
* Breadth-first file walk with a hard depth and entry budget. Iterative on
* purpose -- no recursion means no stack blowup on a pathological tree, and the
* budget means the plugin always answers. `visit` returns false to stop the walk.
*/
async function walkFiles(
startDir: string,
visit: (absPath: string, name: string) => Promise<boolean>,
): Promise<WalkStats> {
const stats: WalkStats = {
entriesSeen: 0,
hitEntryBudget: false,
hitDepthLimit: false,
stoppedEarly: false,
};
const queue: Array<{ dir: string; depth: number }> = [{ dir: startDir, depth: 0 }];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) break;
let entries;
try {
entries = await readdir(current.dir, { withFileTypes: true });
} catch {
continue; // Unreadable directory: skip it rather than failing the whole search.
}
for (const entry of entries) {
if (stats.entriesSeen >= MAX_DIR_ENTRIES) {
stats.hitEntryBudget = true;
return stats;
}
stats.entriesSeen++;
const full = join(current.dir, entry.name);
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
if (current.depth + 1 > MAX_DEPTH) {
stats.hitDepthLimit = true;
continue;
}
queue.push({ dir: full, depth: current.depth + 1 });
continue;
}
// Symlinks are deliberately not followed: a link could point outside the root.
if (!entry.isFile()) continue;
if (!(await visit(full, entry.name))) {
stats.stoppedEarly = true;
return stats;
}
}
}
return stats;
}
/**
* A walk that ran out of budget must never read as "there is nothing there" --
* a small model will take a bare "no matches" as proof and stop looking.
*/
function cutShortLead(stats: WalkStats, alsoCutShort = false): string {
if (!stats.hitEntryBudget && !stats.hitDepthLimit && !alsoCutShort) return "";
return "The search was cut short by the size limits below, so this does NOT prove nothing matches. ";
}
function walkNote(stats: WalkStats): string {
const notes: string[] = [];
if (stats.hitEntryBudget) {
notes.push(`stopped after ${MAX_DIR_ENTRIES} entries -- narrow the path to see the rest`);
}
if (stats.hitDepthLimit) notes.push(`did not descend past depth ${MAX_DEPTH}`);
return notes.length === 0 ? "" : `\n[walk ${notes.join("; ")}]`;
}
function toPosix(value: string): string {
return value.split(sep).join("/");
}
/**
* Root-relative path with forward slashes. Windows backslashes come back from
* the model inside JSON, where "src\tools" turns into a tab -- forward slashes
* survive the round trip and resolveInRoot accepts them either way.
*/
function relPosix(ws: Workspace, absPath: string): string {
return toPosix(ws.rel(absPath));
}
/** Translates a glob (**, *, ?, {a,b}) into an anchored, case-insensitive RegExp. */
function globToRegExp(pattern: string): RegExp {
const glob = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
let source = "";
let braceDepth = 0;
let i = 0;
while (i < glob.length) {
const ch = glob[i];
if (ch === "*") {
if (glob[i + 1] === "*") {
while (glob[i] === "*") i++;
if (glob[i] === "/") {
i++;
source += "(?:[^/]*/)*"; // "**/" also matches zero directories
} else {
source += ".*";
}
continue;
}
source += "[^/]*";
i++;
continue;
}
if (ch === "?") {
source += "[^/]";
i++;
continue;
}
if (ch === "{") {
source += "(?:";
braceDepth++;
i++;
continue;
}
if (ch === "}" && braceDepth > 0) {
source += ")";
braceDepth--;
i++;
continue;
}
if (ch === "," && braceDepth > 0) {
source += "|";
i++;
continue;
}
source += /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
i++;
}
// Close anything the model forgot to close, rather than throwing on its typo.
while (braceDepth > 0) {
source += ")";
braceDepth--;
}
return new RegExp(`^${source}$`, "i");
}
interface GlobMatcher {
test: (relPath: string) => boolean;
/** True when a bare pattern like "*.ts" was made recursive. */
promoted: boolean;
effective: string;
}
function makeGlobMatcher(pattern: string): GlobMatcher {
const cleaned = pattern.trim().replace(/\\/g, "/").replace(/^\.\//, "");
// A small model reaches for "*.ts" first and expects the whole tree, not just
// the top directory, so a separator-free pattern is made recursive.
const promoted = !cleaned.includes("/");
const effective = promoted ? `**/${cleaned}` : cleaned;
const regex = globToRegExp(effective);
return { test: (relPath) => regex.test(toPosix(relPath)), promoted, effective };
}
function boundedLimit(value: number, fallback: number): number {
if (!Number.isFinite(value)) return fallback;
return Math.min(Math.max(Math.trunc(value), 1), RESULT_CEILING);
}
function displayLine(line: string): string {
const trimmed = line.trim();
return trimmed.length > MAX_DISPLAY_CHARS ? `${trimmed.slice(0, MAX_DISPLAY_CHARS)}...` : trimmed;
}
function escapeForLiteralSearch(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
type OutputMode = "content" | "files" | "count";
function normalizeMode(raw: string): OutputMode | null {
const value = raw.trim().toLowerCase();
if (value === "" || value === "content" || value === "text" || value === "lines") return "content";
if (value === "files" || value === "file" || value === "paths" || value === "files_with_matches") {
return "files";
}
if (value === "count" || value === "counts") return "count";
return null;
}
export function searchTools(ws: Workspace): Tool[] {
const tools: Tool[] = [];
tools.push(
tool({
name: "glob_files",
description:
"Find files by their PATH using a glob pattern, newest-modified first. Use this when you " +
"know the shape of the name but not where it lives, e.g. '**/*.ts' for every TypeScript " +
"file, 'src/**/test_*.py' for test files under src, or '*.{json,yaml}' for config. " +
"Supports **, *, ? and {a,b}; matching is case-insensitive. A pattern with no '/' is " +
"searched recursively. Returns a list of paths with sizes -- no file contents.",
parameters: {
pattern: z
.string()
.describe("Glob pattern, e.g. '**/*.ts', 'src/**/*.test.js', '*.{json,md}'."),
path: z
.string()
.default(".")
.describe("Directory to search under, relative to the workspace root. '.' is the root."),
limit: z
.number()
.int()
.default(100)
.describe("Maximum number of paths to return. Extra matches are counted, not shown."),
},
implementation: async ({ pattern, path, limit }, ctx) => {
const startDir = ws.resolveInRoot(path); // containment errors propagate on purpose
try {
if (pattern.trim() === "") {
return "Error: pattern is empty. Pass something like '**/*.ts' or '*.json'.";
}
if (!existsSync(startDir)) {
return `Error: "${path}" does not exist. Use list_directory to see what is there.`;
}
const cap = boundedLimit(limit, 100);
const matcher = makeGlobMatcher(pattern);
ctx.status(`Globbing ${matcher.effective}`);
const found: Array<{ rel: string; mtimeMs: number; size: number }> = [];
let collectCapHit = false;
const stats = await walkFiles(startDir, async (abs) => {
const relRoot = relPosix(ws, abs);
if (!matcher.test(relRoot) && !matcher.test(relative(startDir, abs))) return true;
try {
const info = await stat(abs);
found.push({ rel: relRoot, mtimeMs: info.mtimeMs, size: info.size });
} catch {
// File vanished between readdir and stat; ignore it.
}
if (found.length >= MATCH_COLLECT_CAP) {
collectCapHit = true;
return false;
}
return true;
});
if (found.length === 0) {
return (
`${cutShortLead(stats)}No files match "${pattern}" under "${path}".` +
` Patterns look like '**/*.ts' or 'src/**/test_*.py'.` +
` If you only know part of the name, use find_file instead.${walkNote(stats)}`
);
}
found.sort((a, b) => b.mtimeMs - a.mtimeMs);
const shown = found.slice(0, cap);
const promotedNote = matcher.promoted ? ` (searched recursively as "${matcher.effective}")` : "";
const total = `${found.length}${collectCapHit ? "+" : ""}`;
const header =
found.length > shown.length
? `${total} file(s) match "${pattern}"${promotedNote}; showing the ${shown.length} most recently modified:`
: `${total} file(s) match "${pattern}"${promotedNote}, newest first:`;
const body = shown.map((hit) => `${hit.rel} (${formatBytes(hit.size)})`).join("\n");
return clamp(`${header}\n${body}${walkNote(stats)}`, MAX_OUTPUT_CHARS, "file list");
} catch (caught) {
const reason = caught instanceof Error ? caught.message : String(caught);
return `Error: glob_files failed (${reason}). Try a simpler pattern like '**/*.ts', or use find_file.`;
}
},
}),
);
tools.push(
tool({
name: "grep",
description:
"Search file CONTENTS with a real regular expression and return matching lines with their " +
"path and line number. Use this to find where something is defined or used, e.g. " +
"'function\\s+parseConfig' or 'TODO|FIXME'. Set output_mode to 'files' for just the file " +
"paths, or 'count' for per-file match counts, when the full lines would be too much. " +
"Binary files and files over the configured size limit are skipped.",
parameters: {
pattern: z
.string()
.default("")
.describe("JavaScript regular expression, e.g. 'class\\s+\\w+' or 'import .* from'."),
query: z.string().optional().describe("Alias for pattern."),
regex: z.string().optional().describe("Alias for pattern."),
path: z
.string()
.default(".")
.describe("Directory to search under, relative to the workspace root. '.' is the root."),
file_glob: z
.string()
.default("")
.describe("Optional file filter glob, e.g. '*.ts' or 'src/**/*.py'. Empty means all files."),
ignore_case: z.boolean().default(true).describe("Case-insensitive matching. Usually leave true."),
context_lines: z
.number()
.int()
.default(0)
.describe("Lines of surrounding context to show around each match, 0 to 5."),
output_mode: z
.string()
.default("content")
.describe("One of: 'content' (path:line: text), 'files' (paths only), 'count' (per-file counts)."),
limit: z
.number()
.int()
.default(100)
.describe("Maximum matches (or files, in files/count mode) to return."),
},
implementation: async (
{ pattern, query, regex, path, file_glob, ignore_case, context_lines, output_mode, limit },
ctx,
) => {
pattern = firstText(pattern, query, regex);
const startDir = ws.resolveInRoot(path); // containment errors propagate on purpose
try {
if (pattern.trim() === "") {
return "Error: pattern is empty. Pass the text or regex you want to find, e.g. 'parseConfig'.";
}
if (!existsSync(startDir)) {
return `Error: "${path}" does not exist. Use list_directory to see what is there.`;
}
let regex: RegExp;
try {
regex = new RegExp(pattern, ignore_case ? "i" : "");
} catch (caught) {
const reason = caught instanceof Error ? caught.message : String(caught);
return (
`Error: "${pattern}" is not a valid regular expression (${reason}). ` +
`If you meant to search for that text literally, escape it like this: ` +
`${escapeForLiteralSearch(pattern)}`
);
}
const mode = normalizeMode(output_mode);
const modeNote =
mode === null ? `[unknown output_mode "${output_mode}"; used "content"]\n` : "";
const effectiveMode: OutputMode = mode ?? "content";
const cap = boundedLimit(limit, 100);
const ctxLines = Math.min(Math.max(Math.trunc(context_lines) || 0, 0), 5);
const fileFilter = file_glob.trim() === "" ? null : makeGlobMatcher(file_glob);
ctx.status(`grep /${pattern}/ in ${relPosix(ws, startDir)}`);
const contentLines: string[] = [];
const fileLines: string[] = [];
let emitted = 0;
let totalMatches = 0;
let filesWithMatches = 0;
let filesRead = 0;
let skippedBinary = 0;
let skippedLarge = 0;
let hitReadBudget = false;
const stats = await walkFiles(startDir, async (abs) => {
if (filesRead >= MAX_FILES_READ) {
hitReadBudget = true;
return false;
}
const rel = relPosix(ws, abs);
if (fileFilter !== null && !fileFilter.test(rel) && !fileFilter.test(relative(startDir, abs))) {
return true;
}
let buffer: Buffer;
try {
const info = await stat(abs);
if (info.size > ws.maxBytes) {
skippedLarge++;
return true;
}
buffer = await readFile(abs);
} catch {
return true; // unreadable or vanished
}
// NUL in the first 8KB is the cheap, reliable binary tell.
if (buffer.subarray(0, 8192).includes(0)) {
skippedBinary++;
return true;
}
filesRead++;
const lines = buffer.toString("utf-8").split(/\r?\n/);
const hits: number[] = [];
for (let i = 0; i < lines.length; i++) {
const scannable =
lines[i].length > MAX_SCAN_LINE_CHARS ? lines[i].slice(0, MAX_SCAN_LINE_CHARS) : lines[i];
if (regex.test(scannable)) hits.push(i);
}
if (hits.length === 0) return true;
filesWithMatches++;
totalMatches += hits.length;
if (effectiveMode === "files") {
fileLines.push(rel);
emitted++;
return emitted < cap;
}
if (effectiveMode === "count") {
fileLines.push(`${rel}: ${hits.length}`);
emitted++;
return emitted < cap;
}
const hitSet = new Set(hits);
let lastPrinted = -1;
for (const idx of hits) {
if (emitted >= cap) break;
const from = Math.max(0, idx - ctxLines);
const to = Math.min(lines.length - 1, idx + ctxLines);
if (ctxLines > 0 && lastPrinted >= 0 && from > lastPrinted + 1) contentLines.push("--");
for (let i = Math.max(from, lastPrinted + 1); i <= to; i++) {
const marker = hitSet.has(i) ? ":" : "-";
contentLines.push(`${rel}:${i + 1}${marker} ${displayLine(lines[i])}`);
}
lastPrinted = Math.max(lastPrinted, to);
emitted++;
}
return emitted < cap;
});
const skips: string[] = [];
if (skippedBinary > 0) skips.push(`${skippedBinary} binary file(s) skipped`);
if (skippedLarge > 0) {
skips.push(`${skippedLarge} file(s) over ${ws.maxFileSizeKb} KB skipped`);
}
if (hitReadBudget) skips.push(`stopped after reading ${MAX_FILES_READ} files`);
const skipNote = skips.length === 0 ? "" : `\n[${skips.join("; ")}]`;
if (totalMatches === 0) {
return (
`${modeNote}No matches for /${pattern}/ in ${filesRead} file(s) under "${path}".` +
(fileFilter === null ? "" : ` File filter: ${file_glob}.`) +
` Try a shorter pattern, or ignore_case=true.${skipNote}${walkNote(stats)}`
);
}
const truncated = emitted >= cap || stats.stoppedEarly;
if (effectiveMode === "files") {
const header = truncated
? `${filesWithMatches}+ file(s) contain /${pattern}/; showing ${fileLines.length}:`
: `${filesWithMatches} file(s) contain /${pattern}/:`;
return clamp(`${modeNote}${header}\n${fileLines.join("\n")}${skipNote}${walkNote(stats)}`, MAX_OUTPUT_CHARS, "file list");
}
if (effectiveMode === "count") {
const header = truncated
? `Match counts per file (showing ${fileLines.length}, more exist):`
: `${totalMatches} match(es) across ${filesWithMatches} file(s):`;
return clamp(`${modeNote}${header}\n${fileLines.join("\n")}${skipNote}${walkNote(stats)}`, MAX_OUTPUT_CHARS, "count list");
}
const header = truncated
? `First ${emitted} match(es) for /${pattern}/ (more exist -- narrow the path or file_glob):`
: `${totalMatches} match(es) for /${pattern}/ in ${filesWithMatches} file(s):`;
return clamp(
`${modeNote}${header}\n${contentLines.join("\n")}${skipNote}${walkNote(stats)}`,
MAX_OUTPUT_CHARS,
"grep output",
);
} catch (caught) {
const reason = caught instanceof Error ? caught.message : String(caught);
return `Error: grep failed (${reason}). Try a narrower path, a simpler pattern, or output_mode='files'.`;
}
},
}),
);
tools.push(
tool({
name: "find_file",
description:
"Find files whose name contains a piece of text, anywhere in the workspace. Use this when " +
"you know roughly what a file is called but not where it is and do not want to write a " +
"glob, e.g. 'config', 'readme', '.env'. Best matches first. Returns paths only.",
parameters: {
name_fragment: z
.string()
.default("")
.describe("Part of the file name to look for. Case-insensitive, e.g. 'config' or 'test'."),
name: z.string().optional().describe("Alias for name_fragment."),
query: z.string().optional().describe("Alias for name_fragment."),
limit: z.number().int().default(50).describe("Maximum number of paths to return."),
},
implementation: async ({ name_fragment, name, query, limit }, ctx) => {
try {
const needle = firstText(name_fragment, name, query).trim().toLowerCase();
if (needle === "") {
return "Error: name_fragment is empty. Pass part of a file name, e.g. 'config'.";
}
const cap = boundedLimit(limit, 50);
ctx.status(`Looking for files named like "${needle}"`);
const found: Array<{ rel: string; score: number; mtimeMs: number; size: number }> = [];
const stats = await walkFiles(ws.root, async (abs, name) => {
const lower = name.toLowerCase();
const rel = relPosix(ws, abs);
let score: number;
if (lower === needle) score = 0;
else if (lower.startsWith(needle)) score = 1;
else if (lower.includes(needle)) score = 2;
else if (rel.toLowerCase().includes(needle)) score = 3;
else return true;
try {
const info = await stat(abs);
found.push({ rel, score, mtimeMs: info.mtimeMs, size: info.size });
} catch {
// File vanished between readdir and stat; ignore it.
}
return found.length < MATCH_COLLECT_CAP;
});
if (found.length === 0) {
return (
`No file name contains "${name_fragment}".` +
` Try a shorter fragment, or use grep to search inside files.${walkNote(stats)}`
);
}
found.sort((a, b) => (a.score === b.score ? b.mtimeMs - a.mtimeMs : a.score - b.score));
const shown = found.slice(0, cap);
const header =
found.length > shown.length
? `${found.length} file(s) match "${name_fragment}"; showing the best ${shown.length}:`
: `${found.length} file(s) match "${name_fragment}":`;
const body = shown.map((hit) => `${hit.rel} (${formatBytes(hit.size)})`).join("\n");
return clamp(`${header}\n${body}${walkNote(stats)}`, MAX_OUTPUT_CHARS, "file list");
} catch (caught) {
const reason = caught instanceof Error ? caught.message : String(caught);
return `Error: find_file failed (${reason}). Try a shorter name_fragment.`;
}
},
}),
);
return tools;
}
import { tool, type Tool } from "@lmstudio/sdk";
import { existsSync } from "fs";
import { readdir, readFile, stat } from "fs/promises";
import { join, relative, sep } from "path";
import { z } from "zod";
import { clamp, firstText, formatBytes, IGNORED_DIRS, type Workspace } from "../workspace";
/** Walk limits. These exist so a monorepo or a symlink farm cannot hang the plugin. */
const MAX_DEPTH = 12;
const MAX_DIR_ENTRIES = 20000;
const MAX_FILES_READ = 3000;
const MATCH_COLLECT_CAP = 5000;
const RESULT_CEILING = 500;
const MAX_OUTPUT_CHARS = 8000;
const MAX_DISPLAY_CHARS = 300;
/** Matching is done against at most this many chars of a line, so a minified bundle cannot stall a regex. */
const MAX_SCAN_LINE_CHARS = 5000;
interface WalkStats {
entriesSeen: number;
hitEntryBudget: boolean;
hitDepthLimit: boolean;
stoppedEarly: boolean;
}
/**
* Breadth-first file walk with a hard depth and entry budget. Iterative on
* purpose -- no recursion means no stack blowup on a pathological tree, and the
* budget means the plugin always answers. `visit` returns false to stop the walk.
*/
async function walkFiles(
startDir: string,
visit: (absPath: string, name: string) => Promise<boolean>,
): Promise<WalkStats> {
const stats: WalkStats = {
entriesSeen: 0,
hitEntryBudget: false,
hitDepthLimit: false,
stoppedEarly: false,
};
const queue: Array<{ dir: string; depth: number }> = [{ dir: startDir, depth: 0 }];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) break;
let entries;
try {
entries = await readdir(current.dir, { withFileTypes: true });
} catch {
continue; // Unreadable directory: skip it rather than failing the whole search.
}
for (const entry of entries) {
if (stats.entriesSeen >= MAX_DIR_ENTRIES) {
stats.hitEntryBudget = true;
return stats;
}
stats.entriesSeen++;
const full = join(current.dir, entry.name);
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
if (current.depth + 1 > MAX_DEPTH) {
stats.hitDepthLimit = true;
continue;
}
queue.push({ dir: full, depth: current.depth + 1 });
continue;
}
// Symlinks are deliberately not followed: a link could point outside the root.
if (!entry.isFile()) continue;
if (!(await visit(full, entry.name))) {
stats.stoppedEarly = true;
return stats;
}
}
}
return stats;
}
/**
* A walk that ran out of budget must never read as "there is nothing there" --
* a small model will take a bare "no matches" as proof and stop looking.
*/
function cutShortLead(stats: WalkStats, alsoCutShort = false): string {
if (!stats.hitEntryBudget && !stats.hitDepthLimit && !alsoCutShort) return "";
return "The search was cut short by the size limits below, so this does NOT prove nothing matches. ";
}
function walkNote(stats: WalkStats): string {
const notes: string[] = [];
if (stats.hitEntryBudget) {
notes.push(`stopped after ${MAX_DIR_ENTRIES} entries -- narrow the path to see the rest`);
}
if (stats.hitDepthLimit) notes.push(`did not descend past depth ${MAX_DEPTH}`);
return notes.length === 0 ? "" : `\n[walk ${notes.join("; ")}]`;
}
function toPosix(value: string): string {
return value.split(sep).join("/");
}
/**
* Root-relative path with forward slashes. Windows backslashes come back from
* the model inside JSON, where "src\tools" turns into a tab -- forward slashes
* survive the round trip and resolveInRoot accepts them either way.
*/
function relPosix(ws: Workspace, absPath: string): string {
return toPosix(ws.rel(absPath));
}
/** Translates a glob (**, *, ?, {a,b}) into an anchored, case-insensitive RegExp. */
function globToRegExp(pattern: string): RegExp {
const glob = pattern.replace(/\\/g, "/").replace(/^\.\//, "");
let source = "";
let braceDepth = 0;
let i = 0;
while (i < glob.length) {
const ch = glob[i];
if (ch === "*") {
if (glob[i + 1] === "*") {
while (glob[i] === "*") i++;
if (glob[i] === "/") {
i++;
source += "(?:[^/]*/)*"; // "**/" also matches zero directories
} else {
source += ".*";
}
continue;
}
source += "[^/]*";
i++;
continue;
}
if (ch === "?") {
source += "[^/]";
i++;
continue;
}
if (ch === "{") {
source += "(?:";
braceDepth++;
i++;
continue;
}
if (ch === "}" && braceDepth > 0) {
source += ")";
braceDepth--;
i++;
continue;
}
if (ch === "," && braceDepth > 0) {
source += "|";
i++;
continue;
}
source += /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
i++;
}
// Close anything the model forgot to close, rather than throwing on its typo.
while (braceDepth > 0) {
source += ")";
braceDepth--;
}
return new RegExp(`^${source}$`, "i");
}
interface GlobMatcher {
test: (relPath: string) => boolean;
/** True when a bare pattern like "*.ts" was made recursive. */
promoted: boolean;
effective: string;
}
function makeGlobMatcher(pattern: string): GlobMatcher {
const cleaned = pattern.trim().replace(/\\/g, "/").replace(/^\.\//, "");
// A small model reaches for "*.ts" first and expects the whole tree, not just
// the top directory, so a separator-free pattern is made recursive.
const promoted = !cleaned.includes("/");
const effective = promoted ? `**/${cleaned}` : cleaned;
const regex = globToRegExp(effective);
return { test: (relPath) => regex.test(toPosix(relPath)), promoted, effective };
}
function boundedLimit(value: number, fallback: number): number {
if (!Number.isFinite(value)) return fallback;
return Math.min(Math.max(Math.trunc(value), 1), RESULT_CEILING);
}
function displayLine(line: string): string {
const trimmed = line.trim();
return trimmed.length > MAX_DISPLAY_CHARS ? `${trimmed.slice(0, MAX_DISPLAY_CHARS)}...` : trimmed;
}
function escapeForLiteralSearch(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
type OutputMode = "content" | "files" | "count";
function normalizeMode(raw: string): OutputMode | null {
const value = raw.trim().toLowerCase();
if (value === "" || value === "content" || value === "text" || value === "lines") return "content";
if (value === "files" || value === "file" || value === "paths" || value === "files_with_matches") {
return "files";
}
if (value === "count" || value === "counts") return "count";
return null;
}
export function searchTools(ws: Workspace): Tool[] {
const tools: Tool[] = [];
tools.push(
tool({
name: "glob_files",
description:
"Find files by their PATH using a glob pattern, newest-modified first. Use this when you " +
"know the shape of the name but not where it lives, e.g. '**/*.ts' for every TypeScript " +
"file, 'src/**/test_*.py' for test files under src, or '*.{json,yaml}' for config. " +
"Supports **, *, ? and {a,b}; matching is case-insensitive. A pattern with no '/' is " +
"searched recursively. Returns a list of paths with sizes -- no file contents.",
parameters: {
pattern: z
.string()
.describe("Glob pattern, e.g. '**/*.ts', 'src/**/*.test.js', '*.{json,md}'."),
path: z
.string()
.default(".")
.describe("Directory to search under, relative to the workspace root. '.' is the root."),
limit: z
.number()
.int()
.default(100)
.describe("Maximum number of paths to return. Extra matches are counted, not shown."),
},
implementation: async ({ pattern, path, limit }, ctx) => {
const startDir = ws.resolveInRoot(path); // containment errors propagate on purpose
try {
if (pattern.trim() === "") {
return "Error: pattern is empty. Pass something like '**/*.ts' or '*.json'.";
}
if (!existsSync(startDir)) {
return `Error: "${path}" does not exist. Use list_directory to see what is there.`;
}
const cap = boundedLimit(limit, 100);
const matcher = makeGlobMatcher(pattern);
ctx.status(`Globbing ${matcher.effective}`);
const found: Array<{ rel: string; mtimeMs: number; size: number }> = [];
let collectCapHit = false;
const stats = await walkFiles(startDir, async (abs) => {
const relRoot = relPosix(ws, abs);
if (!matcher.test(relRoot) && !matcher.test(relative(startDir, abs))) return true;
try {
const info = await stat(abs);
found.push({ rel: relRoot, mtimeMs: info.mtimeMs, size: info.size });
} catch {
// File vanished between readdir and stat; ignore it.
}
if (found.length >= MATCH_COLLECT_CAP) {
collectCapHit = true;
return false;
}
return true;
});
if (found.length === 0) {
return (
`${cutShortLead(stats)}No files match "${pattern}" under "${path}".` +
` Patterns look like '**/*.ts' or 'src/**/test_*.py'.` +
` If you only know part of the name, use find_file instead.${walkNote(stats)}`
);
}
found.sort((a, b) => b.mtimeMs - a.mtimeMs);
const shown = found.slice(0, cap);
const promotedNote = matcher.promoted ? ` (searched recursively as "${matcher.effective}")` : "";
const total = `${found.length}${collectCapHit ? "+" : ""}`;
const header =
found.length > shown.length
? `${total} file(s) match "${pattern}"${promotedNote}; showing the ${shown.length} most recently modified:`
: `${total} file(s) match "${pattern}"${promotedNote}, newest first:`;
const body = shown.map((hit) => `${hit.rel} (${formatBytes(hit.size)})`).join("\n");
return clamp(`${header}\n${body}${walkNote(stats)}`, MAX_OUTPUT_CHARS, "file list");
} catch (caught) {
const reason = caught instanceof Error ? caught.message : String(caught);
return `Error: glob_files failed (${reason}). Try a simpler pattern like '**/*.ts', or use find_file.`;
}
},
}),
);
tools.push(
tool({
name: "grep",
description:
"Search file CONTENTS with a real regular expression and return matching lines with their " +
"path and line number. Use this to find where something is defined or used, e.g. " +
"'function\\s+parseConfig' or 'TODO|FIXME'. Set output_mode to 'files' for just the file " +
"paths, or 'count' for per-file match counts, when the full lines would be too much. " +
"Binary files and files over the configured size limit are skipped.",
parameters: {
pattern: z
.string()
.default("")
.describe("JavaScript regular expression, e.g. 'class\\s+\\w+' or 'import .* from'."),
query: z.string().optional().describe("Alias for pattern."),
regex: z.string().optional().describe("Alias for pattern."),
path: z
.string()
.default(".")
.describe("Directory to search under, relative to the workspace root. '.' is the root."),
file_glob: z
.string()
.default("")
.describe("Optional file filter glob, e.g. '*.ts' or 'src/**/*.py'. Empty means all files."),
ignore_case: z.boolean().default(true).describe("Case-insensitive matching. Usually leave true."),
context_lines: z
.number()
.int()
.default(0)
.describe("Lines of surrounding context to show around each match, 0 to 5."),
output_mode: z
.string()
.default("content")
.describe("One of: 'content' (path:line: text), 'files' (paths only), 'count' (per-file counts)."),
limit: z
.number()
.int()
.default(100)
.describe("Maximum matches (or files, in files/count mode) to return."),
},
implementation: async (
{ pattern, query, regex, path, file_glob, ignore_case, context_lines, output_mode, limit },
ctx,
) => {
pattern = firstText(pattern, query, regex);
const startDir = ws.resolveInRoot(path); // containment errors propagate on purpose
try {
if (pattern.trim() === "") {
return "Error: pattern is empty. Pass the text or regex you want to find, e.g. 'parseConfig'.";
}
if (!existsSync(startDir)) {
return `Error: "${path}" does not exist. Use list_directory to see what is there.`;
}
let regex: RegExp;
try {
regex = new RegExp(pattern, ignore_case ? "i" : "");
} catch (caught) {
const reason = caught instanceof Error ? caught.message : String(caught);
return (
`Error: "${pattern}" is not a valid regular expression (${reason}). ` +
`If you meant to search for that text literally, escape it like this: ` +
`${escapeForLiteralSearch(pattern)}`
);
}
const mode = normalizeMode(output_mode);
const modeNote =
mode === null ? `[unknown output_mode "${output_mode}"; used "content"]\n` : "";
const effectiveMode: OutputMode = mode ?? "content";
const cap = boundedLimit(limit, 100);
const ctxLines = Math.min(Math.max(Math.trunc(context_lines) || 0, 0), 5);
const fileFilter = file_glob.trim() === "" ? null : makeGlobMatcher(file_glob);
ctx.status(`grep /${pattern}/ in ${relPosix(ws, startDir)}`);
const contentLines: string[] = [];
const fileLines: string[] = [];
let emitted = 0;
let totalMatches = 0;
let filesWithMatches = 0;
let filesRead = 0;
let skippedBinary = 0;
let skippedLarge = 0;
let hitReadBudget = false;
const stats = await walkFiles(startDir, async (abs) => {
if (filesRead >= MAX_FILES_READ) {
hitReadBudget = true;
return false;
}
const rel = relPosix(ws, abs);
if (fileFilter !== null && !fileFilter.test(rel) && !fileFilter.test(relative(startDir, abs))) {
return true;
}
let buffer: Buffer;
try {
const info = await stat(abs);
if (info.size > ws.maxBytes) {
skippedLarge++;
return true;
}
buffer = await readFile(abs);
} catch {
return true; // unreadable or vanished
}
// NUL in the first 8KB is the cheap, reliable binary tell.
if (buffer.subarray(0, 8192).includes(0)) {
skippedBinary++;
return true;
}
filesRead++;
const lines = buffer.toString("utf-8").split(/\r?\n/);
const hits: number[] = [];
for (let i = 0; i < lines.length; i++) {
const scannable =
lines[i].length > MAX_SCAN_LINE_CHARS ? lines[i].slice(0, MAX_SCAN_LINE_CHARS) : lines[i];
if (regex.test(scannable)) hits.push(i);
}
if (hits.length === 0) return true;
filesWithMatches++;
totalMatches += hits.length;
if (effectiveMode === "files") {
fileLines.push(rel);
emitted++;
return emitted < cap;
}
if (effectiveMode === "count") {
fileLines.push(`${rel}: ${hits.length}`);
emitted++;
return emitted < cap;
}
const hitSet = new Set(hits);
let lastPrinted = -1;
for (const idx of hits) {
if (emitted >= cap) break;
const from = Math.max(0, idx - ctxLines);
const to = Math.min(lines.length - 1, idx + ctxLines);
if (ctxLines > 0 && lastPrinted >= 0 && from > lastPrinted + 1) contentLines.push("--");
for (let i = Math.max(from, lastPrinted + 1); i <= to; i++) {
const marker = hitSet.has(i) ? ":" : "-";
contentLines.push(`${rel}:${i + 1}${marker} ${displayLine(lines[i])}`);
}
lastPrinted = Math.max(lastPrinted, to);
emitted++;
}
return emitted < cap;
});
const skips: string[] = [];
if (skippedBinary > 0) skips.push(`${skippedBinary} binary file(s) skipped`);
if (skippedLarge > 0) {
skips.push(`${skippedLarge} file(s) over ${ws.maxFileSizeKb} KB skipped`);
}
if (hitReadBudget) skips.push(`stopped after reading ${MAX_FILES_READ} files`);
const skipNote = skips.length === 0 ? "" : `\n[${skips.join("; ")}]`;
if (totalMatches === 0) {
return (
`${modeNote}No matches for /${pattern}/ in ${filesRead} file(s) under "${path}".` +
(fileFilter === null ? "" : ` File filter: ${file_glob}.`) +
` Try a shorter pattern, or ignore_case=true.${skipNote}${walkNote(stats)}`
);
}
const truncated = emitted >= cap || stats.stoppedEarly;
if (effectiveMode === "files") {
const header = truncated
? `${filesWithMatches}+ file(s) contain /${pattern}/; showing ${fileLines.length}:`
: `${filesWithMatches} file(s) contain /${pattern}/:`;
return clamp(`${modeNote}${header}\n${fileLines.join("\n")}${skipNote}${walkNote(stats)}`, MAX_OUTPUT_CHARS, "file list");
}
if (effectiveMode === "count") {
const header = truncated
? `Match counts per file (showing ${fileLines.length}, more exist):`
: `${totalMatches} match(es) across ${filesWithMatches} file(s):`;
return clamp(`${modeNote}${header}\n${fileLines.join("\n")}${skipNote}${walkNote(stats)}`, MAX_OUTPUT_CHARS, "count list");
}
const header = truncated
? `First ${emitted} match(es) for /${pattern}/ (more exist -- narrow the path or file_glob):`
: `${totalMatches} match(es) for /${pattern}/ in ${filesWithMatches} file(s):`;
return clamp(
`${modeNote}${header}\n${contentLines.join("\n")}${skipNote}${walkNote(stats)}`,
MAX_OUTPUT_CHARS,
"grep output",
);
} catch (caught) {
const reason = caught instanceof Error ? caught.message : String(caught);
return `Error: grep failed (${reason}). Try a narrower path, a simpler pattern, or output_mode='files'.`;
}
},
}),
);
tools.push(
tool({
name: "find_file",
description:
"Find files whose name contains a piece of text, anywhere in the workspace. Use this when " +
"you know roughly what a file is called but not where it is and do not want to write a " +
"glob, e.g. 'config', 'readme', '.env'. Best matches first. Returns paths only.",
parameters: {
name_fragment: z
.string()
.default("")
.describe("Part of the file name to look for. Case-insensitive, e.g. 'config' or 'test'."),
name: z.string().optional().describe("Alias for name_fragment."),
query: z.string().optional().describe("Alias for name_fragment."),
limit: z.number().int().default(50).describe("Maximum number of paths to return."),
},
implementation: async ({ name_fragment, name, query, limit }, ctx) => {
try {
const needle = firstText(name_fragment, name, query).trim().toLowerCase();
if (needle === "") {
return "Error: name_fragment is empty. Pass part of a file name, e.g. 'config'.";
}
const cap = boundedLimit(limit, 50);
ctx.status(`Looking for files named like "${needle}"`);
const found: Array<{ rel: string; score: number; mtimeMs: number; size: number }> = [];
const stats = await walkFiles(ws.root, async (abs, name) => {
const lower = name.toLowerCase();
const rel = relPosix(ws, abs);
let score: number;
if (lower === needle) score = 0;
else if (lower.startsWith(needle)) score = 1;
else if (lower.includes(needle)) score = 2;
else if (rel.toLowerCase().includes(needle)) score = 3;
else return true;
try {
const info = await stat(abs);
found.push({ rel, score, mtimeMs: info.mtimeMs, size: info.size });
} catch {
// File vanished between readdir and stat; ignore it.
}
return found.length < MATCH_COLLECT_CAP;
});
if (found.length === 0) {
return (
`No file name contains "${name_fragment}".` +
` Try a shorter fragment, or use grep to search inside files.${walkNote(stats)}`
);
}
found.sort((a, b) => (a.score === b.score ? b.mtimeMs - a.mtimeMs : a.score - b.score));
const shown = found.slice(0, cap);
const header =
found.length > shown.length
? `${found.length} file(s) match "${name_fragment}"; showing the best ${shown.length}:`
: `${found.length} file(s) match "${name_fragment}":`;
const body = shown.map((hit) => `${hit.rel} (${formatBytes(hit.size)})`).join("\n");
return clamp(`${header}\n${body}${walkNote(stats)}`, MAX_OUTPUT_CHARS, "file list");
} catch (caught) {
const reason = caught instanceof Error ? caught.message : String(caught);
return `Error: find_file failed (${reason}). Try a shorter name_fragment.`;
}
},
}),
);
return tools;
}