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