/** * KHAELOR * File: src/tools/glob.ts * Description: The glob tool — file discovery by name pattern, mtime-ordered, ignore-aware (TOOL_PROTOCOL §6). * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import * as path from "node:path"; import { globToRegExp, walkFiles } from "../workspace/walk.js"; import { resolveToolPath } from "./format.js"; import type { ToolDefinition } from "./registry.js"; import type { ToolContext, ToolResult } from "./types.js"; export interface GlobInput { pattern: string; path?: string; } const DESCRIPTION = 'Find files by name pattern, e.g. "**/*.ts" or "src/**/config.*". Returns matching file paths ' + "ordered by most recently modified, at most 100. Respects .gitignore and .khaelorignore. Use this " + "to discover file layout; use grep to search file contents."; const MAX_RESULTS = 100; const BUILTIN_IGNORES = ["node_modules", ".git", "dist"]; async function executeGlob(input: GlobInput, ctx: ToolContext): Promise { const startedAt = Date.now(); const cwd = ctx.workspace.cwd(); const root = input.path !== undefined ? resolveToolPath(cwd, input.path) : cwd; let regex: RegExp; try { regex = globToRegExp(input.pattern); } catch { return { content: `Invalid glob pattern "${input.pattern}". Use patterns like "**/*.ts" or "src/**/config.*".`, isError: true, metadata: { title: `Glob ${input.pattern} · invalid`, durationMs: Date.now() - startedAt }, }; } // Built-in noise dirs stay ignored unless the pattern explicitly targets them (§6.2). const builtinIgnores = BUILTIN_IGNORES.filter((dir) => !input.pattern.includes(dir)); const files = await walkFiles(root, { builtinIgnores, signal: ctx.signal }); const matched = files.filter((f) => regex.test(path.relative(root, f.path).split(path.sep).join("/")), ); matched.sort((a, b) => b.mtimeMs - a.mtimeMs); const relPaths = matched.map((f) => path.relative(root, f.path).split(path.sep).join("/")); if (relPaths.length === 0) { // Nearest-extension hint when the pattern names a literal extension (§6.3). let hint = ""; const extMatch = /\.([A-Za-z0-9]+)$/.exec(input.pattern); if (extMatch !== null) { const stem = input.pattern.slice(0, -((extMatch[1] as string).length)); const stemRegex = ((): RegExp | undefined => { try { return globToRegExp(`${stem}*`); } catch { return undefined; } })(); if (stemRegex !== undefined) { const counts = new Map(); for (const f of files) { const rel = path.relative(root, f.path).split(path.sep).join("/"); if (!stemRegex.test(rel)) continue; const ext = path.extname(rel); if (ext.length === 0) continue; counts.set(ext, (counts.get(ext) ?? 0) + 1); } const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]; if (best !== undefined) { hint = ` Nearest existing extension: ${best[0]} (${best[1]} ${best[1] === 1 ? "file" : "files"}).`; } } } return { content: `No files match "${input.pattern}" under ${root}.${hint}`, metadata: { title: `Glob ${input.pattern} · 0 files`, files: 0, durationMs: Date.now() - startedAt, }, }; } const total = relPaths.length; const shown = relPaths.slice(0, MAX_RESULTS); const lines = [ `${total} ${total === 1 ? "file matches" : "files match"} "${input.pattern}" (newest first):`, ...shown, ]; let truncation; if (total > MAX_RESULTS) { const spillPath = await ctx.spill("glob", relPaths.join("\n")); lines.push(`[Showing ${MAX_RESULTS} of ${total}. Full list: ${spillPath} — or narrow the pattern.]`); truncation = { originalBytes: Buffer.byteLength(relPaths.join("\n"), "utf8"), originalLines: total, shownHeadLines: MAX_RESULTS, shownTailLines: 0, omittedLines: total - MAX_RESULTS, spillPath, }; } return { content: lines.join("\n"), metadata: { title: `Glob ${input.pattern} · ${total} ${total === 1 ? "file" : "files"}`, files: total, durationMs: Date.now() - startedAt, ...(truncation !== undefined ? { truncation } : {}), }, }; } /** Create the glob tool definition. */ export function createGlobTool(): ToolDefinition { return { name: "glob", description: DESCRIPTION, capability: "file.read", inputSchema: { type: "object", properties: { pattern: { type: "string", description: "Glob pattern to match file paths against.", }, path: { type: "string", description: "Directory to search in. Defaults to the working directory.", }, }, required: ["pattern"], }, execute: executeGlob, }; }