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 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+ ships real `grep` / `find` /
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. 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).
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`/`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
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 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.
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 placeholder
57
- tools are registered and whether the discipline section is injected.
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
- - 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.
78
+
79
+ It executes no commands, writes no files, and touches no network.
80
80
 
81
81
  ## License
82
82
 
@@ -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 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).
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 PLACEHOLDER_NAMES = ["grep", "find", "ls"] as 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+, 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.
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. Register search-tool names so pi stops generating the bash guideline.
90
- // Done in session_start: action methods (getAllTools/registerTool) are not
91
- // available during extension loading, and tools registered here are
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 hasFfgrep = active.has("ffgrep");
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 = PLACEHOLDER_NAMES.filter((n) => all.has(n));
162
- const activeTools = PLACEHOLDER_NAMES.filter((n) => active.has(n));
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.1.15",
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/fallback.test.mjs"
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
  }
@@ -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
- });