diff --git a/package.json b/package.json index 2bb3311e..43572309 100644 --- a/package.json +++ b/package.json @@ -32,13 +32,14 @@ "@mariozechner/pi-agent-core": "^0.50.3", "@mariozechner/pi-ai": "^0.50.3", "@mariozechner/pi-coding-agent": "^0.50.3", - "@sinclair/typebox": "^0.34.41", "@nestjs/common": "^11.1.12", "@nestjs/core": "^11.1.12", "@nestjs/platform-express": "^11.1.12", "@nestjs/platform-socket.io": "^11.1.12", "@nestjs/serve-static": "^5.0.4", "@nestjs/websockets": "^11.1.12", + "@sinclair/typebox": "^0.34.41", + "fast-glob": "^3.3.3", "nestjs-pino": "^4.5.0", "pino": "^10.3.0", "pino-http": "^11.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c662cc0a..81c3cb44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@sinclair/typebox': specifier: ^0.34.41 version: 0.34.48 + fast-glob: + specifier: ^3.3.3 + version: 3.3.3 nestjs-pino: specifier: ^4.5.0 version: 4.5.0(@nestjs/common@11.1.12(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.0)(rxjs@7.8.2) diff --git a/src/agent/tools.ts b/src/agent/tools.ts index 3bfcc72e..c83478c1 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -4,6 +4,7 @@ import { createCodingTools } from "@mariozechner/pi-coding-agent"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { createExecTool } from "./tools/exec.js"; import { createProcessTool } from "./tools/process.js"; +import { createGlobTool } from "./tools/glob.js"; export function resolveModel(options: AgentOptions) { if (options.provider && options.model) { @@ -21,5 +22,6 @@ export function resolveTools(options: AgentOptions): AgentTool[] { const baseTools = createCodingTools(cwd).filter((tool) => tool.name !== "bash") as AgentTool[]; const execTool = createExecTool(cwd); const processTool = createProcessTool(cwd); - return [...baseTools, execTool as AgentTool, processTool as AgentTool]; + const globTool = createGlobTool(cwd); + return [...baseTools, execTool as AgentTool, processTool as AgentTool, globTool as AgentTool]; } diff --git a/src/agent/tools/glob.ts b/src/agent/tools/glob.ts new file mode 100644 index 00000000..d3188356 --- /dev/null +++ b/src/agent/tools/glob.ts @@ -0,0 +1,133 @@ +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 { + 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, + }, + }; + }, + }; +}