pi-tool-discipline 0.1.8 → 0.1.9
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/extensions/index.ts +2 -20
- package/extensions/search.ts +63 -27
- package/extensions/strip.ts +23 -0
- package/package.json +3 -2
package/extensions/index.ts
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
29
29
|
import { Type } from "typebox";
|
|
30
30
|
import { grepFiles, findFiles, listDir, grepSchema, findSchema, lsSchema } from "./search.js";
|
|
31
|
+
import { stripBashGuidelines } from "./strip.js";
|
|
31
32
|
|
|
32
33
|
const MARK = "<!-- pi-tool-discipline:v1 -->";
|
|
33
34
|
|
|
@@ -80,29 +81,10 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
|
|
|
80
81
|
"List directory entries. Fallback for environments without fffind; prefer fffind when available.",
|
|
81
82
|
snippet: "List directory entries (fallback when fffind is unavailable)",
|
|
82
83
|
parameters: lsSchema,
|
|
83
|
-
execute: async (params, cwd) => listDir({ ...params, cwd }),
|
|
84
|
+
execute: async (params, cwd, signal) => listDir({ ...params, cwd, signal }),
|
|
84
85
|
},
|
|
85
86
|
};
|
|
86
87
|
|
|
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
|
-
*/
|
|
93
|
-
function stripBashGuidelines(prompt: string): string {
|
|
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
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
88
|
export default function toolDiscipline(pi: ExtensionAPI) {
|
|
107
89
|
// A. Register search-tool names so pi stops generating the bash guideline.
|
|
108
90
|
// Done in session_start: action methods (getAllTools/registerTool) are not
|
package/extensions/search.ts
CHANGED
|
@@ -14,6 +14,15 @@ const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
|
14
14
|
const TRUNCATE_MARKER = "\n[output truncated]";
|
|
15
15
|
const CAPPED_MARKER = "\n[search capped — traversal stopped early]";
|
|
16
16
|
|
|
17
|
+
/** Cancellation error, matching pi built-in tools ("Operation aborted"). */
|
|
18
|
+
function abortError(): Error {
|
|
19
|
+
return new Error("Operation aborted");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
23
|
+
if (signal?.aborted) throw abortError();
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
interface FileMatch {
|
|
18
27
|
file: string;
|
|
19
28
|
line: number;
|
|
@@ -23,15 +32,19 @@ interface FileMatch {
|
|
|
23
32
|
interface WalkState {
|
|
24
33
|
visited: number;
|
|
25
34
|
capped: boolean;
|
|
35
|
+
rootError?: string;
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
/**
|
|
29
39
|
* Collect regular files under dir (depth-limited, symlink-safe, bounded,
|
|
30
40
|
* abort-aware). Only isFile() entries are collected — FIFOs, devices, sockets
|
|
31
41
|
* would block a read. Individual OS filesystem requests may still be
|
|
32
|
-
* uninterruptible, but
|
|
42
|
+
* uninterruptible, but the signal is checked before/after every operation.
|
|
43
|
+
* A failure to read the REQUESTED ROOT (depth 0) is recorded in rootError;
|
|
44
|
+
* nested unreadable entries are skipped.
|
|
33
45
|
*/
|
|
34
46
|
async function walk(dir: string, out: string[], state: WalkState, signal?: AbortSignal, depth = 0): Promise<void> {
|
|
47
|
+
throwIfAborted(signal);
|
|
35
48
|
if (depth > 12 || state.visited >= MAX_VISITED) {
|
|
36
49
|
state.capped = true;
|
|
37
50
|
return;
|
|
@@ -39,14 +52,12 @@ async function walk(dir: string, out: string[], state: WalkState, signal?: Abort
|
|
|
39
52
|
let entries: string[];
|
|
40
53
|
try {
|
|
41
54
|
entries = await readdir(dir);
|
|
42
|
-
} catch {
|
|
55
|
+
} catch (error: any) {
|
|
56
|
+
if (depth === 0) state.rootError = error?.message ?? String(error);
|
|
43
57
|
return;
|
|
44
58
|
}
|
|
59
|
+
throwIfAborted(signal);
|
|
45
60
|
for (const entry of entries) {
|
|
46
|
-
if (signal?.aborted) {
|
|
47
|
-
state.capped = true;
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
61
|
if (out.length >= MAX_FILES || state.visited >= MAX_VISITED) {
|
|
51
62
|
state.capped = true;
|
|
52
63
|
return; // in-loop bound
|
|
@@ -56,20 +67,26 @@ async function walk(dir: string, out: string[], state: WalkState, signal?: Abort
|
|
|
56
67
|
state.visited++;
|
|
57
68
|
try {
|
|
58
69
|
const lst = await lstat(p);
|
|
70
|
+
throwIfAborted(signal);
|
|
59
71
|
if (lst.isSymbolicLink()) continue; // never follow symlinks
|
|
60
72
|
if (lst.isDirectory()) await walk(p, out, state, signal, depth + 1);
|
|
61
73
|
else if (lst.isFile()) out.push(p); // regular files only
|
|
62
|
-
} catch {
|
|
74
|
+
} catch (error: any) {
|
|
75
|
+
if (error?.message === "Operation aborted") throw error;
|
|
63
76
|
// unreadable entries are skipped
|
|
64
77
|
}
|
|
65
78
|
}
|
|
66
79
|
}
|
|
67
80
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Truncate by BYTE length, keep complete lines, reserve space for all markers.
|
|
83
|
+
* Returns the original text unchanged when it fits within maxBytes.
|
|
84
|
+
*/
|
|
85
|
+
function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES, extraMarkerBytes = 0): string {
|
|
70
86
|
const buf = Buffer.from(text, "utf8");
|
|
71
|
-
|
|
72
|
-
|
|
87
|
+
if (buf.length <= maxBytes) return text;
|
|
88
|
+
const budget = maxBytes - Buffer.byteLength(TRUNCATE_MARKER) - extraMarkerBytes;
|
|
89
|
+
if (budget <= 0) return TRUNCATE_MARKER.trim();
|
|
73
90
|
const cut = buf.subarray(0, budget).toString("utf8");
|
|
74
91
|
const lastNewline = cut.lastIndexOf("\n");
|
|
75
92
|
if (lastNewline <= 0) return TRUNCATE_MARKER.trim(); // nothing complete fits
|
|
@@ -86,6 +103,12 @@ async function resolveRoot(cwd: string, sub?: string): Promise<string | null> {
|
|
|
86
103
|
}
|
|
87
104
|
}
|
|
88
105
|
|
|
106
|
+
function walkFiles(root: string, signal?: AbortSignal): Promise<{ files: string[]; state: WalkState }> {
|
|
107
|
+
const files: string[] = [];
|
|
108
|
+
const state: WalkState = { visited: 0, capped: false };
|
|
109
|
+
return walk(root, files, state, signal).then(() => ({ files, state }));
|
|
110
|
+
}
|
|
111
|
+
|
|
89
112
|
export async function grepFiles(opts: {
|
|
90
113
|
pattern: string;
|
|
91
114
|
path?: string;
|
|
@@ -94,33 +117,33 @@ export async function grepFiles(opts: {
|
|
|
94
117
|
cwd: string;
|
|
95
118
|
signal?: AbortSignal;
|
|
96
119
|
}): Promise<string> {
|
|
120
|
+
throwIfAborted(opts.signal);
|
|
97
121
|
const root = await resolveRoot(opts.cwd, opts.path);
|
|
98
122
|
if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`;
|
|
99
123
|
const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
|
|
100
124
|
const limit = opts.maxResults ?? 100;
|
|
101
|
-
const files
|
|
102
|
-
|
|
103
|
-
await walk(root, files, state, opts.signal);
|
|
125
|
+
const { files, state } = await walkFiles(root, opts.signal);
|
|
126
|
+
if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}`;
|
|
104
127
|
const matches: FileMatch[] = [];
|
|
105
128
|
for (const file of files) {
|
|
106
|
-
|
|
107
|
-
state.capped = true;
|
|
108
|
-
break;
|
|
109
|
-
}
|
|
129
|
+
throwIfAborted(opts.signal);
|
|
110
130
|
if (matches.length >= limit) break;
|
|
111
131
|
try {
|
|
112
132
|
if ((await stat(file)).size > MAX_FILE_BYTES) continue; // cap only before reading content
|
|
113
133
|
} catch {
|
|
114
134
|
continue;
|
|
115
135
|
}
|
|
136
|
+
throwIfAborted(opts.signal);
|
|
116
137
|
let content: string;
|
|
117
138
|
try {
|
|
118
139
|
content = await readFile(file, "utf8");
|
|
119
140
|
} catch {
|
|
120
141
|
continue;
|
|
121
142
|
}
|
|
143
|
+
throwIfAborted(opts.signal);
|
|
122
144
|
const lines = content.split("\n");
|
|
123
145
|
for (let i = 0; i < lines.length; i++) {
|
|
146
|
+
throwIfAborted(opts.signal);
|
|
124
147
|
const haystack = opts.caseSensitive ? lines[i] : lines[i].toLowerCase();
|
|
125
148
|
if (haystack.includes(pattern)) {
|
|
126
149
|
matches.push({ file: relative(root, file), line: i + 1, text: lines[i].trim().slice(0, 200) });
|
|
@@ -131,8 +154,9 @@ export async function grepFiles(opts: {
|
|
|
131
154
|
if (matches.length === 0) return state.capped ? `No matches found${CAPPED_MARKER}` : "No matches found";
|
|
132
155
|
let out = "";
|
|
133
156
|
for (const m of matches) out += `${m.file}:${m.line}: ${m.text}\n`;
|
|
134
|
-
out = truncate(out);
|
|
135
|
-
|
|
157
|
+
out = truncate(out, MAX_OUTPUT_BYTES, state.capped ? Buffer.byteLength(CAPPED_MARKER) : 0);
|
|
158
|
+
if (state.capped) out += CAPPED_MARKER;
|
|
159
|
+
return out;
|
|
136
160
|
}
|
|
137
161
|
|
|
138
162
|
export async function findFiles(opts: {
|
|
@@ -142,30 +166,42 @@ export async function findFiles(opts: {
|
|
|
142
166
|
cwd: string;
|
|
143
167
|
signal?: AbortSignal;
|
|
144
168
|
}): Promise<string> {
|
|
169
|
+
throwIfAborted(opts.signal);
|
|
145
170
|
const root = await resolveRoot(opts.cwd, opts.path);
|
|
146
171
|
if (!root) return `Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`;
|
|
147
|
-
const files
|
|
148
|
-
|
|
149
|
-
await walk(root, files, state, opts.signal);
|
|
172
|
+
const { files, state } = await walkFiles(root, opts.signal);
|
|
173
|
+
if (state.rootError) return `Error: cannot read search root ${root}: ${state.rootError}`;
|
|
150
174
|
const needle = opts.pattern?.toLowerCase();
|
|
151
175
|
// Match against the RELATIVE path so a pattern matching an ancestor
|
|
152
176
|
// directory does not hit every file, and rendered output stays relative.
|
|
153
|
-
const rel =
|
|
154
|
-
const
|
|
177
|
+
const rel: string[] = [];
|
|
178
|
+
for (const f of files) {
|
|
179
|
+
throwIfAborted(opts.signal);
|
|
180
|
+
rel.push(relative(root, f));
|
|
181
|
+
}
|
|
182
|
+
const hits: string[] = [];
|
|
183
|
+
for (const r of rel) {
|
|
184
|
+
throwIfAborted(opts.signal);
|
|
185
|
+
if (!needle || r.toLowerCase().includes(needle)) hits.push(r);
|
|
186
|
+
if (hits.length >= (opts.maxResults ?? 100)) break;
|
|
187
|
+
}
|
|
155
188
|
if (hits.length === 0) return state.capped ? `No matching files found${CAPPED_MARKER}` : "No matching files found";
|
|
156
|
-
let out = truncate(hits.
|
|
189
|
+
let out = truncate(hits.join("\n"), MAX_OUTPUT_BYTES, state.capped ? Buffer.byteLength(CAPPED_MARKER) : 0);
|
|
157
190
|
if (state.capped) out += CAPPED_MARKER;
|
|
158
191
|
return out;
|
|
159
192
|
}
|
|
160
193
|
|
|
161
|
-
export async function listDir(opts: { path?: string; cwd: string }): Promise<string> {
|
|
194
|
+
export async function listDir(opts: { path?: string; cwd: string; signal?: AbortSignal }): Promise<string> {
|
|
195
|
+
throwIfAborted(opts.signal);
|
|
162
196
|
const root = await resolveRoot(opts.cwd, opts.path);
|
|
163
197
|
if (!root) return `Error: directory not found: ${resolve(opts.cwd, opts.path || ".")}`;
|
|
164
198
|
try {
|
|
165
199
|
const entries = await readdir(root);
|
|
200
|
+
throwIfAborted(opts.signal);
|
|
166
201
|
if (entries.length === 0) return "(empty directory)";
|
|
167
202
|
return truncate(entries.join("\n"));
|
|
168
203
|
} catch (error: any) {
|
|
204
|
+
if (error?.message === "Operation aborted") throw error;
|
|
169
205
|
return `Error listing ${root}: ${error.message}`;
|
|
170
206
|
}
|
|
171
207
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whole-line stripping of pi's generated bash file-operation guidelines.
|
|
3
|
+
* Kept dependency-free so it is testable outside the pi runtime.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Strip only the exact generated guideline bullets, line-anchored (whole-line,
|
|
8
|
+
* global), so quoted references inside project instructions or custom prompts
|
|
9
|
+
* are not rewritten. Activation normally prevents pi from generating these
|
|
10
|
+
* anyway; this is a belt-and-suspenders fallback.
|
|
11
|
+
*/
|
|
12
|
+
export function stripBashGuidelines(prompt: string): string {
|
|
13
|
+
return prompt
|
|
14
|
+
.replace(/^- Use bash for file operations like ls, rg, find(?=\r?$)/gm, "- Use ffgrep/fffind for file operations like ls, rg, find")
|
|
15
|
+
.replace(
|
|
16
|
+
/^- Use bash or PowerShell for file operations like listing, searching, and finding files(?=\r?$)/gm,
|
|
17
|
+
"- Use ffgrep/fffind for file operations like listing, searching, and finding files",
|
|
18
|
+
)
|
|
19
|
+
.replace(
|
|
20
|
+
/^- Use PowerShell for file operations like listing, searching, and finding files(?=\r?$)/gm,
|
|
21
|
+
"- Use ffgrep/fffind for file operations like listing, searching, and finding files",
|
|
22
|
+
);
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tool-discipline",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"access": "public"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
|
-
"typecheck": "tsc --noEmit"
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"test": "node --experimental-strip-types test/fallback.test.mjs"
|
|
41
42
|
},
|
|
42
43
|
"peerDependencies": {
|
|
43
44
|
"@earendil-works/pi-coding-agent": "*",
|