pi-tool-discipline 0.1.4 → 0.1.6

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,20 @@ 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**.
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.
29
31
  2. **System-prompt injection (fallback + rules).** On every agent start,
30
32
  appends an idempotent "Tool Discipline" section to the system prompt:
31
33
  content search with `ffgrep`, path search with `fffind`, file reads with
32
34
  `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.
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.
36
38
 
37
39
  ## Install
38
40
 
@@ -13,24 +13,28 @@
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. 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.
21
22
  * 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.
23
+ * prompt (idempotent) and strips the bash guideline text as a fallback.
24
24
  */
25
25
 
26
26
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
27
  import { Type } from "typebox";
28
+ import { grepFiles, findFiles, listDir, grepSchema, findSchema, lsSchema } from "./search.js";
28
29
 
29
30
  const MARK = "<!-- pi-tool-discipline:v1 -->";
30
31
 
31
- /** Placeholder tool names that flip pi's hasGrep/hasFind/hasLs checks. */
32
+ /** Tool names that flip pi's hasGrep/hasFind/hasLs checks. */
32
33
  const PLACEHOLDER_NAMES = ["grep", "find", "ls"] as const;
33
34
 
35
+ /** FFF search tools that indicate pi-fff (or equivalent) is installed. */
36
+ const FFF_TOOLS = ["ffgrep", "fffind"];
37
+
34
38
  /**
35
39
  * The exact guidelines pi's builder injects when no search tool is active.
36
40
  * Stripped from the system prompt as a fallback (plan B).
@@ -44,19 +48,49 @@ const BASH_GUIDELINES = [
44
48
  const DISCIPLINE = `
45
49
  ## Tool Discipline (pi-tool-discipline)
46
50
 
47
- Tool priority for search (always use the highest available):
51
+ Search tool priority:
48
52
 
49
- - Content search: ffgrep (tool) > rg (bash) > grep (bash)
50
- - Path search: fffind (tool) > find (bash)
53
+ 1. ffgrep / fffind (from @ff-labs/pi-fff) always preferred. They work with absolute paths outside the workspace and support regex / path / exclude filters.
54
+ 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.
55
+ 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.
56
+ 4. Read files with \`read\` (offset/limit for large files).
57
+ 5. Bash stays allowed only when dedicated tools cannot do the job: pipelines, git, npm, running programs, network requests, file mutations.
58
+ 6. If bash searching is truly unavoidable, use \`rg\` (never \`grep\`).`;
51
59
 
52
- Rules:
60
+ interface FallbackTool {
61
+ label: string;
62
+ description: string;
63
+ snippet: string;
64
+ parameters: ReturnType<typeof Type.Object>;
65
+ execute: (params: any, cwd: string) => string;
66
+ }
53
67
 
54
- - Search and read files ONLY with dedicated tools, never with bash commands.
55
- - ffgrep/fffind work with absolute paths outside the workspace (separate index), and ffgrep supports regex, path and exclude filters. If a search seems to miss something, adjust its parameters (path, exclude, regex, caseSensitive) — do NOT fall back to bash \`grep\`/\`rg\`/\`find\`/\`ls\`.
56
- - Read files with \`read\` (use \`offset\`/\`limit\` for large files); never use bash \`cat\`/\`head\`/\`tail\`/\`sed\` to read.
57
- - Do NOT use the bash tool for \`grep\`/\`rg\`/\`find\`/\`ls\`/\`cat\`/\`sed\`/\`head\`/\`tail\`/\`which\` searches or file reads.
58
- - Bash stays allowed only when dedicated tools cannot do the job: pipelines, git, npm, running programs, network requests, file mutations.
59
- - If bash searching is truly unavoidable, prefer \`rg\` over \`grep\`.`;
68
+ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
69
+ grep: {
70
+ label: "grep (fallback)",
71
+ description:
72
+ "Search file contents for a text pattern. Fallback for environments without ffgrep; prefer ffgrep when available.",
73
+ snippet: "Search file contents (fallback when ffgrep is unavailable)",
74
+ parameters: grepSchema,
75
+ execute: (params, cwd) => grepFiles({ ...params, cwd }),
76
+ },
77
+ find: {
78
+ label: "find (fallback)",
79
+ description:
80
+ "Find files by path/name substring. Fallback for environments without fffind; prefer fffind when available.",
81
+ snippet: "Find files by path/name (fallback when fffind is unavailable)",
82
+ parameters: findSchema,
83
+ execute: (params, cwd) => findFiles({ ...params, cwd }),
84
+ },
85
+ ls: {
86
+ label: "ls (fallback)",
87
+ description:
88
+ "List directory entries. Fallback for environments without fffind; prefer fffind when available.",
89
+ snippet: "List directory entries (fallback when fffind is unavailable)",
90
+ parameters: lsSchema,
91
+ execute: (params, cwd) => listDir({ ...params, cwd }),
92
+ },
93
+ };
60
94
 
61
95
  function stripBashGuidelines(prompt: string): string {
62
96
  let out = prompt;
@@ -67,35 +101,56 @@ function stripBashGuidelines(prompt: string): string {
67
101
  }
68
102
 
69
103
  export default function toolDiscipline(pi: ExtensionAPI) {
70
- // A. Register placeholder search tools so pi stops generating the bash guideline.
104
+ // A. Register search-tool names so pi stops generating the bash guideline.
71
105
  // Done in session_start: action methods (getAllTools/registerTool) are not
72
106
  // available during extension loading, and tools registered here are
73
107
  // refreshed into the session (and system prompt) immediately.
74
- pi.on("session_start", () => {
75
- const existing = new Set(pi.getAllTools().map((t) => t.name));
76
- for (const name of PLACEHOLDER_NAMES) {
77
- if (existing.has(name)) continue; // already present the check already passes
108
+ pi.on("session_start", (event, ctx) => {
109
+ 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
+ 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
+ const hasFfgrep = active.has("ffgrep");
116
+ const hasFffind = active.has("fffind");
117
+ 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
+ const registerSearchTool = (name: string, fffActive: boolean) => {
124
+ if (all.has(name)) return; // already registered by another extension
125
+ const fallback = FALLBACK_TOOLS[name];
78
126
  pi.registerTool({
79
- name,
80
- label: `${name} (placeholder)`,
81
- description:
82
- `Placeholder tool registered by pi-tool-discipline so pi knows a ${name} tool exists ` +
83
- `and does not inject its default "use bash for file operations" guideline. ` +
84
- `Do not call this tool use ffgrep for content search and fffind for path search instead.`,
85
- parameters: Type.Object({}),
86
- async execute() {
87
- return {
88
- content: [
89
- {
90
- type: "text",
91
- text: "This placeholder tool has no implementation. Use ffgrep for content search and fffind for path search instead.",
92
- },
93
- ],
94
- details: { placeholder: true },
95
- };
96
- },
97
- });
98
- }
127
+ name,
128
+ label: fallback.label,
129
+ description: fallback.description,
130
+ promptSnippet: fffActive ? undefined : fallback.snippet,
131
+ promptGuidelines: [
132
+ "Use ffgrep/fffind when they are available; grep/find/ls are fallbacks only for environments without pi-fff.",
133
+ ],
134
+ parameters: fallback.parameters,
135
+ async execute(_toolCallId, params, _signal, _onUpdate, execCtx) {
136
+ const text = fallback.execute(params ?? {}, execCtx.cwd);
137
+ return {
138
+ content: [{ type: "text", text }],
139
+ details: { fallback: true },
140
+ };
141
+ },
142
+ });
143
+ registeredAny = true;
144
+ };
145
+ registerSearchTool("grep", hasFfgrep);
146
+ registerSearchTool("find", hasFffind);
147
+ registerSearchTool("ls", hasFffind);
148
+ // 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.
153
+ if (registeredAny) (pi as unknown as { refreshTools: () => void }).refreshTools();
99
154
  });
100
155
 
101
156
  // B. Inject the discipline into the system prompt (idempotent per turn).
@@ -106,16 +161,21 @@ export default function toolDiscipline(pi: ExtensionAPI) {
106
161
  return { systemPrompt: `${prompt}\n${MARK}\n${DISCIPLINE}` };
107
162
  });
108
163
 
109
- // Status command: /tool-discipline — verify placeholder tools and injection.
164
+ // Status command: /tool-discipline — verify tool registration and injection.
110
165
  pi.registerCommand("tool-discipline", {
111
- description: "Show pi-tool-discipline status (placeholder tools + injected guideline)",
166
+ description: "Show pi-tool-discipline status (tools + injected guideline)",
112
167
  handler: async (_args, ctx) => {
113
- const tools = pi.getAllTools().map((t) => t.name);
114
- const placeholders = PLACEHOLDER_NAMES.filter((n) => tools.includes(n));
168
+ const all = new Set(pi.getAllTools().map((t) => t.name));
169
+ const active = new Set(pi.getActiveTools());
170
+ const fff = FFF_TOOLS.filter((name) => active.has(name));
171
+ const registered = PLACEHOLDER_NAMES.filter((n) => all.has(n));
172
+ const visible = PLACEHOLDER_NAMES.filter((n) => active.has(n));
115
173
  const injected = ctx.getSystemPrompt().includes(MARK);
116
174
  ctx.ui.notify(
117
175
  `pi-tool-discipline\n` +
118
- `placeholder tools: ${placeholders.length > 0 ? placeholders.join(", ") : "(none)"}\n` +
176
+ `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` +
119
179
  `discipline injected: ${injected ? "yes" : "no"}`,
120
180
  "info",
121
181
  );
@@ -0,0 +1,135 @@
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_FILE_BYTES = 1024 * 1024; // content search skips files larger than 1 MiB
12
+ const MAX_OUTPUT_BYTES = 50 * 1024;
13
+
14
+ interface FileMatch {
15
+ file: string;
16
+ line: number;
17
+ text: string;
18
+ }
19
+
20
+ /**
21
+ * Collect files under dir (depth-limited, symlink-safe, bounded).
22
+ * Does NOT filter by size — path search must find large files too.
23
+ */
24
+ function walk(dir: string, out: string[], depth = 0): void {
25
+ if (depth > 12) return;
26
+ let entries: string[];
27
+ try {
28
+ entries = readdirSync(dir);
29
+ } catch {
30
+ return;
31
+ }
32
+ for (const entry of entries) {
33
+ if (out.length >= MAX_FILES) return; // enforce limit inside the loop
34
+ if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
35
+ const p = join(dir, entry);
36
+ try {
37
+ const lst = lstatSync(p);
38
+ if (lst.isSymbolicLink()) continue; // never follow symlinks
39
+ if (lst.isDirectory()) walk(p, out, depth + 1);
40
+ else out.push(p);
41
+ } catch {
42
+ // unreadable entries are skipped
43
+ }
44
+ }
45
+ }
46
+
47
+ /** Truncate by BYTE length (not UTF-16 chars), keeping complete lines. */
48
+ function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES): string {
49
+ const buf = Buffer.from(text, "utf8");
50
+ if (buf.length <= maxBytes) return text;
51
+ const cut = buf.subarray(0, maxBytes).toString("utf8");
52
+ const lastNewline = cut.lastIndexOf("\n");
53
+ const base = lastNewline > 0 ? cut.slice(0, lastNewline) : cut;
54
+ return `${base}\n[output truncated]`;
55
+ }
56
+
57
+ export function grepFiles(opts: {
58
+ pattern: string;
59
+ path?: string;
60
+ caseSensitive?: boolean;
61
+ maxResults?: number;
62
+ cwd: string;
63
+ }): string {
64
+ const root = resolve(opts.cwd, opts.path || ".");
65
+ const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
66
+ const limit = opts.maxResults ?? 100;
67
+ const files: string[] = [];
68
+ walk(root, files);
69
+ const matches: FileMatch[] = [];
70
+ for (const file of files) {
71
+ if (matches.length >= limit) break;
72
+ try {
73
+ if (statSync(file).size > MAX_FILE_BYTES) continue; // cap only before reading content
74
+ } catch {
75
+ continue;
76
+ }
77
+ let content: string;
78
+ try {
79
+ content = readFileSync(file, "utf8");
80
+ } catch {
81
+ continue;
82
+ }
83
+ const lines = content.split("\n");
84
+ for (let i = 0; i < lines.length; i++) {
85
+ const haystack = opts.caseSensitive ? lines[i] : lines[i].toLowerCase();
86
+ if (haystack.includes(pattern)) {
87
+ matches.push({ file: relative(root, file), line: i + 1, text: lines[i].trim().slice(0, 200) });
88
+ if (matches.length >= limit) break;
89
+ }
90
+ }
91
+ }
92
+ if (matches.length === 0) return "No matches found";
93
+ let out = "";
94
+ for (const m of matches) out += `${m.file}:${m.line}: ${m.text}\n`;
95
+ return truncate(out);
96
+ }
97
+
98
+ export function findFiles(opts: { pattern?: string; path?: string; maxResults?: number; cwd: string }): string {
99
+ const root = resolve(opts.cwd, opts.path || ".");
100
+ const files: string[] = [];
101
+ walk(root, files);
102
+ const needle = opts.pattern?.toLowerCase();
103
+ // Match against the RELATIVE path so a pattern matching an ancestor
104
+ // directory does not hit every file, and rendered output stays relative.
105
+ const rel = files.map((f) => relative(root, f));
106
+ const hits = needle ? rel.filter((r) => r.toLowerCase().includes(needle)) : rel;
107
+ if (hits.length === 0) return "No matching files found";
108
+ return truncate(hits.slice(0, opts.maxResults ?? 100).join("\n"));
109
+ }
110
+
111
+ export function listDir(opts: { path?: string; cwd: string }): string {
112
+ const dir = resolve(opts.cwd, opts.path || ".");
113
+ try {
114
+ return truncate(readdirSync(dir).join("\n"));
115
+ } catch (error: any) {
116
+ return `Error listing ${dir}: ${error.message}`;
117
+ }
118
+ }
119
+
120
+ export const grepSchema = Type.Object({
121
+ pattern: Type.String({ description: "Text to search for in file contents" }),
122
+ path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
123
+ caseSensitive: Type.Optional(Type.Boolean({ description: "Case-sensitive match (default false)" })),
124
+ maxResults: Type.Optional(Type.Number({ description: "Max matches (default 100)" })),
125
+ });
126
+
127
+ export const findSchema = Type.Object({
128
+ pattern: Type.Optional(Type.String({ description: "Substring to match in file path or name (empty lists all)" })),
129
+ path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
130
+ maxResults: Type.Optional(Type.Number({ description: "Max results (default 100)" })),
131
+ });
132
+
133
+ export const lsSchema = Type.Object({
134
+ path: Type.Optional(Type.String({ description: "Directory to list (defaults to cwd)" })),
135
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tool-discipline",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",