@hydraharness/harness-tool-fs-search 0.1.1-rc.6
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/LICENSE +21 -0
- package/README.md +132 -0
- package/lib/index.js +1268 -0
- package/lib/invariant.js +23 -0
- package/lib/types/direct-call.d.ts +15 -0
- package/lib/types/glob.d.ts +146 -0
- package/lib/types/grep.d.ts +142 -0
- package/lib/types/index.d.ts +74 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/presentation.d.ts +125 -0
- package/lib/types/search-core.d.ts +207 -0
- package/package.json +69 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1268 @@
|
|
|
1
|
+
import z from "@hydraharness/schemastery";
|
|
2
|
+
import { MAX_TIMER_DELAY_MS } from "@hydraharness/harness-timeout";
|
|
3
|
+
import { isAbsolute, relative, sep } from "node:path";
|
|
4
|
+
import { defineTool } from "@hydraharness/harness-tools";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { HarnessError } from "@hydraharness/harness-llm";
|
|
7
|
+
import { ItemRetainer, TextRetainer } from "@hydraharness/harness-output-retention";
|
|
8
|
+
//#region lib/types/search-core.js
|
|
9
|
+
/**
|
|
10
|
+
* Shared execution plumbing for the `glob` / `grep` search tools: the
|
|
11
|
+
* package-owned `SEARCH_*` error vocabulary, one spawn helper that runs the
|
|
12
|
+
* PACKAGED ripgrep binary (`@vscode/ripgrep`) with a plain argv vector and
|
|
13
|
+
* returns complete raw stdout, the best-effort formatted-result spill handoff,
|
|
14
|
+
* and workdir-relative path display.
|
|
15
|
+
*
|
|
16
|
+
* Both tools execute as ordinary foreground spawns through `ctx.subprocess` —
|
|
17
|
+
* never `ctx.shell`, never `ctx.shell.start()`, never a model-visible background
|
|
18
|
+
* task. The ripgrep binary ships inside the npm package, so no system `rg`
|
|
19
|
+
* install is required, and no shell layer exists between the argv vector and
|
|
20
|
+
* ripgrep, so no shell quoting is involved. Raw `rg` stdout is an internal
|
|
21
|
+
* transport detail: the tools request a per-run stdout capture budget from the
|
|
22
|
+
* subprocess seam, parse only complete in-memory stdout within
|
|
23
|
+
* `rawOutputMaxBytes`, and never read spill files. The model-facing recovery
|
|
24
|
+
* artifact is the formatted result saved through `ctx.spillStore.saveText()`
|
|
25
|
+
* ({@link trySaveFormattedResult}).
|
|
26
|
+
*
|
|
27
|
+
* @module @hydraharness/harness-tool-fs-search/search-core
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Default cap on the complete raw `rg` stdout the tools will parse (the
|
|
31
|
+
* `rawOutputMaxBytes` config), matching Claude Code's ripgrep raw buffer.
|
|
32
|
+
*/
|
|
33
|
+
const RAW_OUTPUT_MAX_BYTES = 2e7;
|
|
34
|
+
/**
|
|
35
|
+
* Default cooperative tool-call timeout budget in milliseconds (the `timeoutMs`
|
|
36
|
+
* config), attached to both tool definitions for
|
|
37
|
+
* `@hydraharness/harness-tool-call-timeout-policy` to enforce through `exec.signal`.
|
|
38
|
+
*/
|
|
39
|
+
const SEARCH_TIMEOUT_MS = 3e4;
|
|
40
|
+
/**
|
|
41
|
+
* Default cap in bytes on the retained stderr tail of one search run — a
|
|
42
|
+
* diagnostic excerpt only (the tool never reads a stderr spill path, and the
|
|
43
|
+
* collect disposition requests none).
|
|
44
|
+
*/
|
|
45
|
+
const SEARCH_STDERR_MAX_BYTES = 64 * 1024;
|
|
46
|
+
/** Default terminate grace period for a search process (ms). */
|
|
47
|
+
const SEARCH_GRACE_MS = 3e3;
|
|
48
|
+
/**
|
|
49
|
+
* Default cap in bytes on one search's serialized `presentationMeta` (the
|
|
50
|
+
* `searchMetaMaxBytes` config). The inline match/path caps already bound the item
|
|
51
|
+
* COUNT, but retained matches of a broad search (many long lines) can still
|
|
52
|
+
* serialize to hundreds of kilobytes, and `meta` is persisted with the session
|
|
53
|
+
* log and re-sent on every request. A deployment's final output budget
|
|
54
|
+
* (`@hydraharness/harness-spill-policy`) only shrinks a result's `content`, never its `meta`, so the
|
|
55
|
+
* projection owns this cap. 64 KiB holds the full default-capped result of a
|
|
56
|
+
* typical search while bounding the pathological one.
|
|
57
|
+
*/
|
|
58
|
+
const SEARCH_META_MAX_BYTES = 65536;
|
|
59
|
+
/**
|
|
60
|
+
* Typed search failure. Extends {@link HarnessError} so it carries a stable
|
|
61
|
+
* {@link SearchErrorCode} and chains `cause`; the tool registry exposes
|
|
62
|
+
* `{ name, code }` on `isError` results so retry/permission/UI layers can
|
|
63
|
+
* branch without parsing messages.
|
|
64
|
+
*/
|
|
65
|
+
var SearchError = class extends HarnessError {
|
|
66
|
+
code;
|
|
67
|
+
constructor(message, code, options) {
|
|
68
|
+
super(message, code, options);
|
|
69
|
+
this.code = code;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* The retained stderr tail as a diagnostic excerpt, with a truncation note when
|
|
74
|
+
* the subprocess seam dropped bytes.
|
|
75
|
+
*/
|
|
76
|
+
function stderrExcerpt(stderrText, truncated) {
|
|
77
|
+
const text = stderrText.trim();
|
|
78
|
+
if (text.length === 0) return "";
|
|
79
|
+
return truncated ? `${text} [stderr truncated]` : text;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Classify a nonzero-exit `rg` run into the search error vocabulary. There is
|
|
83
|
+
* no shell layer, so an exit 127 or shell "command not found" text cannot
|
|
84
|
+
* occur — a launch failure rejects at spawn (see {@link runRipgrep}).
|
|
85
|
+
*/
|
|
86
|
+
function classifyRunFailure(toolName, exitCode, stderrText, stderrTruncated) {
|
|
87
|
+
const stderr = stderrExcerpt(stderrText, stderrTruncated);
|
|
88
|
+
if (/regex parse error|error parsing glob/i.test(stderr)) return new SearchError(`${toolName} pattern rejected by ripgrep: ${stderr}`, "SEARCH_INVALID_PATTERN");
|
|
89
|
+
return new SearchError(`${toolName} search failed (exit ${exitCode})${stderr.length > 0 ? `: ${stderr}` : ""}`, "SEARCH_FAILED");
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Acquire the COMPLETE raw stdout of a finished run, enforcing
|
|
93
|
+
* `rawOutputMaxBytes` on the in-memory transport. A truncated result means the
|
|
94
|
+
* subprocess seam could not retain complete stdout within the requested
|
|
95
|
+
* budget, so the tool fails clearly instead of parsing a silently-partial
|
|
96
|
+
* stream.
|
|
97
|
+
*/
|
|
98
|
+
function completeStdout(toolName, stdout, rawOutputMaxBytes) {
|
|
99
|
+
const narrow = "narrow pattern, path, or include and retry";
|
|
100
|
+
if (!stdout.lossy) {
|
|
101
|
+
const inlineBytes = Buffer.byteLength(stdout.text, "utf8");
|
|
102
|
+
if (inlineBytes > rawOutputMaxBytes) throw new SearchError(`${toolName} produced ${inlineBytes} bytes of raw output, over the ${rawOutputMaxBytes}-byte cap; ${narrow}`, "SEARCH_RAW_OUTPUT_OVERFLOW");
|
|
103
|
+
return stdout.text;
|
|
104
|
+
}
|
|
105
|
+
throw new SearchError(`${toolName} produced more raw output than the subprocess seam retained within the ${rawOutputMaxBytes}-byte cap; ${narrow}`, "SEARCH_RAW_OUTPUT_OVERFLOW");
|
|
106
|
+
}
|
|
107
|
+
let rgPathPromise;
|
|
108
|
+
/**
|
|
109
|
+
* The packaged ripgrep binary path, resolved lazily once per process.
|
|
110
|
+
*
|
|
111
|
+
* A single-file runtime uses the executable's `-rg` sidecar because a native
|
|
112
|
+
* helper cannot be spawned from pkg's virtual filesystem. Node-mode builds
|
|
113
|
+
* fall back to the platform package selected by `@vscode/ripgrep`. Resolving
|
|
114
|
+
* at the call boundary keeps a missing or corrupt binary at the first search
|
|
115
|
+
* call as `SEARCH_FAILED`, rather than failing the Loader composition.
|
|
116
|
+
*
|
|
117
|
+
* @returns the packaged binary's absolute path; the memoized promise rejects
|
|
118
|
+
* when the platform package cannot be resolved.
|
|
119
|
+
*/
|
|
120
|
+
function resolveRgPath() {
|
|
121
|
+
rgPathPromise ??= Promise.resolve().then(async () => {
|
|
122
|
+
const executableSidecar = `${process.execPath}-rg`;
|
|
123
|
+
if ("pkg" in process && existsSync(executableSidecar)) return executableSidecar;
|
|
124
|
+
return (await import("@vscode/ripgrep")).rgPath;
|
|
125
|
+
});
|
|
126
|
+
return rgPathPromise;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Run the packaged ripgrep binary with a plain argv vector and return its
|
|
130
|
+
* complete raw stdout. The working directory is the calling agent's session
|
|
131
|
+
* cwd (`exec.agent.session.header.cwd`) when available, else
|
|
132
|
+
* `process.cwd()`. `exec.signal` is forwarded so the cooperative tool timeout
|
|
133
|
+
* (`@hydraharness/harness-tool-call-timeout-policy`) and caller cancellation terminate the
|
|
134
|
+
* process tree.
|
|
135
|
+
*
|
|
136
|
+
* The spawn is unconfined (a plain `ctx.subprocess` call), so `--no-config`
|
|
137
|
+
* is prepended: a host `RIPGREP_CONFIG_PATH` (or `rg.conf` next to the
|
|
138
|
+
* binary) can otherwise inject `--pre` and make ripgrep execute an arbitrary
|
|
139
|
+
* preprocessor for every matched file. The collect dispositions are the
|
|
140
|
+
* seam's diagnostic-tail shape (no spill files): the tools never read a raw
|
|
141
|
+
* spill path, and truncated stdout fails as `SEARCH_RAW_OUTPUT_OVERFLOW`.
|
|
142
|
+
*
|
|
143
|
+
* Exit semantics are tool-owned: exit 0 is success with results, exit 1 is
|
|
144
|
+
* success with zero results (`noMatches`), anything else throws a
|
|
145
|
+
* {@link SearchError} (abort/timeout → `SEARCH_ABORTED`, invalid pattern →
|
|
146
|
+
* `SEARCH_INVALID_PATTERN`, the rest → `SEARCH_FAILED` /
|
|
147
|
+
* `SEARCH_RAW_OUTPUT_OVERFLOW`). Both launch-time failure domains are
|
|
148
|
+
* classified: a synchronous throw at spawn CREATION (a NUL in argv, an abort
|
|
149
|
+
* racing the pre-check, a rejected `@vscode/ripgrep` resolution) and a
|
|
150
|
+
* rejection of `handle.done` (the seam's infrastructure failures) both become
|
|
151
|
+
* `SEARCH_FAILED` with the original as `cause` — an abort already observed by
|
|
152
|
+
* creation time becomes `SEARCH_ABORTED` instead.
|
|
153
|
+
*
|
|
154
|
+
* @param ctx - the plugin context; execution uses its `subprocess` service.
|
|
155
|
+
* @param exec - the tool-execution context; supplies the session cwd and the abort signal.
|
|
156
|
+
* @param toolName - `glob` or `grep`, used in error messages.
|
|
157
|
+
* @param argv - the ripgrep arguments (every model value an unquoted argv element; no shell layer exists).
|
|
158
|
+
* @param rawOutputMaxBytes - cap on the complete raw stdout the tool will parse.
|
|
159
|
+
* @param graceMs - the seam's terminate-escalation grace period.
|
|
160
|
+
* @param stderrMaxBytes - cap on the retained stderr diagnostic tail.
|
|
161
|
+
* @returns the complete stdout, the zero-result flag, and the resolved workdir.
|
|
162
|
+
*/
|
|
163
|
+
async function runRipgrep(ctx, exec, toolName, argv, rawOutputMaxBytes, graceMs, stderrMaxBytes) {
|
|
164
|
+
if (exec.signal.aborted) throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, "SEARCH_ABORTED");
|
|
165
|
+
const workdir = exec.agent?.session.header.cwd ?? process.cwd();
|
|
166
|
+
let handle;
|
|
167
|
+
try {
|
|
168
|
+
handle = ctx.subprocess.spawn({
|
|
169
|
+
argv: [
|
|
170
|
+
await resolveRgPath(),
|
|
171
|
+
"--no-config",
|
|
172
|
+
...argv
|
|
173
|
+
],
|
|
174
|
+
cwd: workdir,
|
|
175
|
+
stdio: {
|
|
176
|
+
stdin: "ignore",
|
|
177
|
+
stdout: { maxBytes: rawOutputMaxBytes },
|
|
178
|
+
stderr: { maxBytes: stderrMaxBytes }
|
|
179
|
+
},
|
|
180
|
+
graceMs,
|
|
181
|
+
signal: exec.signal
|
|
182
|
+
});
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (exec.signal.aborted) throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, "SEARCH_ABORTED");
|
|
185
|
+
throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, "SEARCH_FAILED", { cause: error });
|
|
186
|
+
}
|
|
187
|
+
let outcome;
|
|
188
|
+
try {
|
|
189
|
+
outcome = await handle.done;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
throw new SearchError(`${toolName} could not start its search command (ripgrep launch failed)`, "SEARCH_FAILED", { cause: error });
|
|
192
|
+
}
|
|
193
|
+
const stdout = handle.collected.stdout?.readFrom(0);
|
|
194
|
+
const stderr = handle.collected.stderr?.readFrom(0);
|
|
195
|
+
if (stdout === void 0 || stderr === void 0) throw new SearchError(`${toolName} search command produced no collected output streams`, "SEARCH_FAILED");
|
|
196
|
+
if (exec.signal.aborted) throw new SearchError(`${toolName} was aborted before completion (tool timeout or caller cancellation)`, "SEARCH_ABORTED");
|
|
197
|
+
if (outcome.signal !== null || outcome.exitCode === null) throw new SearchError(`${toolName} search command was killed by signal ${outcome.signal ?? "(unknown)"}`, "SEARCH_FAILED");
|
|
198
|
+
if (outcome.exitCode !== 0 && outcome.exitCode !== 1) throw classifyRunFailure(toolName, outcome.exitCode, stderr.text, stderr.lossy);
|
|
199
|
+
return {
|
|
200
|
+
stdout: completeStdout(toolName, stdout, rawOutputMaxBytes),
|
|
201
|
+
noMatches: outcome.exitCode === 1,
|
|
202
|
+
workdir
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Map an `rg` output path to its display form: absolute paths inside the
|
|
207
|
+
* resolved workdir become workdir-relative; everything else (relative output,
|
|
208
|
+
* paths outside the workdir) passes through unchanged. Display-only —
|
|
209
|
+
* returned paths are follow-up-readable in co-located workdir/filesystem
|
|
210
|
+
* deployments where both resolve the same workspace (the documented v1
|
|
211
|
+
* deployment requirement).
|
|
212
|
+
*
|
|
213
|
+
* @param path - one path as ripgrep printed it.
|
|
214
|
+
* @param workdir - the resolved workdir the command ran in.
|
|
215
|
+
* @returns the workdir-relative display path when possible, else `path` unchanged.
|
|
216
|
+
*/
|
|
217
|
+
function toWorkdirRelative(path, workdir) {
|
|
218
|
+
if (!isAbsolute(path)) return path;
|
|
219
|
+
const rel = relative(workdir, path);
|
|
220
|
+
if (rel.length === 0) return ".";
|
|
221
|
+
if (rel === ".." || rel.startsWith(`..${sep}`)) return path;
|
|
222
|
+
return rel;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and
|
|
226
|
+
* mark the cut. The cap is a per-line budget fact; the complete line stays in
|
|
227
|
+
* the searched file for `read`.
|
|
228
|
+
*
|
|
229
|
+
* @param line - the matched line text (trailing newline already stripped).
|
|
230
|
+
* @param maxBytes - the preview budget in bytes.
|
|
231
|
+
* @returns the preview, suffixed with ` (line truncated)` when bytes were cut.
|
|
232
|
+
*/
|
|
233
|
+
function previewLine(line, maxBytes) {
|
|
234
|
+
const retainer = new TextRetainer({
|
|
235
|
+
kind: "head",
|
|
236
|
+
maxBytes
|
|
237
|
+
});
|
|
238
|
+
retainer.push(line);
|
|
239
|
+
const kept = retainer.finish();
|
|
240
|
+
return kept.truncated ? `${kept.text} (line truncated)` : kept.text;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Apply the shared inline cap to a canonical `grep` match list: preview each
|
|
244
|
+
* retained line to `maxLineBytes` and keep the first `maxMatches`. The single
|
|
245
|
+
* retention pass both the model-facing render ({@link module:@hydraharness/harness-tool-fs-search/grep}
|
|
246
|
+
* `formatGrepOutput`) and the search-card projection
|
|
247
|
+
* ({@link module:@hydraharness/harness-tool-fs-search/presentation} `grepSearchMeta`)
|
|
248
|
+
* consume, so text and card never disagree about which matches survived.
|
|
249
|
+
*
|
|
250
|
+
* @param matches - every match the search parsed (the canonical value's matches).
|
|
251
|
+
* @param maxMatches - the inline match cap (the `grepMaxMatches` config).
|
|
252
|
+
* @param maxLineBytes - the per-matched-line preview budget in bytes.
|
|
253
|
+
* @returns the retention outcome over the previewed matches.
|
|
254
|
+
*/
|
|
255
|
+
function retainGrepMatches(matches, maxMatches, maxLineBytes) {
|
|
256
|
+
const retainer = new ItemRetainer({
|
|
257
|
+
kind: "head",
|
|
258
|
+
maxItems: maxMatches
|
|
259
|
+
});
|
|
260
|
+
for (const match of matches) retainer.push({
|
|
261
|
+
...match,
|
|
262
|
+
line: previewLine(match.line, maxLineBytes)
|
|
263
|
+
});
|
|
264
|
+
return retainer.finish();
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Best-effort save of one COMPLETE formatted search result through
|
|
268
|
+
* `ctx.spillStore.saveText()` — the model-facing recovery path for a capped
|
|
269
|
+
* result. `spillStore` is read with `ctx.get()` (not static inject) because
|
|
270
|
+
* formatted-result spill is optional; the spill owner is the calling agent's
|
|
271
|
+
* session header id and the source is the tool execution identity. A missing
|
|
272
|
+
* backend, a call with no session owner, or a `saveText()` rejection logs a
|
|
273
|
+
* warning and returns `undefined` — the caller keeps the inline result and
|
|
274
|
+
* reports that the complete result could not be saved; search success never
|
|
275
|
+
* turns into `isError` because spill storage is unavailable.
|
|
276
|
+
*
|
|
277
|
+
* @param ctx - the plugin context; `spillStore` is looked up opportunistically.
|
|
278
|
+
* @param exec - the tool-execution context; supplies the owning session, tool name, and call id.
|
|
279
|
+
* @param suggestedName - the backend-sanitized filename hint (e.g. `grep-results.txt`).
|
|
280
|
+
* @param content - the complete formatted result to persist.
|
|
281
|
+
* @returns the saved spill reference, or `undefined` when the result could not be saved.
|
|
282
|
+
*/
|
|
283
|
+
async function trySaveFormattedResult(ctx, exec, suggestedName, content) {
|
|
284
|
+
const sessionId = exec.agent?.session.header.id;
|
|
285
|
+
if (sessionId === void 0) {
|
|
286
|
+
ctx.logger.warn(`tool-fs-search: no session owner for ${exec.name} result; complete result not saved`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const spillStore = ctx.get("spillStore");
|
|
290
|
+
if (!spillStore) {
|
|
291
|
+
ctx.logger.warn(`tool-fs-search: no ctx.spillStore backend loaded; complete ${exec.name} result not saved`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const save = {
|
|
295
|
+
owner: { sessionId },
|
|
296
|
+
source: {
|
|
297
|
+
toolName: exec.name,
|
|
298
|
+
callId: exec.callId,
|
|
299
|
+
label: "result"
|
|
300
|
+
},
|
|
301
|
+
suggestedName,
|
|
302
|
+
content
|
|
303
|
+
};
|
|
304
|
+
try {
|
|
305
|
+
return await spillStore.saveText(save);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
ctx.logger.warn(`tool-fs-search: saveText failed for ${exec.name}: ${String(error)}; complete result not saved`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
//#endregion
|
|
312
|
+
//#region lib/types/presentation.js
|
|
313
|
+
/**
|
|
314
|
+
* Result-time search-card presentation for `grep` and `glob`. Both tools land on
|
|
315
|
+
* one `card: 'search'` render intent ({@link SearchResultView}) with two
|
|
316
|
+
* `shape`-discriminated variants: `grep` projects its matches grouped by file
|
|
317
|
+
* ({@link SearchMatchesResultView}), `glob` projects a flat path list
|
|
318
|
+
* ({@link SearchPathsResultView}). This module owns the value→`presentationMeta`
|
|
319
|
+
* projection each tool declares and the defensive `meta`→view narrowing each
|
|
320
|
+
* tool's `presentResult` reads back on replay.
|
|
321
|
+
*
|
|
322
|
+
* The canonical value never crosses the wire — only the model-facing render text
|
|
323
|
+
* and this JSON `meta` do — so the structured shape a UI renders MUST ride in
|
|
324
|
+
* `meta`. Each projection consumes the SAME retained matches/paths the
|
|
325
|
+
* model-facing render consumes ({@link module:@hydraharness/harness-tool-fs-search/search-core}
|
|
326
|
+
* `retainGrepMatches`/`retainGlobPaths`), so text and card agree about which
|
|
327
|
+
* results survived the inline cap, and reports `total` (every result found) and
|
|
328
|
+
* `truncated`, so a UI never presents a capped result as complete.
|
|
329
|
+
*
|
|
330
|
+
* A second, independent cap bounds the JSON `meta` itself: the retained matches
|
|
331
|
+
* of a broad search (hundreds of long lines) can still serialize to hundreds of
|
|
332
|
+
* kilobytes, and `meta` is persisted with the session log and re-sent on every
|
|
333
|
+
* request. {@link capMetaBytes} drops trailing groups/paths until the serialized
|
|
334
|
+
* `meta` fits `maxMetaBytes` and marks the result `truncated`; a deployment's
|
|
335
|
+
* final output budget (`@hydraharness/harness-spill-policy`) only shrinks `content`, never `meta`,
|
|
336
|
+
* so this projection owns keeping `meta` bounded.
|
|
337
|
+
*
|
|
338
|
+
* @module @hydraharness/harness-tool-fs-search/presentation
|
|
339
|
+
*/
|
|
340
|
+
/**
|
|
341
|
+
* Group flat matches by file (first-seen order) into the structured by-file shape
|
|
342
|
+
* a UI renders as expandable per-file groups. The grouping matches the
|
|
343
|
+
* model-facing text grouping
|
|
344
|
+
* ({@link module:@hydraharness/harness-tool-fs-search/grep} `formatGrepMatches`), so
|
|
345
|
+
* card and text agree about file order and membership.
|
|
346
|
+
*
|
|
347
|
+
* @param matches - the retained matches to group, in output order.
|
|
348
|
+
* @returns one entry per file, in first-seen order.
|
|
349
|
+
*/
|
|
350
|
+
function groupMatchesByFile(matches) {
|
|
351
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
352
|
+
for (const match of matches) {
|
|
353
|
+
const entry = {
|
|
354
|
+
lineNumber: match.lineNumber,
|
|
355
|
+
line: match.line
|
|
356
|
+
};
|
|
357
|
+
const group = byFile.get(match.path);
|
|
358
|
+
if (group !== void 0) group.push(entry);
|
|
359
|
+
else byFile.set(match.path, [entry]);
|
|
360
|
+
}
|
|
361
|
+
return Array.from(byFile, ([path, fileMatches]) => ({
|
|
362
|
+
path,
|
|
363
|
+
matches: fileMatches
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
/** The serialized UTF-8 byte size of one meta payload (the size persisted and re-sent). */
|
|
367
|
+
function metaBytes(meta) {
|
|
368
|
+
return Buffer.byteLength(JSON.stringify(meta), "utf8");
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Drop trailing top-level items (file groups or paths) until the serialized meta
|
|
372
|
+
* fits `maxMetaBytes`, marking the result `truncated` when anything was dropped.
|
|
373
|
+
* `total` is preserved (it counts what the search found, not what meta retains).
|
|
374
|
+
* A single item too large to fit on its own is kept: the invariant is a bounded
|
|
375
|
+
* payload wherever droppable, never an empty card that hides a real result.
|
|
376
|
+
*
|
|
377
|
+
* @param meta - the projected meta, already capped to the inline item count.
|
|
378
|
+
* @param maxMetaBytes - the serialized-meta byte budget.
|
|
379
|
+
* @returns the same meta when it fits, else a byte-bounded copy marked `truncated`.
|
|
380
|
+
*/
|
|
381
|
+
function capMetaBytes(meta, maxMetaBytes) {
|
|
382
|
+
if (metaBytes(meta) <= maxMetaBytes) return meta;
|
|
383
|
+
if (meta.shape === "matches") {
|
|
384
|
+
const files = [...meta.files];
|
|
385
|
+
while (files.length > 1 && metaBytes({
|
|
386
|
+
...meta,
|
|
387
|
+
files,
|
|
388
|
+
truncated: true
|
|
389
|
+
}) > maxMetaBytes) files.pop();
|
|
390
|
+
return {
|
|
391
|
+
...meta,
|
|
392
|
+
files,
|
|
393
|
+
truncated: true
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const paths = [...meta.paths];
|
|
397
|
+
while (paths.length > 1 && metaBytes({
|
|
398
|
+
...meta,
|
|
399
|
+
paths,
|
|
400
|
+
truncated: true
|
|
401
|
+
}) > maxMetaBytes) paths.pop();
|
|
402
|
+
return {
|
|
403
|
+
...meta,
|
|
404
|
+
paths,
|
|
405
|
+
truncated: true
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Project the retained `grep` matches into {@link SearchMeta} for the search
|
|
410
|
+
* card. Consumes the same {@link RetainedItems} the model-facing render consumes
|
|
411
|
+
* (preview budget and inline match cap already applied), groups the retained
|
|
412
|
+
* matches by file, reports `total` (every parsed match) and `truncated`, then
|
|
413
|
+
* bounds the serialized meta to `maxMetaBytes`.
|
|
414
|
+
*
|
|
415
|
+
* @param retained - the retention outcome over every parsed match (previewed, capped).
|
|
416
|
+
* @param maxMetaBytes - the serialized-meta byte budget.
|
|
417
|
+
* @returns the `matches`-shaped search metadata.
|
|
418
|
+
*/
|
|
419
|
+
function grepSearchMeta(retained, maxMetaBytes) {
|
|
420
|
+
return capMetaBytes({
|
|
421
|
+
shape: "matches",
|
|
422
|
+
files: groupMatchesByFile(retained.items),
|
|
423
|
+
truncated: retained.truncated,
|
|
424
|
+
total: retained.seen
|
|
425
|
+
}, maxMetaBytes);
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Project the retained `glob` paths into {@link SearchMeta} for the search card.
|
|
429
|
+
* Consumes the same {@link RetainedItems} the model-facing render consumes (inline
|
|
430
|
+
* path cap already applied), reports `total` (every discovered path) and
|
|
431
|
+
* `truncated`, then bounds the serialized meta to `maxMetaBytes`.
|
|
432
|
+
*
|
|
433
|
+
* @param retained - the retention outcome over every discovered path (capped).
|
|
434
|
+
* @param maxMetaBytes - the serialized-meta byte budget.
|
|
435
|
+
* @returns the `paths`-shaped search metadata.
|
|
436
|
+
*/
|
|
437
|
+
function globSearchMeta(retained, maxMetaBytes) {
|
|
438
|
+
return capMetaBytes({
|
|
439
|
+
shape: "paths",
|
|
440
|
+
paths: retained.items,
|
|
441
|
+
truncated: retained.truncated,
|
|
442
|
+
total: retained.seen
|
|
443
|
+
}, maxMetaBytes);
|
|
444
|
+
}
|
|
445
|
+
/** Whether `value` is a valid {@link SearchLineMatch} (defensive narrowing from opaque `meta`). */
|
|
446
|
+
function isSearchLineMatch(value) {
|
|
447
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
448
|
+
const { lineNumber, line } = value;
|
|
449
|
+
return typeof lineNumber === "number" && typeof line === "string";
|
|
450
|
+
}
|
|
451
|
+
/** Whether `value` is a valid {@link SearchFileMatches} (defensive narrowing from opaque `meta`). */
|
|
452
|
+
function isSearchFileMatches(value) {
|
|
453
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
454
|
+
const { path, matches } = value;
|
|
455
|
+
return typeof path === "string" && Array.isArray(matches) && matches.every(isSearchLineMatch);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Narrow opaque live or replayed result metadata to a {@link SearchResultView}.
|
|
459
|
+
* Malformed metadata returns `undefined` so `presentResult` can fall back to the
|
|
460
|
+
* generic card instead of throwing during replay of an older or hand-edited log.
|
|
461
|
+
* The view carries no result text: a UI without a search card falls back to the
|
|
462
|
+
* raw `tool/result` content.
|
|
463
|
+
*
|
|
464
|
+
* A zero-result meta (`files: []` / `paths: []`) narrows to a valid empty card —
|
|
465
|
+
* unlike the mirrored `diffsFromMeta`, which rejects empty diffs, because a
|
|
466
|
+
* zero-match grep is a legitimate result a UI shows as "no matches", not an
|
|
467
|
+
* absent projection.
|
|
468
|
+
*
|
|
469
|
+
* @param meta - result metadata (the {@link SearchMeta} the tool projected).
|
|
470
|
+
* @returns the search view, or `undefined` for absent or malformed metadata.
|
|
471
|
+
*/
|
|
472
|
+
function searchViewFromMeta(meta) {
|
|
473
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return void 0;
|
|
474
|
+
const record = meta;
|
|
475
|
+
const { truncated, total } = record;
|
|
476
|
+
if (typeof truncated !== "boolean" || typeof total !== "number") return void 0;
|
|
477
|
+
if (record.shape === "matches") {
|
|
478
|
+
const { files } = record;
|
|
479
|
+
if (!Array.isArray(files) || !files.every(isSearchFileMatches)) return void 0;
|
|
480
|
+
return {
|
|
481
|
+
card: "search",
|
|
482
|
+
shape: "matches",
|
|
483
|
+
files,
|
|
484
|
+
truncated,
|
|
485
|
+
total
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
if (record.shape === "paths") {
|
|
489
|
+
const { paths } = record;
|
|
490
|
+
if (!Array.isArray(paths) || !paths.every((path) => typeof path === "string")) return void 0;
|
|
491
|
+
return {
|
|
492
|
+
card: "search",
|
|
493
|
+
shape: "paths",
|
|
494
|
+
paths,
|
|
495
|
+
truncated,
|
|
496
|
+
total
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region lib/types/direct-call.js
|
|
502
|
+
/** Shared top-level-call post-policy selection for search result spill. @module hydra-tool-fs-search/direct-call */
|
|
503
|
+
/**
|
|
504
|
+
* Return the accepted canonical value only when this tool still owns a direct
|
|
505
|
+
* successful top-level call and no downstream policy replaced either projection.
|
|
506
|
+
* @param ctx - the tool plugin context used to resolve the live scoped owner.
|
|
507
|
+
* @param tool - the exact registered definition whose value may be projected.
|
|
508
|
+
* @param exec - the completed execution identity.
|
|
509
|
+
* @param result - the canonical result before post-policy decisions are applied.
|
|
510
|
+
* @param decision - the composed downstream post-policy decision.
|
|
511
|
+
* @returns the canonical value to project, or `undefined` when spill must defer.
|
|
512
|
+
*/
|
|
513
|
+
function acceptedDirectCallValue(ctx, tool, exec, result, decision) {
|
|
514
|
+
if (decision.kind !== "accept" || decision.content !== void 0 || Object.hasOwn(decision, "value") || exec.parent !== void 0 || exec.name !== tool.name || result.isError || ctx.tools.get(exec.name, exec.agent) !== tool) return void 0;
|
|
515
|
+
return result.value;
|
|
516
|
+
}
|
|
517
|
+
//#endregion
|
|
518
|
+
//#region lib/types/glob.js
|
|
519
|
+
/**
|
|
520
|
+
* The model-facing `glob` tool: discover files whose paths match a glob
|
|
521
|
+
* pattern, sorted by modification time. Execution spawns the packaged
|
|
522
|
+
* ripgrep binary (`@vscode/ripgrep`) directly through the subprocess seam
|
|
523
|
+
* with a plain argv vector — this module owns the model-facing schema,
|
|
524
|
+
* argument validation, argv construction, result parsing, inline sampling,
|
|
525
|
+
* and formatting; process concerns (spawn execution, tree termination,
|
|
526
|
+
* environment scrubbing, output capture) stay behind `ctx.subprocess`.
|
|
527
|
+
* @module @hydraharness/harness-tool-fs-search/glob
|
|
528
|
+
*/
|
|
529
|
+
/**
|
|
530
|
+
* Default cap on paths retained inline by one `glob` call (the `globMaxResults`
|
|
531
|
+
* config), matching Claude Code's default `GlobTool` result limit.
|
|
532
|
+
*/
|
|
533
|
+
const GLOB_MAX_RESULTS = 100;
|
|
534
|
+
/**
|
|
535
|
+
* Directory names ripgrep must never descend into for a discovery listing: VCS
|
|
536
|
+
* metadata stores. `--no-ignore --hidden` would otherwise surface them in every
|
|
537
|
+
* broad search. Each name is excluded with TWO negated `--glob`s (see
|
|
538
|
+
* {@link buildGlobCommand}): an any-depth directory glob that matches — and
|
|
539
|
+
* prunes — the directory during traversal, and a contents glob that still
|
|
540
|
+
* excludes the internals when the search root itself is at or inside the
|
|
541
|
+
* directory (an explicit `path` of `.git` or `sub/.git`), where the prune glob
|
|
542
|
+
* alone never matches.
|
|
543
|
+
*/
|
|
544
|
+
const GLOB_VCS_EXCLUDES = [
|
|
545
|
+
".git",
|
|
546
|
+
".svn",
|
|
547
|
+
".hg",
|
|
548
|
+
".bzr",
|
|
549
|
+
".jj",
|
|
550
|
+
".sl"
|
|
551
|
+
];
|
|
552
|
+
/**
|
|
553
|
+
* Validate value constraints the schema DSL can't express: a non-blank
|
|
554
|
+
* `pattern`. A blank optional `path` means the default session workspace.
|
|
555
|
+
* Throws a plain `Error` (an ordinary tool argument error) otherwise.
|
|
556
|
+
*
|
|
557
|
+
* @param args - the schema-validated `glob` arguments.
|
|
558
|
+
* @returns the accepted input, unchanged.
|
|
559
|
+
*/
|
|
560
|
+
function parseGlobArgs(args) {
|
|
561
|
+
if (args.pattern.trim().length === 0) throw new Error("pattern must be a non-empty string");
|
|
562
|
+
if (args.path === void 0 || args.path.trim().length === 0) return { pattern: args.pattern };
|
|
563
|
+
return {
|
|
564
|
+
pattern: args.pattern,
|
|
565
|
+
path: args.path
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Build the fixed `rg --files` argv for one `glob` call. Every
|
|
570
|
+
* model-controlled value ({@link GlobInput.pattern}, {@link GlobInput.path})
|
|
571
|
+
* is a plain argv element — no shell layer exists, so no quoting applies; the
|
|
572
|
+
* search root rides behind `--` so a leading-dash path can never be parsed as
|
|
573
|
+
* a flag. `--sort=modified` orders by modification time, `--no-ignore
|
|
574
|
+
* --hidden` searches ignored and hidden files, and
|
|
575
|
+
* {@link GLOB_VCS_EXCLUDES} keeps VCS metadata out.
|
|
576
|
+
*
|
|
577
|
+
* @param input - the validated arguments.
|
|
578
|
+
* @returns the complete ripgrep argument vector (excluding the binary itself).
|
|
579
|
+
*/
|
|
580
|
+
function buildGlobCommand(input) {
|
|
581
|
+
const parts = [
|
|
582
|
+
"--files",
|
|
583
|
+
`--glob=${input.pattern}`,
|
|
584
|
+
"--sort=modified",
|
|
585
|
+
"--no-ignore",
|
|
586
|
+
"--hidden",
|
|
587
|
+
...GLOB_VCS_EXCLUDES.flatMap((name) => [`--glob=!**/${name}`, `--glob=!**/${name}/**`])
|
|
588
|
+
];
|
|
589
|
+
if (input.path !== void 0) parts.push("--", input.path);
|
|
590
|
+
return parts;
|
|
591
|
+
}
|
|
592
|
+
/** Remove the displayed search-root prefix before choosing a top-level group. */
|
|
593
|
+
function relativeToSearchRoot(path, root) {
|
|
594
|
+
if (root === ".") return path.startsWith(`.${sep}`) ? path.slice(2) : path;
|
|
595
|
+
let rootEnd = root.length;
|
|
596
|
+
while (rootEnd > 0 && root[rootEnd - 1] === sep) rootEnd -= 1;
|
|
597
|
+
const trimmedRoot = root.slice(0, rootEnd);
|
|
598
|
+
if (trimmedRoot.length === 0) return stripLeadingSeparators(path);
|
|
599
|
+
if (path === trimmedRoot) return "";
|
|
600
|
+
if (path.startsWith(`${trimmedRoot}${sep}`)) return path.slice(trimmedRoot.length + 1);
|
|
601
|
+
return path;
|
|
602
|
+
}
|
|
603
|
+
/** Strip only separators recognized by the execution platform. */
|
|
604
|
+
function stripLeadingSeparators(path) {
|
|
605
|
+
let start = 0;
|
|
606
|
+
while (path[start] === sep) start += 1;
|
|
607
|
+
return path.slice(start);
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* The leading path segment of one display path — the top-level entry, relative
|
|
611
|
+
* to the search root, that the path sits under. A path with no separator is its
|
|
612
|
+
* own top-level entry. Leading separators are stripped first so an absolute path
|
|
613
|
+
* (one outside the workdir, which {@link toWorkdirRelative} leaves untouched)
|
|
614
|
+
* groups by its first real name instead of collapsing every such path into one
|
|
615
|
+
* empty group.
|
|
616
|
+
*/
|
|
617
|
+
function topLevelSegment(path) {
|
|
618
|
+
const trimmed = stripLeadingSeparators(path);
|
|
619
|
+
const cut = trimmed.indexOf(sep);
|
|
620
|
+
return cut === -1 ? trimmed : trimmed.slice(0, cut);
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Choose the inline page of an over-cap result by round-robin across the
|
|
624
|
+
* complete result's top-level entries, instead of taking its head.
|
|
625
|
+
*
|
|
626
|
+
* Every top-level entry receives a slot before any receives a second; exhausted
|
|
627
|
+
* groups drop out. Group order and order within each group follow `paths`, so a
|
|
628
|
+
* flat result reproduces the modification-time head.
|
|
629
|
+
*
|
|
630
|
+
* @param paths - the complete result, in ripgrep's modification-time order.
|
|
631
|
+
* @param maxItems - how many paths the page may hold; the caller has already established it is smaller than `paths`.
|
|
632
|
+
* @param root - the search root in the same display-path space as `paths`.
|
|
633
|
+
* @returns the page grouped by top-level entry, with the shown/total top-level spread.
|
|
634
|
+
*/
|
|
635
|
+
function sampleAcrossTopLevel(paths, maxItems, root = ".") {
|
|
636
|
+
const groups = /* @__PURE__ */ new Map();
|
|
637
|
+
let active = [];
|
|
638
|
+
for (const path of paths) {
|
|
639
|
+
const key = topLevelSegment(relativeToSearchRoot(path, root));
|
|
640
|
+
const group = groups.get(key);
|
|
641
|
+
if (group === void 0) {
|
|
642
|
+
const items = [path];
|
|
643
|
+
groups.set(key, items);
|
|
644
|
+
active.push({
|
|
645
|
+
key,
|
|
646
|
+
items,
|
|
647
|
+
index: 0,
|
|
648
|
+
current: path
|
|
649
|
+
});
|
|
650
|
+
} else group.push(path);
|
|
651
|
+
}
|
|
652
|
+
const taken = /* @__PURE__ */ new Map();
|
|
653
|
+
let count = 0;
|
|
654
|
+
while (active.length > 0 && count < maxItems) {
|
|
655
|
+
const nextActive = [];
|
|
656
|
+
for (const { key, items, index, current } of active) {
|
|
657
|
+
if (count >= maxItems) break;
|
|
658
|
+
count += 1;
|
|
659
|
+
const bucket = taken.get(key);
|
|
660
|
+
if (bucket === void 0) taken.set(key, [current]);
|
|
661
|
+
else bucket.push(current);
|
|
662
|
+
const nextIndex = index + 1;
|
|
663
|
+
const nextPath = items[nextIndex];
|
|
664
|
+
if (nextPath !== void 0) nextActive.push({
|
|
665
|
+
key,
|
|
666
|
+
items,
|
|
667
|
+
index: nextIndex,
|
|
668
|
+
current: nextPath
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
active = nextActive;
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
items: [...taken.values()].flat(),
|
|
675
|
+
shown: taken.size,
|
|
676
|
+
total: groups.size
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Format a capped sampled page and its complete-result recovery path. A flat
|
|
681
|
+
* result keeps the plain footer because its sample is the modification-time head.
|
|
682
|
+
*
|
|
683
|
+
* @param sample - the inline page and its top-level spread.
|
|
684
|
+
* @param seen - how many paths the complete result holds; always more than the page.
|
|
685
|
+
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
|
|
686
|
+
* @returns the model-facing text.
|
|
687
|
+
*/
|
|
688
|
+
function formatGlobOutput(sample, seen, spillRef) {
|
|
689
|
+
const basis = sample.total === seen ? "." : `, sampled across ${sample.shown} of the ${sample.total} top-level entries this pattern matched instead of taken in modification-time order.` + (sample.shown < sample.total ? " Narrow path to inspect a specific subtree." : "");
|
|
690
|
+
return formatGlobPage(sample.items, seen, spillRef, basis);
|
|
691
|
+
}
|
|
692
|
+
/** Format one bounded page and the recovery path for its complete sorted result. */
|
|
693
|
+
function formatGlobPage(items, seen, spillRef, basis) {
|
|
694
|
+
const body = items.join("\n");
|
|
695
|
+
const recovery = spillRef !== void 0 ? `Full sorted result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}` : "The complete result could not be saved; narrow pattern or path to see more.";
|
|
696
|
+
return `${body}\n\n(Showing ${items.length} of ${seen} paths${basis} ${recovery})`;
|
|
697
|
+
}
|
|
698
|
+
/** Bound and format one canonical path list for the Native surface relative to its search root. */
|
|
699
|
+
function renderGlobPaths(paths, caps, root, spillRef) {
|
|
700
|
+
if (paths.length === 0) return "No files found";
|
|
701
|
+
if (paths.length <= caps.maxResults) return paths.join("\n");
|
|
702
|
+
if (!caps.sampleOverCapGlobResults) return formatGlobPage(paths.slice(0, caps.maxResults), paths.length, spillRef, ".");
|
|
703
|
+
return formatGlobOutput(sampleAcrossTopLevel(paths, caps.maxResults, root), paths.length, spillRef);
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* The inline page of paths a completed `glob` card shows, computed the SAME way
|
|
707
|
+
* {@link renderGlobPaths} computes its model-facing page so the card and the text
|
|
708
|
+
* agree on which paths survived the cap. A result within the cap is shown whole;
|
|
709
|
+
* an over-cap result is either the modification-time head or the top-level sample,
|
|
710
|
+
* matching the deployment's `sampleOverCapGlobResults`.
|
|
711
|
+
*
|
|
712
|
+
* @param paths - the complete discovered path list, in modification-time order.
|
|
713
|
+
* @param caps - the resolved glob caps (the inline cap and the sampling switch).
|
|
714
|
+
* @param root - the search root in the same display-path space as `paths`.
|
|
715
|
+
* @returns the inline page and whether the complete result was capped.
|
|
716
|
+
*/
|
|
717
|
+
function globCardPage(paths, caps, root) {
|
|
718
|
+
if (paths.length <= caps.maxResults) return {
|
|
719
|
+
items: paths,
|
|
720
|
+
truncated: false
|
|
721
|
+
};
|
|
722
|
+
if (!caps.sampleOverCapGlobResults) return {
|
|
723
|
+
items: paths.slice(0, caps.maxResults),
|
|
724
|
+
truncated: true
|
|
725
|
+
};
|
|
726
|
+
return {
|
|
727
|
+
items: sampleAcrossTopLevel(paths, caps.maxResults, root).items,
|
|
728
|
+
truncated: true
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Pending-call presentation: a search card titled by the pattern (and root).
|
|
733
|
+
*
|
|
734
|
+
* @param args - the raw tool arguments; `pattern` and `path` feed the title.
|
|
735
|
+
* @returns the generic card view (`kind: 'search'`) shown while the call runs.
|
|
736
|
+
*/
|
|
737
|
+
function presentGlobCall(args) {
|
|
738
|
+
const where = args.path !== void 0 ? ` in ${args.path}` : "";
|
|
739
|
+
return {
|
|
740
|
+
card: "generic",
|
|
741
|
+
title: `Glob ${args.pattern}${where}`,
|
|
742
|
+
kind: "search",
|
|
743
|
+
rawInput: args.pattern
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Completed-call presentation: the search card projected from the result's
|
|
748
|
+
* `presentationMeta` (the discovered path list, with the truncation signal). A UI
|
|
749
|
+
* without a search card falls back to the raw `tool/result` content, so the view
|
|
750
|
+
* carries no result text of its own. Malformed or absent metadata (an obsolete or
|
|
751
|
+
* hand-edited replayed log) falls back to the generic card.
|
|
752
|
+
*
|
|
753
|
+
* @param _args - the raw tool arguments; unused, the view derives from the result.
|
|
754
|
+
* @param result - the final model-facing tool result carrying the projected metadata.
|
|
755
|
+
* @returns the search card view, or `undefined` for the generic fallback.
|
|
756
|
+
*/
|
|
757
|
+
function presentGlobResult(_args, result) {
|
|
758
|
+
if (result.isError) return void 0;
|
|
759
|
+
const view = searchViewFromMeta(result.meta);
|
|
760
|
+
if (view === void 0 || view.shape !== "paths") return void 0;
|
|
761
|
+
return view;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Register the `glob` tool and its system-prompt guidance.
|
|
765
|
+
*
|
|
766
|
+
* @param ctx - the plugin context; registrations are effects scoped to it, and
|
|
767
|
+
* execution uses its `subprocess` service.
|
|
768
|
+
* @param caps - the deployment's resolved glob caps (plugin config after defaulting).
|
|
769
|
+
*/
|
|
770
|
+
function applyGlobTool(ctx, caps) {
|
|
771
|
+
const overCapGuidance = caps.sampleOverCapGlobResults ? "while a larger one is sampled across top-level entries, so it spans the tree instead of one subtree." : "while a larger one keeps the modification-time-ordered head.";
|
|
772
|
+
ctx.systemPrompt.section({
|
|
773
|
+
name: "tool:glob",
|
|
774
|
+
order: 103,
|
|
775
|
+
text: `Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, ${overCapGuidance}`
|
|
776
|
+
});
|
|
777
|
+
const overCapDescription = caps.sampleOverCapGlobResults ? `a larger result instead returns ${caps.maxResults} paths sampled across top-level entries` : `a larger result returns the first ${caps.maxResults} paths in modification-time order`;
|
|
778
|
+
const tool = defineTool({
|
|
779
|
+
name: "glob",
|
|
780
|
+
description: `Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to ${caps.maxResults} paths come back in modification-time order; ${overCapDescription}, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.`,
|
|
781
|
+
parameters: {
|
|
782
|
+
pattern: {
|
|
783
|
+
type: "string",
|
|
784
|
+
required: true,
|
|
785
|
+
description: "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth."
|
|
786
|
+
},
|
|
787
|
+
path: {
|
|
788
|
+
type: "string",
|
|
789
|
+
description: "Directory to search in. Defaults to the session workspace; a relative path resolves against it."
|
|
790
|
+
}
|
|
791
|
+
},
|
|
792
|
+
timeoutMs: caps.timeoutMs,
|
|
793
|
+
output: {
|
|
794
|
+
schema: {
|
|
795
|
+
type: "object",
|
|
796
|
+
additionalProperties: false,
|
|
797
|
+
properties: {
|
|
798
|
+
root: {
|
|
799
|
+
type: "string",
|
|
800
|
+
required: true
|
|
801
|
+
},
|
|
802
|
+
paths: {
|
|
803
|
+
type: "array",
|
|
804
|
+
required: true,
|
|
805
|
+
items: { type: "string" }
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
render: (_args, value) => [{
|
|
810
|
+
type: "text",
|
|
811
|
+
text: renderGlobPaths(value.paths, caps, value.root)
|
|
812
|
+
}],
|
|
813
|
+
presentationMeta: (_args, value) => {
|
|
814
|
+
const page = globCardPage(value.paths, caps, value.root);
|
|
815
|
+
return globSearchMeta({
|
|
816
|
+
items: page.items,
|
|
817
|
+
truncated: page.truncated,
|
|
818
|
+
seen: value.paths.length
|
|
819
|
+
}, caps.maxMetaBytes);
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
async execute(args, exec) {
|
|
823
|
+
const input = parseGlobArgs(args);
|
|
824
|
+
const run = await runRipgrep(ctx, exec, "glob", buildGlobCommand(input), caps.rawOutputMaxBytes, caps.graceMs, caps.stderrMaxBytes);
|
|
825
|
+
const root = input.path === void 0 ? "." : toWorkdirRelative(input.path, run.workdir);
|
|
826
|
+
if (run.noMatches) return {
|
|
827
|
+
root,
|
|
828
|
+
paths: []
|
|
829
|
+
};
|
|
830
|
+
const all = [];
|
|
831
|
+
for (const line of run.stdout.split("\n")) {
|
|
832
|
+
if (line.length === 0) continue;
|
|
833
|
+
const displayPath = toWorkdirRelative(line, run.workdir);
|
|
834
|
+
all.push(displayPath);
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
root,
|
|
838
|
+
paths: all
|
|
839
|
+
};
|
|
840
|
+
},
|
|
841
|
+
presentCall: presentGlobCall,
|
|
842
|
+
presentResult: presentGlobResult
|
|
843
|
+
});
|
|
844
|
+
ctx.tools.register(tool);
|
|
845
|
+
ctx.on("tools/post-execute", async (exec, result, next) => {
|
|
846
|
+
const decision = await next();
|
|
847
|
+
const value = acceptedDirectCallValue(ctx, tool, exec, result, decision);
|
|
848
|
+
if (value === void 0) return decision;
|
|
849
|
+
const paths = value.paths;
|
|
850
|
+
if (paths.length <= caps.maxResults) return decision;
|
|
851
|
+
const spillRef = await trySaveFormattedResult(ctx, exec, "glob-results.txt", paths.join("\n"));
|
|
852
|
+
return {
|
|
853
|
+
kind: "accept",
|
|
854
|
+
content: [{
|
|
855
|
+
type: "text",
|
|
856
|
+
text: renderGlobPaths(paths, caps, value.root, spillRef)
|
|
857
|
+
}],
|
|
858
|
+
...decision.additionalContexts !== void 0 ? { additionalContexts: decision.additionalContexts } : {}
|
|
859
|
+
};
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
//#endregion
|
|
863
|
+
//#region lib/types/grep.js
|
|
864
|
+
/**
|
|
865
|
+
* The model-facing `grep` tool: search file contents with a ripgrep regular
|
|
866
|
+
* expression. Execution spawns the packaged ripgrep binary
|
|
867
|
+
* (`@vscode/ripgrep`) directly through the subprocess seam with a plain argv
|
|
868
|
+
* vector using a fixed line-oriented `rg --json` command so file path, line
|
|
869
|
+
* number, and line text parse without colon-splitting ambiguity — this module
|
|
870
|
+
* owns the model-facing schema, argument validation, argv construction,
|
|
871
|
+
* `--json` record parsing, per-line preview retention, match retention,
|
|
872
|
+
* grouping, and formatting; process concerns stay behind `ctx.subprocess`.
|
|
873
|
+
*
|
|
874
|
+
* @module @hydraharness/harness-tool-fs-search/grep
|
|
875
|
+
*/
|
|
876
|
+
/**
|
|
877
|
+
* Default cap on flat matches retained inline by one `grep` call (the
|
|
878
|
+
* `grepMaxMatches` config), matching Claude Code's default `GrepTool`
|
|
879
|
+
* `head_limit`.
|
|
880
|
+
*/
|
|
881
|
+
const GREP_MAX_MATCHES = 250;
|
|
882
|
+
/**
|
|
883
|
+
* Default cap in bytes on one matched-line preview (the `grepMaxLineBytes`
|
|
884
|
+
* config); the cut preserves UTF-8 boundaries.
|
|
885
|
+
*/
|
|
886
|
+
const GREP_MAX_LINE_BYTES = 2e3;
|
|
887
|
+
/**
|
|
888
|
+
* Reject an `include` that is not ONE positive glob filter: blank strings,
|
|
889
|
+
* negated patterns (`!…`), and comma-separated lists. A comma inside a brace
|
|
890
|
+
* group is fine — `*.{ts,tsx}` is one glob with alternation, not a list.
|
|
891
|
+
*/
|
|
892
|
+
function validateInclude(include) {
|
|
893
|
+
if (include.trim().length === 0) throw new Error("include must be a non-empty glob when given");
|
|
894
|
+
if (include.startsWith("!")) throw new Error("include must be a positive glob filter; negated patterns (\"!…\") are not supported");
|
|
895
|
+
let braceDepth = 0;
|
|
896
|
+
for (const char of include) if (char === "{") braceDepth++;
|
|
897
|
+
else if (char === "}") braceDepth = Math.max(0, braceDepth - 1);
|
|
898
|
+
else if (char === "," && braceDepth === 0) throw new Error("include must be one glob, not a comma-separated list (use {a,b} alternation instead)");
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Validate value constraints the schema DSL can't express: a non-EMPTY
|
|
902
|
+
* `pattern` (whitespace is a legitimate regex), a single positive `include`
|
|
903
|
+
* glob, and an optional `path` whose blank form means the default session
|
|
904
|
+
* workspace ({@link GrepInput}). Throws a plain `Error` (an ordinary tool
|
|
905
|
+
* argument error) otherwise.
|
|
906
|
+
*
|
|
907
|
+
* @param args - the schema-validated `grep` arguments.
|
|
908
|
+
* @returns the accepted input, unchanged.
|
|
909
|
+
*/
|
|
910
|
+
function parseGrepArgs(args) {
|
|
911
|
+
if (args.pattern.length === 0) throw new Error("pattern must be a non-empty string");
|
|
912
|
+
if (args.include !== void 0) validateInclude(args.include);
|
|
913
|
+
return {
|
|
914
|
+
pattern: args.pattern,
|
|
915
|
+
...args.path !== void 0 && args.path.trim().length > 0 ? { path: args.path } : {},
|
|
916
|
+
...args.include !== void 0 ? { include: args.include } : {}
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
/**
|
|
920
|
+
* Build the fixed line-oriented `rg --json` argv for one `grep` call. Every
|
|
921
|
+
* model-controlled value ({@link GrepInput.pattern}, {@link GrepInput.path},
|
|
922
|
+
* {@link GrepInput.include}) is a plain argv element — no shell layer exists,
|
|
923
|
+
* so no quoting applies; the pattern and include ride in `--flag=value` form
|
|
924
|
+
* and the target behind `--`, so a leading-dash value can never be parsed as
|
|
925
|
+
* a flag.
|
|
926
|
+
*
|
|
927
|
+
* @param input - the validated arguments.
|
|
928
|
+
* @returns the complete ripgrep argument vector (excluding the binary itself).
|
|
929
|
+
*/
|
|
930
|
+
function buildGrepCommand(input) {
|
|
931
|
+
const parts = ["--json", `--regexp=${input.pattern}`];
|
|
932
|
+
if (input.include !== void 0) parts.push(`--glob=${input.include}`);
|
|
933
|
+
if (input.path !== void 0) parts.push("--", input.path);
|
|
934
|
+
return parts;
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* The uniform malformed-output failure: raw `rg --json` is an internal
|
|
938
|
+
* transport, so missing or invalid response fields cause a search failure, not a partial result.
|
|
939
|
+
*/
|
|
940
|
+
function malformedRecord(detail, cause) {
|
|
941
|
+
return new SearchError(`grep received malformed ripgrep --json output (${detail})`, "SEARCH_FAILED", cause !== void 0 ? { cause } : void 0);
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Parse one `rg --json` NDJSON line into a match, `undefined` for the
|
|
945
|
+
* non-match record types (`begin`/`end`/`context`/`summary`). A line that is
|
|
946
|
+
* not JSON, or a `match` record missing its path / line number / line content,
|
|
947
|
+
* throws {@link SearchError} `SEARCH_FAILED`. A match whose line is not valid
|
|
948
|
+
* UTF-8 (ripgrep sends base64 `bytes` instead of `text`) yields a placeholder
|
|
949
|
+
* preview rather than failing the whole search.
|
|
950
|
+
*/
|
|
951
|
+
function parseRecord(line) {
|
|
952
|
+
let parsed;
|
|
953
|
+
try {
|
|
954
|
+
parsed = JSON.parse(line);
|
|
955
|
+
} catch (error) {
|
|
956
|
+
throw malformedRecord("a line is not JSON", error);
|
|
957
|
+
}
|
|
958
|
+
if (typeof parsed !== "object" || parsed === null) throw malformedRecord("a record is not an object");
|
|
959
|
+
const record = parsed;
|
|
960
|
+
if (record.type !== "match") return void 0;
|
|
961
|
+
if (typeof record.data !== "object" || record.data === null) throw malformedRecord("a match record has no data");
|
|
962
|
+
const data = record.data;
|
|
963
|
+
const pathText = typeof data.path === "object" && data.path !== null ? data.path.text : void 0;
|
|
964
|
+
if (typeof pathText !== "string") throw malformedRecord("a match record has no path text");
|
|
965
|
+
if (typeof data.line_number !== "number") throw malformedRecord("a match record has no line number");
|
|
966
|
+
if (typeof data.lines !== "object" || data.lines === null) throw malformedRecord("a match record has no line content");
|
|
967
|
+
const lines = data.lines;
|
|
968
|
+
if (typeof lines.text === "string") return {
|
|
969
|
+
path: pathText,
|
|
970
|
+
lineNumber: data.line_number,
|
|
971
|
+
line: lines.text.replace(/\r?\n$/, "")
|
|
972
|
+
};
|
|
973
|
+
if (typeof lines.bytes === "string") return {
|
|
974
|
+
path: pathText,
|
|
975
|
+
lineNumber: data.line_number,
|
|
976
|
+
line: "(line is not valid UTF-8)"
|
|
977
|
+
};
|
|
978
|
+
throw malformedRecord("a match record has neither line text nor bytes");
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Parse complete `rg --json` stdout into flat matches, in output order (ripgrep
|
|
982
|
+
* emits one file's matches contiguously). Only `match` records are consumed.
|
|
983
|
+
*
|
|
984
|
+
* @param stdout - the complete raw `rg --json` stdout.
|
|
985
|
+
* @returns the flat matches; empty for output with no match records.
|
|
986
|
+
*/
|
|
987
|
+
function parseGrepMatches(stdout) {
|
|
988
|
+
const matches = [];
|
|
989
|
+
for (const line of stdout.split("\n")) {
|
|
990
|
+
if (line.length === 0) continue;
|
|
991
|
+
const match = parseRecord(line);
|
|
992
|
+
if (match !== void 0) matches.push(match);
|
|
993
|
+
}
|
|
994
|
+
return matches;
|
|
995
|
+
}
|
|
996
|
+
/** `match` / `matches` for a count. */
|
|
997
|
+
function matchNoun(count) {
|
|
998
|
+
return count === 1 ? "match" : "matches";
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Group flat matches by file (first-seen order) into the model-facing body:
|
|
1002
|
+
* each file's display path, then one `Line N: <text>` row per match.
|
|
1003
|
+
*
|
|
1004
|
+
* @param matches - the flat matches to render.
|
|
1005
|
+
* @returns the grouped body text.
|
|
1006
|
+
*/
|
|
1007
|
+
function formatGrepMatches(matches) {
|
|
1008
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
1009
|
+
for (const match of matches) {
|
|
1010
|
+
const group = byFile.get(match.path);
|
|
1011
|
+
if (group !== void 0) group.push(match);
|
|
1012
|
+
else byFile.set(match.path, [match]);
|
|
1013
|
+
}
|
|
1014
|
+
const sections = [];
|
|
1015
|
+
for (const [path, group] of byFile) sections.push(`${path}\n${group.map((m) => `Line ${m.lineNumber}: ${m.line}`).join("\n")}`);
|
|
1016
|
+
return sections.join("\n\n");
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Format the model-facing `grep` result: a found-count header, the retained
|
|
1020
|
+
* matches grouped by file, then — when the result was capped — a footer
|
|
1021
|
+
* carrying either the formatted-spill recovery locator or the could-not-save
|
|
1022
|
+
* explanation. The omitted count is a budget fact: the search itself completed.
|
|
1023
|
+
*
|
|
1024
|
+
* @param retained - the retention outcome over every parsed match.
|
|
1025
|
+
* @param spillRef - the saved complete-result reference, or `undefined` when unsaved.
|
|
1026
|
+
* @returns the model-facing text.
|
|
1027
|
+
*/
|
|
1028
|
+
function formatGrepOutput(retained, spillRef) {
|
|
1029
|
+
const header = retained.truncated ? `Found ${retained.kept} of ${retained.seen} matches` : `Found ${retained.seen} ${matchNoun(retained.seen)}`;
|
|
1030
|
+
const body = formatGrepMatches(retained.items);
|
|
1031
|
+
if (!retained.truncated) return `${header}\n\n${body}`;
|
|
1032
|
+
return `${header}\n\n${body}\n\n(${spillRef !== void 0 ? `Full grep result stored at: ${spillRef.locator}. ${spillRef.retrievalHint}` : "The complete result could not be saved; narrow pattern, path, or include to see more."})`;
|
|
1033
|
+
}
|
|
1034
|
+
/** Format one already-retained match list for the Native surface. */
|
|
1035
|
+
function formatRetainedGrep(retained, spillRef) {
|
|
1036
|
+
if (retained.seen === 0) return "No matches found";
|
|
1037
|
+
return formatGrepOutput(retained, spillRef);
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Pending-call presentation: a search card titled by the pattern (and target /
|
|
1041
|
+
* include filter).
|
|
1042
|
+
*
|
|
1043
|
+
* @param args - the raw tool arguments; `pattern`, `path`, and `include` feed the title.
|
|
1044
|
+
* @returns the generic card view (`kind: 'search'`) shown while the call runs.
|
|
1045
|
+
*/
|
|
1046
|
+
function presentGrepCall(args) {
|
|
1047
|
+
const where = args.path !== void 0 ? ` in ${args.path}` : "";
|
|
1048
|
+
const filter = args.include !== void 0 ? ` (${args.include})` : "";
|
|
1049
|
+
return {
|
|
1050
|
+
card: "generic",
|
|
1051
|
+
title: `Grep ${args.pattern}${where}${filter}`,
|
|
1052
|
+
kind: "search",
|
|
1053
|
+
rawInput: args.pattern
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* Completed-call presentation: the search card projected from the result's
|
|
1058
|
+
* `presentationMeta` (matches grouped by file, with the truncation signal). A UI
|
|
1059
|
+
* without a search card falls back to the raw `tool/result` content, so the view
|
|
1060
|
+
* carries no result text of its own. Malformed or absent metadata (an obsolete or
|
|
1061
|
+
* hand-edited replayed log) falls back to the generic card.
|
|
1062
|
+
*
|
|
1063
|
+
* @param _args - the raw tool arguments; unused, the view derives from the result.
|
|
1064
|
+
* @param result - the final model-facing tool result carrying the projected metadata.
|
|
1065
|
+
* @returns the search card view, or `undefined` for the generic fallback.
|
|
1066
|
+
*/
|
|
1067
|
+
function presentGrepResult(_args, result) {
|
|
1068
|
+
if (result.isError) return void 0;
|
|
1069
|
+
const view = searchViewFromMeta(result.meta);
|
|
1070
|
+
if (view === void 0 || view.shape !== "matches") return void 0;
|
|
1071
|
+
return view;
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Register the `grep` tool and its system-prompt guidance.
|
|
1075
|
+
*
|
|
1076
|
+
* @param ctx - the plugin context; registrations are effects scoped to it, and
|
|
1077
|
+
* execution uses its `subprocess` service.
|
|
1078
|
+
* @param caps - the deployment's resolved grep caps (plugin config after defaulting).
|
|
1079
|
+
*/
|
|
1080
|
+
function applyGrepTool(ctx, caps) {
|
|
1081
|
+
ctx.systemPrompt.section({
|
|
1082
|
+
name: "tool:grep",
|
|
1083
|
+
order: 104,
|
|
1084
|
+
text: "Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context."
|
|
1085
|
+
});
|
|
1086
|
+
const tool = defineTool({
|
|
1087
|
+
name: "grep",
|
|
1088
|
+
description: `Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first ${caps.maxMatches} matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.`,
|
|
1089
|
+
parameters: {
|
|
1090
|
+
pattern: {
|
|
1091
|
+
type: "string",
|
|
1092
|
+
required: true,
|
|
1093
|
+
description: "Regular expression to search for (ripgrep syntax)."
|
|
1094
|
+
},
|
|
1095
|
+
path: {
|
|
1096
|
+
type: "string",
|
|
1097
|
+
description: "File or directory to search. Defaults to the session workspace; a relative path resolves against it."
|
|
1098
|
+
},
|
|
1099
|
+
include: {
|
|
1100
|
+
type: "string",
|
|
1101
|
+
description: "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported."
|
|
1102
|
+
}
|
|
1103
|
+
},
|
|
1104
|
+
timeoutMs: caps.timeoutMs,
|
|
1105
|
+
output: {
|
|
1106
|
+
schema: {
|
|
1107
|
+
type: "object",
|
|
1108
|
+
additionalProperties: false,
|
|
1109
|
+
properties: { matches: {
|
|
1110
|
+
type: "array",
|
|
1111
|
+
required: true,
|
|
1112
|
+
items: {
|
|
1113
|
+
type: "object",
|
|
1114
|
+
additionalProperties: false,
|
|
1115
|
+
properties: {
|
|
1116
|
+
path: {
|
|
1117
|
+
type: "string",
|
|
1118
|
+
required: true
|
|
1119
|
+
},
|
|
1120
|
+
lineNumber: {
|
|
1121
|
+
type: "integer",
|
|
1122
|
+
required: true
|
|
1123
|
+
},
|
|
1124
|
+
line: {
|
|
1125
|
+
type: "string",
|
|
1126
|
+
required: true
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
} }
|
|
1131
|
+
},
|
|
1132
|
+
render: (_args, value) => [{
|
|
1133
|
+
type: "text",
|
|
1134
|
+
text: formatRetainedGrep(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes))
|
|
1135
|
+
}],
|
|
1136
|
+
presentationMeta: (_args, value) => grepSearchMeta(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes), caps.maxMetaBytes)
|
|
1137
|
+
},
|
|
1138
|
+
async execute(args, exec) {
|
|
1139
|
+
const run = await runRipgrep(ctx, exec, "grep", buildGrepCommand(parseGrepArgs(args)), caps.rawOutputMaxBytes, caps.graceMs, caps.stderrMaxBytes);
|
|
1140
|
+
if (run.noMatches) return { matches: [] };
|
|
1141
|
+
const all = [];
|
|
1142
|
+
for (const raw of parseGrepMatches(run.stdout)) {
|
|
1143
|
+
const match = {
|
|
1144
|
+
path: toWorkdirRelative(raw.path, run.workdir),
|
|
1145
|
+
lineNumber: raw.lineNumber,
|
|
1146
|
+
line: raw.line
|
|
1147
|
+
};
|
|
1148
|
+
all.push(match);
|
|
1149
|
+
}
|
|
1150
|
+
return { matches: all };
|
|
1151
|
+
},
|
|
1152
|
+
presentCall: presentGrepCall,
|
|
1153
|
+
presentResult: presentGrepResult
|
|
1154
|
+
});
|
|
1155
|
+
ctx.tools.register(tool);
|
|
1156
|
+
ctx.on("tools/post-execute", async (exec, result, next) => {
|
|
1157
|
+
const decision = await next();
|
|
1158
|
+
const value = acceptedDirectCallValue(ctx, tool, exec, result, decision);
|
|
1159
|
+
if (value === void 0) return decision;
|
|
1160
|
+
const matches = value.matches;
|
|
1161
|
+
if (matches.length <= caps.maxMatches) return decision;
|
|
1162
|
+
const previewedAll = matches.map((match) => ({
|
|
1163
|
+
...match,
|
|
1164
|
+
line: previewLine(match.line, caps.maxLineBytes)
|
|
1165
|
+
}));
|
|
1166
|
+
const spillRef = await trySaveFormattedResult(ctx, exec, "grep-results.txt", `Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewedAll)}`);
|
|
1167
|
+
return {
|
|
1168
|
+
kind: "accept",
|
|
1169
|
+
content: [{
|
|
1170
|
+
type: "text",
|
|
1171
|
+
text: formatRetainedGrep(retainGrepMatches(matches, caps.maxMatches, caps.maxLineBytes), spillRef)
|
|
1172
|
+
}],
|
|
1173
|
+
...decision.additionalContexts !== void 0 ? { additionalContexts: decision.additionalContexts } : {}
|
|
1174
|
+
};
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
//#endregion
|
|
1178
|
+
//#region lib/types/index.js
|
|
1179
|
+
/**
|
|
1180
|
+
* The model-facing filesystem discovery tool suite (`glob`, `grep`) over the
|
|
1181
|
+
* packaged ripgrep binary (`@vscode/ripgrep`). This single plugin registers
|
|
1182
|
+
* both tools; the binary ships inside the npm dependency, so no system `rg`
|
|
1183
|
+
* install and no shell layer is involved.
|
|
1184
|
+
*
|
|
1185
|
+
* ## Spawn-backed, not a `ctx.fs` provider method
|
|
1186
|
+
*
|
|
1187
|
+
* Local workspace discovery is a process-backed `rg` workflow, so these tools
|
|
1188
|
+
* execute through `ctx.subprocess.spawn()` with fixed ripgrep argv templates —
|
|
1189
|
+
* never `ctx.shell`, never `ctx.shell.start()`, never a model-visible background
|
|
1190
|
+
* task. The tool layer owns schemas, argument validation, argv construction
|
|
1191
|
+
* ({@link module:@hydraharness/harness-tool-fs-search/glob} /
|
|
1192
|
+
* {@link module:@hydraharness/harness-tool-fs-search/grep}), result parsing,
|
|
1193
|
+
* retention, formatted-result spill, and timeout declaration; the subprocess
|
|
1194
|
+
* seam owns spawn execution, process-tree termination, environment scrubbing,
|
|
1195
|
+
* and raw output capture. The package injects `tools`, `systemPrompt`, and
|
|
1196
|
+
* `subprocess` — deliberately NOT `fs`, and `ctx.spillStore` is read
|
|
1197
|
+
* opportunistically with `ctx.get()` because formatted-result spill is optional.
|
|
1198
|
+
*
|
|
1199
|
+
* Returned paths are displayed relative to the resolved workdir and are
|
|
1200
|
+
* follow-up-readable only in co-located deployments where the workdir and the
|
|
1201
|
+
* filesystem `read` root are the same workspace — a documented v1 deployment
|
|
1202
|
+
* requirement, not runtime-validated.
|
|
1203
|
+
*
|
|
1204
|
+
* @module @hydraharness/harness-tool-fs-search
|
|
1205
|
+
*/
|
|
1206
|
+
/** Cordis plugin name used by loader diagnostics. */
|
|
1207
|
+
const name = "tool-fs-search";
|
|
1208
|
+
/** Services required by the search tool suite (`spillStore` is optional, read via `ctx.get()`). */
|
|
1209
|
+
const inject = [
|
|
1210
|
+
"tools",
|
|
1211
|
+
"systemPrompt",
|
|
1212
|
+
"subprocess"
|
|
1213
|
+
];
|
|
1214
|
+
const Config = z.object({
|
|
1215
|
+
sampleOverCapGlobResults: z.boolean().required(),
|
|
1216
|
+
globMaxResults: z.number().default(100),
|
|
1217
|
+
grepMaxMatches: z.number().default(250),
|
|
1218
|
+
grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES),
|
|
1219
|
+
searchMetaMaxBytes: z.number().default(SEARCH_META_MAX_BYTES),
|
|
1220
|
+
rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES),
|
|
1221
|
+
graceMs: z.number().default(SEARCH_GRACE_MS),
|
|
1222
|
+
stderrMaxBytes: z.number().default(SEARCH_STDERR_MAX_BYTES),
|
|
1223
|
+
timeoutMs: z.number().default(SEARCH_TIMEOUT_MS)
|
|
1224
|
+
});
|
|
1225
|
+
/** Every search cap counts items/bytes/milliseconds — a positive integer, or retention and timeout arithmetic misbehaves silently. */
|
|
1226
|
+
function assertPositiveInteger(name, value) {
|
|
1227
|
+
if (!Number.isInteger(value) || value < 1) throw new Error(`tool-fs-search: ${name} must be a positive integer`);
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Register the `glob`/`grep` filesystem discovery tool suite. The packaged
|
|
1231
|
+
* ripgrep binary is always available (an npm dependency), so registration is
|
|
1232
|
+
* unconditional.
|
|
1233
|
+
*
|
|
1234
|
+
* @param ctx - plugin context; registrations are effects scoped to this plugin.
|
|
1235
|
+
* @param config - resolved plugin configuration from schemastery.
|
|
1236
|
+
*/
|
|
1237
|
+
async function apply(ctx, config) {
|
|
1238
|
+
const resolved = config;
|
|
1239
|
+
assertPositiveInteger("globMaxResults", resolved.globMaxResults);
|
|
1240
|
+
assertPositiveInteger("grepMaxMatches", resolved.grepMaxMatches);
|
|
1241
|
+
assertPositiveInteger("grepMaxLineBytes", resolved.grepMaxLineBytes);
|
|
1242
|
+
assertPositiveInteger("searchMetaMaxBytes", resolved.searchMetaMaxBytes);
|
|
1243
|
+
assertPositiveInteger("rawOutputMaxBytes", resolved.rawOutputMaxBytes);
|
|
1244
|
+
assertPositiveInteger("graceMs", resolved.graceMs);
|
|
1245
|
+
if (resolved.graceMs > MAX_TIMER_DELAY_MS) throw new Error(`tool-fs-search: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
1246
|
+
assertPositiveInteger("stderrMaxBytes", resolved.stderrMaxBytes);
|
|
1247
|
+
assertPositiveInteger("timeoutMs", resolved.timeoutMs);
|
|
1248
|
+
applyGlobTool(ctx, {
|
|
1249
|
+
sampleOverCapGlobResults: resolved.sampleOverCapGlobResults,
|
|
1250
|
+
maxResults: resolved.globMaxResults,
|
|
1251
|
+
maxMetaBytes: resolved.searchMetaMaxBytes,
|
|
1252
|
+
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
|
1253
|
+
graceMs: resolved.graceMs,
|
|
1254
|
+
stderrMaxBytes: resolved.stderrMaxBytes,
|
|
1255
|
+
timeoutMs: resolved.timeoutMs
|
|
1256
|
+
});
|
|
1257
|
+
applyGrepTool(ctx, {
|
|
1258
|
+
maxMatches: resolved.grepMaxMatches,
|
|
1259
|
+
maxLineBytes: resolved.grepMaxLineBytes,
|
|
1260
|
+
maxMetaBytes: resolved.searchMetaMaxBytes,
|
|
1261
|
+
rawOutputMaxBytes: resolved.rawOutputMaxBytes,
|
|
1262
|
+
graceMs: resolved.graceMs,
|
|
1263
|
+
stderrMaxBytes: resolved.stderrMaxBytes,
|
|
1264
|
+
timeoutMs: resolved.timeoutMs
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
//#endregion
|
|
1268
|
+
export { Config, GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, RAW_OUTPUT_MAX_BYTES, SEARCH_GRACE_MS, SEARCH_META_MAX_BYTES, SEARCH_STDERR_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, apply, applyGlobTool, applyGrepTool, buildGlobCommand, buildGrepCommand, formatGlobOutput, formatGrepMatches, formatGrepOutput, inject, name, parseGlobArgs, parseGrepArgs, parseGrepMatches, presentGlobCall, presentGlobResult, presentGrepCall, presentGrepResult, previewLine, resolveRgPath, runRipgrep, sampleAcrossTopLevel, toWorkdirRelative, trySaveFormattedResult };
|