pi-tool-discipline 0.1.7 → 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
@@ -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
 
@@ -37,24 +37,14 @@ const PLACEHOLDER_NAMES = ["grep", "find", "ls"] as const;
37
37
  /** FFF search tools that indicate pi-fff (or equivalent) is installed. */
38
38
  const FFF_TOOLS = ["ffgrep", "fffind"];
39
39
 
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
40
  const DISCIPLINE = `
51
41
  ## Tool Discipline (pi-tool-discipline)
52
42
 
53
43
  Search tool priority:
54
44
 
55
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.
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.
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.
58
48
  4. Read files with \`read\` (offset/limit for large files).
59
49
  5. Bash stays allowed only when dedicated tools cannot do the job: pipelines, git, npm, running programs, network requests, file mutations.
60
50
  6. If bash searching is truly unavoidable, use \`rg\` (never \`grep\`).`;
@@ -64,7 +54,7 @@ interface FallbackTool {
64
54
  description: string;
65
55
  snippet: string;
66
56
  parameters: ReturnType<typeof Type.Object>;
67
- execute: (params: any, cwd: string) => string;
57
+ execute: (params: any, cwd: string, signal?: AbortSignal) => Promise<string>;
68
58
  }
69
59
 
70
60
  const FALLBACK_TOOLS: Record<string, FallbackTool> = {
@@ -74,7 +64,7 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
74
64
  "Search file contents for a text pattern. Fallback for environments without ffgrep; prefer ffgrep when available.",
75
65
  snippet: "Search file contents (fallback when ffgrep is unavailable)",
76
66
  parameters: grepSchema,
77
- execute: (params, cwd) => grepFiles({ ...params, cwd }),
67
+ execute: async (params, cwd, signal) => grepFiles({ ...params, cwd, signal }),
78
68
  },
79
69
  find: {
80
70
  label: "find (fallback)",
@@ -82,7 +72,7 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
82
72
  "Find files by path/name substring. Fallback for environments without fffind; prefer fffind when available.",
83
73
  snippet: "Find files by path/name (fallback when fffind is unavailable)",
84
74
  parameters: findSchema,
85
- execute: (params, cwd) => findFiles({ ...params, cwd }),
75
+ execute: async (params, cwd, signal) => findFiles({ ...params, cwd, signal }),
86
76
  },
87
77
  ls: {
88
78
  label: "ls (fallback)",
@@ -90,16 +80,27 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
90
80
  "List directory entries. Fallback for environments without fffind; prefer fffind when available.",
91
81
  snippet: "List directory entries (fallback when fffind is unavailable)",
92
82
  parameters: lsSchema,
93
- execute: (params, cwd) => listDir({ ...params, cwd }),
83
+ execute: async (params, cwd) => listDir({ ...params, cwd }),
94
84
  },
95
85
  };
96
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
+ */
97
93
  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;
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
+ );
103
104
  }
104
105
 
105
106
  export default function toolDiscipline(pi: ExtensionAPI) {
@@ -140,8 +141,8 @@ export default function toolDiscipline(pi: ExtensionAPI) {
140
141
  "Use ffgrep/fffind when they are available; grep/find/ls are fallbacks only for environments without pi-fff.",
141
142
  ],
142
143
  parameters: fallback.parameters,
143
- async execute(_toolCallId, params, _signal, _onUpdate, execCtx) {
144
- 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);
145
146
  return {
146
147
  content: [{ type: "text", text }],
147
148
  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,7 @@ 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]";
15
16
 
16
17
  interface FileMatch {
17
18
  file: string;
@@ -19,30 +20,44 @@ interface FileMatch {
19
20
  text: string;
20
21
  }
21
22
 
23
+ interface WalkState {
24
+ visited: number;
25
+ capped: boolean;
26
+ }
27
+
22
28
  /**
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.
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.
28
33
  */
29
- function walk(dir: string, out: string[], state: { visited: number }, depth = 0): void {
30
- if (depth > 12 || state.visited >= MAX_VISITED) 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
+ }
31
39
  let entries: string[];
32
40
  try {
33
- entries = readdirSync(dir);
41
+ entries = await readdir(dir);
34
42
  } catch {
35
43
  return;
36
44
  }
37
45
  for (const entry of entries) {
38
- if (out.length >= MAX_FILES || state.visited >= MAX_VISITED) return; // in-loop bound
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
+ }
39
54
  if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
40
55
  const p = join(dir, entry);
41
56
  state.visited++;
42
57
  try {
43
- const lst = lstatSync(p);
58
+ const lst = await lstat(p);
44
59
  if (lst.isSymbolicLink()) continue; // never follow symlinks
45
- if (lst.isDirectory()) walk(p, out, state, depth + 1);
60
+ if (lst.isDirectory()) await walk(p, out, state, signal, depth + 1);
46
61
  else if (lst.isFile()) out.push(p); // regular files only
47
62
  } catch {
48
63
  // unreadable entries are skipped
@@ -61,29 +76,46 @@ function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES): string {
61
76
  return `${cut.slice(0, lastNewline)}\n${TRUNCATE_MARKER}`;
62
77
  }
63
78
 
64
- 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: {
65
90
  pattern: string;
66
91
  path?: string;
67
92
  caseSensitive?: boolean;
68
93
  maxResults?: number;
69
94
  cwd: string;
70
- }): string {
71
- 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 || ".")}`;
72
99
  const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
73
100
  const limit = opts.maxResults ?? 100;
74
101
  const files: string[] = [];
75
- walk(root, files, { visited: 0 });
102
+ const state: WalkState = { visited: 0, capped: false };
103
+ await walk(root, files, state, opts.signal);
76
104
  const matches: FileMatch[] = [];
77
105
  for (const file of files) {
106
+ if (opts.signal?.aborted) {
107
+ state.capped = true;
108
+ break;
109
+ }
78
110
  if (matches.length >= limit) break;
79
111
  try {
80
- 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
81
113
  } catch {
82
114
  continue;
83
115
  }
84
116
  let content: string;
85
117
  try {
86
- content = readFileSync(file, "utf8");
118
+ content = await readFile(file, "utf8");
87
119
  } catch {
88
120
  continue;
89
121
  }
@@ -96,31 +128,45 @@ export function grepFiles(opts: {
96
128
  }
97
129
  }
98
130
  }
99
- if (matches.length === 0) return "No matches found";
131
+ if (matches.length === 0) return state.capped ? `No matches found${CAPPED_MARKER}` : "No matches found";
100
132
  let out = "";
101
133
  for (const m of matches) out += `${m.file}:${m.line}: ${m.text}\n`;
102
- return truncate(out);
134
+ out = truncate(out);
135
+ return state.capped ? `${out}${CAPPED_MARKER}` : out;
103
136
  }
104
137
 
105
- export function findFiles(opts: { pattern?: string; path?: string; maxResults?: number; cwd: string }): string {
106
- 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 || ".")}`;
107
147
  const files: string[] = [];
108
- walk(root, files, { visited: 0 });
148
+ const state: WalkState = { visited: 0, capped: false };
149
+ await walk(root, files, state, opts.signal);
109
150
  const needle = opts.pattern?.toLowerCase();
110
151
  // Match against the RELATIVE path so a pattern matching an ancestor
111
152
  // directory does not hit every file, and rendered output stays relative.
112
153
  const rel = files.map((f) => relative(root, f));
113
154
  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"));
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;
116
159
  }
117
160
 
118
- export function listDir(opts: { path?: string; cwd: string }): string {
119
- 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 || ".")}`;
120
164
  try {
121
- 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"));
122
168
  } catch (error: any) {
123
- return `Error listing ${dir}: ${error.message}`;
169
+ return `Error listing ${root}: ${error.message}`;
124
170
  }
125
171
  }
126
172
 
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.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
  }