multica/src/agent/tools/glob.ts
Jiayuan 95e6ae439d
feat(agent): add glob tool for file pattern matching (#18)
Add a glob tool that allows agents to find files matching glob patterns.
Features:
- Supports standard glob patterns (e.g., **/*.ts, src/**/*.{js,jsx})
- Results sorted by modification time (most recent first)
- Configurable result limit and ignore patterns
- Default ignores for node_modules, .git, dist, etc.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 04:33:01 +08:00

133 lines
3.9 KiB
TypeScript

import { Type } from "@sinclair/typebox";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import fg from "fast-glob";
import * as path from "path";
import * as fs from "fs/promises";
const GlobSchema = Type.Object({
pattern: Type.String({
description: "Glob pattern to match files (e.g., '**/*.ts', 'src/**/*.{js,jsx}').",
}),
cwd: Type.Optional(
Type.String({
description: "Directory to search in. Defaults to current working directory.",
}),
),
limit: Type.Optional(
Type.Number({
description: "Maximum number of results to return. Defaults to 100.",
minimum: 1,
maximum: 1000,
}),
),
ignore: Type.Optional(
Type.Array(Type.String(), {
description: "Patterns to exclude from results (e.g., ['node_modules/**', '*.test.ts']).",
}),
),
});
type GlobArgs = {
pattern: string;
cwd?: string;
limit?: number;
ignore?: string[];
};
export type GlobResult = {
files: string[];
count: number;
truncated: boolean;
};
const DEFAULT_LIMIT = 100;
const DEFAULT_IGNORE = [
"**/node_modules/**",
"**/.git/**",
"**/dist/**",
"**/build/**",
"**/.next/**",
"**/coverage/**",
];
export function createGlobTool(defaultCwd?: string): AgentTool<typeof GlobSchema, GlobResult> {
return {
name: "glob",
label: "Glob",
description:
"Find files matching a glob pattern. Returns file paths sorted by modification time (most recent first). " +
"Use this to discover files in the codebase before reading them. " +
"Examples: '**/*.ts' for all TypeScript files, 'src/**/*.{js,jsx}' for JS files in src.",
parameters: GlobSchema,
execute: async (_toolCallId, args, _signal) => {
const { pattern, cwd, limit, ignore } = args as GlobArgs;
if (!pattern || pattern.trim() === "") {
throw new Error("Pattern must not be empty");
}
const searchDir = cwd || defaultCwd || process.cwd();
const maxResults = Math.min(limit || DEFAULT_LIMIT, 1000);
const ignorePatterns = ignore || DEFAULT_IGNORE;
// Verify the search directory exists
try {
const stat = await fs.stat(searchDir);
if (!stat.isDirectory()) {
throw new Error(`Path is not a directory: ${searchDir}`);
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`Directory not found: ${searchDir}`);
}
throw err;
}
// Run glob search
const files = await fg(pattern, {
cwd: searchDir,
ignore: ignorePatterns,
onlyFiles: true,
followSymbolicLinks: true,
dot: true, // Include dotfiles
absolute: false, // Return relative paths
suppressErrors: true, // Don't throw on permission errors
});
// Get file stats for sorting by modification time
const filesWithStats = await Promise.all(
files.map(async (file) => {
const fullPath = path.join(searchDir, file);
try {
const stat = await fs.stat(fullPath);
return { file, mtime: stat.mtimeMs };
} catch {
// If we can't stat the file, use 0 as mtime
return { file, mtime: 0 };
}
}),
);
// Sort by modification time (most recent first)
filesWithStats.sort((a, b) => b.mtime - a.mtime);
// Apply limit
const truncated = filesWithStats.length > maxResults;
const limitedFiles = filesWithStats.slice(0, maxResults).map((f) => f.file);
const resultText =
limitedFiles.length > 0
? limitedFiles.join("\n") + (truncated ? `\n... (${filesWithStats.length - maxResults} more files)` : "")
: "No files found matching the pattern.";
return {
content: [{ type: "text", text: resultText }],
details: {
files: limitedFiles,
count: limitedFiles.length,
truncated,
},
};
},
};
}