pi-tool-discipline 0.1.8 → 0.1.10

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,13 @@ 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
+ throwIfAborted(signal); // cancellation wins over the fs error
57
+ if (depth === 0) state.rootError = error?.message ?? String(error);
43
58
  return;
44
59
  }
60
+ throwIfAborted(signal);
45
61
  for (const entry of entries) {
46
- if (signal?.aborted) {
47
- state.capped = true;
48
- return;
49
- }
50
62
  if (out.length >= MAX_FILES || state.visited >= MAX_VISITED) {
51
63
  state.capped = true;
52
64
  return; // in-loop bound
@@ -56,36 +68,50 @@ async function walk(dir: string, out: string[], state: WalkState, signal?: Abort
56
68
  state.visited++;
57
69
  try {
58
70
  const lst = await lstat(p);
71
+ throwIfAborted(signal);
59
72
  if (lst.isSymbolicLink()) continue; // never follow symlinks
60
73
  if (lst.isDirectory()) await walk(p, out, state, signal, depth + 1);
61
74
  else if (lst.isFile()) out.push(p); // regular files only
62
- } catch {
75
+ } catch (error: any) {
76
+ if (error?.message === "Operation aborted") throw error;
63
77
  // unreadable entries are skipped
64
78
  }
65
79
  }
66
80
  }
67
81
 
68
- /** Truncate by BYTE length, keep complete lines, reserve space for the marker. */
69
- function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES): string {
82
+ /**
83
+ * Truncate by BYTE length, keep complete lines, reserve space for all markers.
84
+ * Returns the original text unchanged when it fits within maxBytes.
85
+ */
86
+ function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES, extraMarkerBytes = 0): string {
70
87
  const buf = Buffer.from(text, "utf8");
71
- const budget = maxBytes - Buffer.byteLength(TRUNCATE_MARKER);
72
- if (buf.length <= budget) return text;
88
+ if (buf.length + extraMarkerBytes <= maxBytes) return text;
89
+ const budget = maxBytes - Buffer.byteLength(TRUNCATE_MARKER) - extraMarkerBytes;
90
+ if (budget <= 0) return TRUNCATE_MARKER.trim();
73
91
  const cut = buf.subarray(0, budget).toString("utf8");
74
92
  const lastNewline = cut.lastIndexOf("\n");
75
93
  if (lastNewline <= 0) return TRUNCATE_MARKER.trim(); // nothing complete fits
76
94
  return `${cut.slice(0, lastNewline)}\n${TRUNCATE_MARKER}`;
77
95
  }
78
96
 
79
- async function resolveRoot(cwd: string, sub?: string): Promise<string | null> {
97
+ async function resolveRoot(cwd: string, sub?: string, signal?: AbortSignal): Promise<string | null> {
80
98
  const root = resolve(cwd, sub || ".");
81
99
  try {
82
100
  const st = await stat(root);
101
+ throwIfAborted(signal);
83
102
  return st.isDirectory() ? root : null;
84
- } catch {
103
+ } catch (error: any) {
104
+ throwIfAborted(signal);
85
105
  return null;
86
106
  }
87
107
  }
88
108
 
109
+ function walkFiles(root: string, signal?: AbortSignal): Promise<{ files: string[]; state: WalkState }> {
110
+ const files: string[] = [];
111
+ const state: WalkState = { visited: 0, capped: false };
112
+ return walk(root, files, state, signal).then(() => ({ files, state }));
113
+ }
114
+
89
115
  export async function grepFiles(opts: {
90
116
  pattern: string;
91
117
  path?: string;
@@ -94,33 +120,35 @@ export async function grepFiles(opts: {
94
120
  cwd: string;
95
121
  signal?: AbortSignal;
96
122
  }): Promise<string> {
97
- const root = await resolveRoot(opts.cwd, opts.path);
123
+ throwIfAborted(opts.signal);
124
+ const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
98
125
  if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`;
99
126
  const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
100
127
  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);
128
+ const { files, state } = await walkFiles(root, opts.signal);
129
+ if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}`;
104
130
  const matches: FileMatch[] = [];
105
131
  for (const file of files) {
106
- if (opts.signal?.aborted) {
107
- state.capped = true;
108
- break;
109
- }
132
+ throwIfAborted(opts.signal);
110
133
  if (matches.length >= limit) break;
111
134
  try {
112
135
  if ((await stat(file)).size > MAX_FILE_BYTES) continue; // cap only before reading content
113
- } catch {
136
+ } catch (error: any) {
137
+ throwIfAborted(opts.signal);
114
138
  continue;
115
139
  }
140
+ throwIfAborted(opts.signal);
116
141
  let content: string;
117
142
  try {
118
143
  content = await readFile(file, "utf8");
119
- } catch {
144
+ } catch (error: any) {
145
+ throwIfAborted(opts.signal);
120
146
  continue;
121
147
  }
148
+ throwIfAborted(opts.signal);
122
149
  const lines = content.split("\n");
123
150
  for (let i = 0; i < lines.length; i++) {
151
+ throwIfAborted(opts.signal);
124
152
  const haystack = opts.caseSensitive ? lines[i] : lines[i].toLowerCase();
125
153
  if (haystack.includes(pattern)) {
126
154
  matches.push({ file: relative(root, file), line: i + 1, text: lines[i].trim().slice(0, 200) });
@@ -131,8 +159,9 @@ export async function grepFiles(opts: {
131
159
  if (matches.length === 0) return state.capped ? `No matches found${CAPPED_MARKER}` : "No matches found";
132
160
  let out = "";
133
161
  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;
162
+ out = truncate(out, MAX_OUTPUT_BYTES, state.capped ? Buffer.byteLength(CAPPED_MARKER) : 0);
163
+ if (state.capped) out += CAPPED_MARKER;
164
+ return out;
136
165
  }
137
166
 
138
167
  export async function findFiles(opts: {
@@ -142,30 +171,42 @@ export async function findFiles(opts: {
142
171
  cwd: string;
143
172
  signal?: AbortSignal;
144
173
  }): Promise<string> {
145
- const root = await resolveRoot(opts.cwd, opts.path);
174
+ throwIfAborted(opts.signal);
175
+ const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
146
176
  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);
177
+ const { files, state } = await walkFiles(root, opts.signal);
178
+ if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}`;
150
179
  const needle = opts.pattern?.toLowerCase();
151
180
  // Match against the RELATIVE path so a pattern matching an ancestor
152
181
  // 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;
182
+ const rel: string[] = [];
183
+ for (const f of files) {
184
+ throwIfAborted(opts.signal);
185
+ rel.push(relative(root, f));
186
+ }
187
+ const hits: string[] = [];
188
+ for (const r of rel) {
189
+ throwIfAborted(opts.signal);
190
+ if (!needle || r.toLowerCase().includes(needle)) hits.push(r);
191
+ if (hits.length >= (opts.maxResults ?? 100)) break;
192
+ }
155
193
  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"));
194
+ let out = truncate(hits.join("\n"), MAX_OUTPUT_BYTES, state.capped ? Buffer.byteLength(CAPPED_MARKER) : 0);
157
195
  if (state.capped) out += CAPPED_MARKER;
158
196
  return out;
159
197
  }
160
198
 
161
- export async function listDir(opts: { path?: string; cwd: string }): Promise<string> {
162
- const root = await resolveRoot(opts.cwd, opts.path);
199
+ export async function listDir(opts: { path?: string; cwd: string; signal?: AbortSignal }): Promise<string> {
200
+ throwIfAborted(opts.signal);
201
+ const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
163
202
  if (!root) return `Error: directory not found: ${resolve(opts.cwd, opts.path || ".")}`;
164
203
  try {
165
204
  const entries = await readdir(root);
205
+ throwIfAborted(opts.signal);
166
206
  if (entries.length === 0) return "(empty directory)";
167
207
  return truncate(entries.join("\n"));
168
208
  } catch (error: any) {
209
+ throwIfAborted(opts.signal);
169
210
  return `Error listing ${root}: ${error.message}`;
170
211
  }
171
212
  }
@@ -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.10",
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": "*",