pi-tool-discipline 0.1.7 → 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.
package/README.md CHANGED
@@ -28,8 +28,9 @@ Two mechanisms, applied automatically in every session:
28
28
  `Use bash for file operations like ls, rg, find` guideline is **never
29
29
  generated** — the model is never told to use bash for searching. On older
30
30
  pi versions without these built-ins, fs-based fallbacks
31
- (`extensions/search.ts`) are registered instead (visible only when
32
- `ffgrep`/`fffind` from pi-fff are absent).
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).
33
34
  2. **System-prompt injection (rules).** On every agent start, appends an
34
35
  idempotent "Tool Discipline" section to the system prompt: content search
35
36
  with `ffgrep`, path search with `fffind`, file reads with `read`
@@ -46,8 +47,9 @@ pi install npm:pi-tool-discipline
46
47
  pi -e npm:pi-tool-discipline
47
48
  ```
48
49
 
49
- Requires `@ff-labs/pi-fff` (or any other extension providing `ffgrep`/`fffind`)
50
- 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.
51
53
 
52
54
  ## Verify
53
55
 
@@ -66,10 +68,15 @@ This extension runs with full system access like any pi extension. What it does:
66
68
  - On pi versions without built-in search tools, registers read-only fs-based
67
69
  fallback implementations that read file contents under the searched path.
68
70
 
69
- It never writes files, executes commands, or touches the network. Note that
70
- any installed tool, including this one, can be invoked by the model; the
71
- fallback search tools only read. Review the source in `extensions/` before
72
- installing.
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.
73
80
 
74
81
  ## License
75
82
 
@@ -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
 
@@ -37,24 +38,14 @@ const PLACEHOLDER_NAMES = ["grep", "find", "ls"] as const;
37
38
  /** FFF search tools that indicate pi-fff (or equivalent) is installed. */
38
39
  const FFF_TOOLS = ["ffgrep", "fffind"];
39
40
 
40
- /**
41
- * The exact guidelines pi's builder injects when no search tool is active.
42
- * Stripped from the system prompt as a fallback (plan B).
43
- */
44
- const BASH_GUIDELINES = [
45
- "Use bash for file operations like ls, rg, find",
46
- "Use bash or PowerShell for file operations like listing, searching, and finding files",
47
- "Use PowerShell for file operations like listing, searching, and finding files",
48
- ];
49
-
50
41
  const DISCIPLINE = `
51
42
  ## Tool Discipline (pi-tool-discipline)
52
43
 
53
44
  Search tool priority:
54
45
 
55
46
  1. ffgrep / fffind (from @ff-labs/pi-fff) — always preferred. They work with absolute paths outside the workspace and support regex / path / exclude filters.
56
- 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.
57
- 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.
47
+ 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.
48
+ 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.
58
49
  4. Read files with \`read\` (offset/limit for large files).
59
50
  5. Bash stays allowed only when dedicated tools cannot do the job: pipelines, git, npm, running programs, network requests, file mutations.
60
51
  6. If bash searching is truly unavoidable, use \`rg\` (never \`grep\`).`;
@@ -64,7 +55,7 @@ interface FallbackTool {
64
55
  description: string;
65
56
  snippet: string;
66
57
  parameters: ReturnType<typeof Type.Object>;
67
- execute: (params: any, cwd: string) => string;
58
+ execute: (params: any, cwd: string, signal?: AbortSignal) => Promise<string>;
68
59
  }
69
60
 
70
61
  const FALLBACK_TOOLS: Record<string, FallbackTool> = {
@@ -74,7 +65,7 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
74
65
  "Search file contents for a text pattern. Fallback for environments without ffgrep; prefer ffgrep when available.",
75
66
  snippet: "Search file contents (fallback when ffgrep is unavailable)",
76
67
  parameters: grepSchema,
77
- execute: (params, cwd) => grepFiles({ ...params, cwd }),
68
+ execute: async (params, cwd, signal) => grepFiles({ ...params, cwd, signal }),
78
69
  },
79
70
  find: {
80
71
  label: "find (fallback)",
@@ -82,7 +73,7 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
82
73
  "Find files by path/name substring. Fallback for environments without fffind; prefer fffind when available.",
83
74
  snippet: "Find files by path/name (fallback when fffind is unavailable)",
84
75
  parameters: findSchema,
85
- execute: (params, cwd) => findFiles({ ...params, cwd }),
76
+ execute: async (params, cwd, signal) => findFiles({ ...params, cwd, signal }),
86
77
  },
87
78
  ls: {
88
79
  label: "ls (fallback)",
@@ -90,18 +81,10 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
90
81
  "List directory entries. Fallback for environments without fffind; prefer fffind when available.",
91
82
  snippet: "List directory entries (fallback when fffind is unavailable)",
92
83
  parameters: lsSchema,
93
- execute: (params, cwd) => listDir({ ...params, cwd }),
84
+ execute: async (params, cwd, signal) => listDir({ ...params, cwd, signal }),
94
85
  },
95
86
  };
96
87
 
97
- function stripBashGuidelines(prompt: string): string {
98
- let out = prompt;
99
- for (const guideline of BASH_GUIDELINES) {
100
- out = out.replaceAll(guideline, "Use ffgrep/fffind for file operations like ls, rg, find");
101
- }
102
- return out;
103
- }
104
-
105
88
  export default function toolDiscipline(pi: ExtensionAPI) {
106
89
  // A. Register search-tool names so pi stops generating the bash guideline.
107
90
  // Done in session_start: action methods (getAllTools/registerTool) are not
@@ -140,8 +123,8 @@ export default function toolDiscipline(pi: ExtensionAPI) {
140
123
  "Use ffgrep/fffind when they are available; grep/find/ls are fallbacks only for environments without pi-fff.",
141
124
  ],
142
125
  parameters: fallback.parameters,
143
- async execute(_toolCallId, params, _signal, _onUpdate, execCtx) {
144
- const text = fallback.execute(params ?? {}, execCtx.cwd);
126
+ async execute(_toolCallId, params, signal, _onUpdate, execCtx) {
127
+ const text = await fallback.execute(params ?? {}, execCtx.cwd, signal);
145
128
  return {
146
129
  content: [{ type: "text", text }],
147
130
  details: { fallback: true },
@@ -1,8 +1,8 @@
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
 
@@ -12,6 +12,16 @@ const MAX_VISITED = 5000; // total entries touched, bounds slow/odd trees
12
12
  const MAX_FILE_BYTES = 1024 * 1024; // content search skips files larger than 1 MiB
13
13
  const MAX_OUTPUT_BYTES = 50 * 1024;
14
14
  const TRUNCATE_MARKER = "\n[output truncated]";
15
+ const CAPPED_MARKER = "\n[search capped — traversal stopped early]";
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
+ }
15
25
 
16
26
  interface FileMatch {
17
27
  file: string;
@@ -19,76 +29,121 @@ interface FileMatch {
19
29
  text: string;
20
30
  }
21
31
 
32
+ interface WalkState {
33
+ visited: number;
34
+ capped: boolean;
35
+ rootError?: string;
36
+ }
37
+
22
38
  /**
23
- * Collect regular files under dir (depth-limited, symlink-safe, bounded).
24
- * Only isFile() entries are collected — FIFOs, devices, sockets can block a
25
- * synchronous read. Synchronous by design (fallback tools, low frequency);
26
- * the walk is bounded by MAX_FILES/MAX_VISITED so it cannot hang forever.
27
- * Does NOT filter by size path search must find large files too.
39
+ * Collect regular files under dir (depth-limited, symlink-safe, bounded,
40
+ * abort-aware). Only isFile() entries are collected — FIFOs, devices, sockets
41
+ * would block a read. Individual OS filesystem requests may still be
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.
28
45
  */
29
- function walk(dir: string, out: string[], state: { visited: number }, depth = 0): void {
30
- if (depth > 12 || state.visited >= MAX_VISITED) return;
46
+ async function walk(dir: string, out: string[], state: WalkState, signal?: AbortSignal, depth = 0): Promise<void> {
47
+ throwIfAborted(signal);
48
+ if (depth > 12 || state.visited >= MAX_VISITED) {
49
+ state.capped = true;
50
+ return;
51
+ }
31
52
  let entries: string[];
32
53
  try {
33
- entries = readdirSync(dir);
34
- } catch {
54
+ entries = await readdir(dir);
55
+ } catch (error: any) {
56
+ if (depth === 0) state.rootError = error?.message ?? String(error);
35
57
  return;
36
58
  }
59
+ throwIfAborted(signal);
37
60
  for (const entry of entries) {
38
- if (out.length >= MAX_FILES || state.visited >= MAX_VISITED) return; // in-loop bound
61
+ if (out.length >= MAX_FILES || state.visited >= MAX_VISITED) {
62
+ state.capped = true;
63
+ return; // in-loop bound
64
+ }
39
65
  if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
40
66
  const p = join(dir, entry);
41
67
  state.visited++;
42
68
  try {
43
- const lst = lstatSync(p);
69
+ const lst = await lstat(p);
70
+ throwIfAborted(signal);
44
71
  if (lst.isSymbolicLink()) continue; // never follow symlinks
45
- if (lst.isDirectory()) walk(p, out, state, depth + 1);
72
+ if (lst.isDirectory()) await walk(p, out, state, signal, depth + 1);
46
73
  else if (lst.isFile()) out.push(p); // regular files only
47
- } catch {
74
+ } catch (error: any) {
75
+ if (error?.message === "Operation aborted") throw error;
48
76
  // unreadable entries are skipped
49
77
  }
50
78
  }
51
79
  }
52
80
 
53
- /** Truncate by BYTE length, keep complete lines, reserve space for the marker. */
54
- 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 {
55
86
  const buf = Buffer.from(text, "utf8");
56
- const budget = maxBytes - Buffer.byteLength(TRUNCATE_MARKER);
57
- 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();
58
90
  const cut = buf.subarray(0, budget).toString("utf8");
59
91
  const lastNewline = cut.lastIndexOf("\n");
60
92
  if (lastNewline <= 0) return TRUNCATE_MARKER.trim(); // nothing complete fits
61
93
  return `${cut.slice(0, lastNewline)}\n${TRUNCATE_MARKER}`;
62
94
  }
63
95
 
64
- export function grepFiles(opts: {
96
+ async function resolveRoot(cwd: string, sub?: string): Promise<string | null> {
97
+ const root = resolve(cwd, sub || ".");
98
+ try {
99
+ const st = await stat(root);
100
+ return st.isDirectory() ? root : null;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
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
+
112
+ export async function grepFiles(opts: {
65
113
  pattern: string;
66
114
  path?: string;
67
115
  caseSensitive?: boolean;
68
116
  maxResults?: number;
69
117
  cwd: string;
70
- }): string {
71
- const root = resolve(opts.cwd, opts.path || ".");
118
+ signal?: AbortSignal;
119
+ }): Promise<string> {
120
+ throwIfAborted(opts.signal);
121
+ const root = await resolveRoot(opts.cwd, opts.path);
122
+ if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`;
72
123
  const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
73
124
  const limit = opts.maxResults ?? 100;
74
- const files: string[] = [];
75
- walk(root, files, { visited: 0 });
125
+ const { files, state } = await walkFiles(root, opts.signal);
126
+ if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}`;
76
127
  const matches: FileMatch[] = [];
77
128
  for (const file of files) {
129
+ throwIfAborted(opts.signal);
78
130
  if (matches.length >= limit) break;
79
131
  try {
80
- if (statSync(file).size > MAX_FILE_BYTES) continue; // cap only before reading content
132
+ if ((await stat(file)).size > MAX_FILE_BYTES) continue; // cap only before reading content
81
133
  } catch {
82
134
  continue;
83
135
  }
136
+ throwIfAborted(opts.signal);
84
137
  let content: string;
85
138
  try {
86
- content = readFileSync(file, "utf8");
139
+ content = await readFile(file, "utf8");
87
140
  } catch {
88
141
  continue;
89
142
  }
143
+ throwIfAborted(opts.signal);
90
144
  const lines = content.split("\n");
91
145
  for (let i = 0; i < lines.length; i++) {
146
+ throwIfAborted(opts.signal);
92
147
  const haystack = opts.caseSensitive ? lines[i] : lines[i].toLowerCase();
93
148
  if (haystack.includes(pattern)) {
94
149
  matches.push({ file: relative(root, file), line: i + 1, text: lines[i].trim().slice(0, 200) });
@@ -96,31 +151,58 @@ export function grepFiles(opts: {
96
151
  }
97
152
  }
98
153
  }
99
- if (matches.length === 0) return "No matches found";
154
+ if (matches.length === 0) return state.capped ? `No matches found${CAPPED_MARKER}` : "No matches found";
100
155
  let out = "";
101
156
  for (const m of matches) out += `${m.file}:${m.line}: ${m.text}\n`;
102
- return truncate(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;
103
160
  }
104
161
 
105
- export function findFiles(opts: { pattern?: string; path?: string; maxResults?: number; cwd: string }): string {
106
- const root = resolve(opts.cwd, opts.path || ".");
107
- const files: string[] = [];
108
- walk(root, files, { visited: 0 });
162
+ export async function findFiles(opts: {
163
+ pattern?: string;
164
+ path?: string;
165
+ maxResults?: number;
166
+ cwd: string;
167
+ signal?: AbortSignal;
168
+ }): Promise<string> {
169
+ throwIfAborted(opts.signal);
170
+ const root = await resolveRoot(opts.cwd, opts.path);
171
+ if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`;
172
+ const { files, state } = await walkFiles(root, opts.signal);
173
+ if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}`;
109
174
  const needle = opts.pattern?.toLowerCase();
110
175
  // Match against the RELATIVE path so a pattern matching an ancestor
111
176
  // directory does not hit every file, and rendered output stays relative.
112
- const rel = files.map((f) => relative(root, f));
113
- const hits = needle ? rel.filter((r) => r.toLowerCase().includes(needle)) : rel;
114
- if (hits.length === 0) return "No matching files found";
115
- return truncate(hits.slice(0, opts.maxResults ?? 100).join("\n"));
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
+ }
188
+ if (hits.length === 0) return state.capped ? `No matching files found${CAPPED_MARKER}` : "No matching files found";
189
+ let out = truncate(hits.join("\n"), MAX_OUTPUT_BYTES, state.capped ? Buffer.byteLength(CAPPED_MARKER) : 0);
190
+ if (state.capped) out += CAPPED_MARKER;
191
+ return out;
116
192
  }
117
193
 
118
- export function listDir(opts: { path?: string; cwd: string }): string {
119
- const dir = resolve(opts.cwd, opts.path || ".");
194
+ export async function listDir(opts: { path?: string; cwd: string; signal?: AbortSignal }): Promise<string> {
195
+ throwIfAborted(opts.signal);
196
+ const root = await resolveRoot(opts.cwd, opts.path);
197
+ if (!root) return `Error: directory not found: ${resolve(opts.cwd, opts.path || ".")}`;
120
198
  try {
121
- return truncate(readdirSync(dir).join("\n"));
199
+ const entries = await readdir(root);
200
+ throwIfAborted(opts.signal);
201
+ if (entries.length === 0) return "(empty directory)";
202
+ return truncate(entries.join("\n"));
122
203
  } catch (error: any) {
123
- return `Error listing ${dir}: ${error.message}`;
204
+ if (error?.message === "Operation aborted") throw error;
205
+ return `Error listing ${root}: ${error.message}`;
124
206
  }
125
207
  }
126
208
 
@@ -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.7",
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,16 +37,17 @@
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": "*",
44
- "@sinclair/typebox": "*"
45
+ "typebox": "*"
45
46
  },
46
47
  "devDependencies": {
47
48
  "@earendil-works/pi-coding-agent": "*",
48
- "@sinclair/typebox": "*",
49
49
  "@types/node": "^22.0.0",
50
+ "typebox": "*",
50
51
  "typescript": "^5.0.0"
51
52
  }
52
53
  }