UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

297 lines • 17.3 kB
import { createInterface } from "node:readline"; import { spawn } from "child_process"; import { Minimatch } from "minimatch"; import path from "path"; import { Type } from "typebox"; import { ensureTool } from "../../utils/tools-manager.js"; import { pathExists, resolveToCwd } from "./path-utils.js"; import { findRenderers } from "./renderers/find.js"; import { wrapToolDefinition } from "./tool-definition-wrapper.js"; import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "./truncate.js"; /** Relativize a find result against the search root and normalize it to posix separators. */ export function relativizeFindResultPath(resultPath, searchPath, pathModule = path) { 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: [], }; const DEFAULT_LIMIT = 1000; const defaultFindOperations = { exists: pathExists, // This is a placeholder. Actual fd execution happens in execute() when no custom glob is provided. glob: () => [], }; export function createFindToolDefinition(cwd, options) { 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 }, signal, _onUpdate, ctx) { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new Error("Operation aborted")); return; } let settled = false; let stopChild; const settle = (fn) => { 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); // fd treats --max-results 0 as unlimited, so a limit below 1 means 1. const effectiveLimit = Math.max(1, Math.floor(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; } // One result past the limit tells "exactly N matches" apart from "more than N". const results = await ops.glob(pattern, searchPath, { ignore: ["**/node_modules/**", "**/.git/**"], limit: effectiveLimit + 1, }); 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; } const resultLimitReached = results.length > effectiveLimit; // Relativize paths against the search root for stable output. const relativized = results .slice(0, effectiveLimit) .map((p) => relativizeFindResultPath(p, searchPath)); const rawOutput = relativized.join("\n"); const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER }); let resultOutput = truncation.content; const details = {}; const notices = []; 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 = ["--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"); // 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. // On Windows `\` is a path separator, so native patterns like 'src\**\*.ts' are // normalized to '/' (#9262). On POSIX `\` stays a glob escape character. let effectivePattern = process.platform === "win32" ? pattern.replaceAll("\\", "/") : pattern; const isPathPattern = effectivePattern.includes("/"); if (isPathPattern && !effectivePattern.startsWith("/") && !effectivePattern.startsWith("**/") && effectivePattern !== "**") { effectivePattern = `**/${effectivePattern}`; } // fd before 10.5.1 never matches --full-path globs on Windows, and workarounds for that // break on fd 10.5.1 (sharkdp/fd#2067). pi cannot rely on either fd version, so on // Windows fd lists every entry and minimatch matches path patterns. The options keep // fd's glob behavior: `*` matches dotfiles, `+(a|b)` is literal, and an all-lowercase // pattern matches case-insensitively (smart case). A trailing '/**' becomes '/**/*' // because minimatch's 'src/**' also matches 'src' itself and fd's does not. const windowsPathMatcher = process.platform === "win32" && isPathPattern ? new Minimatch(effectivePattern.replace(/\/\*\*$/, "/**/*"), { dot: true, noext: true, nocase: effectivePattern === effectivePattern.toLowerCase(), }) : undefined; if (windowsPathMatcher) { args.push("--", "", searchPath); } else { // One result past the limit tells "exactly N matches" apart from "more than N" (#7121). args.push("--glob", "--max-results", String(effectiveLimit + 1)); if (isPathPattern) args.push("--full-path"); args.push("--", effectivePattern, searchPath); } // An abort during the .git lookup above already rejected the call, but there was no // child to stop yet. Do not start fd. if (signal?.aborted) return; const child = spawn(fdPath, args, { stdio: ["ignore", "pipe", "pipe"] }); const rl = createInterface({ input: child.stdout }); let stderr = ""; // Results within the limit, stored only up to the first one past the byte cap: // truncateHead drops the rest anyway, so they are counted but not kept. const storedResults = []; let resultCount = 0; // Byte size of all results within the limit, each counted with a trailing newline. let outputBytes = 0; let resultLimitReached = false; stopChild = () => { if (!child.killed) { child.kill(); } }; const cleanup = () => { rl.close(); }; child.stderr?.on("data", (chunk) => { stderr += chunk.toString(); }); rl.on("line", (rawLine) => { const line = rawLine.replace(/\r$/, "").trim(); if (!line) return; if (windowsPathMatcher && !windowsPathMatcher.match(line)) return; if (resultCount === effectiveLimit) { resultLimitReached = true; // Without --max-results, fd keeps listing after the limit. if (windowsPathMatcher) stopChild?.(); return; } const result = relativizeFindResultPath(line, searchPath); resultCount++; if (outputBytes - 1 <= DEFAULT_MAX_BYTES) storedResults.push(result); outputBytes += Buffer.byteLength(result, "utf-8") + 1; }); 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; } if (code !== 0 && resultCount === 0) { const errorMsg = stderr.trim() || `fd exited with code ${code}`; settle(() => reject(new Error(errorMsg))); return; } if (resultCount === 0) { settle(() => resolve({ content: [{ type: "text", text: "No files found matching pattern" }], details: undefined, })); return; } // Stored results extend past the byte cap when more exist, so truncateHead decides // truncation as for the full output; only the totals need the unstored rest. const truncation = { ...truncateHead(storedResults.join("\n"), { maxLines: Number.MAX_SAFE_INTEGER }), totalLines: resultCount, totalBytes: outputBytes - 1, }; let resultOutput = truncation.content; const details = {}; const notices = []; 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, options) { return wrapToolDefinition(createFindToolDefinition(cwd, options)); } //# sourceMappingURL=find.js.map