pi-tool-discipline 0.1.15 → 0.2.0
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 +30 -30
- package/extensions/index.ts +19 -98
- package/package.json +4 -6
- package/extensions/search.ts +0 -264
package/README.md
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
# pi-tool-discipline
|
|
2
2
|
|
|
3
|
-
<p align="center">
|
|
4
|
-
<img src="assets/banner.png" alt="pi-tool-discipline" width="600">
|
|
5
|
-
</p>
|
|
6
|
-
|
|
7
3
|
pi extension that enforces a **search-tools-first discipline** and removes the
|
|
8
4
|
conflicting "use bash for file operations" guidance pi injects by default.
|
|
5
|
+
**Prompt-level only: it activates the right tools and edits the system prompt;
|
|
6
|
+
it does not intercept or parse bash commands.**
|
|
9
7
|
|
|
10
8
|
## Why
|
|
11
9
|
|
|
@@ -21,24 +19,36 @@ tool, not bash").
|
|
|
21
19
|
|
|
22
20
|
Two mechanisms, applied automatically in every session:
|
|
23
21
|
|
|
24
|
-
1. **Activate search tools (root fix).** pi 0.84
|
|
22
|
+
1. **Activate search tools (root fix).** pi >= 0.84 ships real `grep` / `find` /
|
|
25
23
|
`ls` built-in tool definitions but only activates `read`/`bash`/`edit`/
|
|
26
24
|
`write` by default. This extension activates the built-ins, so pi's
|
|
27
25
|
`hasGrep`/`hasFind`/`hasLs` check passes and the conflicting
|
|
28
26
|
`Use bash for file operations like ls, rg, find` guideline is **never
|
|
29
|
-
generated** — the model is never told to use bash for searching.
|
|
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).
|
|
27
|
+
generated** — the model is never told to use bash for searching.
|
|
34
28
|
2. **System-prompt injection (rules).** On every agent start, appends an
|
|
35
29
|
idempotent "Tool Discipline" section to the system prompt: content search
|
|
36
30
|
with `ffgrep`, path search with `fffind`, file reads with `read`
|
|
37
|
-
(offset/limit), no bash `grep`/`
|
|
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
|
|
31
|
+
(offset/limit), no bash `grep`/`find`/`ls`/`cat`/`sed`/`head`/`tail`/
|
|
32
|
+
`which` for searching or reading, bash reserved for pipelines/git/npm/network, `rg`
|
|
33
|
+
(never `grep`) as the last resort in pipelines. Also strips the bash guideline text as
|
|
40
34
|
a belt-and-suspenders fallback.
|
|
41
35
|
|
|
36
|
+
## Why no bash interception?
|
|
37
|
+
|
|
38
|
+
Earlier versions (<= 0.1.x) added a runtime guard: a hand-written
|
|
39
|
+
bash/PowerShell command parser that blocked prohibited file operations at
|
|
40
|
+
`tool_call` time. Maintaining a parser that precisely recognizes nested
|
|
41
|
+
subshells, here-docs, wrappers and redirects — without misjudging legitimate
|
|
42
|
+
pipelines — grew to ~1000 lines and became a hang/freeze risk (a stray `)` in
|
|
43
|
+
a command could spin the event loop at 100% CPU, freezing the whole session).
|
|
44
|
+
|
|
45
|
+
Behavior guidance at the prompt level is probabilistic but safe; code-level
|
|
46
|
+
enforcement would require near-complete shell parsing — impractical and
|
|
47
|
+
hazardous. The activation + injection mechanisms above already remove the
|
|
48
|
+
root cause (pi no longer tells the model to use bash), so interception added
|
|
49
|
+
little value. This version drops it entirely: no command parsing, no
|
|
50
|
+
interception, no hang surface.
|
|
51
|
+
|
|
42
52
|
## Install
|
|
43
53
|
|
|
44
54
|
```bash
|
|
@@ -47,14 +57,14 @@ pi install npm:pi-tool-discipline
|
|
|
47
57
|
pi -e npm:pi-tool-discipline
|
|
48
58
|
```
|
|
49
59
|
|
|
50
|
-
Requires
|
|
51
|
-
`ffgrep`/`fffind`; without it,
|
|
52
|
-
|
|
60
|
+
Requires **pi >= 0.84** (built-in `grep`/`find`/`ls` definitions). With
|
|
61
|
+
`@ff-labs/pi-fff` installed, the model prefers `ffgrep`/`fffind`; without it,
|
|
62
|
+
Pi's built-in `grep`/`find`/`ls` tools (activated by this extension) are used.
|
|
53
63
|
|
|
54
64
|
## Verify
|
|
55
65
|
|
|
56
|
-
Run `/tool-discipline` in a pi session — it reports whether the
|
|
57
|
-
|
|
66
|
+
Run `/tool-discipline` in a pi session — it reports whether the search tools
|
|
67
|
+
are registered/active and whether the discipline section is injected.
|
|
58
68
|
|
|
59
69
|
You can also inspect the system prompt: the string
|
|
60
70
|
`Use bash for file operations like ls, rg, find` should no longer appear, and
|
|
@@ -65,18 +75,8 @@ You can also inspect the system prompt: the string
|
|
|
65
75
|
This extension runs with full system access like any pi extension. What it does:
|
|
66
76
|
- Activates pi's built-in `grep`/`find`/`ls` tools (read-only file search).
|
|
67
77
|
- Injects text into the system prompt (discipline rules).
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
78
|
+
|
|
79
|
+
It executes no commands, writes no files, and touches no network.
|
|
80
80
|
|
|
81
81
|
## License
|
|
82
82
|
|
package/extensions/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-tool-discipline
|
|
2
|
+
* pi-tool-discipline (slim)
|
|
3
3
|
*
|
|
4
4
|
* Enforces a "search tools first" file-search discipline in pi sessions and
|
|
5
5
|
* neutralizes the built-in "Use bash for file operations like ls, rg, find"
|
|
@@ -12,28 +12,27 @@
|
|
|
12
12
|
* search tool exists and tells the model to use bash — fighting AGENTS.md
|
|
13
13
|
* rules that say to use the grep tool instead.
|
|
14
14
|
*
|
|
15
|
-
* How it works:
|
|
16
|
-
* A. Ensures `grep` / `find` / `ls` tools are ACTIVE in the
|
|
17
|
-
* ships real
|
|
18
|
-
* activates read/bash/edit/write by default
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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).
|
|
15
|
+
* How it works (prompt-level only — no bash interception):
|
|
16
|
+
* A. Ensures pi's built-in `grep` / `find` / `ls` tools are ACTIVE in the
|
|
17
|
+
* session (pi 0.84+ ships real definitions via createAllToolDefinitions
|
|
18
|
+
* but only activates read/bash/edit/write by default). Activating them
|
|
19
|
+
* flips pi's hasGrep/hasFind/hasLs check, so the conflicting bash
|
|
20
|
+
* guideline is never generated and the model always has search tools.
|
|
24
21
|
* B. On before_agent_start, appends the tool-discipline rules to the system
|
|
25
22
|
* prompt (idempotent) and strips the bash guideline text as a fallback.
|
|
23
|
+
*
|
|
24
|
+
* No runtime tool_call interception: behavior is guided at the prompt level,
|
|
25
|
+
* which means no shell-command parsing and no risk of parser hangs. Requires
|
|
26
|
+
* pi >= 0.84 for the built-in grep/find/ls definitions.
|
|
26
27
|
*/
|
|
27
28
|
|
|
28
29
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
29
|
-
import { Type } from "typebox";
|
|
30
|
-
import { grepFiles, findFiles, listDir, grepSchema, findSchema, lsSchema } from "./search.js";
|
|
31
30
|
import { stripBashGuidelines } from "./strip.js";
|
|
32
31
|
|
|
33
32
|
const MARK = "<!-- pi-tool-discipline:v1 -->";
|
|
34
33
|
|
|
35
34
|
/** Tool names that flip pi's hasGrep/hasFind/hasLs checks. */
|
|
36
|
-
const
|
|
35
|
+
const SEARCH_TOOLS = ["grep", "find", "ls"] as const;
|
|
37
36
|
|
|
38
37
|
/** FFF search tools that indicate pi-fff (or equivalent) is installed. */
|
|
39
38
|
const FFF_TOOLS = ["ffgrep", "fffind"];
|
|
@@ -44,103 +43,25 @@ const DISCIPLINE = `
|
|
|
44
43
|
Search tool priority:
|
|
45
44
|
|
|
46
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.
|
|
47
|
-
2. Without pi-fff, use the grep / find / ls TOOLS (Pi built-ins on pi 0.84
|
|
46
|
+
2. Without pi-fff, use the grep / find / ls TOOLS (Pi built-ins on pi 0.84+). 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.
|
|
48
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.
|
|
49
48
|
4. Read files with \`read\` (offset/limit for large files).
|
|
50
49
|
5. Bash stays allowed only when dedicated tools cannot do the job: pipelines, git, npm, running programs, network requests, file mutations.
|
|
51
50
|
6. If bash searching is truly unavoidable, use \`rg\` (never \`grep\`).`;
|
|
52
51
|
|
|
53
|
-
interface FallbackTool {
|
|
54
|
-
label: string;
|
|
55
|
-
description: string;
|
|
56
|
-
snippet: string;
|
|
57
|
-
parameters: ReturnType<typeof Type.Object>;
|
|
58
|
-
execute: (params: any, cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const FALLBACK_TOOLS: Record<string, FallbackTool> = {
|
|
62
|
-
grep: {
|
|
63
|
-
label: "grep (fallback)",
|
|
64
|
-
description:
|
|
65
|
-
"Search file contents for a text pattern. Fallback for environments without ffgrep; prefer ffgrep when available.",
|
|
66
|
-
snippet: "Search file contents (fallback when ffgrep is unavailable)",
|
|
67
|
-
parameters: grepSchema,
|
|
68
|
-
execute: async (params, cwd, signal) => grepFiles({ ...params, cwd, signal }),
|
|
69
|
-
},
|
|
70
|
-
find: {
|
|
71
|
-
label: "find (fallback)",
|
|
72
|
-
description:
|
|
73
|
-
"Find files by path/name substring. Fallback for environments without fffind; prefer fffind when available.",
|
|
74
|
-
snippet: "Find files by path/name (fallback when fffind is unavailable)",
|
|
75
|
-
parameters: findSchema,
|
|
76
|
-
execute: async (params, cwd, signal) => findFiles({ ...params, cwd, signal }),
|
|
77
|
-
},
|
|
78
|
-
ls: {
|
|
79
|
-
label: "ls (fallback)",
|
|
80
|
-
description:
|
|
81
|
-
"List directory entries. Fallback for environments without fffind; prefer fffind when available.",
|
|
82
|
-
snippet: "List directory entries (fallback when fffind is unavailable)",
|
|
83
|
-
parameters: lsSchema,
|
|
84
|
-
execute: async (params, cwd, signal) => listDir({ ...params, cwd, signal }),
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
|
|
88
52
|
export default function toolDiscipline(pi: ExtensionAPI) {
|
|
89
|
-
// A.
|
|
90
|
-
// Done in session_start: action methods (
|
|
91
|
-
// available during extension loading, and tools
|
|
53
|
+
// A. Activate pi's built-in search tools so pi stops generating the bash
|
|
54
|
+
// guideline. Done in session_start: action methods (setActiveTools) are
|
|
55
|
+
// not available during extension loading, and tools activated here are
|
|
92
56
|
// refreshed into the session (and system prompt) immediately.
|
|
93
57
|
pi.on("session_start", () => {
|
|
94
58
|
const all = new Set(pi.getAllTools().map((t) => t.name));
|
|
95
59
|
const active = new Set(pi.getActiveTools());
|
|
96
|
-
const
|
|
97
|
-
const hasFffind = active.has("fffind");
|
|
98
|
-
|
|
99
|
-
// 1) Activate built-in grep/find/ls when they exist (pi 0.84+ ships real
|
|
100
|
-
// definitions via createAllToolDefinitions but only activates
|
|
101
|
-
// read/bash/edit/write by default). Activating them flips
|
|
102
|
-
// hasGrep/hasFind/hasLs so the bash guideline is never generated, and
|
|
103
|
-
// gives the model real built-in search tools.
|
|
104
|
-
const toActivate = PLACEHOLDER_NAMES.filter((name) => all.has(name) && !active.has(name));
|
|
60
|
+
const toActivate = SEARCH_TOOLS.filter((name) => all.has(name) && !active.has(name));
|
|
105
61
|
if (toActivate.length > 0) {
|
|
106
62
|
// setActiveTools rebuilds the system prompt immediately.
|
|
107
63
|
pi.setActiveTools([...active, ...toActivate]);
|
|
108
64
|
}
|
|
109
|
-
|
|
110
|
-
// 2) Older pi without built-in grep/find/ls: register working fs-based
|
|
111
|
-
// fallbacks (search.ts). Visibility is per-capability: hidden while
|
|
112
|
-
// the FFF counterpart is active, visible when it is not.
|
|
113
|
-
let registeredAny = false;
|
|
114
|
-
const registerSearchTool = (name: string, fffActive: boolean) => {
|
|
115
|
-
if (all.has(name)) return; // built-in exists — handled above
|
|
116
|
-
const fallback = FALLBACK_TOOLS[name];
|
|
117
|
-
pi.registerTool({
|
|
118
|
-
name,
|
|
119
|
-
label: fallback.label,
|
|
120
|
-
description: fallback.description,
|
|
121
|
-
promptSnippet: fffActive ? undefined : fallback.snippet,
|
|
122
|
-
promptGuidelines: [
|
|
123
|
-
"Use ffgrep/fffind when they are available; grep/find/ls are fallbacks only for environments without pi-fff.",
|
|
124
|
-
],
|
|
125
|
-
parameters: fallback.parameters,
|
|
126
|
-
async execute(_toolCallId, params, signal, _onUpdate, execCtx) {
|
|
127
|
-
const text = await fallback.execute(params ?? {}, execCtx.cwd, signal);
|
|
128
|
-
return {
|
|
129
|
-
content: [{ type: "text", text }],
|
|
130
|
-
details: { fallback: true },
|
|
131
|
-
};
|
|
132
|
-
},
|
|
133
|
-
});
|
|
134
|
-
registeredAny = true;
|
|
135
|
-
};
|
|
136
|
-
registerSearchTool("grep", hasFfgrep);
|
|
137
|
-
registerSearchTool("find", hasFffind);
|
|
138
|
-
registerSearchTool("ls", hasFffind);
|
|
139
|
-
// Tools registered in session_start do not enter selectedTools until the
|
|
140
|
-
// registry is refreshed. refreshTools exists at runtime (ExtensionActions)
|
|
141
|
-
// but is not declared on ExtensionAPI's type. Known limitation: with peer
|
|
142
|
-
// version "*", other pi versions may differ.
|
|
143
|
-
if (registeredAny) (pi as unknown as { refreshTools: () => void }).refreshTools();
|
|
144
65
|
});
|
|
145
66
|
|
|
146
67
|
// B. Inject the discipline into the system prompt (idempotent per turn).
|
|
@@ -158,8 +79,8 @@ export default function toolDiscipline(pi: ExtensionAPI) {
|
|
|
158
79
|
const all = new Set(pi.getAllTools().map((t) => t.name));
|
|
159
80
|
const active = new Set(pi.getActiveTools());
|
|
160
81
|
const fff = FFF_TOOLS.filter((name) => active.has(name));
|
|
161
|
-
const registered =
|
|
162
|
-
const activeTools =
|
|
82
|
+
const registered = SEARCH_TOOLS.filter((n) => all.has(n));
|
|
83
|
+
const activeTools = SEARCH_TOOLS.filter((n) => active.has(n));
|
|
163
84
|
const injected = ctx.getSystemPrompt().includes(MARK);
|
|
164
85
|
ctx.ui.notify(
|
|
165
86
|
`pi-tool-discipline\n` +
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tool-discipline",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "pi extension: enforce ffgrep/fffind-first search discipline and neutralize the default bash file-operation guideline",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "pi extension (prompt-level): enforce ffgrep/fffind-first search discipline and neutralize the default bash file-operation guideline",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -38,16 +38,14 @@
|
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"typecheck": "tsc --noEmit",
|
|
41
|
-
"test": "node --experimental-strip-types test/
|
|
41
|
+
"test": "node --experimental-strip-types test/strip.test.mjs"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@earendil-works/pi-coding-agent": "
|
|
45
|
-
"typebox": "*"
|
|
44
|
+
"@earendil-works/pi-coding-agent": ">=0.84"
|
|
46
45
|
},
|
|
47
46
|
"devDependencies": {
|
|
48
47
|
"@earendil-works/pi-coding-agent": "*",
|
|
49
48
|
"@types/node": "^22.0.0",
|
|
50
|
-
"typebox": "*",
|
|
51
49
|
"typescript": "^5.0.0"
|
|
52
50
|
}
|
|
53
51
|
}
|
package/extensions/search.ts
DELETED
|
@@ -1,264 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fallback search implementations for environments without @ff-labs/pi-fff.
|
|
3
|
-
* Pure Node fs-based (async, abort-aware); no shell, no external deps.
|
|
4
|
-
*/
|
|
5
|
-
import { readdir, readFile, stat, lstat } from "fs/promises";
|
|
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_VISITED = 5000; // total entries touched, bounds slow/odd trees
|
|
12
|
-
const MAX_FILE_BYTES = 1024 * 1024; // content search skips files larger than 1 MiB
|
|
13
|
-
const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
14
|
-
const MAX_RESULTS = 1000; // hard ceiling for matches/results
|
|
15
|
-
|
|
16
|
-
/** Normalize maxResults: non-finite/non-number falls back to the default. */
|
|
17
|
-
function clampLimit(raw: number | undefined): number {
|
|
18
|
-
const n = typeof raw === "number" && Number.isFinite(raw) ? Math.floor(raw) : 100;
|
|
19
|
-
return Math.min(Math.max(1, n), MAX_RESULTS);
|
|
20
|
-
}
|
|
21
|
-
const TRUNCATE_MARKER = "\n[output truncated]";
|
|
22
|
-
const CAPPED_MARKER = "\n[search capped — traversal stopped early]";
|
|
23
|
-
|
|
24
|
-
/** Cancellation error, matching pi built-in tools ("Operation aborted"). */
|
|
25
|
-
function abortError(): Error {
|
|
26
|
-
return new Error("Operation aborted");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function throwIfAborted(signal?: AbortSignal): void {
|
|
30
|
-
if (signal?.aborted) throw abortError();
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface FileMatch {
|
|
34
|
-
file: string;
|
|
35
|
-
line: number;
|
|
36
|
-
text: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
interface WalkState {
|
|
40
|
-
visited: number;
|
|
41
|
-
capped: boolean;
|
|
42
|
-
rootError?: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Injectable fs ops so tests can deterministically trigger fs errors. */
|
|
46
|
-
interface WalkFsOps {
|
|
47
|
-
readdir: (dir: string) => Promise<string[]>;
|
|
48
|
-
lstat: (p: string) => Promise<{ isSymbolicLink(): boolean; isDirectory(): boolean; isFile(): boolean }>;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const defaultFs: WalkFsOps = { readdir, lstat };
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Collect regular files under dir (depth-limited, symlink-safe, bounded,
|
|
55
|
-
* abort-aware). Only isFile() entries are collected — FIFOs, devices, sockets
|
|
56
|
-
* would block a read. Individual OS filesystem requests may still be
|
|
57
|
-
* uninterruptible, but the signal is checked before/after every operation.
|
|
58
|
-
* A failure to read the REQUESTED ROOT (depth 0) is recorded in rootError;
|
|
59
|
-
* nested unreadable entries are skipped.
|
|
60
|
-
*/
|
|
61
|
-
export async function walk(
|
|
62
|
-
dir: string,
|
|
63
|
-
out: string[],
|
|
64
|
-
state: WalkState,
|
|
65
|
-
signal?: AbortSignal,
|
|
66
|
-
depth = 0,
|
|
67
|
-
fsOps: WalkFsOps = defaultFs,
|
|
68
|
-
): Promise<void> {
|
|
69
|
-
throwIfAborted(signal);
|
|
70
|
-
if (depth > 12 || state.visited >= MAX_VISITED) {
|
|
71
|
-
state.capped = true;
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
let entries: string[];
|
|
75
|
-
try {
|
|
76
|
-
entries = await fsOps.readdir(dir);
|
|
77
|
-
} catch (error: any) {
|
|
78
|
-
throwIfAborted(signal); // cancellation wins over the fs error
|
|
79
|
-
if (depth === 0) state.rootError = error?.message ?? String(error);
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
throwIfAborted(signal);
|
|
83
|
-
for (const entry of entries) {
|
|
84
|
-
if (out.length >= MAX_FILES || state.visited >= MAX_VISITED) {
|
|
85
|
-
state.capped = true;
|
|
86
|
-
return; // in-loop bound
|
|
87
|
-
}
|
|
88
|
-
if (entry.startsWith(".") || SKIP_DIRS.has(entry)) continue;
|
|
89
|
-
const p = join(dir, entry);
|
|
90
|
-
state.visited++;
|
|
91
|
-
try {
|
|
92
|
-
const lst = await fsOps.lstat(p);
|
|
93
|
-
throwIfAborted(signal);
|
|
94
|
-
if (lst.isSymbolicLink()) continue; // never follow symlinks
|
|
95
|
-
if (lst.isDirectory()) {
|
|
96
|
-
await walk(p, out, state, signal, depth + 1, fsOps);
|
|
97
|
-
throwIfAborted(signal);
|
|
98
|
-
} else if (lst.isFile()) out.push(p); // regular files only
|
|
99
|
-
} catch (error: any) {
|
|
100
|
-
throwIfAborted(signal); // cancellation wins over the fs error
|
|
101
|
-
// unreadable entries are skipped
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
throwIfAborted(signal); // post-recursion check
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Truncate by BYTE length, keep complete lines, reserve space for all markers.
|
|
109
|
-
* Returns the original text unchanged when it fits within maxBytes.
|
|
110
|
-
*/
|
|
111
|
-
function truncate(text: string, maxBytes = MAX_OUTPUT_BYTES, extraMarkerBytes = 0): string {
|
|
112
|
-
const buf = Buffer.from(text, "utf8");
|
|
113
|
-
if (buf.length + extraMarkerBytes <= maxBytes) return text;
|
|
114
|
-
const budget = maxBytes - Buffer.byteLength(TRUNCATE_MARKER) - extraMarkerBytes;
|
|
115
|
-
if (budget <= 0) return TRUNCATE_MARKER.trim();
|
|
116
|
-
let cut = buf.subarray(0, budget).toString("utf8");
|
|
117
|
-
// A multibyte char split by subarray becomes U+FFFD (3 bytes) which can
|
|
118
|
-
// exceed the budget — trim back to a valid encoded prefix.
|
|
119
|
-
while (Buffer.byteLength(cut, "utf8") > budget) cut = cut.slice(0, -1);
|
|
120
|
-
const lastNewline = cut.lastIndexOf("\n");
|
|
121
|
-
if (lastNewline <= 0) return `${cut}${TRUNCATE_MARKER}`; // no complete line: bounded prefix + marker
|
|
122
|
-
return `${cut.slice(0, lastNewline)}\n${TRUNCATE_MARKER}`;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
async function resolveRoot(cwd: string, sub?: string, signal?: AbortSignal): Promise<string | null> {
|
|
126
|
-
const root = resolve(cwd, sub || ".");
|
|
127
|
-
try {
|
|
128
|
-
const st = await stat(root);
|
|
129
|
-
throwIfAborted(signal);
|
|
130
|
-
return st.isDirectory() ? root : null;
|
|
131
|
-
} catch (error: any) {
|
|
132
|
-
throwIfAborted(signal);
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function walkFiles(root: string, signal?: AbortSignal): Promise<{ files: string[]; state: WalkState }> {
|
|
138
|
-
const files: string[] = [];
|
|
139
|
-
const state: WalkState = { visited: 0, capped: false };
|
|
140
|
-
return walk(root, files, state, signal).then(() => ({ files, state }));
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export async function grepFiles(opts: {
|
|
144
|
-
pattern: string;
|
|
145
|
-
path?: string;
|
|
146
|
-
caseSensitive?: boolean;
|
|
147
|
-
maxResults?: number;
|
|
148
|
-
cwd: string;
|
|
149
|
-
signal?: AbortSignal;
|
|
150
|
-
}): Promise<string> {
|
|
151
|
-
throwIfAborted(opts.signal);
|
|
152
|
-
const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
|
|
153
|
-
if (!root) return truncate(`Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`);
|
|
154
|
-
const pattern = opts.caseSensitive ? opts.pattern : opts.pattern.toLowerCase();
|
|
155
|
-
// Hard internal ceiling: bounds memory/work even for direct API calls that
|
|
156
|
-
// bypass schema validation (NaN/Infinity included).
|
|
157
|
-
const limit = clampLimit(opts.maxResults);
|
|
158
|
-
const { files, state } = await walkFiles(root, opts.signal);
|
|
159
|
-
throwIfAborted(opts.signal);
|
|
160
|
-
if (state.rootError) return truncate(`Error: cannot read search root ${root}: ${state.rootError}`);
|
|
161
|
-
const matches: FileMatch[] = [];
|
|
162
|
-
for (const file of files) {
|
|
163
|
-
throwIfAborted(opts.signal);
|
|
164
|
-
if (matches.length >= limit) break;
|
|
165
|
-
try {
|
|
166
|
-
const size = (await stat(file)).size;
|
|
167
|
-
throwIfAborted(opts.signal);
|
|
168
|
-
if (size > MAX_FILE_BYTES) continue; // cap only before reading content
|
|
169
|
-
} catch (error: any) {
|
|
170
|
-
throwIfAborted(opts.signal);
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
throwIfAborted(opts.signal);
|
|
174
|
-
let content: string;
|
|
175
|
-
try {
|
|
176
|
-
content = await readFile(file, "utf8");
|
|
177
|
-
} catch (error: any) {
|
|
178
|
-
throwIfAborted(opts.signal);
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
throwIfAborted(opts.signal);
|
|
182
|
-
const lines = content.split("\n");
|
|
183
|
-
for (let i = 0; i < lines.length; i++) {
|
|
184
|
-
throwIfAborted(opts.signal);
|
|
185
|
-
const haystack = opts.caseSensitive ? lines[i] : lines[i].toLowerCase();
|
|
186
|
-
if (haystack.includes(pattern)) {
|
|
187
|
-
matches.push({ file: relative(root, file), line: i + 1, text: lines[i].trim().slice(0, 200) });
|
|
188
|
-
if (matches.length >= limit) break;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
if (matches.length === 0) return state.capped ? `No matches found${CAPPED_MARKER}` : "No matches found";
|
|
193
|
-
let out = "";
|
|
194
|
-
for (const m of matches) out += `${m.file}:${m.line}: ${m.text}\n`;
|
|
195
|
-
out = truncate(out, MAX_OUTPUT_BYTES, state.capped ? Buffer.byteLength(CAPPED_MARKER) : 0);
|
|
196
|
-
if (state.capped) out += CAPPED_MARKER;
|
|
197
|
-
return out;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
export async function findFiles(opts: {
|
|
201
|
-
pattern?: string;
|
|
202
|
-
path?: string;
|
|
203
|
-
maxResults?: number;
|
|
204
|
-
cwd: string;
|
|
205
|
-
signal?: AbortSignal;
|
|
206
|
-
}): Promise<string> {
|
|
207
|
-
throwIfAborted(opts.signal);
|
|
208
|
-
const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
|
|
209
|
-
if (!root) return truncate(`Error: search path not found: ${resolve(opts.cwd, opts.path || ".")}`);
|
|
210
|
-
const { files, state } = await walkFiles(root, opts.signal);
|
|
211
|
-
throwIfAborted(opts.signal);
|
|
212
|
-
if (state.rootError) return truncate(`Error: cannot read search root ${root}: ${state.rootError}`);
|
|
213
|
-
const needle = opts.pattern?.toLowerCase();
|
|
214
|
-
const resultLimit = clampLimit(opts.maxResults);
|
|
215
|
-
// Match against the RELATIVE path so a pattern matching an ancestor
|
|
216
|
-
// directory does not hit every file, and rendered output stays relative.
|
|
217
|
-
const rel: string[] = [];
|
|
218
|
-
for (const f of files) {
|
|
219
|
-
throwIfAborted(opts.signal);
|
|
220
|
-
rel.push(relative(root, f));
|
|
221
|
-
}
|
|
222
|
-
const hits: string[] = [];
|
|
223
|
-
for (const r of rel) {
|
|
224
|
-
throwIfAborted(opts.signal);
|
|
225
|
-
if (!needle || r.toLowerCase().includes(needle)) hits.push(r);
|
|
226
|
-
if (hits.length >= resultLimit) break;
|
|
227
|
-
}
|
|
228
|
-
if (hits.length === 0) return state.capped ? `No matching files found${CAPPED_MARKER}` : "No matching files found";
|
|
229
|
-
let out = truncate(hits.join("\n"), MAX_OUTPUT_BYTES, state.capped ? Buffer.byteLength(CAPPED_MARKER) : 0);
|
|
230
|
-
if (state.capped) out += CAPPED_MARKER;
|
|
231
|
-
return out;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
export async function listDir(opts: { path?: string; cwd: string; signal?: AbortSignal }): Promise<string> {
|
|
235
|
-
throwIfAborted(opts.signal);
|
|
236
|
-
const root = await resolveRoot(opts.cwd, opts.path, opts.signal);
|
|
237
|
-
if (!root) return truncate(`Error: directory not found: ${resolve(opts.cwd, opts.path || ".")}`);
|
|
238
|
-
try {
|
|
239
|
-
const entries = await readdir(root);
|
|
240
|
-
throwIfAborted(opts.signal);
|
|
241
|
-
if (entries.length === 0) return "(empty directory)";
|
|
242
|
-
return truncate(entries.join("\n"));
|
|
243
|
-
} catch (error: any) {
|
|
244
|
-
throwIfAborted(opts.signal);
|
|
245
|
-
return truncate(`Error listing ${root}: ${error.message}`);
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export const grepSchema = Type.Object({
|
|
250
|
-
pattern: Type.String({ description: "Text to search for in file contents" }),
|
|
251
|
-
path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
|
|
252
|
-
caseSensitive: Type.Optional(Type.Boolean({ description: "Case-sensitive match (default false)" })),
|
|
253
|
-
maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_RESULTS, description: "Max matches (default 100)" })),
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
export const findSchema = Type.Object({
|
|
257
|
-
pattern: Type.Optional(Type.String({ description: "Substring to match in file path or name (empty lists all)" })),
|
|
258
|
-
path: Type.Optional(Type.String({ description: "Directory to search (defaults to cwd)" })),
|
|
259
|
-
maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_RESULTS, description: "Max results (default 100)" })),
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
export const lsSchema = Type.Object({
|
|
263
|
-
path: Type.Optional(Type.String({ description: "Directory to list (defaults to cwd)" })),
|
|
264
|
-
});
|