pi-tool-discipline 0.1.7 → 0.1.8
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 +15 -8
- package/extensions/index.ts +24 -23
- package/extensions/search.ts +76 -30
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -28,8 +28,9 @@ Two mechanisms, applied automatically in every session:
|
|
|
28
28
|
`Use bash for file operations like ls, rg, find` guideline is **never
|
|
29
29
|
generated** — the model is never told to use bash for searching. On older
|
|
30
30
|
pi versions without these built-ins, fs-based fallbacks
|
|
31
|
-
(`extensions/search.ts`) are registered instead
|
|
32
|
-
`ffgrep`/`fffind` from pi-fff are
|
|
31
|
+
(`extensions/search.ts`) are registered instead, with their text snippet
|
|
32
|
+
suppressed while `ffgrep`/`fffind` from pi-fff are active (the registered
|
|
33
|
+
tool schema remains present either way).
|
|
33
34
|
2. **System-prompt injection (rules).** On every agent start, appends an
|
|
34
35
|
idempotent "Tool Discipline" section to the system prompt: content search
|
|
35
36
|
with `ffgrep`, path search with `fffind`, file reads with `read`
|
|
@@ -46,8 +47,9 @@ pi install npm:pi-tool-discipline
|
|
|
46
47
|
pi -e npm:pi-tool-discipline
|
|
47
48
|
```
|
|
48
49
|
|
|
49
|
-
Requires `@ff-labs/pi-fff`
|
|
50
|
-
|
|
50
|
+
Requires nothing extra. With `@ff-labs/pi-fff` installed, the model prefers
|
|
51
|
+
`ffgrep`/`fffind`; without it, Pi's built-in `grep`/`find`/`ls` tools (activated
|
|
52
|
+
by this extension, or fs-based fallbacks on older pi versions) are used.
|
|
51
53
|
|
|
52
54
|
## Verify
|
|
53
55
|
|
|
@@ -66,10 +68,15 @@ This extension runs with full system access like any pi extension. What it does:
|
|
|
66
68
|
- On pi versions without built-in search tools, registers read-only fs-based
|
|
67
69
|
fallback implementations that read file contents under the searched path.
|
|
68
70
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
**Disclosure:** pi's built-in `grep`/`find` tools execute the `rg`/`fd`
|
|
72
|
+
binaries, and pi may auto-download those binaries from GitHub on first use
|
|
73
|
+
(`ensureTool`). This extension itself does not execute commands, write files,
|
|
74
|
+
or touch the network — that claim covers only its own fs-based fallback
|
|
75
|
+
implementations, not the pi built-ins it activates.
|
|
76
|
+
|
|
77
|
+
The fallback search tools only read. Note that any installed tool, including
|
|
78
|
+
this one, can be invoked by the model. Review the source in `extensions/`
|
|
79
|
+
before installing.
|
|
73
80
|
|
|
74
81
|
## License
|
|
75
82
|
|
package/extensions/index.ts
CHANGED
|
@@ -37,24 +37,14 @@ const PLACEHOLDER_NAMES = ["grep", "find", "ls"] as const;
|
|
|
37
37
|
/** FFF search tools that indicate pi-fff (or equivalent) is installed. */
|
|
38
38
|
const FFF_TOOLS = ["ffgrep", "fffind"];
|
|
39
39
|
|
|
40
|
-
/**
|
|
41
|
-
* The exact guidelines pi's builder injects when no search tool is active.
|
|
42
|
-
* Stripped from the system prompt as a fallback (plan B).
|
|
43
|
-
*/
|
|
44
|
-
const BASH_GUIDELINES = [
|
|
45
|
-
"Use bash for file operations like ls, rg, find",
|
|
46
|
-
"Use bash or PowerShell for file operations like listing, searching, and finding files",
|
|
47
|
-
"Use PowerShell for file operations like listing, searching, and finding files",
|
|
48
|
-
];
|
|
49
|
-
|
|
50
40
|
const DISCIPLINE = `
|
|
51
41
|
## Tool Discipline (pi-tool-discipline)
|
|
52
42
|
|
|
53
43
|
Search tool priority:
|
|
54
44
|
|
|
55
45
|
1. ffgrep / fffind (from @ff-labs/pi-fff) — always preferred. They work with absolute paths outside the workspace and support regex / path / exclude filters.
|
|
56
|
-
2. Without pi-fff, use the grep / find / ls TOOLS (fallbacks
|
|
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
|
|
46
|
+
2. Without pi-fff, use the grep / find / ls TOOLS (Pi built-ins on pi 0.84+, fs fallbacks on older versions). Fill their parameters according to each tool's declared schema — built-in grep uses ignoreCase/limit, built-in find uses a glob pattern. Never fall back to bash's grep or find.
|
|
47
|
+
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 tools) instead.
|
|
58
48
|
4. Read files with \`read\` (offset/limit for large files).
|
|
59
49
|
5. Bash stays allowed only when dedicated tools cannot do the job: pipelines, git, npm, running programs, network requests, file mutations.
|
|
60
50
|
6. If bash searching is truly unavoidable, use \`rg\` (never \`grep\`).`;
|
|
@@ -64,7 +54,7 @@ interface FallbackTool {
|
|
|
64
54
|
description: string;
|
|
65
55
|
snippet: string;
|
|
66
56
|
parameters: ReturnType<typeof Type.Object>;
|
|
67
|
-
execute: (params: any, cwd: string) => string
|
|
57
|
+
execute: (params: any, cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
68
58
|
}
|
|
69
59
|
|
|
70
60
|
const FALLBACK_TOOLS: Record<string, FallbackTool> = {
|
|
@@ -74,7 +64,7 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
|
|
|
74
64
|
"Search file contents for a text pattern. Fallback for environments without ffgrep; prefer ffgrep when available.",
|
|
75
65
|
snippet: "Search file contents (fallback when ffgrep is unavailable)",
|
|
76
66
|
parameters: grepSchema,
|
|
77
|
-
execute: (params, cwd) => grepFiles({ ...params, cwd }),
|
|
67
|
+
execute: async (params, cwd, signal) => grepFiles({ ...params, cwd, signal }),
|
|
78
68
|
},
|
|
79
69
|
find: {
|
|
80
70
|
label: "find (fallback)",
|
|
@@ -82,7 +72,7 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
|
|
|
82
72
|
"Find files by path/name substring. Fallback for environments without fffind; prefer fffind when available.",
|
|
83
73
|
snippet: "Find files by path/name (fallback when fffind is unavailable)",
|
|
84
74
|
parameters: findSchema,
|
|
85
|
-
execute: (params, cwd) => findFiles({ ...params, cwd }),
|
|
75
|
+
execute: async (params, cwd, signal) => findFiles({ ...params, cwd, signal }),
|
|
86
76
|
},
|
|
87
77
|
ls: {
|
|
88
78
|
label: "ls (fallback)",
|
|
@@ -90,16 +80,27 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
|
|
|
90
80
|
"List directory entries. Fallback for environments without fffind; prefer fffind when available.",
|
|
91
81
|
snippet: "List directory entries (fallback when fffind is unavailable)",
|
|
92
82
|
parameters: lsSchema,
|
|
93
|
-
execute: (params, cwd) => listDir({ ...params, cwd }),
|
|
83
|
+
execute: async (params, cwd) => listDir({ ...params, cwd }),
|
|
94
84
|
},
|
|
95
85
|
};
|
|
96
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Strip only the exact generated guideline bullets (with the "- " prefix), so
|
|
89
|
+
* quoted references inside project instructions or custom prompts are not
|
|
90
|
+
* rewritten. Activation normally prevents pi from generating these anyway;
|
|
91
|
+
* this is a belt-and-suspenders fallback.
|
|
92
|
+
*/
|
|
97
93
|
function stripBashGuidelines(prompt: string): string {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
94
|
+
return prompt
|
|
95
|
+
.replace("- Use bash for file operations like ls, rg, find", "- Use ffgrep/fffind for file operations like ls, rg, find")
|
|
96
|
+
.replace(
|
|
97
|
+
"- Use bash or PowerShell for file operations like listing, searching, and finding files",
|
|
98
|
+
"- Use ffgrep/fffind for file operations like listing, searching, and finding files",
|
|
99
|
+
)
|
|
100
|
+
.replace(
|
|
101
|
+
"- Use PowerShell for file operations like listing, searching, and finding files",
|
|
102
|
+
"- Use ffgrep/fffind for file operations like listing, searching, and finding files",
|
|
103
|
+
);
|
|
103
104
|
}
|
|
104
105
|
|
|
105
106
|
export default function toolDiscipline(pi: ExtensionAPI) {
|
|
@@ -140,8 +141,8 @@ export default function toolDiscipline(pi: ExtensionAPI) {
|
|
|
140
141
|
"Use ffgrep/fffind when they are available; grep/find/ls are fallbacks only for environments without pi-fff.",
|
|
141
142
|
],
|
|
142
143
|
parameters: fallback.parameters,
|
|
143
|
-
async execute(_toolCallId, params,
|
|
144
|
-
const text = fallback.execute(params ?? {}, execCtx.cwd);
|
|
144
|
+
async execute(_toolCallId, params, signal, _onUpdate, execCtx) {
|
|
145
|
+
const text = await fallback.execute(params ?? {}, execCtx.cwd, signal);
|
|
145
146
|
return {
|
|
146
147
|
content: [{ type: "text", text }],
|
|
147
148
|
details: { fallback: true },
|
package/extensions/search.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Fallback search implementations for environments without @ff-labs/pi-fff.
|
|
3
|
-
* Pure Node fs-based; no shell, no external deps.
|
|
3
|
+
* Pure Node fs-based (async, abort-aware); no shell, no external deps.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { readdir, readFile, stat, lstat } from "fs/promises";
|
|
6
6
|
import { join, resolve, relative } from "path";
|
|
7
7
|
import { Type } from "typebox";
|
|
8
8
|
|
|
@@ -12,6 +12,7 @@ const MAX_VISITED = 5000; // total entries touched, bounds slow/odd trees
|
|
|
12
12
|
const MAX_FILE_BYTES = 1024 * 1024; // content search skips files larger than 1 MiB
|
|
13
13
|
const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
14
14
|
const TRUNCATE_MARKER = "\n[output truncated]";
|
|
15
|
+
const CAPPED_MARKER = "\n[search capped — traversal stopped early]";
|
|
15
16
|
|
|
16
17
|
interface FileMatch {
|
|
17
18
|
file: string;
|
|
@@ -19,30 +20,44 @@ interface FileMatch {
|
|
|
19
20
|
text: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
interface WalkState {
|
|
24
|
+
visited: number;
|
|
25
|
+
capped: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
/**
|
|
23
|
-
* Collect regular files under dir (depth-limited, symlink-safe, bounded
|
|
24
|
-
* Only isFile() entries are collected — FIFOs, devices, sockets
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* Does NOT filter by size — path search must find large files too.
|
|
29
|
+
* Collect regular files under dir (depth-limited, symlink-safe, bounded,
|
|
30
|
+
* abort-aware). Only isFile() entries are collected — FIFOs, devices, sockets
|
|
31
|
+
* would block a read. Individual OS filesystem requests may still be
|
|
32
|
+
* uninterruptible, but traversal checks the signal between operations.
|
|
28
33
|
*/
|
|
29
|
-
function walk(dir: string, out: string[], state:
|
|
30
|
-
if (depth > 12 || state.visited >= MAX_VISITED)
|
|
34
|
+
async function walk(dir: string, out: string[], state: WalkState, signal?: AbortSignal, depth = 0): Promise<void> {
|
|
35
|
+
if (depth > 12 || state.visited >= MAX_VISITED) {
|
|
36
|
+
state.capped = true;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
31
39
|
let entries: string[];
|
|
32
40
|
try {
|
|
33
|
-
entries =
|
|
41
|
+
entries = await readdir(dir);
|
|
34
42
|
} catch {
|
|
35
43
|
return;
|
|
36
44
|
}
|
|
37
45
|
for (const entry of entries) {
|
|
38
|
-
if (
|
|
46
|
+
if (signal?.aborted) {
|
|
47
|
+
state.capped = true;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (out.length >= MAX_FILES || state.visited >= MAX_VISITED) {
|
|
51
|
+
state.capped = true;
|
|
52
|
+
return; // in-loop bound
|
|
53
|
+
}
|
|
39
54
|
if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
|
|
40
55
|
const p = join(dir, entry);
|
|
41
56
|
state.visited++;
|
|
42
57
|
try {
|
|
43
|
-
const lst =
|
|
58
|
+
const lst = await lstat(p);
|
|
44
59
|
if (lst.isSymbolicLink()) continue; // never follow symlinks
|
|
45
|
-
if (lst.isDirectory()) walk(p, out, state, depth + 1);
|
|
60
|
+
if (lst.isDirectory()) await walk(p, out, state, signal, depth + 1);
|
|
46
61
|
else if (lst.isFile()) out.push(p); // regular files only
|
|
47
62
|
} catch {
|
|
48
63
|
// unreadable entries are skipped
|
|
@@ -61,29 +76,46 @@ function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES): string {
|
|
|
61
76
|
return `${cut.slice(0, lastNewline)}\n${TRUNCATE_MARKER}`;
|
|
62
77
|
}
|
|
63
78
|
|
|
64
|
-
|
|
79
|
+
async function resolveRoot(cwd: string, sub?: string): Promise<string | null> {
|
|
80
|
+
const root = resolve(cwd, sub || ".");
|
|
81
|
+
try {
|
|
82
|
+
const st = await stat(root);
|
|
83
|
+
return st.isDirectory() ? root : null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function grepFiles(opts: {
|
|
65
90
|
pattern: string;
|
|
66
91
|
path?: string;
|
|
67
92
|
caseSensitive?: boolean;
|
|
68
93
|
maxResults?: number;
|
|
69
94
|
cwd: string;
|
|
70
|
-
|
|
71
|
-
|
|
95
|
+
signal?: AbortSignal;
|
|
96
|
+
}): Promise<string> {
|
|
97
|
+
const root = await resolveRoot(opts.cwd, opts.path);
|
|
98
|
+
if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`;
|
|
72
99
|
const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
|
|
73
100
|
const limit = opts.maxResults ?? 100;
|
|
74
101
|
const files: string[] = [];
|
|
75
|
-
|
|
102
|
+
const state: WalkState = { visited: 0, capped: false };
|
|
103
|
+
await walk(root, files, state, opts.signal);
|
|
76
104
|
const matches: FileMatch[] = [];
|
|
77
105
|
for (const file of files) {
|
|
106
|
+
if (opts.signal?.aborted) {
|
|
107
|
+
state.capped = true;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
78
110
|
if (matches.length >= limit) break;
|
|
79
111
|
try {
|
|
80
|
-
if (
|
|
112
|
+
if ((await stat(file)).size > MAX_FILE_BYTES) continue; // cap only before reading content
|
|
81
113
|
} catch {
|
|
82
114
|
continue;
|
|
83
115
|
}
|
|
84
116
|
let content: string;
|
|
85
117
|
try {
|
|
86
|
-
content =
|
|
118
|
+
content = await readFile(file, "utf8");
|
|
87
119
|
} catch {
|
|
88
120
|
continue;
|
|
89
121
|
}
|
|
@@ -96,31 +128,45 @@ export function grepFiles(opts: {
|
|
|
96
128
|
}
|
|
97
129
|
}
|
|
98
130
|
}
|
|
99
|
-
if (matches.length === 0) return "No matches found";
|
|
131
|
+
if (matches.length === 0) return state.capped ? `No matches found${CAPPED_MARKER}` : "No matches found";
|
|
100
132
|
let out = "";
|
|
101
133
|
for (const m of matches) out += `${m.file}:${m.line}: ${m.text}\n`;
|
|
102
|
-
|
|
134
|
+
out = truncate(out);
|
|
135
|
+
return state.capped ? `${out}${CAPPED_MARKER}` : out;
|
|
103
136
|
}
|
|
104
137
|
|
|
105
|
-
export function findFiles(opts: {
|
|
106
|
-
|
|
138
|
+
export async function findFiles(opts: {
|
|
139
|
+
pattern?: string;
|
|
140
|
+
path?: string;
|
|
141
|
+
maxResults?: number;
|
|
142
|
+
cwd: string;
|
|
143
|
+
signal?: AbortSignal;
|
|
144
|
+
}): Promise<string> {
|
|
145
|
+
const root = await resolveRoot(opts.cwd, opts.path);
|
|
146
|
+
if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`;
|
|
107
147
|
const files: string[] = [];
|
|
108
|
-
|
|
148
|
+
const state: WalkState = { visited: 0, capped: false };
|
|
149
|
+
await walk(root, files, state, opts.signal);
|
|
109
150
|
const needle = opts.pattern?.toLowerCase();
|
|
110
151
|
// Match against the RELATIVE path so a pattern matching an ancestor
|
|
111
152
|
// directory does not hit every file, and rendered output stays relative.
|
|
112
153
|
const rel = files.map((f) => relative(root, f));
|
|
113
154
|
const hits = needle ? rel.filter((r) => r.toLowerCase().includes(needle)) : rel;
|
|
114
|
-
if (hits.length === 0) return "No matching files found";
|
|
115
|
-
|
|
155
|
+
if (hits.length === 0) return state.capped ? `No matching files found${CAPPED_MARKER}` : "No matching files found";
|
|
156
|
+
let out = truncate(hits.slice(0, opts.maxResults ?? 100).join("\n"));
|
|
157
|
+
if (state.capped) out += CAPPED_MARKER;
|
|
158
|
+
return out;
|
|
116
159
|
}
|
|
117
160
|
|
|
118
|
-
export function listDir(opts: { path?: string; cwd: string }): string {
|
|
119
|
-
const
|
|
161
|
+
export async function listDir(opts: { path?: string; cwd: string }): Promise<string> {
|
|
162
|
+
const root = await resolveRoot(opts.cwd, opts.path);
|
|
163
|
+
if (!root) return `Error: directory not found: ${resolve(opts.cwd, opts.path || ".")}`;
|
|
120
164
|
try {
|
|
121
|
-
|
|
165
|
+
const entries = await readdir(root);
|
|
166
|
+
if (entries.length === 0) return "(empty directory)";
|
|
167
|
+
return truncate(entries.join("\n"));
|
|
122
168
|
} catch (error: any) {
|
|
123
|
-
return `Error listing ${
|
|
169
|
+
return `Error listing ${root}: ${error.message}`;
|
|
124
170
|
}
|
|
125
171
|
}
|
|
126
172
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tool-discipline",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
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",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
43
|
"@earendil-works/pi-coding-agent": "*",
|
|
44
|
-
"
|
|
44
|
+
"typebox": "*"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@earendil-works/pi-coding-agent": "*",
|
|
48
|
-
"@sinclair/typebox": "*",
|
|
49
48
|
"@types/node": "^22.0.0",
|
|
49
|
+
"typebox": "*",
|
|
50
50
|
"typescript": "^5.0.0"
|
|
51
51
|
}
|
|
52
52
|
}
|