pi-tool-discipline 0.1.6 → 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 +35 -20
- package/extensions/index.ts +56 -48
- package/extensions/search.ts +88 -35
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -21,20 +21,23 @@ 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, with their text snippet
|
|
32
|
+
suppressed while `ffgrep`/`fffind` from pi-fff are active (the registered
|
|
33
|
+
tool schema remains present either way).
|
|
34
|
+
2. **System-prompt injection (rules).** On every agent start, appends an
|
|
35
|
+
idempotent "Tool Discipline" section to the system prompt: content search
|
|
36
|
+
with `ffgrep`, path search with `fffind`, file reads with `read`
|
|
37
|
+
(offset/limit), no bash `grep`/`rg`/`find`/`ls`/`cat`/`sed`/`head`/`tail`/
|
|
38
|
+
`which` for searching, bash reserved for pipelines/git/npm/network, `rg`
|
|
39
|
+
(never `grep`) as the last resort. Also strips the bash guideline text as
|
|
40
|
+
a belt-and-suspenders fallback.
|
|
38
41
|
|
|
39
42
|
## Install
|
|
40
43
|
|
|
@@ -44,8 +47,9 @@ pi install npm:pi-tool-discipline
|
|
|
44
47
|
pi -e npm:pi-tool-discipline
|
|
45
48
|
```
|
|
46
49
|
|
|
47
|
-
Requires `@ff-labs/pi-fff`
|
|
48
|
-
|
|
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.
|
|
49
53
|
|
|
50
54
|
## Verify
|
|
51
55
|
|
|
@@ -58,10 +62,21 @@ You can also inspect the system prompt: the string
|
|
|
58
62
|
|
|
59
63
|
## Security
|
|
60
64
|
|
|
61
|
-
This extension runs with full system access like any pi extension.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
This extension runs with full system access like any pi extension. What it does:
|
|
66
|
+
- Activates pi's built-in `grep`/`find`/`ls` tools (read-only file search).
|
|
67
|
+
- Injects text into the system prompt (discipline rules).
|
|
68
|
+
- On pi versions without built-in search tools, registers read-only fs-based
|
|
69
|
+
fallback implementations that read file contents under the searched path.
|
|
70
|
+
|
|
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.
|
|
65
80
|
|
|
66
81
|
## License
|
|
67
82
|
|
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
|
*/
|
|
@@ -35,24 +37,14 @@ const PLACEHOLDER_NAMES = ["grep", "find", "ls"] as const;
|
|
|
35
37
|
/** FFF search tools that indicate pi-fff (or equivalent) is installed. */
|
|
36
38
|
const FFF_TOOLS = ["ffgrep", "fffind"];
|
|
37
39
|
|
|
38
|
-
/**
|
|
39
|
-
* The exact guidelines pi's builder injects when no search tool is active.
|
|
40
|
-
* Stripped from the system prompt as a fallback (plan B).
|
|
41
|
-
*/
|
|
42
|
-
const BASH_GUIDELINES = [
|
|
43
|
-
"Use bash for file operations like ls, rg, find",
|
|
44
|
-
"Use bash or PowerShell for file operations like listing, searching, and finding files",
|
|
45
|
-
"Use PowerShell for file operations like listing, searching, and finding files",
|
|
46
|
-
];
|
|
47
|
-
|
|
48
40
|
const DISCIPLINE = `
|
|
49
41
|
## Tool Discipline (pi-tool-discipline)
|
|
50
42
|
|
|
51
43
|
Search tool priority:
|
|
52
44
|
|
|
53
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.
|
|
54
|
-
2. Without pi-fff, use the grep / find / ls TOOLS (fallbacks
|
|
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
|
|
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.
|
|
56
48
|
4. Read files with \`read\` (offset/limit for large files).
|
|
57
49
|
5. Bash stays allowed only when dedicated tools cannot do the job: pipelines, git, npm, running programs, network requests, file mutations.
|
|
58
50
|
6. If bash searching is truly unavoidable, use \`rg\` (never \`grep\`).`;
|
|
@@ -62,7 +54,7 @@ interface FallbackTool {
|
|
|
62
54
|
description: string;
|
|
63
55
|
snippet: string;
|
|
64
56
|
parameters: ReturnType<typeof Type.Object>;
|
|
65
|
-
execute: (params: any, cwd: string) => string
|
|
57
|
+
execute: (params: any, cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
66
58
|
}
|
|
67
59
|
|
|
68
60
|
const FALLBACK_TOOLS: Record<string, FallbackTool> = {
|
|
@@ -72,7 +64,7 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
|
|
|
72
64
|
"Search file contents for a text pattern. Fallback for environments without ffgrep; prefer ffgrep when available.",
|
|
73
65
|
snippet: "Search file contents (fallback when ffgrep is unavailable)",
|
|
74
66
|
parameters: grepSchema,
|
|
75
|
-
execute: (params, cwd) => grepFiles({ ...params, cwd }),
|
|
67
|
+
execute: async (params, cwd, signal) => grepFiles({ ...params, cwd, signal }),
|
|
76
68
|
},
|
|
77
69
|
find: {
|
|
78
70
|
label: "find (fallback)",
|
|
@@ -80,7 +72,7 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
|
|
|
80
72
|
"Find files by path/name substring. Fallback for environments without fffind; prefer fffind when available.",
|
|
81
73
|
snippet: "Find files by path/name (fallback when fffind is unavailable)",
|
|
82
74
|
parameters: findSchema,
|
|
83
|
-
execute: (params, cwd) => findFiles({ ...params, cwd }),
|
|
75
|
+
execute: async (params, cwd, signal) => findFiles({ ...params, cwd, signal }),
|
|
84
76
|
},
|
|
85
77
|
ls: {
|
|
86
78
|
label: "ls (fallback)",
|
|
@@ -88,16 +80,27 @@ const FALLBACK_TOOLS: Record<string, FallbackTool> = {
|
|
|
88
80
|
"List directory entries. Fallback for environments without fffind; prefer fffind when available.",
|
|
89
81
|
snippet: "List directory entries (fallback when fffind is unavailable)",
|
|
90
82
|
parameters: lsSchema,
|
|
91
|
-
execute: (params, cwd) => listDir({ ...params, cwd }),
|
|
83
|
+
execute: async (params, cwd) => listDir({ ...params, cwd }),
|
|
92
84
|
},
|
|
93
85
|
};
|
|
94
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
|
+
*/
|
|
95
93
|
function stripBashGuidelines(prompt: string): string {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
);
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
export default function toolDiscipline(pi: ExtensionAPI) {
|
|
@@ -105,23 +108,29 @@ export default function toolDiscipline(pi: ExtensionAPI) {
|
|
|
105
108
|
// Done in session_start: action methods (getAllTools/registerTool) are not
|
|
106
109
|
// available during extension loading, and tools registered here are
|
|
107
110
|
// refreshed into the session (and system prompt) immediately.
|
|
108
|
-
pi.on("session_start", (
|
|
111
|
+
pi.on("session_start", () => {
|
|
109
112
|
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
113
|
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
114
|
const hasFfgrep = active.has("ffgrep");
|
|
116
115
|
const hasFffind = active.has("fffind");
|
|
116
|
+
|
|
117
|
+
// 1) Activate built-in grep/find/ls when they exist (pi 0.84+ ships real
|
|
118
|
+
// definitions via createAllToolDefinitions but only activates
|
|
119
|
+
// read/bash/edit/write by default). Activating them flips
|
|
120
|
+
// hasGrep/hasFind/hasLs so the bash guideline is never generated, and
|
|
121
|
+
// gives the model real built-in search tools.
|
|
122
|
+
const toActivate = PLACEHOLDER_NAMES.filter((name) => all.has(name) && !active.has(name));
|
|
123
|
+
if (toActivate.length > 0) {
|
|
124
|
+
// setActiveTools rebuilds the system prompt immediately.
|
|
125
|
+
pi.setActiveTools([...active, ...toActivate]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 2) Older pi without built-in grep/find/ls: register working fs-based
|
|
129
|
+
// fallbacks (search.ts). Visibility is per-capability: hidden while
|
|
130
|
+
// the FFF counterpart is active, visible when it is not.
|
|
117
131
|
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
132
|
const registerSearchTool = (name: string, fffActive: boolean) => {
|
|
124
|
-
if (all.has(name)) return; //
|
|
133
|
+
if (all.has(name)) return; // built-in exists — handled above
|
|
125
134
|
const fallback = FALLBACK_TOOLS[name];
|
|
126
135
|
pi.registerTool({
|
|
127
136
|
name,
|
|
@@ -132,8 +141,8 @@ export default function toolDiscipline(pi: ExtensionAPI) {
|
|
|
132
141
|
"Use ffgrep/fffind when they are available; grep/find/ls are fallbacks only for environments without pi-fff.",
|
|
133
142
|
],
|
|
134
143
|
parameters: fallback.parameters,
|
|
135
|
-
async execute(_toolCallId, params,
|
|
136
|
-
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);
|
|
137
146
|
return {
|
|
138
147
|
content: [{ type: "text", text }],
|
|
139
148
|
details: { fallback: true },
|
|
@@ -146,10 +155,9 @@ export default function toolDiscipline(pi: ExtensionAPI) {
|
|
|
146
155
|
registerSearchTool("find", hasFffind);
|
|
147
156
|
registerSearchTool("ls", hasFffind);
|
|
148
157
|
// 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.
|
|
158
|
+
// registry is refreshed. refreshTools exists at runtime (ExtensionActions)
|
|
159
|
+
// but is not declared on ExtensionAPI's type. Known limitation: with peer
|
|
160
|
+
// version "*", other pi versions may differ.
|
|
153
161
|
if (registeredAny) (pi as unknown as { refreshTools: () => void }).refreshTools();
|
|
154
162
|
});
|
|
155
163
|
|
|
@@ -161,7 +169,7 @@ export default function toolDiscipline(pi: ExtensionAPI) {
|
|
|
161
169
|
return { systemPrompt: `${prompt}\n${MARK}\n${DISCIPLINE}` };
|
|
162
170
|
});
|
|
163
171
|
|
|
164
|
-
// Status command: /tool-discipline — verify tool
|
|
172
|
+
// Status command: /tool-discipline — verify tool activation and injection.
|
|
165
173
|
pi.registerCommand("tool-discipline", {
|
|
166
174
|
description: "Show pi-tool-discipline status (tools + injected guideline)",
|
|
167
175
|
handler: async (_args, ctx) => {
|
|
@@ -169,13 +177,13 @@ export default function toolDiscipline(pi: ExtensionAPI) {
|
|
|
169
177
|
const active = new Set(pi.getActiveTools());
|
|
170
178
|
const fff = FFF_TOOLS.filter((name) => active.has(name));
|
|
171
179
|
const registered = PLACEHOLDER_NAMES.filter((n) => all.has(n));
|
|
172
|
-
const
|
|
180
|
+
const activeTools = PLACEHOLDER_NAMES.filter((n) => active.has(n));
|
|
173
181
|
const injected = ctx.getSystemPrompt().includes(MARK);
|
|
174
182
|
ctx.ui.notify(
|
|
175
183
|
`pi-tool-discipline\n` +
|
|
176
184
|
`fff active: ${fff.length > 0 ? fff.join(", ") : "(none)"}\n` +
|
|
177
|
-
`grep/find/ls
|
|
178
|
-
`
|
|
185
|
+
`grep/find/ls defined: ${registered.length > 0 ? registered.join(", ") : "(none)"}\n` +
|
|
186
|
+
`grep/find/ls active: ${activeTools.length > 0 ? activeTools.join(", ") : "(none)"}\n` +
|
|
179
187
|
`discipline injected: ${injected ? "yes" : "no"}`,
|
|
180
188
|
"info",
|
|
181
189
|
);
|
package/extensions/search.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
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
|
|
|
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]";
|
|
15
|
+
const CAPPED_MARKER = "\n[search capped — traversal stopped early]";
|
|
13
16
|
|
|
14
17
|
interface FileMatch {
|
|
15
18
|
file: string;
|
|
@@ -17,66 +20,102 @@ interface FileMatch {
|
|
|
17
20
|
text: string;
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
interface WalkState {
|
|
24
|
+
visited: number;
|
|
25
|
+
capped: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
20
28
|
/**
|
|
21
|
-
* Collect files under dir (depth-limited, symlink-safe, bounded
|
|
22
|
-
*
|
|
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.
|
|
23
33
|
*/
|
|
24
|
-
function walk(dir: string, out: string[], depth = 0): void {
|
|
25
|
-
if (depth > 12)
|
|
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
|
+
}
|
|
26
39
|
let entries: string[];
|
|
27
40
|
try {
|
|
28
|
-
entries =
|
|
41
|
+
entries = await readdir(dir);
|
|
29
42
|
} catch {
|
|
30
43
|
return;
|
|
31
44
|
}
|
|
32
45
|
for (const entry of entries) {
|
|
33
|
-
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
|
+
}
|
|
34
54
|
if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
|
|
35
55
|
const p = join(dir, entry);
|
|
56
|
+
state.visited++;
|
|
36
57
|
try {
|
|
37
|
-
const lst =
|
|
58
|
+
const lst = await lstat(p);
|
|
38
59
|
if (lst.isSymbolicLink()) continue; // never follow symlinks
|
|
39
|
-
if (lst.isDirectory()) walk(p, out, depth + 1);
|
|
40
|
-
else out.push(p);
|
|
60
|
+
if (lst.isDirectory()) await walk(p, out, state, signal, depth + 1);
|
|
61
|
+
else if (lst.isFile()) out.push(p); // regular files only
|
|
41
62
|
} catch {
|
|
42
63
|
// unreadable entries are skipped
|
|
43
64
|
}
|
|
44
65
|
}
|
|
45
66
|
}
|
|
46
67
|
|
|
47
|
-
/** Truncate by BYTE length
|
|
68
|
+
/** Truncate by BYTE length, keep complete lines, reserve space for the marker. */
|
|
48
69
|
function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES): string {
|
|
49
70
|
const buf = Buffer.from(text, "utf8");
|
|
50
|
-
|
|
51
|
-
|
|
71
|
+
const budget = maxBytes - Buffer.byteLength(TRUNCATE_MARKER);
|
|
72
|
+
if (buf.length <= budget) return text;
|
|
73
|
+
const cut = buf.subarray(0, budget).toString("utf8");
|
|
52
74
|
const lastNewline = cut.lastIndexOf("\n");
|
|
53
|
-
|
|
54
|
-
return `${
|
|
75
|
+
if (lastNewline <= 0) return TRUNCATE_MARKER.trim(); // nothing complete fits
|
|
76
|
+
return `${cut.slice(0, lastNewline)}\n${TRUNCATE_MARKER}`;
|
|
55
77
|
}
|
|
56
78
|
|
|
57
|
-
|
|
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: {
|
|
58
90
|
pattern: string;
|
|
59
91
|
path?: string;
|
|
60
92
|
caseSensitive?: boolean;
|
|
61
93
|
maxResults?: number;
|
|
62
94
|
cwd: string;
|
|
63
|
-
|
|
64
|
-
|
|
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 || ".")}`;
|
|
65
99
|
const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
|
|
66
100
|
const limit = opts.maxResults ?? 100;
|
|
67
101
|
const files: string[] = [];
|
|
68
|
-
|
|
102
|
+
const state: WalkState = { visited: 0, capped: false };
|
|
103
|
+
await walk(root, files, state, opts.signal);
|
|
69
104
|
const matches: FileMatch[] = [];
|
|
70
105
|
for (const file of files) {
|
|
106
|
+
if (opts.signal?.aborted) {
|
|
107
|
+
state.capped = true;
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
71
110
|
if (matches.length >= limit) break;
|
|
72
111
|
try {
|
|
73
|
-
if (
|
|
112
|
+
if ((await stat(file)).size > MAX_FILE_BYTES) continue; // cap only before reading content
|
|
74
113
|
} catch {
|
|
75
114
|
continue;
|
|
76
115
|
}
|
|
77
116
|
let content: string;
|
|
78
117
|
try {
|
|
79
|
-
content =
|
|
118
|
+
content = await readFile(file, "utf8");
|
|
80
119
|
} catch {
|
|
81
120
|
continue;
|
|
82
121
|
}
|
|
@@ -89,31 +128,45 @@ export function grepFiles(opts: {
|
|
|
89
128
|
}
|
|
90
129
|
}
|
|
91
130
|
}
|
|
92
|
-
if (matches.length === 0) return "No matches found";
|
|
131
|
+
if (matches.length === 0) return state.capped ? `No matches found${CAPPED_MARKER}` : "No matches found";
|
|
93
132
|
let out = "";
|
|
94
133
|
for (const m of matches) out += `${m.file}:${m.line}: ${m.text}\n`;
|
|
95
|
-
|
|
134
|
+
out = truncate(out);
|
|
135
|
+
return state.capped ? `${out}${CAPPED_MARKER}` : out;
|
|
96
136
|
}
|
|
97
137
|
|
|
98
|
-
export function findFiles(opts: {
|
|
99
|
-
|
|
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 || ".")}`;
|
|
100
147
|
const files: string[] = [];
|
|
101
|
-
|
|
148
|
+
const state: WalkState = { visited: 0, capped: false };
|
|
149
|
+
await walk(root, files, state, opts.signal);
|
|
102
150
|
const needle = opts.pattern?.toLowerCase();
|
|
103
151
|
// Match against the RELATIVE path so a pattern matching an ancestor
|
|
104
152
|
// directory does not hit every file, and rendered output stays relative.
|
|
105
153
|
const rel = files.map((f) => relative(root, f));
|
|
106
154
|
const hits = needle ? rel.filter((r) => r.toLowerCase().includes(needle)) : rel;
|
|
107
|
-
if (hits.length === 0) return "No matching files found";
|
|
108
|
-
|
|
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;
|
|
109
159
|
}
|
|
110
160
|
|
|
111
|
-
export function listDir(opts: { path?: string; cwd: string }): string {
|
|
112
|
-
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 || ".")}`;
|
|
113
164
|
try {
|
|
114
|
-
|
|
165
|
+
const entries = await readdir(root);
|
|
166
|
+
if (entries.length === 0) return "(empty directory)";
|
|
167
|
+
return truncate(entries.join("\n"));
|
|
115
168
|
} catch (error: any) {
|
|
116
|
-
return `Error listing ${
|
|
169
|
+
return `Error listing ${root}: ${error.message}`;
|
|
117
170
|
}
|
|
118
171
|
}
|
|
119
172
|
|
|
@@ -121,13 +174,13 @@ export const grepSchema = Type.Object({
|
|
|
121
174
|
pattern: Type.String({ description: "Text to search for in file contents" }),
|
|
122
175
|
path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
|
|
123
176
|
caseSensitive: Type.Optional(Type.Boolean({ description: "Case-sensitive match (default false)" })),
|
|
124
|
-
maxResults: Type.Optional(Type.
|
|
177
|
+
maxResults: Type.Optional(Type.Integer({ minimum: 1, description: "Max matches (default 100)" })),
|
|
125
178
|
});
|
|
126
179
|
|
|
127
180
|
export const findSchema = Type.Object({
|
|
128
181
|
pattern: Type.Optional(Type.String({ description: "Substring to match in file path or name (empty lists all)" })),
|
|
129
182
|
path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
|
|
130
|
-
maxResults: Type.Optional(Type.
|
|
183
|
+
maxResults: Type.Optional(Type.Integer({ minimum: 1, description: "Max results (default 100)" })),
|
|
131
184
|
});
|
|
132
185
|
|
|
133
186
|
export const lsSchema = Type.Object({
|
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
|
}
|