pi-tool-discipline 0.1.8 → 0.1.9

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.
@@ -28,6 +28,7 @@
28
28
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
29
29
  import { Type } from "typebox";
30
30
  import { grepFiles, findFiles, listDir, grepSchema, findSchema, lsSchema } from "./search.js";
31
+ import { stripBashGuidelines } from "./strip.js";
31
32
 
32
33
  const MARK = "<!-- pi-tool-discipline:v1 -->";
33
34
 
@@ -80,29 +81,10 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
80
81
  "List directory entries. Fallback for environments without fffind; prefer fffind when available.",
81
82
  snippet: "List directory entries (fallback when fffind is unavailable)",
82
83
  parameters: lsSchema,
83
- execute: async (params, cwd) => listDir({ ...params, cwd }),
84
+ execute: async (params, cwd, signal) => listDir({ ...params, cwd, signal }),
84
85
  },
85
86
  };
86
87
 
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
- */
93
- function stripBashGuidelines(prompt: string): string {
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
- );
104
- }
105
-
106
88
  export default function toolDiscipline(pi: ExtensionAPI) {
107
89
  // A. Register search-tool names so pi stops generating the bash guideline.
108
90
  // Done in session_start: action methods (getAllTools/registerTool) are not
@@ -14,6 +14,15 @@ const MAX_OUTPUT_BYTES = 50 * 1024;
14
14
  const TRUNCATE_MARKER = "\n[output truncated]";
15
15
  const CAPPED_MARKER = "\n[search capped — traversal stopped early]";
16
16
 
17
+ /** Cancellation error, matching pi built-in tools ("Operation aborted"). */
18
+ function abortError(): Error {
19
+ return new Error("Operation aborted");
20
+ }
21
+
22
+ function throwIfAborted(signal?: AbortSignal): void {
23
+ if (signal?.aborted) throw abortError();
24
+ }
25
+
17
26
  interface FileMatch {
18
27
  file: string;
19
28
  line: number;
@@ -23,15 +32,19 @@ interface FileMatch {
23
32
  interface WalkState {
24
33
  visited: number;
25
34
  capped: boolean;
35
+ rootError?: string;
26
36
  }
27
37
 
28
38
  /**
29
39
  * Collect regular files under dir (depth-limited, symlink-safe, bounded,
30
40
  * abort-aware). Only isFile() entries are collected — FIFOs, devices, sockets
31
41
  * would block a read. Individual OS filesystem requests may still be
32
- * uninterruptible, but traversal checks the signal between operations.
42
+ * uninterruptible, but the signal is checked before/after every operation.
43
+ * A failure to read the REQUESTED ROOT (depth 0) is recorded in rootError;
44
+ * nested unreadable entries are skipped.
33
45
  */
34
46
  async function walk(dir: string, out: string[], state: WalkState, signal?: AbortSignal, depth = 0): Promise<void> {
47
+ throwIfAborted(signal);
35
48
  if (depth > 12 || state.visited >= MAX_VISITED) {
36
49
  state.capped = true;
37
50
  return;
@@ -39,14 +52,12 @@ async function walk(dir: string, out: string[], state: WalkState, signal?: Abort
39
52
  let entries: string[];
40
53
  try {
41
54
  entries = await readdir(dir);
42
- } catch {
55
+ } catch (error: any) {
56
+ if (depth === 0) state.rootError = error?.message ?? String(error);
43
57
  return;
44
58
  }
59
+ throwIfAborted(signal);
45
60
  for (const entry of entries) {
46
- if (signal?.aborted) {
47
- state.capped = true;
48
- return;
49
- }
50
61
  if (out.length >= MAX_FILES || state.visited >= MAX_VISITED) {
51
62
  state.capped = true;
52
63
  return; // in-loop bound
@@ -56,20 +67,26 @@ async function walk(dir: string, out: string[], state: WalkState, signal?: Abort
56
67
  state.visited++;
57
68
  try {
58
69
  const lst = await lstat(p);
70
+ throwIfAborted(signal);
59
71
  if (lst.isSymbolicLink()) continue; // never follow symlinks
60
72
  if (lst.isDirectory()) await walk(p, out, state, signal, depth + 1);
61
73
  else if (lst.isFile()) out.push(p); // regular files only
62
- } catch {
74
+ } catch (error: any) {
75
+ if (error?.message === "Operation aborted") throw error;
63
76
  // unreadable entries are skipped
64
77
  }
65
78
  }
66
79
  }
67
80
 
68
- /** Truncate by BYTE length, keep complete lines, reserve space for the marker. */
69
- function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES): string {
81
+ /**
82
+ * Truncate by BYTE length, keep complete lines, reserve space for all markers.
83
+ * Returns the original text unchanged when it fits within maxBytes.
84
+ */
85
+ function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES, extraMarkerBytes = 0): string {
70
86
  const buf = Buffer.from(text, "utf8");
71
- const budget = maxBytes - Buffer.byteLength(TRUNCATE_MARKER);
72
- if (buf.length <= budget) return text;
87
+ if (buf.length <= maxBytes) return text;
88
+ const budget = maxBytes - Buffer.byteLength(TRUNCATE_MARKER) - extraMarkerBytes;
89
+ if (budget <= 0) return TRUNCATE_MARKER.trim();
73
90
  const cut = buf.subarray(0, budget).toString("utf8");
74
91
  const lastNewline = cut.lastIndexOf("\n");
75
92
  if (lastNewline <= 0) return TRUNCATE_MARKER.trim(); // nothing complete fits
@@ -86,6 +103,12 @@ async function resolveRoot(cwd: string, sub?: string): Promise<string | null> {
86
103
  }
87
104
  }
88
105
 
106
+ function walkFiles(root: string, signal?: AbortSignal): Promise<{ files: string[]; state: WalkState }> {
107
+ const files: string[] = [];
108
+ const state: WalkState = { visited: 0, capped: false };
109
+ return walk(root, files, state, signal).then(() => ({ files, state }));
110
+ }
111
+
89
112
  export async function grepFiles(opts: {
90
113
  pattern: string;
91
114
  path?: string;
@@ -94,33 +117,33 @@ export async function grepFiles(opts: {
94
117
  cwd: string;
95
118
  signal?: AbortSignal;
96
119
  }): Promise<string> {
120
+ throwIfAborted(opts.signal);
97
121
  const root = await resolveRoot(opts.cwd, opts.path);
98
122
  if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`;
99
123
  const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
100
124
  const limit = opts.maxResults ?? 100;
101
- const files: string[] = [];
102
- const state: WalkState = { visited: 0, capped: false };
103
- await walk(root, files, state, opts.signal);
125
+ const { files, state } = await walkFiles(root, opts.signal);
126
+ if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}`;
104
127
  const matches: FileMatch[] = [];
105
128
  for (const file of files) {
106
- if (opts.signal?.aborted) {
107
- state.capped = true;
108
- break;
109
- }
129
+ throwIfAborted(opts.signal);
110
130
  if (matches.length >= limit) break;
111
131
  try {
112
132
  if ((await stat(file)).size > MAX_FILE_BYTES) continue; // cap only before reading content
113
133
  } catch {
114
134
  continue;
115
135
  }
136
+ throwIfAborted(opts.signal);
116
137
  let content: string;
117
138
  try {
118
139
  content = await readFile(file, "utf8");
119
140
  } catch {
120
141
  continue;
121
142
  }
143
+ throwIfAborted(opts.signal);
122
144
  const lines = content.split("\n");
123
145
  for (let i = 0; i < lines.length; i++) {
146
+ throwIfAborted(opts.signal);
124
147
  const haystack = opts.caseSensitive ? lines[i] : lines[i].toLowerCase();
125
148
  if (haystack.includes(pattern)) {
126
149
  matches.push({ file: relative(root, file), line: i + 1, text: lines[i].trim().slice(0, 200) });
@@ -131,8 +154,9 @@ export async function grepFiles(opts: {
131
154
  if (matches.length === 0) return state.capped ? `No matches found${CAPPED_MARKER}` : "No matches found";
132
155
  let out = "";
133
156
  for (const m of matches) out += `${m.file}:${m.line}: ${m.text}\n`;
134
- out = truncate(out);
135
- return state.capped ? `${out}${CAPPED_MARKER}` : out;
157
+ out = truncate(out, MAX_OUTPUT_BYTES, state.capped ? Buffer.byteLength(CAPPED_MARKER) : 0);
158
+ if (state.capped) out += CAPPED_MARKER;
159
+ return out;
136
160
  }
137
161
 
138
162
  export async function findFiles(opts: {
@@ -142,30 +166,42 @@ export async function findFiles(opts: {
142
166
  cwd: string;
143
167
  signal?: AbortSignal;
144
168
  }): Promise<string> {
169
+ throwIfAborted(opts.signal);
145
170
  const root = await resolveRoot(opts.cwd, opts.path);
146
171
  if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`;
147
- const files: string[] = [];
148
- const state: WalkState = { visited: 0, capped: false };
149
- await walk(root, files, state, opts.signal);
172
+ const { files, state } = await walkFiles(root, opts.signal);
173
+ if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}`;
150
174
  const needle = opts.pattern?.toLowerCase();
151
175
  // Match against the RELATIVE path so a pattern matching an ancestor
152
176
  // directory does not hit every file, and rendered output stays relative.
153
- const rel = files.map((f) => relative(root, f));
154
- const hits = needle ? rel.filter((r) => r.toLowerCase().includes(needle)) : rel;
177
+ const rel: string[] = [];
178
+ for (const f of files) {
179
+ throwIfAborted(opts.signal);
180
+ rel.push(relative(root, f));
181
+ }
182
+ const hits: string[] = [];
183
+ for (const r of rel) {
184
+ throwIfAborted(opts.signal);
185
+ if (!needle || r.toLowerCase().includes(needle)) hits.push(r);
186
+ if (hits.length >= (opts.maxResults ?? 100)) break;
187
+ }
155
188
  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"));
189
+ let out = truncate(hits.join("\n"), MAX_OUTPUT_BYTES, state.capped ? Buffer.byteLength(CAPPED_MARKER) : 0);
157
190
  if (state.capped) out += CAPPED_MARKER;
158
191
  return out;
159
192
  }
160
193
 
161
- export async function listDir(opts: { path?: string; cwd: string }): Promise<string> {
194
+ export async function listDir(opts: { path?: string; cwd: string; signal?: AbortSignal }): Promise<string> {
195
+ throwIfAborted(opts.signal);
162
196
  const root = await resolveRoot(opts.cwd, opts.path);
163
197
  if (!root) return `Error: directory not found: ${resolve(opts.cwd, opts.path || ".")}`;
164
198
  try {
165
199
  const entries = await readdir(root);
200
+ throwIfAborted(opts.signal);
166
201
  if (entries.length === 0) return "(empty directory)";
167
202
  return truncate(entries.join("\n"));
168
203
  } catch (error: any) {
204
+ if (error?.message === "Operation aborted") throw error;
169
205
  return `Error listing ${root}: ${error.message}`;
170
206
  }
171
207
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Whole-line stripping of pi's generated bash file-operation guidelines.
3
+ * Kept dependency-free so it is testable outside the pi runtime.
4
+ */
5
+
6
+ /**
7
+ * Strip only the exact generated guideline bullets, line-anchored (whole-line,
8
+ * global), so quoted references inside project instructions or custom prompts
9
+ * are not rewritten. Activation normally prevents pi from generating these
10
+ * anyway; this is a belt-and-suspenders fallback.
11
+ */
12
+ export function stripBashGuidelines(prompt: string): string {
13
+ return prompt
14
+ .replace(/^- Use bash for file operations like ls, rg, find(?=\r?$)/gm, "- Use ffgrep/fffind for file operations like ls, rg, find")
15
+ .replace(
16
+ /^- Use bash or PowerShell for file operations like listing, searching, and finding files(?=\r?$)/gm,
17
+ "- Use ffgrep/fffind for file operations like listing, searching, and finding files",
18
+ )
19
+ .replace(
20
+ /^- Use PowerShell for file operations like listing, searching, and finding files(?=\r?$)/gm,
21
+ "- Use ffgrep/fffind for file operations like listing, searching, and finding files",
22
+ );
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tool-discipline",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
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",
@@ -37,7 +37,8 @@
37
37
  "access": "public"
38
38
  },
39
39
  "scripts": {
40
- "typecheck": "tsc --noEmit"
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "node --experimental-strip-types test/fallback.test.mjs"
41
42
  },
42
43
  "peerDependencies": {
43
44
  "@earendil-works/pi-coding-agent": "*",