1import { spawnSync } from "node:child_process";
2import { relative } from "node:path";
3import { Type } from "@sinclair/typebox";
4import type { AgentTool } from "@mariozechner/pi-agent-core";
5import { resolveToCwd, ensureWorkspacePath } from "./path-utils.js";
6import { DEFAULT_MAX_BYTES, formatSize, truncateHead } from "../../util/truncate.js";
7import { ToolInputError } from "../../util/errors.js";
8
9const DEFAULT_LIMIT = 1000;
10
11const FindSchema = Type.Object({
12 pattern: Type.String({ description: "Glob pattern to match files, e.g. '*.ts', '**/*.json'" }),
13 path: Type.Optional(Type.String({ description: "Directory to search in (default: current directory)" })),
14 limit: Type.Optional(Type.Number({ description: "Maximum number of results (default: 1000)" })),
15});
16
17export const createFindTool = (workspacePath: string): AgentTool => {
18 const fdResult = spawnSync("which", ["fd"], { encoding: "utf-8" });
19 const fdPath = fdResult.stdout?.trim();
20 if (!fdPath) throw new ToolInputError("fd is not available");
21
22 return {
23 name: "find",
24 label: "Find Files",
25 description: `Search for files by glob pattern using fd. Returns up to ${DEFAULT_LIMIT} results and ${DEFAULT_MAX_BYTES / 1024} KB of output. Respects .gitignore.`,
26 parameters: FindSchema as any,
27 execute: async (_toolCallId: string, params: any) => {
28 const searchDir: string = params.path || ".";
29 const effectiveLimit = params.limit ?? DEFAULT_LIMIT;
30 const searchPath = resolveToCwd(searchDir, workspacePath);
31 ensureWorkspacePath(workspacePath, searchPath);
32
33 const args = [
34 "--glob",
35 "--color=never",
36 "--hidden",
37 "--max-results",
38 String(effectiveLimit),
39 params.pattern,
40 searchPath,
41 ];
42
43 const result = spawnSync(fdPath, args, {
44 encoding: "utf-8",
45 maxBuffer: 10 * 1024 * 1024,
46 });
47
48 const rawOutput = result.stdout?.trim() ?? "";
49 if (!rawOutput) {
50 return {
51 content: [{ type: "text", text: "No files found matching pattern" }],
52 details: { pattern: params.pattern, path: searchDir, matches: 0 },
53 };
54 }
55
56 const lines = rawOutput.split("\n").map((line) => {
57 const isDir = line.endsWith("/");
58 const rel = relative(searchPath, line);
59 return isDir && !rel.endsWith("/") ? `${rel}/` : rel;
60 });
61
62 const relativized = lines.join("\n");
63 const truncated = truncateHead(relativized, { maxLines: Number.MAX_SAFE_INTEGER });
64
65 const notices: string[] = [];
66 if (lines.length >= effectiveLimit) {
67 notices.push(`Result limit reached (${effectiveLimit}). Narrow your pattern for complete results.`);
68 }
69 if (truncated.truncatedBy === "bytes") {
70 const droppedBytes = truncated.totalBytes - truncated.outputBytes;
71 notices.push(`Output truncated by ${formatSize(droppedBytes)} to fit ${formatSize(DEFAULT_MAX_BYTES)} limit.`);
72 }
73
74 const output = notices.length > 0
75 ? `${truncated.content}\n\n${notices.join("\n")}`
76 : truncated.content;
77
78 return {
79 content: [{ type: "text", text: output }],
80 details: {
81 pattern: params.pattern,
82 path: searchDir,
83 matches: lines.length,
84 ...(truncated.truncated && { truncatedBy: truncated.truncatedBy }),
85 },
86 };
87 },
88 };
89};