mixdog 0.9.93 → 0.9.94

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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/src/rules/agent/30-explorer.md +22 -16
  3. package/src/rules/shared/01-tool.md +23 -18
  4. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +22 -6
  5. package/src/runtime/agent/orchestrator/context/collect.mjs +6 -2
  6. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +75 -15
  7. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +42 -2
  8. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +100 -86
  9. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +98 -3
  10. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +3 -3
  11. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +3 -3
  12. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
  13. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +4 -4
  14. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +12 -3
  15. package/src/runtime/agent/orchestrator/tools/builtin/grep-formatting.mjs +22 -0
  16. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +491 -0
  17. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +91 -13
  18. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +90 -27
  19. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +6 -1
  20. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +19 -9
  22. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +7 -2
  23. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +32 -4
  24. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +16 -1
  25. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +543 -19
  26. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +5 -2
  27. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -3
  28. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +48 -0
  29. package/src/runtime/agent/orchestrator/tools/builtin.mjs +71 -1
  30. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +4 -2
  31. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +51 -2
  32. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +6 -17
  33. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -4
  34. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +3 -3
  35. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +3 -3
  36. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +1 -1
  37. package/src/runtime/memory/lib/tool-call-handler.mjs +16 -1
  38. package/src/runtime/shared/background-tasks.mjs +10 -3
  39. package/src/runtime/shared/child-spawn-gate.mjs +50 -26
  40. package/src/runtime/shared/task-notification-envelope.mjs +11 -2
  41. package/src/runtime/shared/tool-card-model.mjs +6 -2
  42. package/src/runtime/shared/tool-surface.mjs +7 -2
  43. package/src/session-runtime/provider-usage.mjs +26 -2
  44. package/src/standalone/explore-tool.mjs +1 -1
  45. package/src/tui/app/use-transcript-window.mjs +7 -1
  46. package/src/tui/components/Spinner.jsx +18 -9
  47. package/src/tui/dist/index.mjs +111 -25
  48. package/src/tui/engine/live-share.mjs +23 -3
  49. package/src/tui/engine/session-api.mjs +7 -0
  50. package/src/tui/engine/turn.mjs +77 -4
  51. package/src/tui/engine.mjs +14 -6
  52. package/src/tui/index.jsx +7 -0
@@ -0,0 +1,491 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { isAbsolute, resolve } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+
5
+ import {
6
+ normalizeOutputPath,
7
+ } from '../path-utils.mjs';
8
+ import {
9
+ splitGrepLineNumberOnlyPrefix,
10
+ splitGrepLinePrefix,
11
+ } from '../grep-formatting.mjs';
12
+ import {
13
+ relativePathPrefix,
14
+ } from '../search-path-diagnostics.mjs';
15
+ import {
16
+ relativeGrepLine,
17
+ } from './search-input-helpers.mjs';
18
+
19
+ export const GREP_CONTEXT_CHAR_BUDGET_DEFAULT = 10_000;
20
+ const GREP_CONTEXT_READ_CONCURRENCY = 4;
21
+ const GREP_FOCUSED_CONTEXT_RADIUS = 12;
22
+ const GREP_FOCUSED_RAW_BLOCKS = 3;
23
+ // Anchors must stay usable as evidence without a follow-up read: keep the
24
+ // complete match line and only cut pathological long lines (minified files).
25
+ // Mid-line ellipsis below ~100 chars measurably pushed models into re-read
26
+ // verification loops (tool-budget bench, 20260805).
27
+ const GREP_COMPACT_ANCHOR_CONTENT_MAX = 400;
28
+
29
+ function parseAnchor(line, {
30
+ workDir,
31
+ rgSpawnCwd,
32
+ grepResolvedPath,
33
+ searchPath,
34
+ outputMode,
35
+ filenameOmitted,
36
+ }) {
37
+ if (filenameOmitted) {
38
+ const split = splitGrepLineNumberOnlyPrefix(line);
39
+ if (!split || split.delimiter !== ':') return null;
40
+ return {
41
+ path: relativePathPrefix(normalizeOutputPath(searchPath), workDir),
42
+ absolutePath: grepResolvedPath,
43
+ lineNo: split.lineNo,
44
+ content: String(line).slice(split.markerEnd),
45
+ };
46
+ }
47
+ const raw = splitGrepLinePrefix(line);
48
+ if (!raw || raw.delimiter !== ':') return null;
49
+ const normalized = relativeGrepLine(line, workDir, false, outputMode, false);
50
+ const display = splitGrepLinePrefix(normalized);
51
+ if (!display || display.delimiter !== ':') return null;
52
+ return {
53
+ path: display.path,
54
+ absolutePath: isAbsolute(raw.path) ? raw.path : resolve(rgSpawnCwd, raw.path),
55
+ lineNo: raw.lineNo,
56
+ content: String(line).slice(raw.markerEnd),
57
+ };
58
+ }
59
+
60
+ function parseAnchors(allLines, options) {
61
+ const anchors = [];
62
+ const seen = new Set();
63
+ for (const line of allLines) {
64
+ const anchor = parseAnchor(line, options);
65
+ if (!anchor) continue;
66
+ const key = `${anchor.absolutePath}\0${anchor.lineNo}`;
67
+ if (seen.has(key)) continue;
68
+ seen.add(key);
69
+ anchors.push({ ...anchor, order: anchors.length });
70
+ }
71
+ return anchors;
72
+ }
73
+
74
+ function splitTopLevelAlternatives(pattern) {
75
+ const source = String(pattern || '');
76
+ const parts = [];
77
+ let start = 0;
78
+ let depth = 0;
79
+ let inClass = false;
80
+ let escaped = false;
81
+ for (let index = 0; index < source.length; index++) {
82
+ const char = source[index];
83
+ if (escaped) {
84
+ escaped = false;
85
+ continue;
86
+ }
87
+ if (char === '\\') {
88
+ escaped = true;
89
+ continue;
90
+ }
91
+ if (inClass) {
92
+ if (char === ']') inClass = false;
93
+ continue;
94
+ }
95
+ if (char === '[') {
96
+ inClass = true;
97
+ continue;
98
+ }
99
+ if (char === '(') {
100
+ depth++;
101
+ continue;
102
+ }
103
+ if (char === ')' && depth > 0) {
104
+ depth--;
105
+ continue;
106
+ }
107
+ if (char === '|' && depth === 0) {
108
+ parts.push(source.slice(start, index));
109
+ start = index + 1;
110
+ }
111
+ }
112
+ if (parts.length === 0) return [source];
113
+ parts.push(source.slice(start));
114
+ return parts;
115
+ }
116
+
117
+ function rankAnchors(anchors, patterns, caseInsensitive) {
118
+ const branches = [];
119
+ for (const pattern of Array.isArray(patterns) ? patterns : [patterns]) {
120
+ for (const branch of splitTopLevelAlternatives(pattern)) {
121
+ if (!branch) continue;
122
+ try {
123
+ branches.push(new RegExp(branch, caseInsensitive ? 'i' : ''));
124
+ } catch {
125
+ // PCRE2/ripgrep syntax is not always valid JavaScript regex.
126
+ // Keep source order when a branch cannot be ranked safely.
127
+ branches.push(null);
128
+ }
129
+ }
130
+ }
131
+ if (branches.length <= 1) return anchors;
132
+ return anchors.map((anchor) => ({
133
+ ...anchor,
134
+ priority: branches.findIndex((branch) => branch?.test(anchor.content)),
135
+ })).map((anchor) => ({
136
+ ...anchor,
137
+ priority: anchor.priority < 0 ? Number.MAX_SAFE_INTEGER : anchor.priority,
138
+ }));
139
+ }
140
+
141
+ function selectAnchors(anchors, headLimit, offset) {
142
+ const afterOffset = offset > 0 ? anchors.slice(offset) : anchors;
143
+ if (afterOffset.length === 0) {
144
+ return { selected: [], shown: 0, omitted: 0, nextOffset: offset };
145
+ }
146
+ const shown = headLimit === Infinity ? afterOffset.length : Math.min(headLimit, afterOffset.length);
147
+ const omitted = afterOffset.length - shown;
148
+ if (omitted <= 0 || shown <= 0) {
149
+ return {
150
+ selected: afterOffset.slice(0, shown),
151
+ shown,
152
+ omitted,
153
+ nextOffset: offset + shown,
154
+ };
155
+ }
156
+ const headCount = Math.max(1, Math.ceil(shown / 2));
157
+ const tailCount = shown - headCount;
158
+ return {
159
+ selected: [
160
+ ...afterOffset.slice(0, headCount),
161
+ ...(tailCount > 0 ? afterOffset.slice(afterOffset.length - tailCount) : []),
162
+ ],
163
+ shown,
164
+ omitted,
165
+ nextOffset: offset + headCount,
166
+ };
167
+ }
168
+
169
+ function mergeIntervals(intervals) {
170
+ const sorted = [...intervals].sort((left, right) => left.start - right.start || left.end - right.end);
171
+ const merged = [];
172
+ for (const interval of sorted) {
173
+ const previous = merged.at(-1);
174
+ if (previous && interval.start <= previous.end + 1) {
175
+ previous.end = Math.max(previous.end, interval.end);
176
+ } else {
177
+ merged.push({ ...interval });
178
+ }
179
+ }
180
+ return merged;
181
+ }
182
+
183
+ async function readFileWindows(entry, radius, signal) {
184
+ const intervals = mergeIntervals(entry.anchors.map((anchor) => ({
185
+ start: Math.max(1, anchor.lineNo - radius),
186
+ end: anchor.lineNo + radius,
187
+ })));
188
+ const lines = new Map();
189
+ if (intervals.length === 0) return lines;
190
+ const input = createReadStream(entry.absolutePath, { encoding: 'utf8' });
191
+ const reader = createInterface({ input, crlfDelay: Infinity });
192
+ let lineNo = 0;
193
+ let intervalIndex = 0;
194
+ const abort = () => input.destroy(Object.assign(new Error('grep context expansion aborted'), { code: 'ABORT_ERR' }));
195
+ if (signal) {
196
+ if (signal.aborted) abort();
197
+ else signal.addEventListener('abort', abort, { once: true });
198
+ }
199
+ try {
200
+ for await (const line of reader) {
201
+ lineNo++;
202
+ while (intervalIndex < intervals.length && lineNo > intervals[intervalIndex].end) intervalIndex++;
203
+ if (intervalIndex >= intervals.length) {
204
+ input.destroy();
205
+ break;
206
+ }
207
+ const interval = intervals[intervalIndex];
208
+ if (lineNo >= interval.start && lineNo <= interval.end) lines.set(lineNo, line);
209
+ }
210
+ return lines;
211
+ } finally {
212
+ if (signal) signal.removeEventListener('abort', abort);
213
+ reader.close();
214
+ input.destroy();
215
+ }
216
+ }
217
+
218
+ async function readAnchorSources(anchors, radius, signal) {
219
+ const groups = new Map();
220
+ for (const anchor of anchors) {
221
+ if (!groups.has(anchor.absolutePath)) {
222
+ groups.set(anchor.absolutePath, {
223
+ absolutePath: anchor.absolutePath,
224
+ anchors: [],
225
+ lines: new Map(),
226
+ error: null,
227
+ });
228
+ }
229
+ groups.get(anchor.absolutePath).anchors.push(anchor);
230
+ }
231
+ const entries = [...groups.values()];
232
+ let next = 0;
233
+ const worker = async () => {
234
+ while (next < entries.length) {
235
+ const entry = entries[next++];
236
+ try {
237
+ entry.lines = await readFileWindows(entry, radius, signal);
238
+ } catch (err) {
239
+ entry.error = err;
240
+ }
241
+ }
242
+ };
243
+ await Promise.all(Array.from(
244
+ { length: Math.min(GREP_CONTEXT_READ_CONCURRENCY, entries.length) },
245
+ () => worker(),
246
+ ));
247
+ return groups;
248
+ }
249
+
250
+ function sourceBlock(anchor, source, radius) {
251
+ if (!source || source.error || !source.lines.has(anchor.lineNo)) {
252
+ return {
253
+ path: anchor.path,
254
+ matchLine: anchor.lineNo,
255
+ startLine: anchor.lineNo,
256
+ endLine: anchor.lineNo,
257
+ contents: [anchor.content],
258
+ sourceComplete: false,
259
+ priority: anchor.priority ?? Number.MAX_SAFE_INTEGER,
260
+ order: anchor.order ?? Number.MAX_SAFE_INTEGER,
261
+ };
262
+ }
263
+ let startLine = Math.max(1, anchor.lineNo - radius);
264
+ let endLine = anchor.lineNo + radius;
265
+ while (startLine < anchor.lineNo && !source.lines.has(startLine)) startLine++;
266
+ while (endLine > anchor.lineNo && !source.lines.has(endLine)) endLine--;
267
+ const contents = [];
268
+ for (let lineNo = startLine; lineNo <= endLine; lineNo++) {
269
+ if (!source.lines.has(lineNo)) break;
270
+ contents.push(source.lines.get(lineNo));
271
+ }
272
+ return {
273
+ path: anchor.path,
274
+ matchLine: anchor.lineNo,
275
+ startLine,
276
+ endLine: startLine + contents.length - 1,
277
+ contents,
278
+ sourceComplete: true,
279
+ priority: anchor.priority ?? Number.MAX_SAFE_INTEGER,
280
+ order: anchor.order ?? Number.MAX_SAFE_INTEGER,
281
+ };
282
+ }
283
+
284
+ function mergeBlocks(blocks) {
285
+ const sorted = [...blocks].sort((left, right) => (
286
+ left.path.localeCompare(right.path)
287
+ || left.startLine - right.startLine
288
+ || left.endLine - right.endLine
289
+ ));
290
+ const merged = [];
291
+ for (const block of sorted) {
292
+ const previous = merged.at(-1);
293
+ if (previous
294
+ && previous.path === block.path
295
+ && block.startLine <= previous.endLine + 1) {
296
+ const overlap = Math.max(0, previous.endLine - block.startLine + 1);
297
+ previous.contents.push(...block.contents.slice(overlap));
298
+ previous.endLine = Math.max(previous.endLine, block.endLine);
299
+ previous.sourceComplete = previous.sourceComplete && block.sourceComplete;
300
+ if (block.priority < previous.priority
301
+ || (block.priority === previous.priority && block.order < previous.order)) {
302
+ previous.matchLine = block.matchLine;
303
+ previous.priority = block.priority;
304
+ previous.order = block.order;
305
+ }
306
+ } else {
307
+ merged.push({ ...block, contents: [...block.contents] });
308
+ }
309
+ }
310
+ return merged.sort((left, right) => (
311
+ left.priority - right.priority
312
+ || left.order - right.order
313
+ ));
314
+ }
315
+
316
+ function pagingNotice({ shown, total, totalKnown, omitted, offset, nextOffset }) {
317
+ const totalStr = totalKnown ? `${total}` : `>=${total}`;
318
+ if (shown === 0) {
319
+ return totalKnown
320
+ ? `[Showing 0 of ${totalStr} matches; offset ${offset} past end]`
321
+ : `[Showing 0 of ${totalStr} matches (results partial); offset ${offset} is beyond the streamed window — matches past it may exist. Narrow path/glob/pattern instead of paging deeper.]`;
322
+ }
323
+ return (omitted > 0 || !totalKnown)
324
+ ? `\n[Showing ${shown} of ${totalStr} matches${totalKnown ? '' : ' (results partial)'}; pass offset:${nextOffset} for more]`
325
+ : '';
326
+ }
327
+
328
+ function renderAtRadius(selected, sources, radius, _budget, notice) {
329
+ const blocks = mergeBlocks(selected.map((anchor) => (
330
+ sourceBlock(anchor, sources.get(anchor.absolutePath), radius)
331
+ )));
332
+ const body = blocks.map((block) => (
333
+ `# ${block.path}:${block.matchLine} [lines ${block.startLine}-${block.endLine}]\n${block.contents.join('\n')}`
334
+ )).join('\n');
335
+ const sourceComplete = blocks.every((block) => block.sourceComplete);
336
+ // Header is a format cue only — internal radius/char-budget numbers are
337
+ // harness noise (neither Claude Code nor Codex surface theirs).
338
+ const text = `[Raw source spans; apply_patch context may be copied verbatim]\n${body}${notice}`;
339
+ return { text, sourceComplete, blockCount: blocks.length };
340
+ }
341
+
342
+ function anchorPriority(left, right) {
343
+ return (left.priority ?? Number.MAX_SAFE_INTEGER) - (right.priority ?? Number.MAX_SAFE_INTEGER)
344
+ || (left.order ?? Number.MAX_SAFE_INTEGER) - (right.order ?? Number.MAX_SAFE_INTEGER);
345
+ }
346
+
347
+ function compactAnchorContent(content) {
348
+ const normalized = String(content || '').trim().replace(/\s+/g, ' ');
349
+ return normalized.length <= GREP_COMPACT_ANCHOR_CONTENT_MAX
350
+ ? normalized
351
+ : `${normalized.slice(0, GREP_COMPACT_ANCHOR_CONTENT_MAX - 1)}…`;
352
+ }
353
+
354
+ function anchorRangeHint(anchor, radius) {
355
+ const startLine = Math.max(1, anchor.lineNo - radius);
356
+ const endLine = anchor.lineNo + radius;
357
+ return `lines ${startLine}-${endLine}`;
358
+ }
359
+
360
+ function renderFocusedContext(selected, sources, radius, _budget, notice) {
361
+ const ordered = [...selected].sort(anchorPriority);
362
+ const rankedBlocks = mergeBlocks(ordered.map((anchor) => (
363
+ sourceBlock(anchor, sources.get(anchor.absolutePath), radius)
364
+ )));
365
+ const rawBlocks = rankedBlocks.slice(0, GREP_FOCUSED_RAW_BLOCKS);
366
+ const covered = (anchor) => rawBlocks.some((block) => (
367
+ anchor.path === block.path
368
+ && anchor.lineNo >= block.startLine
369
+ && anchor.lineNo <= block.endLine
370
+ ));
371
+ const compact = ordered.filter((anchor) => !covered(anchor));
372
+ const raw = rawBlocks.map((block) => (
373
+ `# ${block.path}:${block.matchLine} [lines ${block.startLine}-${block.endLine}]\n${block.contents.join('\n')}`
374
+ )).join('\n');
375
+ const anchors = compact.length
376
+ ? `\n# Additional matches\n${compact.map((anchor) => (
377
+ `${anchor.path}:${anchor.lineNo}:${compactAnchorContent(anchor.content)} [${anchorRangeHint(anchor, radius)}]`
378
+ )).join('\n')}`
379
+ : '';
380
+ const header = compact.length
381
+ ? `[Top ${rawBlocks.length} of ${ordered.length}; remaining as path:line anchors]`
382
+ : null;
383
+ const text = `${header ? `${header}\n` : ''}${raw}${anchors}${notice}`;
384
+ return {
385
+ text,
386
+ sourceComplete: rawBlocks.every((block) => block.sourceComplete),
387
+ blockCount: rawBlocks.length,
388
+ };
389
+ }
390
+
391
+ export async function expandGrepAnchorContextOutput({
392
+ allLines,
393
+ workDir,
394
+ rgSpawnCwd,
395
+ grepResolvedPath,
396
+ searchPath,
397
+ outputMode,
398
+ filenameOmitted,
399
+ headLimit,
400
+ offset,
401
+ totalKnown = true,
402
+ requestedContext,
403
+ maxContext,
404
+ patterns = [],
405
+ caseInsensitive = false,
406
+ charBudget = GREP_CONTEXT_CHAR_BUDGET_DEFAULT,
407
+ signal,
408
+ }) {
409
+ const anchors = rankAnchors(parseAnchors(allLines, {
410
+ workDir,
411
+ rgSpawnCwd,
412
+ grepResolvedPath,
413
+ searchPath,
414
+ outputMode,
415
+ filenameOmitted,
416
+ }), patterns, caseInsensitive);
417
+ const total = anchors.length;
418
+ if (total === 0) return { text: '', total: 0, shown: 0, omitted: 0, sourceComplete: true };
419
+ const window = selectAnchors(anchors, headLimit, offset);
420
+ let notice = pagingNotice({
421
+ ...window,
422
+ total,
423
+ totalKnown,
424
+ offset,
425
+ });
426
+ if (window.selected.length === 0) {
427
+ return {
428
+ text: notice,
429
+ total,
430
+ shown: 0,
431
+ omitted: 0,
432
+ sourceComplete: true,
433
+ };
434
+ }
435
+ const requested = Math.max(0, Math.floor(Number(requestedContext) || 0));
436
+ const target = Math.max(requested, Math.max(0, Math.floor(Number(maxContext) || 0)));
437
+ const budget = Math.max(512, Math.floor(Number(charBudget) || GREP_CONTEXT_CHAR_BUDGET_DEFAULT));
438
+ const sources = await readAnchorSources(window.selected, target, signal);
439
+ let selected = window.selected;
440
+ let shown = window.shown;
441
+ let omitted = window.omitted;
442
+ let radius = target;
443
+ let rendered = renderAtRadius(selected, sources, radius, budget, notice);
444
+ // Progressive disclosure: one or two source clusters that fit are most
445
+ // useful as patch-ready raw text. Broad searches instead expand up to three
446
+ // high-priority source clusters and retain the rest as compact path:line
447
+ // anchors with neutral range metadata. This avoids paying 25 lines for
448
+ // every match without nudging the model into unnecessary follow-up reads.
449
+ const sparseRaw = rendered.blockCount <= 2 && rendered.text.length <= budget;
450
+ if (!sparseRaw) {
451
+ radius = Math.min(target, GREP_FOCUSED_CONTEXT_RADIUS);
452
+ rendered = renderFocusedContext(selected, sources, radius, budget, notice);
453
+ while (rendered.text.length > budget && radius > 0) {
454
+ radius--;
455
+ rendered = renderFocusedContext(selected, sources, radius, budget, notice);
456
+ }
457
+ } else {
458
+ return {
459
+ ...rendered,
460
+ total,
461
+ shown,
462
+ omitted,
463
+ };
464
+ }
465
+ while (rendered.text.length > budget && selected.length > 1) {
466
+ selected = [...selected].sort(anchorPriority).slice(0, -1);
467
+ shown = selected.length;
468
+ omitted = Math.max(0, total - offset - shown);
469
+ notice = pagingNotice({
470
+ shown,
471
+ total,
472
+ totalKnown,
473
+ omitted,
474
+ offset,
475
+ nextOffset: offset + shown,
476
+ });
477
+ rendered = renderFocusedContext(
478
+ selected,
479
+ sources,
480
+ radius,
481
+ budget,
482
+ notice,
483
+ );
484
+ }
485
+ return {
486
+ ...rendered,
487
+ total,
488
+ shown,
489
+ omitted,
490
+ };
491
+ }
@@ -5,9 +5,11 @@
5
5
  import { normalizeOutputPath } from '../path-utils.mjs';
6
6
  import {
7
7
  groupGrepContentByFile,
8
+ parseGrepContextHeader,
8
9
  splitGrepLineNumberOnlyPrefix,
9
10
  splitGrepLinePrefix,
10
11
  } from '../grep-formatting.mjs';
12
+ import { relativePathPrefix } from '../search-path-diagnostics.mjs';
11
13
  import { relativeGrepLine } from './search-input-helpers.mjs';
12
14
 
13
15
  export function grepMissingPatternMessage() {
@@ -22,30 +24,84 @@ export function globMissingPatternMessage() {
22
24
  // In context mode (explicit -A/-B/-C or content_with_context auto), head_limit
23
25
  // and offset count MATCH BLOCKS, not raw output lines, and truncation keeps a
24
26
  // head+tail slice with a middle marker instead of dropping the tail.
25
- function grepBlockMatchAnchor(line, filenameOmitted) {
27
+ function grepContextSourceLine(line, filenameOmitted, fallbackPath = '') {
26
28
  if (filenameOmitted) {
27
29
  const p = splitGrepLineNumberOnlyPrefix(line);
28
- return p && p.delimiter === ':' ? `#${p.lineNo}` : '';
30
+ return p ? {
31
+ path: fallbackPath,
32
+ lineNo: p.lineNo,
33
+ delimiter: p.delimiter,
34
+ content: String(line).slice(p.markerEnd),
35
+ } : null;
29
36
  }
30
37
  const s = splitGrepLinePrefix(line);
31
- return s && s.delimiter === ':' ? `${s.path}\0${s.lineNo}` : '';
38
+ return s ? {
39
+ path: s.path,
40
+ lineNo: s.lineNo,
41
+ delimiter: s.delimiter,
42
+ content: String(line).slice(s.markerEnd),
43
+ } : null;
32
44
  }
33
45
 
34
- function parseGrepContextBlocks(lines, filenameOmitted) {
46
+ function grepBlockMatchAnchor(line, filenameOmitted, fallbackPath) {
47
+ const parsed = grepContextSourceLine(line, filenameOmitted, fallbackPath);
48
+ return parsed && parsed.delimiter === ':' ? `${parsed.path}\0${parsed.lineNo}` : '';
49
+ }
50
+
51
+ function parseRenderableGrepContextBlock(block, filenameOmitted, fallbackPath) {
52
+ const parsed = block.lines.map((line) => grepContextSourceLine(line, filenameOmitted, fallbackPath));
53
+ const contiguous = parsed.length > 0
54
+ && parsed.every((entry, index) => entry
55
+ && entry.path
56
+ && entry.path === parsed[0].path
57
+ && (index === 0 || entry.lineNo === parsed[index - 1].lineNo + 1));
58
+ const match = contiguous ? parsed.find((entry) => entry.delimiter === ':') : null;
59
+ if (!match) return { raw: block.lines.join('\n') };
60
+ return {
61
+ path: match.path,
62
+ matchLine: match.lineNo,
63
+ startLine: parsed[0].lineNo,
64
+ endLine: parsed.at(-1).lineNo,
65
+ contents: parsed.map((entry) => entry.content),
66
+ };
67
+ }
68
+
69
+ function renderGrepContextBlocks(blocks, filenameOmitted, fallbackPath) {
70
+ const merged = [];
71
+ for (const block of blocks) {
72
+ const current = parseRenderableGrepContextBlock(block, filenameOmitted, fallbackPath);
73
+ const previous = merged.at(-1);
74
+ if (previous?.path
75
+ && current.path === previous.path
76
+ && current.startLine <= previous.endLine + 1) {
77
+ const overlap = Math.max(0, previous.endLine - current.startLine + 1);
78
+ previous.contents.push(...current.contents.slice(overlap));
79
+ previous.endLine = Math.max(previous.endLine, current.endLine);
80
+ continue;
81
+ }
82
+ merged.push(current);
83
+ }
84
+ return merged.map((block) => {
85
+ if (!block.path) return block.raw;
86
+ return `# ${block.path}:${block.matchLine} [lines ${block.startLine}-${block.endLine}]\n${block.contents.join('\n')}`;
87
+ });
88
+ }
89
+
90
+ function parseGrepContextBlocks(lines, filenameOmitted, fallbackPath) {
35
91
  const blocks = [];
36
92
  let pending = [];
37
93
  let i = 0;
38
94
  while (i < lines.length) {
39
95
  const line = lines[i];
40
96
  if (line === '--') { pending = []; i++; continue; }
41
- const anchor = grepBlockMatchAnchor(line, filenameOmitted);
97
+ const anchor = grepBlockMatchAnchor(line, filenameOmitted, fallbackPath);
42
98
  if (anchor) {
43
99
  const blockLines = pending.concat([line]);
44
100
  pending = [];
45
101
  i++;
46
102
  while (i < lines.length) {
47
103
  const next = lines[i];
48
- if (next === '--' || grepBlockMatchAnchor(next, filenameOmitted)) break;
104
+ if (next === '--' || grepBlockMatchAnchor(next, filenameOmitted, fallbackPath)) break;
49
105
  blockLines.push(next);
50
106
  i++;
51
107
  }
@@ -58,9 +114,12 @@ function parseGrepContextBlocks(lines, filenameOmitted) {
58
114
  return blocks;
59
115
  }
60
116
 
61
- export function formatGrepContextOutput({ allLines, workDir, outputMode, filenameOmitted, headLimit, offset, totalKnown = true }) {
117
+ export function formatGrepContextOutput({ allLines, workDir, outputMode, filenameOmitted, headLimit, offset, searchPath, totalKnown = true }) {
62
118
  const norm = allLines.map((l) => (l === '--' ? '--' : relativeGrepLine(l, workDir, false, outputMode, filenameOmitted)));
63
- const blocks = parseGrepContextBlocks(norm, filenameOmitted);
119
+ const fallbackPath = filenameOmitted
120
+ ? relativePathPrefix(normalizeOutputPath(searchPath), workDir)
121
+ : '';
122
+ const blocks = parseGrepContextBlocks(norm, filenameOmitted, fallbackPath);
64
123
  const total = blocks.length;
65
124
  if (total === 0) return { text: '', total: 0, shown: 0, omitted: 0 };
66
125
  // Finding 2/3: denominator is the PRE-offset grand total; on a partial rg
@@ -78,7 +137,7 @@ export function formatGrepContextOutput({ allLines, workDir, outputMode, filenam
78
137
  }
79
138
  const shown = headLimit === Infinity ? afterOffset.length : Math.min(headLimit, afterOffset.length);
80
139
  const omitted = afterOffset.length - shown;
81
- const render = (arr) => arr.map((b) => b.lines.join('\n'));
140
+ const render = (arr) => renderGrepContextBlocks(arr, filenameOmitted, fallbackPath);
82
141
  let segments;
83
142
  let nextOffset = offset + shown;
84
143
  if (omitted > 0 && shown > 0) {
@@ -99,16 +158,35 @@ export function formatGrepContextOutput({ allLines, workDir, outputMode, filenam
99
158
  const notice = (omitted > 0 || !totalKnown)
100
159
  ? `\n[Showing ${shown} of ${totalStr} matches${totalKnown ? '' : ' (results partial)'}; pass offset:${nextOffset} for more]`
101
160
  : '';
102
- return { text: segments.join('\n--\n') + notice, total, shown, omitted };
161
+ return {
162
+ text: `${segments.join('\n')}${notice}`,
163
+ total,
164
+ shown,
165
+ omitted,
166
+ };
103
167
  }
104
168
 
105
- // Part 1: drop path:line match lines already emitted by an earlier pattern in
106
- // a pattern[] fan-out. Context ('-') lines and non-match lines pass through.
169
+ // Part 1: drop a numbered match line or patch-ready context block already
170
+ // emitted by an earlier pattern in a pattern[] fan-out.
107
171
  export function dedupeFanoutMatchLines(body, seen) {
108
172
  const text = String(body);
109
173
  if (/^Error:/.test(text)) return text;
110
174
  const out = [];
111
- for (const line of text.split('\n')) {
175
+ const lines = text.split('\n');
176
+ for (let i = 0; i < lines.length; i++) {
177
+ const line = lines[i];
178
+ const header = parseGrepContextHeader(line);
179
+ if (header) {
180
+ const key = `${header.path}\0${header.matchLine}`;
181
+ const duplicate = seen.has(key);
182
+ seen.add(key);
183
+ if (!duplicate) out.push(line);
184
+ for (let sourceIndex = 0; sourceIndex < header.sourceLineCount && i + 1 < lines.length; sourceIndex++) {
185
+ i++;
186
+ if (!duplicate) out.push(lines[i]);
187
+ }
188
+ continue;
189
+ }
112
190
  const s = splitGrepLinePrefix(line);
113
191
  if (s && s.delimiter === ':') {
114
192
  const key = `${s.path}\0${s.lineNo}`;