import { createInterface } from "node:readline"; import type { AgentTool } from "@earendil-works/pi-agent-core"; import { spawn } from "child_process"; import path from "path"; import { type Static, Type } from "typebox"; import { ensureTool } from "../../utils/tools-manager.ts"; import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts"; import { pathExists, resolveToCwd } from "./path-utils.ts"; import { findRenderers } from "./renderers/find.ts"; import { wrapToolDefinition } from "./tool-definition-wrapper.ts"; import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult, truncateHead } from "./truncate.ts"; /** Relativize a find result against the search root and normalize it to posix separators. */ export function relativizeFindResultPath( resultPath: string, searchPath: string, pathModule: path.PlatformPath = path, ): string { const hadTrailingSeparator = resultPath.endsWith(pathModule.sep) || (pathModule.sep === "\\" && resultPath.endsWith("/")); const relativePath = pathModule.isAbsolute(resultPath) ? pathModule.relative(searchPath, resultPath) : resultPath; const posixPath = relativePath.split(pathModule.sep).join("/"); return hadTrailingSeparator && !posixPath.endsWith("/") ? `${posixPath}/` : posixPath; } const findSchema = Type.Object({ pattern: Type.String({ description: "Glob pattern to match files, e.g. '*.ts', '**/*.json', or 'src/**/*.spec.ts'", }), path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })), limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })), }); export const findToolSystemPromptContribution = { snippet: "Find files by glob pattern (respects .gitignore)", guidelines: [], } as const; export type FindToolInput = Static; const DEFAULT_LIMIT = 1000; export interface FindToolDetails { truncation?: TruncationResult; resultLimitReached?: number; } /** * Pluggable operations for the find tool. * Override these to delegate file search to remote systems (for example SSH). */ export interface FindOperations { /** Check if path exists */ exists: (absolutePath: string) => Promise | boolean; /** Find files matching glob pattern. Returns relative or absolute paths. */ glob: (pattern: string, cwd: string, options: { ignore: string[]; limit: number }) => Promise | string[]; } const defaultFindOperations: FindOperations = { exists: pathExists, // This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided. glob: () => [], }; export interface FindToolOptions { /** Custom operations for find. Default: local filesystem plus fd */ operations?: FindOperations; } export function createFindToolDefinition( cwd: string, options?: FindToolOptions, ): ToolDefinition { const customOps = options?.operations; return { name: "find", label: "find", description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`, promptSnippet: findToolSystemPromptContribution.snippet, parameters: findSchema, async execute( _toolCallId, { pattern, path: searchDir, limit }: { pattern: string; path?: string; limit?: number }, signal?: AbortSignal, _onUpdate?, ctx?: ExtensionContext, ) { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new Error("Operation aborted")); return; } let settled = false; let stopChild: (() => void) | undefined; const settle = (fn: () => void) => { if (settled) return; settled = true; signal?.removeEventListener("abort", onAbort); stopChild = undefined; fn(); }; const onAbort = () => { stopChild?.(); settle(() => reject(new Error("Operation aborted"))); }; signal?.addEventListener("abort", onAbort, { once: true }); (async () => { try { const searchPath = resolveToCwd(searchDir || ".", ctx?.cwd || cwd); const effectiveLimit = limit ?? DEFAULT_LIMIT; const ops = customOps ?? defaultFindOperations; // If custom operations provide glob(), use that instead of fd. if (customOps?.glob) { if (!(await ops.exists(searchPath))) { settle(() => reject(new Error(`Path not found: ${searchPath}`))); return; } if (signal?.aborted) { settle(() => reject(new Error("Operation aborted"))); return; } const results = await ops.glob(pattern, searchPath, { ignore: ["**/node_modules/**", "**/.git/**"], limit: effectiveLimit, }); if (signal?.aborted) { settle(() => reject(new Error("Operation aborted"))); return; } if (results.length === 0) { settle(() => resolve({ content: [{ type: "text", text: "No files found matching pattern" }], details: undefined, }), ); return; } // Relativize paths against the search root for stable output. const relativized = results.map((p) => relativizeFindResultPath(p, searchPath)); const resultLimitReached = relativized.length >= effectiveLimit; const rawOutput = relativized.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); let resultOutput = truncation.content; const details: FindToolDetails = {}; const notices: string[] = []; if (resultLimitReached) { notices.push(`${effectiveLimit} results limit reached`); details.resultLimitReached = effectiveLimit; } if (truncation.truncated) { notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); details.truncation = truncation; } if (notices.length > 0) { resultOutput += `\n\n[${notices.join(". ")}]`; } settle(() => resolve({ content: [{ type: "text", text: resultOutput }], details: Object.keys(details).length > 0 ? details : undefined, }), ); return; } // Default implementation uses fd. const fdPath = await ensureTool("fd"); if (signal?.aborted) { settle(() => reject(new Error("Operation aborted"))); return; } if (!fdPath) { settle(() => reject(new Error("fd is not available and could not be downloaded"))); return; } const args: string[] = ["--glob", "--color=never", "--hidden"]; // fd normally ignores .gitignore outside git repos, so keep --no-require-git // there. Inside repos, use fd's default git-aware behavior so parent // .gitignore rules stop at nested repo boundaries: // https://github.com/earendil-works/pi/issues/5960 let insideGitRepo = false; for (let current = searchPath; ; ) { if (await pathExists(path.join(current, ".git"))) { insideGitRepo = true; break; } const parent = path.dirname(current); if (parent === current) break; current = parent; } if (!insideGitRepo) args.push("--no-require-git"); args.push("--max-results", String(effectiveLimit)); // fd --glob matches against the basename unless --full-path is set; in --full-path // mode it matches against the absolute candidate path, so a path-containing // pattern like 'src/**/*.spec.ts' needs a leading '**/' to match anything. let effectivePattern = pattern; if (pattern.includes("/")) { args.push("--full-path"); if (!pattern.startsWith("/") && !pattern.startsWith("**/") && pattern !== "**") { effectivePattern = `**/${pattern}`; } // fd matches full paths using native separators on Windows. if (process.platform === "win32") effectivePattern = effectivePattern.replaceAll("/", String.raw`[/\\]`); } args.push("--", effectivePattern, searchPath); const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "pipe"] }); const rl = createInterface({ input: child.stdout }); let stderr = ""; const lines: string[] = []; stopChild = () => { if (!child.killed) { child.kill(); } }; const cleanup = () => { rl.close(); }; child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); }); rl.on("line", (line) => { lines.push(line); }); child.on("error", (error) => { cleanup(); settle(() => reject(new Error(`Failed to run fd: ${error.message}`))); }); child.on("close", (code) => { cleanup(); if (signal?.aborted) { settle(() => reject(new Error("Operation aborted"))); return; } const output = lines.join("\n"); if (code !== 0) { const errorMsg = stderr.trim() || `fd exited with code ${code}`; if (!output) { settle(() => reject(new Error(errorMsg))); return; } } if (!output) { settle(() => resolve({ content: [{ type: "text", text: "No files found matching pattern" }], details: undefined, }), ); return; } const relativized: string[] = []; for (const rawLine of lines) { const line = rawLine.replace(/\r$/, "").trim(); if (!line) continue; relativized.push(relativizeFindResultPath(line, searchPath)); } const resultLimitReached = relativized.length >= effectiveLimit; const rawOutput = relativized.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); let resultOutput = truncation.content; const details: FindToolDetails = {}; const notices: string[] = []; if (resultLimitReached) { notices.push( `${effectiveLimit} results limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`, ); details.resultLimitReached = effectiveLimit; } if (truncation.truncated) { notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`); details.truncation = truncation; } if (notices.length > 0) { resultOutput += `\n\n[${notices.join(". ")}]`; } settle(() => resolve({ content: [{ type: "text", text: resultOutput }], details: Object.keys(details).length > 0 ? details : undefined, }), ); }); } catch (e) { if (signal?.aborted) { settle(() => reject(new Error("Operation aborted"))); return; } const error = e instanceof Error ? e : new Error(String(e)); settle(() => reject(error)); } })(); }); }, ...findRenderers, }; } export function createFindTool(cwd: string, options?: FindToolOptions): AgentTool { return wrapToolDefinition(createFindToolDefinition(cwd, options)); }