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 +10 -8
- package/extensions/index.ts +108 -48
- package/extensions/search.ts +135 -0
- package/package.json +1 -1
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. **
|
|
25
|
-
`
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
`
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
17
|
-
* already registered).
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
51
|
+
Search tool priority:
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
164
|
+
// Status command: /tool-discipline — verify tool registration and injection.
|
|
110
165
|
pi.registerCommand("tool-discipline", {
|
|
111
|
-
description: "Show pi-tool-discipline status (
|
|
166
|
+
description: "Show pi-tool-discipline status (tools + injected guideline)",
|
|
112
167
|
handler: async (_args, ctx) => {
|
|
113
|
-
const
|
|
114
|
-
const
|
|
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
|
-
`
|
|
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