ls.ts

 1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
 2//
 3// SPDX-License-Identifier: GPL-3.0-or-later
 4
 5import { existsSync, readdirSync, statSync } from "node:fs";
 6import { join } 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";
11
12const DEFAULT_LIMIT = 500;
13
14const LsSchema = Type.Object({
15  path: Type.Optional(Type.String({ description: "Directory to list (default: current directory)" })),
16  limit: Type.Optional(Type.Number({ description: "Maximum number of entries to return (default: 500)" })),
17});
18
19export const createLsTool = (workspacePath: string): AgentTool => ({
20  name: "ls",
21  label: "List Directory",
22  description: `List directory contents. Directories are suffixed with /. Returns up to ${DEFAULT_LIMIT} entries and ${DEFAULT_MAX_BYTES / 1024}KB of output.`,
23  parameters: LsSchema as any,
24  execute: async (_toolCallId: string, params: any) => {
25    const resolved = resolveToCwd(params.path || ".", workspacePath);
26    ensureWorkspacePath(workspacePath, resolved);
27
28    if (!existsSync(resolved)) {
29      throw new Error(`Path does not exist: ${params.path || "."}`);
30    }
31
32    const stats = statSync(resolved);
33    if (!stats.isDirectory()) {
34      throw new Error(`Not a directory: ${params.path || "."}`);
35    }
36
37    const entries = readdirSync(resolved);
38    entries.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
39
40    const effectiveLimit = params.limit ?? DEFAULT_LIMIT;
41    const limited = entries.slice(0, effectiveLimit);
42    const entryLimitReached = entries.length > effectiveLimit;
43
44    const lines = limited.map((entry) => {
45      const entryPath = join(resolved, entry);
46      try {
47        const entryStat = statSync(entryPath);
48        return entryStat.isDirectory() ? `${entry}/` : entry;
49      } catch {
50        return entry;
51      }
52    });
53
54    if (lines.length === 0) {
55      return {
56        content: [{ type: "text", text: "(empty directory)" }],
57        details: {},
58      };
59    }
60
61    const rawOutput = lines.join("\n");
62    const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
63
64    const notices: string[] = [];
65    if (entryLimitReached) {
66      notices.push(`Entry limit reached: showing ${effectiveLimit} of ${entries.length} entries.`);
67    }
68    if (truncation.truncated) {
69      notices.push(
70        `Output truncated: showing ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}.`,
71      );
72    }
73
74    let output = truncation.content;
75    if (notices.length > 0) {
76      output += `\n\n[${notices.join(" ")}]`;
77    }
78
79    return {
80      content: [{ type: "text", text: output }],
81      details: {
82        ...(truncation.truncated ? { truncation } : {}),
83        ...(entryLimitReached ? { entryLimitReached: true } : {}),
84      },
85    };
86  },
87});