/** * file-search — first-class `fd` and `rg` tools for pi. * * On session start the extension resolves a usable binary for each tool: * a normally installed system binary is preferred (silently), then an * existing binary in the agent's managed bin directory (`~/.pi/agent/bin`, * silently — e.g. cached by an earlier download), and only when neither * exists is an official release downloaded into that directory — the single * case that shows a UI notification. Tools await that initialization before * executing, and report a clear error if it failed. */ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as fs from "node:fs"; import * as path from "node:path"; import type { AgentToolResult, ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Cause, Data, Effect, Exit } from "effect"; import { Type, type Static } from "typebox"; import { StringEnum } from "@earendil-works/pi-ai"; import { sanitizeTerminalText } from "../shared/terminal-text.ts"; import { buildFdArgs, buildRgArgs, FD_MAX_DEPTH_LIMIT, FD_MAX_LIMIT, RG_MAX_CONTEXT, RG_MAX_COUNT_LIMIT, } from "./src/args.ts"; import { currentTarget, liveBinaryEnv, managedBinDir, resolveBinary, TOOL_SPECS, type BinaryEnv, type BinarySource, type PlatformTarget, type ResolvedBinary, } from "./src/binaries.ts"; import { formatCapturedOutput, type CapturedOutput } from "./src/output.ts"; import { FD_PARAMETER_DESCRIPTIONS, FD_PROMPT_GUIDELINES, FD_PROMPT_SNIPPET, FD_TOOL_DESCRIPTION, RG_PARAMETER_DESCRIPTIONS, RG_PROMPT_GUIDELINES, RG_PROMPT_SNIPPET, RG_TOOL_DESCRIPTION, } from "./src/prompt.ts"; import { discardCapturedOutput, executeSearchProcess } from "./src/process.ts"; import { OPENPI_TOOL_SURFACE, patchOwnedTools, } from "../shared/tool-surface.ts"; export function makeBinaryInitializers( binDir: string, target: PlatformTarget, env: BinaryEnv, ) { return { fd: Effect.runSync( Effect.cached(resolveBinary(TOOL_SPECS.fd, binDir, target, env)), ), rg: Effect.runSync( Effect.cached(resolveBinary(TOOL_SPECS.rg, binDir, target, env)), ), }; } /** Human-readable install notice, shown only for fresh downloads. */ export function installNotifications(binaries: readonly ResolvedBinary[]) { return binaries .filter((binary) => binary.source === "installed") .map( (binary) => `file-search: no system ${binary.tool} found — downloaded ${binary.tool} ${binary.version ?? ""}`.trimEnd() + ` to ${managedBinDir()}`, ); } class SearchError extends Data.TaggedError("SearchError")<{ readonly message: string; }> {} interface SearchOutcome { readonly output: CapturedOutput; readonly noMatches: boolean; readonly binarySource: BinarySource; } export interface FdToolDetails { readonly binarySource: BinarySource; readonly matchCount: number; readonly truncated: boolean; readonly fullOutputPath?: string; } export interface RgToolDetails { readonly binarySource: BinarySource; readonly outputLines: number; readonly truncated: boolean; readonly fullOutputPath?: string; } const EXEC_TIMEOUT_MS = 60_000; const displayArgument = (value: string) => sanitizeTerminalText(value).replace(/\s+/gu, " ").trim(); function causeMessage(cause: Cause.Cause) { const [first] = Cause.prettyErrors(cause); return first?.message ?? Cause.pretty(cause); } function unwrapToolExit(exit: Exit.Exit, tool: "fd" | "rg") { if (Exit.isSuccess(exit)) return exit.value; if (Cause.hasInterruptsOnly(exit.cause)) { throw new Error(`${tool} search was cancelled.`); } throw new Error(causeMessage(exit.cause)); } export default function fileSearchTools(pi: ExtensionAPI) { let notified = false; const resultDirectories = new Set(); const rememberOutput = (output: CapturedOutput) => { if (output.fullOutputPath) { resultDirectories.add(path.dirname(output.fullOutputPath)); } }; const binDir = managedBinDir(); const target = currentTarget(); const initializers = makeBinaryInitializers(binDir, target, liveBinaryEnv); pi.on("session_start", async (_event, ctx) => { patchOwnedTools(pi, "fileSearch", { enable: OPENPI_TOOL_SURFACE.fileSearch.entry, }); const exit = await Effect.runPromiseExit( Effect.gen(function* () { const initialized = yield* Effect.all( { fd: Effect.exit(initializers.fd), rg: Effect.exit(initializers.rg), }, { concurrency: "unbounded" }, ); if (!ctx.hasUI || notified) return; notified = true; for (const tool of ["fd", "rg"] as const) { const toolExit = initialized[tool]; if (Exit.isSuccess(toolExit)) { for (const message of installNotifications([toolExit.value])) { ctx.ui.notify(message, "info"); } } else { ctx.ui.notify( `file-search ${tool} setup failed: ${causeMessage(toolExit.cause)}`, "error", ); } } }), ); if (Exit.isFailure(exit) && ctx.hasUI && !notified) { notified = true; ctx.ui.notify( `file-search setup failed: ${causeMessage(exit.cause)}`, "error", ); } }); /** Await init, stream the binary output to disk, and classify its exit. */ function runSearch(tool: "fd" | "rg", args: string[], ctx: ExtensionContext) { return Effect.gen(function* () { const binary = yield* initializers[tool]; const result = yield* executeSearchProcess({ command: binary.command, args, cwd: ctx.cwd, tempPrefix: `pi-${tool}-`, }); if (result.output.captureLimitExceeded) { return { output: result.output, noMatches: false, binarySource: binary.source, } satisfies SearchOutcome; } // ripgrep exits 1 for "no matches"; fd exits 0 even with no results. if (tool === "rg" && result.code === 1 && result.output.lineCount === 0) { return { output: result.output, noMatches: true, binarySource: binary.source, } satisfies SearchOutcome; } if (result.code !== 0) { yield* discardCapturedOutput(result.output); const detail = result.stderr.trim() || `exit code ${result.code}`; return yield* new SearchError({ message: `${tool} failed: ${detail}` }); } return { output: result.output, noMatches: result.output.lineCount === 0, binarySource: binary.source, } satisfies SearchOutcome; }).pipe( Effect.timeout(EXEC_TIMEOUT_MS), Effect.mapError((error) => { if (error instanceof SearchError) return error; return new SearchError({ message: error._tag === "TimeoutError" ? `${tool} timed out.` : error instanceof Error ? error.message : String(error), }); }), Effect.provide(NodeServices.layer), ); } pi.on("session_shutdown", () => { for (const directory of resultDirectories) { try { fs.rmSync(directory, { recursive: true, force: true }); } catch { // Temporary search artifacts are best-effort cleanup. } } resultDirectories.clear(); }); pi.registerTool, FdToolDetails>({ name: "fd", label: "Find Files", description: FD_TOOL_DESCRIPTION, promptSnippet: FD_PROMPT_SNIPPET, promptGuidelines: FD_PROMPT_GUIDELINES, parameters: fdParameters(), async execute(_toolCallId, params, signal, _onUpdate, ctx) { const exit = await Effect.runPromiseExit( Effect.gen(function* () { const outcome = yield* runSearch("fd", buildFdArgs(params), ctx); if (outcome.noMatches) { return { content: [{ type: "text", text: "No files found" }], details: { binarySource: outcome.binarySource, matchCount: 0, truncated: false, }, } satisfies AgentToolResult; } rememberOutput(outcome.output); const formatted = formatCapturedOutput(outcome.output); return { content: [{ type: "text", text: formatted.text }], details: { binarySource: outcome.binarySource, matchCount: formatted.lineCount, truncated: formatted.truncated, fullOutputPath: formatted.fullOutputPath, }, } satisfies AgentToolResult; }), signal ? { signal } : undefined, ); return unwrapToolExit(exit, "fd"); }, renderCall(args, theme) { let text = theme.fg("toolTitle", theme.bold("fd ")); text += theme.fg( "accent", args.pattern ? `"${displayArgument(args.pattern)}"` : "(all)", ); if (args.path) text += theme.fg("muted", ` in ${displayArgument(args.path)}`); const flags = [ args.type && `type=${args.type}`, args.extension && `ext=${displayArgument(args.extension)}`, args.glob && "glob", args.hidden && "hidden", args.max_depth !== undefined && `depth≤${args.max_depth}`, ].filter((flag): flag is string => typeof flag === "string"); if (flags.length > 0) text += " " + theme.fg("dim", flags.join(" ")); return new Text(text, 0, 0); }, renderResult(result, { expanded, isPartial }, theme) { if (isPartial) return new Text(theme.fg("warning", "Searching..."), 0, 0); const details = result.details; if (!details || details.matchCount === 0) { return new Text(theme.fg("dim", "No files found"), 0, 0); } let text = theme.fg( "success", `${details.matchCount} ${details.matchCount === 1 ? "entry" : "entries"}`, ); if (details.truncated) text += theme.fg("warning", " (truncated)"); if (expanded) text += expandedPreview(result, details.fullOutputPath, theme); return new Text(text, 0, 0); }, }); pi.registerTool, RgToolDetails>({ name: "rg", label: "Search Content", description: RG_TOOL_DESCRIPTION, promptSnippet: RG_PROMPT_SNIPPET, promptGuidelines: RG_PROMPT_GUIDELINES, parameters: rgParameters(), prepareArguments(args) { if (!args || typeof args !== "object") { return args as Static>; } const input = args as { limit?: unknown; max_matches_per_file?: unknown; }; if ( input.max_matches_per_file === undefined && typeof input.limit === "number" ) { const { limit, ...rest } = input; return { ...rest, max_matches_per_file: limit, } as Static>; } return args as Static>; }, async execute(_toolCallId, params, signal, _onUpdate, ctx) { const exit = await Effect.runPromiseExit( Effect.gen(function* () { const outcome = yield* runSearch("rg", buildRgArgs(params), ctx); if (outcome.noMatches) { return { content: [{ type: "text", text: "No matches found" }], details: { binarySource: outcome.binarySource, outputLines: 0, truncated: false, }, } satisfies AgentToolResult; } rememberOutput(outcome.output); const formatted = formatCapturedOutput(outcome.output); return { content: [{ type: "text", text: formatted.text }], details: { binarySource: outcome.binarySource, outputLines: formatted.lineCount, truncated: formatted.truncated, fullOutputPath: formatted.fullOutputPath, }, } satisfies AgentToolResult; }), signal ? { signal } : undefined, ); return unwrapToolExit(exit, "rg"); }, renderCall(args, theme) { let text = theme.fg("toolTitle", theme.bold("rg ")); text += theme.fg("accent", `"${displayArgument(args.pattern)}"`); if (args.path) text += theme.fg("muted", ` in ${displayArgument(args.path)}`); const flags = [ args.glob && `glob=${displayArgument(args.glob)}`, args.file_type && `type=${displayArgument(args.file_type)}`, args.fixed_strings && "literal", args.hidden && "hidden", args.context !== undefined && `ctx=${args.context}`, ].filter((flag): flag is string => typeof flag === "string"); if (flags.length > 0) text += " " + theme.fg("dim", flags.join(" ")); return new Text(text, 0, 0); }, renderResult(result, { expanded, isPartial }, theme) { if (isPartial) return new Text(theme.fg("warning", "Searching..."), 0, 0); const details = result.details; if (!details || details.outputLines === 0) { return new Text(theme.fg("dim", "No matches found"), 0, 0); } let text = theme.fg( "success", `${details.outputLines} output ${details.outputLines === 1 ? "line" : "lines"}`, ); if (details.truncated) text += theme.fg("warning", " (truncated)"); if (expanded) text += expandedPreview(result, details.fullOutputPath, theme); return new Text(text, 0, 0); }, }); } const PREVIEW_LINES = 20; interface ThemeLike { fg(color: string, text: string): string; } export function expandedPreview( result: { content: { type: string; text?: string }[] }, fullOutputPath: string | undefined, theme: ThemeLike, ) { let text = ""; const content = result.content[0]; if (content?.type === "text" && content.text) { const lines = sanitizeTerminalText(content.text).split("\n"); for (const line of lines.slice(0, PREVIEW_LINES)) { text += `\n${theme.fg("dim", line)}`; } if (lines.length > PREVIEW_LINES) { text += `\n${theme.fg("muted", `... ${lines.length - PREVIEW_LINES} more lines`)}`; } } if (fullOutputPath) { text += `\n${theme.fg("dim", `Full output: ${displayArgument(fullOutputPath)}`)}`; } return text; } function fdParameters() { return Type.Object({ pattern: Type.Optional( Type.String({ description: FD_PARAMETER_DESCRIPTIONS.pattern }), ), path: Type.Optional( Type.String({ description: FD_PARAMETER_DESCRIPTIONS.path }), ), type: Type.Optional( StringEnum(["file", "directory", "symlink"] as const, { description: FD_PARAMETER_DESCRIPTIONS.type, }), ), extension: Type.Optional( Type.String({ description: FD_PARAMETER_DESCRIPTIONS.extension }), ), glob: Type.Optional( Type.Boolean({ description: FD_PARAMETER_DESCRIPTIONS.glob }), ), hidden: Type.Optional( Type.Boolean({ description: FD_PARAMETER_DESCRIPTIONS.hidden }), ), max_depth: Type.Optional( Type.Integer({ description: FD_PARAMETER_DESCRIPTIONS.max_depth, minimum: 1, maximum: FD_MAX_DEPTH_LIMIT, }), ), limit: Type.Optional( Type.Integer({ description: FD_PARAMETER_DESCRIPTIONS.limit, minimum: 1, maximum: FD_MAX_LIMIT, }), ), }); } function rgParameters() { return Type.Object({ pattern: Type.String({ description: RG_PARAMETER_DESCRIPTIONS.pattern }), path: Type.Optional( Type.String({ description: RG_PARAMETER_DESCRIPTIONS.path }), ), glob: Type.Optional( Type.String({ description: RG_PARAMETER_DESCRIPTIONS.glob }), ), file_type: Type.Optional( Type.String({ description: RG_PARAMETER_DESCRIPTIONS.file_type }), ), case_sensitive: Type.Optional( Type.Boolean({ description: RG_PARAMETER_DESCRIPTIONS.case_sensitive }), ), fixed_strings: Type.Optional( Type.Boolean({ description: RG_PARAMETER_DESCRIPTIONS.fixed_strings }), ), hidden: Type.Optional( Type.Boolean({ description: RG_PARAMETER_DESCRIPTIONS.hidden }), ), context: Type.Optional( Type.Integer({ description: RG_PARAMETER_DESCRIPTIONS.context, minimum: 0, maximum: RG_MAX_CONTEXT, }), ), max_matches_per_file: Type.Optional( Type.Integer({ description: RG_PARAMETER_DESCRIPTIONS.max_matches_per_file, minimum: 1, maximum: RG_MAX_COUNT_LIMIT, }), ), }); }