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 +26 -18
- package/extensions/index.ts +32 -25
- package/extensions/search.ts +22 -15
- package/package.json +1 -1
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. **
|
|
25
|
-
`
|
|
26
|
-
`
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
`
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -13,12 +13,14 @@
|
|
|
13
13
|
* rules that say to use the grep tool instead.
|
|
14
14
|
*
|
|
15
15
|
* How it works:
|
|
16
|
-
* A.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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", (
|
|
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; //
|
|
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.
|
|
150
|
-
//
|
|
151
|
-
//
|
|
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
|
|
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
|
|
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
|
|
178
|
-
`
|
|
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
|
);
|
package/extensions/search.ts
CHANGED
|
@@ -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; //
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
return `${
|
|
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.
|
|
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.
|
|
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