find.ts

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