pi-tool-discipline 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,20 +21,23 @@ tool, not bash").
21
21
 
22
22
  Two mechanisms, applied automatically in every session:
23
23
 
24
- 1. **Search-tool registration (root fix).** Registers tools named `grep`,
25
- `find`, and `ls` (skipped if already registered). Their presence flips pi's
26
- `hasGrep`/`hasFind`/`hasLs` check, so the conflicting bash guideline is
27
- **never generated**. Each tool always has a working fs-based implementation
28
- (`extensions/search.ts`); visibility is toggled per capability hidden
29
- while its FFF counterpart (`ffgrep`/`fffind`) is active, visible as a
30
- fallback when pi-fff is not installed.
31
- 2. **System-prompt injection (fallback + rules).** On every agent start,
32
- appends an idempotent "Tool Discipline" section to the system prompt:
33
- content search with `ffgrep`, path search with `fffind`, file reads with
34
- `read` (offset/limit), no bash `grep`/`rg`/`find`/`ls`/`cat`/`sed`/`head`/
35
- `tail`/`which` for searching, bash reserved for pipelines/git/npm/network,
36
- `rg` (never `grep`) as the last resort. Also strips the default bash
37
- guideline text in environments where the registration is disabled.
24
+ 1. **Activate search tools (root fix).** pi 0.84+ ships real `grep` / `find` /
25
+ `ls` built-in tool definitions but only activates `read`/`bash`/`edit`/
26
+ `write` by default. This extension activates the built-ins, so pi's
27
+ `hasGrep`/`hasFind`/`hasLs` check passes and the conflicting
28
+ `Use bash for file operations like ls, rg, find` guideline is **never
29
+ generated** the model is never told to use bash for searching. On older
30
+ pi versions without these built-ins, fs-based fallbacks
31
+ (`extensions/search.ts`) are registered instead, with their text snippet
32
+ suppressed while `ffgrep`/`fffind` from pi-fff are active (the registered
33
+ tool schema remains present either way).
34
+ 2. **System-prompt injection (rules).** On every agent start, appends an
35
+ idempotent "Tool Discipline" section to the system prompt: content search
36
+ with `ffgrep`, path search with `fffind`, file reads with `read`
37
+ (offset/limit), no bash `grep`/`rg`/`find`/`ls`/`cat`/`sed`/`head`/`tail`/
38
+ `which` for searching, bash reserved for pipelines/git/npm/network, `rg`
39
+ (never `grep`) as the last resort. Also strips the bash guideline text as
40
+ a belt-and-suspenders fallback.
38
41
 
39
42
  ## Install
40
43
 
@@ -44,8 +47,9 @@ pi install npm:pi-tool-discipline
44
47
  pi -e npm:pi-tool-discipline
45
48
  ```
46
49
 
47
- Requires `@ff-labs/pi-fff` (or any other extension providing `ffgrep`/`fffind`)
48
- for the discipline to point at real search tools.
50
+ Requires nothing extra. With `@ff-labs/pi-fff` installed, the model prefers
51
+ `ffgrep`/`fffind`; without it, Pi's built-in `grep`/`find`/`ls` tools (activated
52
+ by this extension, or fs-based fallbacks on older pi versions) are used.
49
53
 
50
54
  ## Verify
51
55
 
@@ -58,10 +62,21 @@ You can also inspect the system prompt: the string
58
62
 
59
63
  ## Security
60
64
 
61
- This extension runs with full system access like any pi extension. It only
62
- registers inert placeholder tools and appends text to the system prompt; it
63
- does not execute commands, read files, or touch network. Review the source in
64
- `extensions/index.ts` before installing.
65
+ This extension runs with full system access like any pi extension. What it does:
66
+ - Activates pi's built-in `grep`/`find`/`ls` tools (read-only file search).
67
+ - Injects text into the system prompt (discipline rules).
68
+ - On pi versions without built-in search tools, registers read-only fs-based
69
+ fallback implementations that read file contents under the searched path.
70
+
71
+ **Disclosure:** pi's built-in `grep`/`find` tools execute the `rg`/`fd`
72
+ binaries, and pi may auto-download those binaries from GitHub on first use
73
+ (`ensureTool`). This extension itself does not execute commands, write files,
74
+ or touch the network — that claim covers only its own fs-based fallback
75
+ implementations, not the pi built-ins it activates.
76
+
77
+ The fallback search tools only read. Note that any installed tool, including
78
+ this one, can be invoked by the model. Review the source in `extensions/`
79
+ before installing.
65
80
 
66
81
  ## License
67
82
 
@@ -13,12 +13,14 @@
13
13
  * rules that say to use the grep tool instead.
14
14
  *
15
15
  * How it works:
16
- * A. Registers working search tools named `grep` / `find` / `ls` (skipped
17
- * when already registered). Their presence flips pi's hasGrep/hasFind/
18
- * hasLs check, so the conflicting bash guideline is never generated.
19
- * Each tool is always functional (fs-based, see search.ts); visibility
20
- * is toggled via promptSnippet hidden while its FFF counterpart
21
- * (ffgrep / fffind) is active, visible as a fallback when it is not.
16
+ * A. Ensures `grep` / `find` / `ls` tools are ACTIVE in the session. pi 0.84+
17
+ * ships real built-in definitions (createAllToolDefinitions) but only
18
+ * activates read/bash/edit/write by default this extension activates
19
+ * the built-ins, so pi's hasGrep/hasFind/hasLs check passes and the
20
+ * conflicting bash guideline is never generated, and the model always has
21
+ * search tools. On older pi versions without the built-ins, fs-based
22
+ * fallbacks (search.ts) are registered instead (visible only when the
23
+ * FFF counterpart ffgrep/fffind is absent).
22
24
  * B. On before_agent_start, appends the tool-discipline rules to the system
23
25
  * prompt (idempotent) and strips the bash guideline text as a fallback.
24
26
  */
@@ -35,24 +37,14 @@ const PLACEHOLDER_NAMES = ["grep", "find", "ls"] as const;
35
37
  /** FFF search tools that indicate pi-fff (or equivalent) is installed. */
36
38
  const FFF_TOOLS = ["ffgrep", "fffind"];
37
39
 
38
- /**
39
- * The exact guidelines pi's builder injects when no search tool is active.
40
- * Stripped from the system prompt as a fallback (plan B).
41
- */
42
- const BASH_GUIDELINES = [
43
- "Use bash for file operations like ls, rg, find",
44
- "Use bash or PowerShell for file operations like listing, searching, and finding files",
45
- "Use PowerShell for file operations like listing, searching, and finding files",
46
- ];
47
-
48
40
  const DISCIPLINE = `
49
41
  ## Tool Discipline (pi-tool-discipline)
50
42
 
51
43
  Search tool priority:
52
44
 
53
45
  1. ffgrep / fffind (from @ff-labs/pi-fff) — always preferred. They work with absolute paths outside the workspace and support regex / path / exclude filters.
54
- 2. Without pi-fff, use the grep / find / ls TOOLS (fallbacks provided by this extension). If a search seems to miss something, adjust its parameters (path, caseSensitive, maxResults) never fall back to bash's grep or find.
55
- 3. Never run bash \`grep\` or \`find\`. Never use bash \`ls\`/\`cat\`/\`head\`/\`tail\`/\`sed\`/\`which\` directly for searching or reading — use ffgrep / fffind / read (or the grep/find/ls fallback tools) instead.
46
+ 2. Without pi-fff, use the grep / find / ls TOOLS (Pi built-ins on pi 0.84+, fs fallbacks on older versions). Fill their parameters according to each tool's declared schema built-in grep uses ignoreCase/limit, built-in find uses a glob pattern. Never fall back to bash's grep or find.
47
+ 3. Never run bash \`grep\` or \`find\`. Never use bash \`ls\`/\`cat\`/\`head\`/\`tail\`/\`sed\`/\`which\` directly for searching or reading — use ffgrep / fffind / read (or the grep/find/ls tools) instead.
56
48
  4. Read files with \`read\` (offset/limit for large files).
57
49
  5. Bash stays allowed only when dedicated tools cannot do the job: pipelines, git, npm, running programs, network requests, file mutations.
58
50
  6. If bash searching is truly unavoidable, use \`rg\` (never \`grep\`).`;
@@ -62,7 +54,7 @@ interface FallbackTool {
62
54
  description: string;
63
55
  snippet: string;
64
56
  parameters: ReturnType<typeof Type.Object>;
65
- execute: (params: any, cwd: string) => string;
57
+ execute: (params: any, cwd: string, signal?: AbortSignal) => Promise<string>;
66
58
  }
67
59
 
68
60
  const FALLBACK_TOOLS: Record<string, FallbackTool> = {
@@ -72,7 +64,7 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
72
64
  "Search file contents for a text pattern. Fallback for environments without ffgrep; prefer ffgrep when available.",
73
65
  snippet: "Search file contents (fallback when ffgrep is unavailable)",
74
66
  parameters: grepSchema,
75
- execute: (params, cwd) => grepFiles({ ...params, cwd }),
67
+ execute: async (params, cwd, signal) => grepFiles({ ...params, cwd, signal }),
76
68
  },
77
69
  find: {
78
70
  label: "find (fallback)",
@@ -80,7 +72,7 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
80
72
  "Find files by path/name substring. Fallback for environments without fffind; prefer fffind when available.",
81
73
  snippet: "Find files by path/name (fallback when fffind is unavailable)",
82
74
  parameters: findSchema,
83
- execute: (params, cwd) => findFiles({ ...params, cwd }),
75
+ execute: async (params, cwd, signal) => findFiles({ ...params, cwd, signal }),
84
76
  },
85
77
  ls: {
86
78
  label: "ls (fallback)",
@@ -88,16 +80,27 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
88
80
  "List directory entries. Fallback for environments without fffind; prefer fffind when available.",
89
81
  snippet: "List directory entries (fallback when fffind is unavailable)",
90
82
  parameters: lsSchema,
91
- execute: (params, cwd) => listDir({ ...params, cwd }),
83
+ execute: async (params, cwd) => listDir({ ...params, cwd }),
92
84
  },
93
85
  };
94
86
 
87
+ /**
88
+ * Strip only the exact generated guideline bullets (with the "- " prefix), so
89
+ * quoted references inside project instructions or custom prompts are not
90
+ * rewritten. Activation normally prevents pi from generating these anyway;
91
+ * this is a belt-and-suspenders fallback.
92
+ */
95
93
  function stripBashGuidelines(prompt: string): string {
96
- let out = prompt;
97
- for (const guideline of BASH_GUIDELINES) {
98
- out = out.replaceAll(guideline, "Use ffgrep/fffind for file operations like ls, rg, find");
99
- }
100
- return out;
94
+ return prompt
95
+ .replace("- Use bash for file operations like ls, rg, find", "- Use ffgrep/fffind for file operations like ls, rg, find")
96
+ .replace(
97
+ "- Use bash or PowerShell for file operations like listing, searching, and finding files",
98
+ "- Use ffgrep/fffind for file operations like listing, searching, and finding files",
99
+ )
100
+ .replace(
101
+ "- Use PowerShell for file operations like listing, searching, and finding files",
102
+ "- Use ffgrep/fffind for file operations like listing, searching, and finding files",
103
+ );
101
104
  }
102
105
 
103
106
  export default function toolDiscipline(pi: ExtensionAPI) {
@@ -105,23 +108,29 @@ export default function toolDiscipline(pi: ExtensionAPI) {
105
108
  // Done in session_start: action methods (getAllTools/registerTool) are not
106
109
  // available during extension loading, and tools registered here are
107
110
  // refreshed into the session (and system prompt) immediately.
108
- pi.on("session_start", (event, ctx) => {
111
+ pi.on("session_start", () => {
109
112
  const all = new Set(pi.getAllTools().map((t) => t.name));
110
- // Use ACTIVE tools (respects --exclude-tools / allowed lists), not the
111
- // full registry: getAllTools() still reports excluded tools.
112
113
  const active = new Set(pi.getActiveTools());
113
- // Decide per capability so partial FFF availability (e.g. only ffgrep
114
- // active) still leaves a real fallback for the missing side.
115
114
  const hasFfgrep = active.has("ffgrep");
116
115
  const hasFffind = active.has("fffind");
116
+
117
+ // 1) Activate built-in grep/find/ls when they exist (pi 0.84+ ships real
118
+ // definitions via createAllToolDefinitions but only activates
119
+ // read/bash/edit/write by default). Activating them flips
120
+ // hasGrep/hasFind/hasLs so the bash guideline is never generated, and
121
+ // gives the model real built-in search tools.
122
+ const toActivate = PLACEHOLDER_NAMES.filter((name) => all.has(name) && !active.has(name));
123
+ if (toActivate.length > 0) {
124
+ // setActiveTools rebuilds the system prompt immediately.
125
+ pi.setActiveTools([...active, ...toActivate]);
126
+ }
127
+
128
+ // 2) Older pi without built-in grep/find/ls: register working fs-based
129
+ // fallbacks (search.ts). Visibility is per-capability: hidden while
130
+ // the FFF counterpart is active, visible when it is not.
117
131
  let registeredAny = false;
118
- // All three names always get a WORKING implementation (no inert
119
- // placeholders — a refreshed tool can be called by the model, so an
120
- // empty implementation would be a trap). Visibility toggles via
121
- // promptSnippet: hidden when the FFF counterpart is active, visible
122
- // (with a fallback hint) when it is not.
123
132
  const registerSearchTool = (name: string, fffActive: boolean) => {
124
- if (all.has(name)) return; // already registered by another extension
133
+ if (all.has(name)) return; // built-in exists handled above
125
134
  const fallback = FALLBACK_TOOLS[name];
126
135
  pi.registerTool({
127
136
  name,
@@ -132,8 +141,8 @@ export default function toolDiscipline(pi: ExtensionAPI) {
132
141
  "Use ffgrep/fffind when they are available; grep/find/ls are fallbacks only for environments without pi-fff.",
133
142
  ],
134
143
  parameters: fallback.parameters,
135
- async execute(_toolCallId, params, _signal, _onUpdate, execCtx) {
136
- const text = fallback.execute(params ?? {}, execCtx.cwd);
144
+ async execute(_toolCallId, params, signal, _onUpdate, execCtx) {
145
+ const text = await fallback.execute(params ?? {}, execCtx.cwd, signal);
137
146
  return {
138
147
  content: [{ type: "text", text }],
139
148
  details: { fallback: true },
@@ -146,10 +155,9 @@ export default function toolDiscipline(pi: ExtensionAPI) {
146
155
  registerSearchTool("find", hasFffind);
147
156
  registerSearchTool("ls", hasFffind);
148
157
  // Tools registered in session_start do not enter selectedTools until the
149
- // registry is refreshed. Without this, pi keeps injecting the bash
150
- // guideline (verified empirically on pi 0.84.4). refreshTools exists at
151
- // runtime (ExtensionActions) but is not declared on ExtensionAPI's type.
152
- // Known limitation: with peer version "*", other pi versions may differ.
158
+ // registry is refreshed. refreshTools exists at runtime (ExtensionActions)
159
+ // but is not declared on ExtensionAPI's type. Known limitation: with peer
160
+ // version "*", other pi versions may differ.
153
161
  if (registeredAny) (pi as unknown as { refreshTools: () => void }).refreshTools();
154
162
  });
155
163
 
@@ -161,7 +169,7 @@ export default function toolDiscipline(pi: ExtensionAPI) {
161
169
  return { systemPrompt: `${prompt}\n${MARK}\n${DISCIPLINE}` };
162
170
  });
163
171
 
164
- // Status command: /tool-discipline — verify tool registration and injection.
172
+ // Status command: /tool-discipline — verify tool activation and injection.
165
173
  pi.registerCommand("tool-discipline", {
166
174
  description: "Show pi-tool-discipline status (tools + injected guideline)",
167
175
  handler: async (_args, ctx) => {
@@ -169,13 +177,13 @@ export default function toolDiscipline(pi: ExtensionAPI) {
169
177
  const active = new Set(pi.getActiveTools());
170
178
  const fff = FFF_TOOLS.filter((name) => active.has(name));
171
179
  const registered = PLACEHOLDER_NAMES.filter((n) => all.has(n));
172
- const visible = PLACEHOLDER_NAMES.filter((n) => active.has(n));
180
+ const activeTools = PLACEHOLDER_NAMES.filter((n) => active.has(n));
173
181
  const injected = ctx.getSystemPrompt().includes(MARK);
174
182
  ctx.ui.notify(
175
183
  `pi-tool-discipline\n` +
176
184
  `fff active: ${fff.length > 0 ? fff.join(", ") : "(none)"}\n` +
177
- `grep/find/ls registered: ${registered.length > 0 ? registered.join(", ") : "(none)"}\n` +
178
- `visible to model: ${visible.length > 0 ? visible.join(", ") : "(none)"}\n` +
185
+ `grep/find/ls defined: ${registered.length > 0 ? registered.join(", ") : "(none)"}\n` +
186
+ `grep/find/ls active: ${activeTools.length > 0 ? activeTools.join(", ") : "(none)"}\n` +
179
187
  `discipline injected: ${injected ? "yes" : "no"}`,
180
188
  "info",
181
189
  );
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * Fallback search implementations for environments without @ff-labs/pi-fff.
3
- * Pure Node fs-based; no shell, no external deps.
3
+ * Pure Node fs-based (async, abort-aware); no shell, no external deps.
4
4
  */
5
- import { readdirSync, readFileSync, statSync, lstatSync } from "fs";
5
+ import { readdir, readFile, stat, lstat } from "fs/promises";
6
6
  import { join, resolve, relative } from "path";
7
7
  import { Type } from "typebox";
8
8
 
9
9
  const SKIP_DIRS = new Set(["node_modules", ".git", ".hg", ".svn"]);
10
10
  const MAX_FILES = 2000;
11
+ const MAX_VISITED = 5000; // total entries touched, bounds slow/odd trees
11
12
  const MAX_FILE_BYTES = 1024 * 1024; // content search skips files larger than 1 MiB
12
13
  const MAX_OUTPUT_BYTES = 50 * 1024;
14
+ const TRUNCATE_MARKER = "\n[output truncated]";
15
+ const CAPPED_MARKER = "\n[search capped — traversal stopped early]";
13
16
 
14
17
  interface FileMatch {
15
18
  file: string;
@@ -17,66 +20,102 @@ interface FileMatch {
17
20
  text: string;
18
21
  }
19
22
 
23
+ interface WalkState {
24
+ visited: number;
25
+ capped: boolean;
26
+ }
27
+
20
28
  /**
21
- * Collect files under dir (depth-limited, symlink-safe, bounded).
22
- * Does NOT filter by sizepath search must find large files too.
29
+ * Collect regular files under dir (depth-limited, symlink-safe, bounded,
30
+ * abort-aware). Only isFile() entries are collected FIFOs, devices, sockets
31
+ * would block a read. Individual OS filesystem requests may still be
32
+ * uninterruptible, but traversal checks the signal between operations.
23
33
  */
24
- function walk(dir: string, out: string[], depth = 0): void {
25
- if (depth > 12) return;
34
+ async function walk(dir: string, out: string[], state: WalkState, signal?: AbortSignal, depth = 0): Promise<void> {
35
+ if (depth > 12 || state.visited >= MAX_VISITED) {
36
+ state.capped = true;
37
+ return;
38
+ }
26
39
  let entries: string[];
27
40
  try {
28
- entries = readdirSync(dir);
41
+ entries = await readdir(dir);
29
42
  } catch {
30
43
  return;
31
44
  }
32
45
  for (const entry of entries) {
33
- if (out.length >= MAX_FILES) return; // enforce limit inside the loop
46
+ if (signal?.aborted) {
47
+ state.capped = true;
48
+ return;
49
+ }
50
+ if (out.length >= MAX_FILES || state.visited >= MAX_VISITED) {
51
+ state.capped = true;
52
+ return; // in-loop bound
53
+ }
34
54
  if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
35
55
  const p = join(dir, entry);
56
+ state.visited++;
36
57
  try {
37
- const lst = lstatSync(p);
58
+ const lst = await lstat(p);
38
59
  if (lst.isSymbolicLink()) continue; // never follow symlinks
39
- if (lst.isDirectory()) walk(p, out, depth + 1);
40
- else out.push(p);
60
+ if (lst.isDirectory()) await walk(p, out, state, signal, depth + 1);
61
+ else if (lst.isFile()) out.push(p); // regular files only
41
62
  } catch {
42
63
  // unreadable entries are skipped
43
64
  }
44
65
  }
45
66
  }
46
67
 
47
- /** Truncate by BYTE length (not UTF-16 chars), keeping complete lines. */
68
+ /** Truncate by BYTE length, keep complete lines, reserve space for the marker. */
48
69
  function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES): string {
49
70
  const buf = Buffer.from(text, "utf8");
50
- if (buf.length <= maxBytes) return text;
51
- const cut = buf.subarray(0, maxBytes).toString("utf8");
71
+ const budget = maxBytes - Buffer.byteLength(TRUNCATE_MARKER);
72
+ if (buf.length <= budget) return text;
73
+ const cut = buf.subarray(0, budget).toString("utf8");
52
74
  const lastNewline = cut.lastIndexOf("\n");
53
- const base = lastNewline > 0 ? cut.slice(0, lastNewline) : cut;
54
- return `${base}\n[output truncated]`;
75
+ if (lastNewline <= 0) return TRUNCATE_MARKER.trim(); // nothing complete fits
76
+ return `${cut.slice(0, lastNewline)}\n${TRUNCATE_MARKER}`;
55
77
  }
56
78
 
57
- export function grepFiles(opts: {
79
+ async function resolveRoot(cwd: string, sub?: string): Promise<string | null> {
80
+ const root = resolve(cwd, sub || ".");
81
+ try {
82
+ const st = await stat(root);
83
+ return st.isDirectory() ? root : null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export async function grepFiles(opts: {
58
90
  pattern: string;
59
91
  path?: string;
60
92
  caseSensitive?: boolean;
61
93
  maxResults?: number;
62
94
  cwd: string;
63
- }): string {
64
- const root = resolve(opts.cwd, opts.path || ".");
95
+ signal?: AbortSignal;
96
+ }): Promise<string> {
97
+ const root = await resolveRoot(opts.cwd, opts.path);
98
+ if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`;
65
99
  const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
66
100
  const limit = opts.maxResults ?? 100;
67
101
  const files: string[] = [];
68
- walk(root, files);
102
+ const state: WalkState = { visited: 0, capped: false };
103
+ await walk(root, files, state, opts.signal);
69
104
  const matches: FileMatch[] = [];
70
105
  for (const file of files) {
106
+ if (opts.signal?.aborted) {
107
+ state.capped = true;
108
+ break;
109
+ }
71
110
  if (matches.length >= limit) break;
72
111
  try {
73
- if (statSync(file).size > MAX_FILE_BYTES) continue; // cap only before reading content
112
+ if ((await stat(file)).size > MAX_FILE_BYTES) continue; // cap only before reading content
74
113
  } catch {
75
114
  continue;
76
115
  }
77
116
  let content: string;
78
117
  try {
79
- content = readFileSync(file, "utf8");
118
+ content = await readFile(file, "utf8");
80
119
  } catch {
81
120
  continue;
82
121
  }
@@ -89,31 +128,45 @@ export function grepFiles(opts: {
89
128
  }
90
129
  }
91
130
  }
92
- if (matches.length === 0) return "No matches found";
131
+ if (matches.length === 0) return state.capped ? `No matches found${CAPPED_MARKER}` : "No matches found";
93
132
  let out = "";
94
133
  for (const m of matches) out += `${m.file}:${m.line}: ${m.text}\n`;
95
- return truncate(out);
134
+ out = truncate(out);
135
+ return state.capped ? `${out}${CAPPED_MARKER}` : out;
96
136
  }
97
137
 
98
- export function findFiles(opts: { pattern?: string; path?: string; maxResults?: number; cwd: string }): string {
99
- const root = resolve(opts.cwd, opts.path || ".");
138
+ export async function findFiles(opts: {
139
+ pattern?: string;
140
+ path?: string;
141
+ maxResults?: number;
142
+ cwd: string;
143
+ signal?: AbortSignal;
144
+ }): Promise<string> {
145
+ const root = await resolveRoot(opts.cwd, opts.path);
146
+ if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`;
100
147
  const files: string[] = [];
101
- walk(root, files);
148
+ const state: WalkState = { visited: 0, capped: false };
149
+ await walk(root, files, state, opts.signal);
102
150
  const needle = opts.pattern?.toLowerCase();
103
151
  // Match against the RELATIVE path so a pattern matching an ancestor
104
152
  // directory does not hit every file, and rendered output stays relative.
105
153
  const rel = files.map((f) => relative(root, f));
106
154
  const hits = needle ? rel.filter((r) => r.toLowerCase().includes(needle)) : rel;
107
- if (hits.length === 0) return "No matching files found";
108
- return truncate(hits.slice(0, opts.maxResults ?? 100).join("\n"));
155
+ if (hits.length === 0) return state.capped ? `No matching files found${CAPPED_MARKER}` : "No matching files found";
156
+ let out = truncate(hits.slice(0, opts.maxResults ?? 100).join("\n"));
157
+ if (state.capped) out += CAPPED_MARKER;
158
+ return out;
109
159
  }
110
160
 
111
- export function listDir(opts: { path?: string; cwd: string }): string {
112
- const dir = resolve(opts.cwd, opts.path || ".");
161
+ export async function listDir(opts: { path?: string; cwd: string }): Promise<string> {
162
+ const root = await resolveRoot(opts.cwd, opts.path);
163
+ if (!root) return `Error: directory not found: ${resolve(opts.cwd, opts.path || ".")}`;
113
164
  try {
114
- return truncate(readdirSync(dir).join("\n"));
165
+ const entries = await readdir(root);
166
+ if (entries.length === 0) return "(empty directory)";
167
+ return truncate(entries.join("\n"));
115
168
  } catch (error: any) {
116
- return `Error listing ${dir}: ${error.message}`;
169
+ return `Error listing ${root}: ${error.message}`;
117
170
  }
118
171
  }
119
172
 
@@ -121,13 +174,13 @@ export const grepSchema = Type.Object({
121
174
  pattern: Type.String({ description: "Text to search for in file contents" }),
122
175
  path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
123
176
  caseSensitive: Type.Optional(Type.Boolean({ description: "Case-sensitive match (default false)" })),
124
- maxResults: Type.Optional(Type.Number({ description: "Max matches (default 100)" })),
177
+ maxResults: Type.Optional(Type.Integer({ minimum: 1, description: "Max matches (default 100)" })),
125
178
  });
126
179
 
127
180
  export const findSchema = Type.Object({
128
181
  pattern: Type.Optional(Type.String({ description: "Substring to match in file path or name (empty lists all)" })),
129
182
  path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
130
- maxResults: Type.Optional(Type.Number({ description: "Max results (default 100)" })),
183
+ maxResults: Type.Optional(Type.Integer({ minimum: 1, description: "Max results (default 100)" })),
131
184
  });
132
185
 
133
186
  export const lsSchema = Type.Object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tool-discipline",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "pi extension: enforce ffgrep/fffind-first search discipline and neutralize the default bash file-operation guideline",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "peerDependencies": {
43
43
  "@earendil-works/pi-coding-agent": "*",
44
- "@sinclair/typebox": "*"
44
+ "typebox": "*"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@earendil-works/pi-coding-agent": "*",
48
- "@sinclair/typebox": "*",
49
48
  "@types/node": "^22.0.0",
49
+ "typebox": "*",
50
50
  "typescript": "^5.0.0"
51
51
  }
52
52
  }