pi-tool-discipline 0.1.6 → 0.1.7

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,22 @@ 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 (visible only when
32
+ `ffgrep`/`fffind` from pi-fff are absent).
33
+ 2. **System-prompt injection (rules).** On every agent start, appends an
34
+ idempotent "Tool Discipline" section to the system prompt: content search
35
+ with `ffgrep`, path search with `fffind`, file reads with `read`
36
+ (offset/limit), no bash `grep`/`rg`/`find`/`ls`/`cat`/`sed`/`head`/`tail`/
37
+ `which` for searching, bash reserved for pipelines/git/npm/network, `rg`
38
+ (never `grep`) as the last resort. Also strips the bash guideline text as
39
+ a belt-and-suspenders fallback.
38
40
 
39
41
  ## Install
40
42
 
@@ -58,10 +60,16 @@ You can also inspect the system prompt: the string
58
60
 
59
61
  ## Security
60
62
 
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.
63
+ This extension runs with full system access like any pi extension. What it does:
64
+ - Activates pi's built-in `grep`/`find`/`ls` tools (read-only file search).
65
+ - Injects text into the system prompt (discipline rules).
66
+ - On pi versions without built-in search tools, registers read-only fs-based
67
+ fallback implementations that read file contents under the searched path.
68
+
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.
65
73
 
66
74
  ## License
67
75
 
@@ -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
  */
@@ -105,23 +107,29 @@ export default function toolDiscipline(pi: ExtensionAPI) {
105
107
  // Done in session_start: action methods (getAllTools/registerTool) are not
106
108
  // available during extension loading, and tools registered here are
107
109
  // refreshed into the session (and system prompt) immediately.
108
- pi.on("session_start", (event, ctx) => {
110
+ pi.on("session_start", () => {
109
111
  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
112
  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
113
  const hasFfgrep = active.has("ffgrep");
116
114
  const hasFffind = active.has("fffind");
115
+
116
+ // 1) Activate built-in grep/find/ls when they exist (pi 0.84+ ships real
117
+ // definitions via createAllToolDefinitions but only activates
118
+ // read/bash/edit/write by default). Activating them flips
119
+ // hasGrep/hasFind/hasLs so the bash guideline is never generated, and
120
+ // gives the model real built-in search tools.
121
+ const toActivate = PLACEHOLDER_NAMES.filter((name) => all.has(name) && !active.has(name));
122
+ if (toActivate.length > 0) {
123
+ // setActiveTools rebuilds the system prompt immediately.
124
+ pi.setActiveTools([...active, ...toActivate]);
125
+ }
126
+
127
+ // 2) Older pi without built-in grep/find/ls: register working fs-based
128
+ // fallbacks (search.ts). Visibility is per-capability: hidden while
129
+ // the FFF counterpart is active, visible when it is not.
117
130
  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
131
  const registerSearchTool = (name: string, fffActive: boolean) => {
124
- if (all.has(name)) return; // already registered by another extension
132
+ if (all.has(name)) return; // built-in exists handled above
125
133
  const fallback = FALLBACK_TOOLS[name];
126
134
  pi.registerTool({
127
135
  name,
@@ -146,10 +154,9 @@ export default function toolDiscipline(pi: ExtensionAPI) {
146
154
  registerSearchTool("find", hasFffind);
147
155
  registerSearchTool("ls", hasFffind);
148
156
  // 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.
157
+ // registry is refreshed. refreshTools exists at runtime (ExtensionActions)
158
+ // but is not declared on ExtensionAPI's type. Known limitation: with peer
159
+ // version "*", other pi versions may differ.
153
160
  if (registeredAny) (pi as unknown as { refreshTools: () => void }).refreshTools();
154
161
  });
155
162
 
@@ -161,7 +168,7 @@ export default function toolDiscipline(pi: ExtensionAPI) {
161
168
  return { systemPrompt: `${prompt}\n${MARK}\n${DISCIPLINE}` };
162
169
  });
163
170
 
164
- // Status command: /tool-discipline — verify tool registration and injection.
171
+ // Status command: /tool-discipline — verify tool activation and injection.
165
172
  pi.registerCommand("tool-discipline", {
166
173
  description: "Show pi-tool-discipline status (tools + injected guideline)",
167
174
  handler: async (_args, ctx) => {
@@ -169,13 +176,13 @@ export default function toolDiscipline(pi: ExtensionAPI) {
169
176
  const active = new Set(pi.getActiveTools());
170
177
  const fff = FFF_TOOLS.filter((name) => active.has(name));
171
178
  const registered = PLACEHOLDER_NAMES.filter((n) => all.has(n));
172
- const visible = PLACEHOLDER_NAMES.filter((n) => active.has(n));
179
+ const activeTools = PLACEHOLDER_NAMES.filter((n) => active.has(n));
173
180
  const injected = ctx.getSystemPrompt().includes(MARK);
174
181
  ctx.ui.notify(
175
182
  `pi-tool-discipline\n` +
176
183
  `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` +
184
+ `grep/find/ls defined: ${registered.length > 0 ? registered.join(", ") : "(none)"}\n` +
185
+ `grep/find/ls active: ${activeTools.length > 0 ? activeTools.join(", ") : "(none)"}\n` +
179
186
  `discipline injected: ${injected ? "yes" : "no"}`,
180
187
  "info",
181
188
  );
@@ -8,8 +8,10 @@ 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]";
13
15
 
14
16
  interface FileMatch {
15
17
  file: string;
@@ -18,11 +20,14 @@ interface FileMatch {
18
20
  }
19
21
 
20
22
  /**
21
- * Collect files under dir (depth-limited, symlink-safe, bounded).
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.
22
27
  * Does NOT filter by size — path search must find large files too.
23
28
  */
24
- function walk(dir: string, out: string[], depth = 0): void {
25
- if (depth > 12) return;
29
+ function walk(dir: string, out: string[], state: { visited: number }, depth = 0): void {
30
+ if (depth > 12 || state.visited >= MAX_VISITED) return;
26
31
  let entries: string[];
27
32
  try {
28
33
  entries = readdirSync(dir);
@@ -30,28 +35,30 @@ function walk(dir: string, out: string[], depth = 0): void {
30
35
  return;
31
36
  }
32
37
  for (const entry of entries) {
33
- if (out.length >= MAX_FILES) return; // enforce limit inside the loop
38
+ if (out.length >= MAX_FILES || state.visited >= MAX_VISITED) return; // in-loop bound
34
39
  if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
35
40
  const p = join(dir, entry);
41
+ state.visited++;
36
42
  try {
37
43
  const lst = lstatSync(p);
38
44
  if (lst.isSymbolicLink()) continue; // never follow symlinks
39
- if (lst.isDirectory()) walk(p, out, depth + 1);
40
- else out.push(p);
45
+ if (lst.isDirectory()) walk(p, out, state, depth + 1);
46
+ else if (lst.isFile()) out.push(p); // regular files only
41
47
  } catch {
42
48
  // unreadable entries are skipped
43
49
  }
44
50
  }
45
51
  }
46
52
 
47
- /** Truncate by BYTE length (not UTF-16 chars), keeping complete lines. */
53
+ /** Truncate by BYTE length, keep complete lines, reserve space for the marker. */
48
54
  function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES): string {
49
55
  const buf = Buffer.from(text, "utf8");
50
- if (buf.length <= maxBytes) return text;
51
- const cut = buf.subarray(0, maxBytes).toString("utf8");
56
+ const budget = maxBytes - Buffer.byteLength(TRUNCATE_MARKER);
57
+ if (buf.length <= budget) return text;
58
+ const cut = buf.subarray(0, budget).toString("utf8");
52
59
  const lastNewline = cut.lastIndexOf("\n");
53
- const base = lastNewline > 0 ? cut.slice(0, lastNewline) : cut;
54
- return `${base}\n[output truncated]`;
60
+ if (lastNewline <= 0) return TRUNCATE_MARKER.trim(); // nothing complete fits
61
+ return `${cut.slice(0, lastNewline)}\n${TRUNCATE_MARKER}`;
55
62
  }
56
63
 
57
64
  export function grepFiles(opts: {
@@ -65,7 +72,7 @@ export function grepFiles(opts: {
65
72
  const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
66
73
  const limit = opts.maxResults ?? 100;
67
74
  const files: string[] = [];
68
- walk(root, files);
75
+ walk(root, files, { visited: 0 });
69
76
  const matches: FileMatch[] = [];
70
77
  for (const file of files) {
71
78
  if (matches.length >= limit) break;
@@ -98,7 +105,7 @@ export function grepFiles(opts: {
98
105
  export function findFiles(opts: { pattern?: string; path?: string; maxResults?: number; cwd: string }): string {
99
106
  const root = resolve(opts.cwd, opts.path || ".");
100
107
  const files: string[] = [];
101
- walk(root, files);
108
+ walk(root, files, { visited: 0 });
102
109
  const needle = opts.pattern?.toLowerCase();
103
110
  // Match against the RELATIVE path so a pattern matching an ancestor
104
111
  // directory does not hit every file, and rendered output stays relative.
@@ -121,13 +128,13 @@ export const grepSchema = Type.Object({
121
128
  pattern: Type.String({ description: "Text to search for in file contents" }),
122
129
  path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
123
130
  caseSensitive: Type.Optional(Type.Boolean({ description: "Case-sensitive match (default false)" })),
124
- maxResults: Type.Optional(Type.Number({ description: "Max matches (default 100)" })),
131
+ maxResults: Type.Optional(Type.Integer({ minimum: 1, description: "Max matches (default 100)" })),
125
132
  });
126
133
 
127
134
  export const findSchema = Type.Object({
128
135
  pattern: Type.Optional(Type.String({ description: "Substring to match in file path or name (empty lists all)" })),
129
136
  path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
130
- maxResults: Type.Optional(Type.Number({ description: "Max results (default 100)" })),
137
+ maxResults: Type.Optional(Type.Integer({ minimum: 1, description: "Max results (default 100)" })),
131
138
  });
132
139
 
133
140
  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.7",
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",