find.ts

 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};