find.ts

 1import { spawnSync } from "node:child_process";
 2import { relative } from "node:path";
 3import { Type } from "@sinclair/typebox";
 4import type { AgentTool } from "@mariozechner/pi-ai";
 5import { resolveToCwd } 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
32    const args = [
33      "--glob",
34      "--color=never",
35      "--hidden",
36      "--max-results",
37      String(effectiveLimit),
38      params.pattern,
39      searchPath,
40    ];
41
42    const result = spawnSync(fdPath, args, {
43      encoding: "utf-8",
44      maxBuffer: 10 * 1024 * 1024,
45    });
46
47    const rawOutput = result.stdout?.trim() ?? "";
48    if (!rawOutput) {
49      return {
50        content: [{ type: "text", text: "No files found matching pattern" }],
51        details: { pattern: params.pattern, path: searchDir, matches: 0 },
52      };
53    }
54
55    const lines = rawOutput.split("\n").map((line) => {
56      const isDir = line.endsWith("/");
57      const rel = relative(searchPath, line);
58      return isDir && !rel.endsWith("/") ? `${rel}/` : rel;
59    });
60
61    const relativized = lines.join("\n");
62    const truncated = truncateHead(relativized, { maxLines: Number.MAX_SAFE_INTEGER });
63
64    const notices: string[] = [];
65    if (lines.length >= effectiveLimit) {
66      notices.push(`Result limit reached (${effectiveLimit}). Narrow your pattern for complete results.`);
67    }
68    if (truncated.truncatedBy === "bytes") {
69      const droppedBytes = truncated.totalBytes - truncated.outputBytes;
70      notices.push(`Output truncated by ${formatSize(droppedBytes)} to fit ${formatSize(DEFAULT_MAX_BYTES)} limit.`);
71    }
72
73    const output = notices.length > 0
74      ? `${truncated.content}\n\n${notices.join("\n")}`
75      : truncated.content;
76
77    return {
78      content: [{ type: "text", text: output }],
79      details: {
80        pattern: params.pattern,
81        path: searchDir,
82        matches: lines.length,
83        ...(truncated.truncated && { truncatedBy: truncated.truncatedBy }),
84      },
85    };
86    },
87  };
88};