pi-tool-discipline 0.1.5 → 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,18 +21,22 @@ tool, not bash").
21
21
 
22
22
  Two mechanisms, applied automatically in every session:
23
23
 
24
- 1. **Placeholder tools (root fix).** Registers placeholder tools named
25
- `grep`, `find`, and `ls` (skipped if already registered). They carry no
26
- prompt snippet, so they never appear in the model's tool list and are never
27
- callable in practice. Their mere presence flips pi's `hasGrep`/`hasFind`/
28
- `hasLs` check, so the conflicting bash guideline is **never generated**.
29
- 2. **System-prompt injection (fallback + rules).** On every agent start,
30
- appends an idempotent "Tool Discipline" section to the system prompt:
31
- content search with `ffgrep`, path search with `fffind`, file reads with
32
- `read` (offset/limit), no bash `grep`/`rg`/`find`/`ls`/`cat`/`sed`/`head`/
33
- `tail`/`which` for searching, bash reserved for pipelines/git/npm/network.
34
- Also strips the default bash guideline text in environments where the
35
- placeholder 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.
36
40
 
37
41
  ## Install
38
42
 
@@ -56,10 +60,16 @@ You can also inspect the system prompt: the string
56
60
 
57
61
  ## Security
58
62
 
59
- This extension runs with full system access like any pi extension. It only
60
- registers inert placeholder tools and appends text to the system prompt; it
61
- does not execute commands, read files, or touch network. Review the source in
62
- `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.
63
73
 
64
74
  ## License
65
75
 
@@ -13,24 +13,30 @@
13
13
  * rules that say to use the grep tool instead.
14
14
  *
15
15
  * How it works:
16
- * A. Registers placeholder tools named `grep` / `find` / `ls` (skipped when
17
- * already registered). They carry no promptSnippet, so they never appear
18
- * in the model's tool list and are never actually callable in practice.
19
- * Their presence flips pi's hasGrep/hasFind/hasLs check, so the
20
- * conflicting bash guideline is never generated.
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).
21
24
  * B. On before_agent_start, appends the tool-discipline rules to the system
22
- * prompt (idempotent) and strips the bash guideline text as a fallback
23
- * for environments where the placeholder registration is disabled.
25
+ * prompt (idempotent) and strips the bash guideline text as a fallback.
24
26
  */
25
27
 
26
28
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
29
  import { Type } from "typebox";
30
+ import { grepFiles, findFiles, listDir, grepSchema, findSchema, lsSchema } from "./search.js";
28
31
 
29
32
  const MARK = "<!-- pi-tool-discipline:v1 -->";
30
33
 
31
- /** Placeholder tool names that flip pi's hasGrep/hasFind/hasLs checks. */
34
+ /** Tool names that flip pi's hasGrep/hasFind/hasLs checks. */
32
35
  const PLACEHOLDER_NAMES = ["grep", "find", "ls"] as const;
33
36
 
37
+ /** FFF search tools that indicate pi-fff (or equivalent) is installed. */
38
+ const FFF_TOOLS = ["ffgrep", "fffind"];
39
+
34
40
  /**
35
41
  * The exact guidelines pi's builder injects when no search tool is active.
36
42
  * Stripped from the system prompt as a fallback (plan B).
@@ -47,12 +53,47 @@ const DISCIPLINE = `
47
53
  Search tool priority:
48
54
 
49
55
  1. ffgrep / fffind (from @ff-labs/pi-fff) — always preferred. They work with absolute paths outside the workspace and support regex / path / exclude filters.
50
- 2. If ffgrep/fffind cannot do the job, search with a dedicated grep/find TOOL and adjusted parameters (path, exclude, regex) — never with bash's grep or find.
51
- 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 instead.
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.
52
58
  4. Read files with \`read\` (offset/limit for large files).
53
59
  5. Bash stays allowed only when dedicated tools cannot do the job: pipelines, git, npm, running programs, network requests, file mutations.
54
60
  6. If bash searching is truly unavoidable, use \`rg\` (never \`grep\`).`;
55
61
 
62
+ interface FallbackTool {
63
+ label: string;
64
+ description: string;
65
+ snippet: string;
66
+ parameters: ReturnType<typeof Type.Object>;
67
+ execute: (params: any, cwd: string) => string;
68
+ }
69
+
70
+ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
71
+ grep: {
72
+ label: "grep (fallback)",
73
+ description:
74
+ "Search file contents for a text pattern. Fallback for environments without ffgrep; prefer ffgrep when available.",
75
+ snippet: "Search file contents (fallback when ffgrep is unavailable)",
76
+ parameters: grepSchema,
77
+ execute: (params, cwd) => grepFiles({ ...params, cwd }),
78
+ },
79
+ find: {
80
+ label: "find (fallback)",
81
+ description:
82
+ "Find files by path/name substring. Fallback for environments without fffind; prefer fffind when available.",
83
+ snippet: "Find files by path/name (fallback when fffind is unavailable)",
84
+ parameters: findSchema,
85
+ execute: (params, cwd) => findFiles({ ...params, cwd }),
86
+ },
87
+ ls: {
88
+ label: "ls (fallback)",
89
+ description:
90
+ "List directory entries. Fallback for environments without fffind; prefer fffind when available.",
91
+ snippet: "List directory entries (fallback when fffind is unavailable)",
92
+ parameters: lsSchema,
93
+ execute: (params, cwd) => listDir({ ...params, cwd }),
94
+ },
95
+ };
96
+
56
97
  function stripBashGuidelines(prompt: string): string {
57
98
  let out = prompt;
58
99
  for (const guideline of BASH_GUIDELINES) {
@@ -62,35 +103,61 @@ function stripBashGuidelines(prompt: string): string {
62
103
  }
63
104
 
64
105
  export default function toolDiscipline(pi: ExtensionAPI) {
65
- // A. Register placeholder search tools so pi stops generating the bash guideline.
106
+ // A. Register search-tool names so pi stops generating the bash guideline.
66
107
  // Done in session_start: action methods (getAllTools/registerTool) are not
67
108
  // available during extension loading, and tools registered here are
68
109
  // refreshed into the session (and system prompt) immediately.
69
110
  pi.on("session_start", () => {
70
- const existing = new Set(pi.getAllTools().map((t) => t.name));
71
- for (const name of PLACEHOLDER_NAMES) {
72
- if (existing.has(name)) continue; // already present — the check already passes
111
+ const all = new Set(pi.getAllTools().map((t) => t.name));
112
+ const active = new Set(pi.getActiveTools());
113
+ const hasFfgrep = active.has("ffgrep");
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.
130
+ let registeredAny = false;
131
+ const registerSearchTool = (name: string, fffActive: boolean) => {
132
+ if (all.has(name)) return; // built-in exists — handled above
133
+ const fallback = FALLBACK_TOOLS[name];
73
134
  pi.registerTool({
74
- name,
75
- label: `${name} (placeholder)`,
76
- description:
77
- `Placeholder tool registered by pi-tool-discipline so pi knows a ${name} tool exists ` +
78
- `and does not inject its default "use bash for file operations" guideline. ` +
79
- `Do not call this tool use ffgrep for content search and fffind for path search instead.`,
80
- parameters: Type.Object({}),
81
- async execute() {
82
- return {
83
- content: [
84
- {
85
- type: "text",
86
- text: "This placeholder tool has no implementation. Use ffgrep for content search and fffind for path search instead.",
87
- },
88
- ],
89
- details: { placeholder: true },
90
- };
91
- },
92
- });
93
- }
135
+ name,
136
+ label: fallback.label,
137
+ description: fallback.description,
138
+ promptSnippet: fffActive ? undefined : fallback.snippet,
139
+ promptGuidelines: [
140
+ "Use ffgrep/fffind when they are available; grep/find/ls are fallbacks only for environments without pi-fff.",
141
+ ],
142
+ parameters: fallback.parameters,
143
+ async execute(_toolCallId, params, _signal, _onUpdate, execCtx) {
144
+ const text = fallback.execute(params ?? {}, execCtx.cwd);
145
+ return {
146
+ content: [{ type: "text", text }],
147
+ details: { fallback: true },
148
+ };
149
+ },
150
+ });
151
+ registeredAny = true;
152
+ };
153
+ registerSearchTool("grep", hasFfgrep);
154
+ registerSearchTool("find", hasFffind);
155
+ registerSearchTool("ls", hasFffind);
156
+ // Tools registered in session_start do not enter selectedTools until the
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.
160
+ if (registeredAny) (pi as unknown as { refreshTools: () => void }).refreshTools();
94
161
  });
95
162
 
96
163
  // B. Inject the discipline into the system prompt (idempotent per turn).
@@ -101,16 +168,21 @@ export default function toolDiscipline(pi: ExtensionAPI) {
101
168
  return { systemPrompt: `${prompt}\n${MARK}\n${DISCIPLINE}` };
102
169
  });
103
170
 
104
- // Status command: /tool-discipline — verify placeholder tools and injection.
171
+ // Status command: /tool-discipline — verify tool activation and injection.
105
172
  pi.registerCommand("tool-discipline", {
106
- description: "Show pi-tool-discipline status (placeholder tools + injected guideline)",
173
+ description: "Show pi-tool-discipline status (tools + injected guideline)",
107
174
  handler: async (_args, ctx) => {
108
- const tools = pi.getAllTools().map((t) => t.name);
109
- const placeholders = PLACEHOLDER_NAMES.filter((n) => tools.includes(n));
175
+ const all = new Set(pi.getAllTools().map((t) => t.name));
176
+ const active = new Set(pi.getActiveTools());
177
+ const fff = FFF_TOOLS.filter((name) => active.has(name));
178
+ const registered = PLACEHOLDER_NAMES.filter((n) => all.has(n));
179
+ const activeTools = PLACEHOLDER_NAMES.filter((n) => active.has(n));
110
180
  const injected = ctx.getSystemPrompt().includes(MARK);
111
181
  ctx.ui.notify(
112
182
  `pi-tool-discipline\n` +
113
- `placeholder tools: ${placeholders.length > 0 ? placeholders.join(", ") : "(none)"}\n` +
183
+ `fff active: ${fff.length > 0 ? fff.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` +
114
186
  `discipline injected: ${injected ? "yes" : "no"}`,
115
187
  "info",
116
188
  );
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Fallback search implementations for environments without @ff-labs/pi-fff.
3
+ * Pure Node fs-based; no shell, no external deps.
4
+ */
5
+ import { readdirSync, readFileSync, statSync, lstatSync } from "fs";
6
+ import { join, resolve, relative } from "path";
7
+ import { Type } from "typebox";
8
+
9
+ const SKIP_DIRS = new Set(["node_modules", ".git", ".hg", ".svn"]);
10
+ const MAX_FILES = 2000;
11
+ const MAX_VISITED = 5000; // total entries touched, bounds slow/odd trees
12
+ const MAX_FILE_BYTES = 1024 * 1024; // content search skips files larger than 1 MiB
13
+ const MAX_OUTPUT_BYTES = 50 * 1024;
14
+ const TRUNCATE_MARKER = "\n[output truncated]";
15
+
16
+ interface FileMatch {
17
+ file: string;
18
+ line: number;
19
+ text: string;
20
+ }
21
+
22
+ /**
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.
28
+ */
29
+ function walk(dir: string, out: string[], state: { visited: number }, depth = 0): void {
30
+ if (depth > 12 || state.visited >= MAX_VISITED) return;
31
+ let entries: string[];
32
+ try {
33
+ entries = readdirSync(dir);
34
+ } catch {
35
+ return;
36
+ }
37
+ for (const entry of entries) {
38
+ if (out.length >= MAX_FILES || state.visited >= MAX_VISITED) return; // in-loop bound
39
+ if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
40
+ const p = join(dir, entry);
41
+ state.visited++;
42
+ try {
43
+ const lst = lstatSync(p);
44
+ if (lst.isSymbolicLink()) continue; // never follow symlinks
45
+ if (lst.isDirectory()) walk(p, out, state, depth + 1);
46
+ else if (lst.isFile()) out.push(p); // regular files only
47
+ } catch {
48
+ // unreadable entries are skipped
49
+ }
50
+ }
51
+ }
52
+
53
+ /** Truncate by BYTE length, keep complete lines, reserve space for the marker. */
54
+ function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES): string {
55
+ const buf = Buffer.from(text, "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");
59
+ const lastNewline = cut.lastIndexOf("\n");
60
+ if (lastNewline <= 0) return TRUNCATE_MARKER.trim(); // nothing complete fits
61
+ return `${cut.slice(0, lastNewline)}\n${TRUNCATE_MARKER}`;
62
+ }
63
+
64
+ export function grepFiles(opts: {
65
+ pattern: string;
66
+ path?: string;
67
+ caseSensitive?: boolean;
68
+ maxResults?: number;
69
+ cwd: string;
70
+ }): string {
71
+ const root = resolve(opts.cwd, opts.path || ".");
72
+ const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
73
+ const limit = opts.maxResults ?? 100;
74
+ const files: string[] = [];
75
+ walk(root, files, { visited: 0 });
76
+ const matches: FileMatch[] = [];
77
+ for (const file of files) {
78
+ if (matches.length >= limit) break;
79
+ try {
80
+ if (statSync(file).size > MAX_FILE_BYTES) continue; // cap only before reading content
81
+ } catch {
82
+ continue;
83
+ }
84
+ let content: string;
85
+ try {
86
+ content = readFileSync(file, "utf8");
87
+ } catch {
88
+ continue;
89
+ }
90
+ const lines = content.split("\n");
91
+ for (let i = 0; i < lines.length; i++) {
92
+ const haystack = opts.caseSensitive ? lines[i] : lines[i].toLowerCase();
93
+ if (haystack.includes(pattern)) {
94
+ matches.push({ file: relative(root, file), line: i + 1, text: lines[i].trim().slice(0, 200) });
95
+ if (matches.length >= limit) break;
96
+ }
97
+ }
98
+ }
99
+ if (matches.length === 0) return "No matches found";
100
+ let out = "";
101
+ for (const m of matches) out += `${m.file}:${m.line}: ${m.text}\n`;
102
+ return truncate(out);
103
+ }
104
+
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 });
109
+ const needle = opts.pattern?.toLowerCase();
110
+ // Match against the RELATIVE path so a pattern matching an ancestor
111
+ // 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"));
116
+ }
117
+
118
+ export function listDir(opts: { path?: string; cwd: string }): string {
119
+ const dir = resolve(opts.cwd, opts.path || ".");
120
+ try {
121
+ return truncate(readdirSync(dir).join("\n"));
122
+ } catch (error: any) {
123
+ return `Error listing ${dir}: ${error.message}`;
124
+ }
125
+ }
126
+
127
+ export const grepSchema = Type.Object({
128
+ pattern: Type.String({ description: "Text to search for in file contents" }),
129
+ path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
130
+ caseSensitive: Type.Optional(Type.Boolean({ description: "Case-sensitive match (default false)" })),
131
+ maxResults: Type.Optional(Type.Integer({ minimum: 1, description: "Max matches (default 100)" })),
132
+ });
133
+
134
+ export const findSchema = Type.Object({
135
+ pattern: Type.Optional(Type.String({ description: "Substring to match in file path or name (empty lists all)" })),
136
+ path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
137
+ maxResults: Type.Optional(Type.Integer({ minimum: 1, description: "Max results (default 100)" })),
138
+ });
139
+
140
+ export const lsSchema = Type.Object({
141
+ path: Type.Optional(Type.String({ description: "Directory to list (defaults to cwd)" })),
142
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tool-discipline",
3
- "version": "0.1.5",
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",