@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/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 };