mixdog 0.9.93 → 0.9.95

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 (186) hide show
  1. package/NOTICE.md +72 -0
  2. package/package.json +16 -6
  3. package/scripts/lib/isolated-root-cleanup.mjs +19 -0
  4. package/scripts/run-suite.mjs +100 -0
  5. package/src/lib/rules-builder.cjs +4 -3
  6. package/src/output-styles/detailed.md +13 -15
  7. package/src/output-styles/extreme-minimal.md +7 -11
  8. package/src/output-styles/minimal.md +4 -7
  9. package/src/output-styles/simple.md +11 -13
  10. package/src/rules/agent/30-explorer.md +22 -16
  11. package/src/rules/lead/01-general.md +1 -2
  12. package/src/rules/lead/lead-brief.md +4 -5
  13. package/src/rules/lead/lead-tool.md +3 -2
  14. package/src/rules/shared/01-tool.md +28 -29
  15. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +2 -2
  16. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +23 -7
  17. package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -0
  18. package/src/runtime/agent/orchestrator/agent-trace.mjs +29 -0
  19. package/src/runtime/agent/orchestrator/context/collect.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/mcp/client.mjs +3 -3
  21. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +97 -7
  22. package/src/runtime/agent/orchestrator/providers/anthropic-model-resolve.mjs +11 -4
  23. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +2 -3
  24. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -1
  25. package/src/runtime/agent/orchestrator/providers/codex-client-meta.mjs +4 -6
  26. package/src/runtime/agent/orchestrator/providers/lib/stream-outcome.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +75 -15
  28. package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +8 -8
  29. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +7 -8
  30. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +2 -2
  31. package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +14 -16
  32. package/src/runtime/agent/orchestrator/providers/openai-ws-headers.mjs +5 -4
  33. package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +10 -10
  34. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -3
  35. package/src/runtime/agent/orchestrator/providers/registry.mjs +51 -4
  36. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -15
  37. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +12 -32
  38. package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +7 -0
  39. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +42 -2
  40. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +10 -3
  41. package/src/runtime/agent/orchestrator/session/context-utils.mjs +10 -11
  42. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +1 -0
  43. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +3 -3
  44. package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +0 -14
  45. package/src/runtime/agent/orchestrator/session/loop/stop-hooks.mjs +9 -8
  46. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +7 -4
  47. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +1 -1
  48. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +1 -1
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +40 -13
  50. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +3 -0
  51. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -1
  52. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +5 -9
  53. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +3 -3
  54. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +103 -92
  55. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +98 -3
  56. package/src/runtime/agent/orchestrator/stall-policy.mjs +2 -3
  57. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +3 -3
  58. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +25 -3
  59. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +10 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +5 -5
  61. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +12 -3
  62. package/src/runtime/agent/orchestrator/tools/builtin/grep-formatting.mjs +22 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +491 -0
  64. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +91 -13
  65. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +90 -27
  66. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +20 -1
  67. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +1 -1
  68. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +4 -4
  69. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +2 -2
  70. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +19 -9
  71. package/src/runtime/agent/orchestrator/tools/builtin/read-snapshot-runtime.mjs +2 -1
  72. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +3 -3
  73. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +7 -2
  74. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +32 -4
  75. package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +1 -1
  76. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +16 -1
  77. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +546 -23
  78. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +8 -5
  79. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +12 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-spawn.mjs +10 -3
  81. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +35 -5
  82. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -3
  83. package/src/runtime/agent/orchestrator/tools/builtin/snapshot-store.mjs +98 -0
  84. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +48 -0
  85. package/src/runtime/agent/orchestrator/tools/builtin.mjs +71 -1
  86. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +7 -3
  87. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +104 -14
  88. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +47 -2
  89. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +6 -17
  90. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -4
  91. package/src/runtime/agent/orchestrator/tools/code-graph/trusted-roots.mjs +3 -1
  92. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +9 -2
  93. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +5 -5
  94. package/src/runtime/agent/orchestrator/tools/patch/matcher.mjs +1 -1
  95. package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +57 -2
  96. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +151 -18
  97. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +5 -1
  98. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +109 -13
  99. package/src/runtime/agent/orchestrator/tools/patch-manifest.json +10 -10
  100. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -5
  101. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +4 -0
  102. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +1 -1
  103. package/src/runtime/channels/backends/discord.mjs +5 -14
  104. package/src/runtime/channels/backends/telegram.mjs +0 -5
  105. package/src/runtime/channels/lib/inbound-handler.mjs +0 -1
  106. package/src/runtime/channels/lib/output-forwarder.mjs +24 -5
  107. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  108. package/src/runtime/channels/lib/worker-main.mjs +1 -1
  109. package/src/runtime/media/renditions.mjs +21 -2
  110. package/src/runtime/memory/lib/tool-call-handler.mjs +16 -1
  111. package/src/runtime/memory/tool-defs.mjs +6 -6
  112. package/src/runtime/shared/atomic-file.mjs +53 -0
  113. package/src/runtime/shared/background-tasks.mjs +16 -6
  114. package/src/runtime/shared/child-spawn-gate.mjs +50 -26
  115. package/src/runtime/shared/resource-admission.mjs +40 -1
  116. package/src/runtime/shared/task-notification-envelope.mjs +11 -2
  117. package/src/runtime/shared/tool-card-model.mjs +6 -2
  118. package/src/runtime/shared/tool-status.mjs +10 -1
  119. package/src/runtime/shared/tool-surface.mjs +5 -0
  120. package/src/runtime/shared/turn-snapshot.mjs +385 -21
  121. package/src/runtime/shared/turn-worktree-snapshot.mjs +540 -0
  122. package/src/session-runtime/context-status.mjs +9 -3
  123. package/src/session-runtime/lifecycle-api.mjs +35 -5
  124. package/src/session-runtime/mcp-glue.mjs +11 -6
  125. package/src/session-runtime/provider-models.mjs +94 -43
  126. package/src/session-runtime/provider-usage.mjs +26 -2
  127. package/src/session-runtime/runtime-core.mjs +46 -8
  128. package/src/session-runtime/runtime-tunables.mjs +4 -0
  129. package/src/session-runtime/self-update.mjs +33 -4
  130. package/src/session-runtime/session-lifecycle.mjs +2 -0
  131. package/src/session-runtime/session-text.mjs +2 -1
  132. package/src/session-runtime/session-turn-api.mjs +106 -22
  133. package/src/session-runtime/workflow.mjs +11 -4
  134. package/src/standalone/agent-tool.mjs +4 -4
  135. package/src/standalone/backend-daemon.mjs +570 -0
  136. package/src/standalone/channel-daemon-transport.mjs +141 -1
  137. package/src/standalone/channel-worker.mjs +3 -2
  138. package/src/standalone/engine-daemon-client.mjs +894 -0
  139. package/src/standalone/engine-daemon-local-bridge.mjs +20 -0
  140. package/src/standalone/engine-daemon-protocol.mjs +33 -0
  141. package/src/standalone/engine-daemon-service.mjs +864 -0
  142. package/src/standalone/engine-daemon-transport.mjs +603 -0
  143. package/src/standalone/explore-tool.mjs +1 -1
  144. package/src/tui/App.jsx +62 -47
  145. package/src/tui/app/app-format.mjs +4 -2
  146. package/src/tui/app/app-view.jsx +5 -0
  147. package/src/tui/app/channel-pickers.mjs +7 -6
  148. package/src/tui/app/core-memory-picker.mjs +4 -4
  149. package/src/tui/app/extension-pickers.mjs +20 -18
  150. package/src/tui/app/maintenance-pickers.mjs +27 -27
  151. package/src/tui/app/onboarding-steps.mjs +23 -18
  152. package/src/tui/app/prompt-submit.mjs +13 -2
  153. package/src/tui/app/route-pickers.mjs +13 -8
  154. package/src/tui/app/settings-picker.mjs +55 -58
  155. package/src/tui/app/slash-dispatch.mjs +32 -28
  156. package/src/tui/app/transcript-window.mjs +19 -0
  157. package/src/tui/app/usage-context-panels.mjs +22 -7
  158. package/src/tui/app/use-mouse-input.mjs +25 -3
  159. package/src/tui/app/use-prompt-queue-history.mjs +31 -16
  160. package/src/tui/app/use-transcript-scroll.mjs +30 -8
  161. package/src/tui/app/use-transcript-window.mjs +22 -1
  162. package/src/tui/app/use-welcome-prompt-hint.mjs +2 -2
  163. package/src/tui/components/PromptInput.jsx +10 -0
  164. package/src/tui/components/Spinner.jsx +89 -86
  165. package/src/tui/components/TextEntryPanel.jsx +14 -0
  166. package/src/tui/components/ToolExecution.jsx +2 -2
  167. package/src/tui/dist/index.mjs +1537 -9718
  168. package/src/tui/engine/agent-job-feed.mjs +2 -2
  169. package/src/tui/engine/live-share.mjs +97 -4
  170. package/src/tui/engine/session-api-ext.mjs +23 -1
  171. package/src/tui/engine/session-api.mjs +88 -41
  172. package/src/tui/engine/session-flow.mjs +43 -4
  173. package/src/tui/engine/tool-card-results.mjs +6 -0
  174. package/src/tui/engine/turn.mjs +84 -4
  175. package/src/tui/engine-local-session.mjs +1108 -0
  176. package/src/tui/engine.mjs +16 -1057
  177. package/src/tui/index.jsx +47 -2
  178. package/src/tui/markdown/stream-fence.mjs +1 -1
  179. package/src/tui/spinner-meta.mjs +80 -0
  180. package/src/tui/spinner-verbs.mjs +35 -0
  181. package/src/ui/statusline-segments.mjs +43 -10
  182. package/src/ui/statusline.mjs +10 -1
  183. package/scripts/tmp-cdp-errors.mjs +0 -41
  184. package/scripts/tmp-cdp-inspect.mjs +0 -41
  185. package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +0 -176
  186. package/src/standalone/channel-daemon.mjs +0 -226
@@ -104,7 +104,7 @@ export const BUILTIN_TOOLS = [
104
104
  type: 'object',
105
105
  properties: {
106
106
  task_id: { type: 'string', description: 'shell async task_id.' },
107
- action: { type: 'string', enum: ['list', 'status', 'read', 'wait', 'cancel'], description: 'Action; avoid polling loops.' },
107
+ action: { type: 'string', enum: ['list', 'status', 'read', 'wait', 'cancel'], description: 'Defaults to list, or wait when task_id is set; avoid polling loops.' },
108
108
  timeout_ms: { type: 'number', description: 'Wait timeout ms.' },
109
109
  },
110
110
  required: [],
@@ -115,7 +115,7 @@ export const BUILTIN_TOOLS = [
115
115
  name: 'grep',
116
116
  title: 'Mixdog Grep',
117
117
  annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
118
- description: 'Content search by literal or regex over file/dir scopes.',
118
+ description: 'Content search by literal or regex over file/dir scopes; matches return copy-ready raw source spans.',
119
119
  inputSchema: {
120
120
  type: 'object',
121
121
  properties: {
@@ -143,7 +143,7 @@ export const BUILTIN_TOOLS = [
143
143
  output_mode: { type: 'string', enum: ['content_with_context', 'files_with_matches', 'count'], description: 'content_with_context (default); files_with_matches/count for existence.' },
144
144
  head_limit: { type: 'number', minimum: 0, description: 'Max results. Defaults to 250; 0 = unlimited.' },
145
145
  offset: { type: 'number', minimum: 0, description: 'Skip results for paging.' },
146
- '-C': { type: 'number', minimum: 0, description: 'Lines before/after each match.' },
146
+ context: { type: 'number', minimum: 0, description: 'Minimum surrounding lines; runtime expands within its call budget. Omit for automatic context; 0 for matches only.' },
147
147
  },
148
148
  anyOf: [
149
149
  { required: ['pattern'] },
@@ -197,7 +197,7 @@ export const BUILTIN_TOOLS = [
197
197
  description: 'Partial path/name words; query[] batches.',
198
198
  },
199
199
  path: { type: 'string', description: 'Base directory.' },
200
- head_limit: { type: 'number', description: 'Max paths. Defaults to 25.' },
200
+ head_limit: { type: 'number', description: 'Max paths across the call. Defaults to 25.' },
201
201
  },
202
202
  required: ['query'],
203
203
  additionalProperties: false,
@@ -207,7 +207,7 @@ export const BUILTIN_TOOLS = [
207
207
  name: 'list',
208
208
  title: 'Mixdog List Directory',
209
209
  annotations: { title: 'Mixdog List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
210
- description: 'List directory entries; batch independent dirs as path[].',
210
+ description: 'List directory entries (path + type); batch independent dirs as path[].',
211
211
  inputSchema: {
212
212
  type: 'object',
213
213
  properties: {
@@ -94,7 +94,13 @@ function fuzzyScore(query, str) {
94
94
  // order but scattered mid-word, no contiguity/boundary structure) is treated
95
95
  // as noise rather than a real hit. Contiguous substring / basename matches
96
96
  // bypass the floor entirely — an exact hit must never be filtered.
97
- const SUBSEQUENCE_MIN_PER_CHAR = 4;
97
+ // Measured separation for scattered junk vs real hits ("readme" probe):
98
+ // genuine matches score ≥8/char (boundary + contiguity structure), while
99
+ // incidental subsequences on unrelated paths land at 5–6/char — 6.5 splits
100
+ // them. The floor is tested against the RAW subsequence score (see
101
+ // fuzzyRank), never the +40 basename rank bonus, which otherwise vaults
102
+ // weak basename subsequences straight past any floor.
103
+ const SUBSEQUENCE_MIN_PER_CHAR = 6.5;
98
104
 
99
105
  // Separator/case-insensitive normalization used for the "contiguous substring"
100
106
  // strong-match test. Mirrors the query normalization in fuzzyScore so
@@ -163,7 +169,7 @@ export function fuzzyRank(query, items, limit = 0) {
163
169
  const score = prepareFuzzyScore(query);
164
170
  // Floor scales with query length: a scattered subsequence earns ~1 point
165
171
  // per char, while any contiguous run (+5/char) or word-boundary hit
166
- // (+8) pushes a genuine match well past 4/char.
172
+ // (+8) pushes a genuine match well past 6.5/char.
167
173
  const floor = normQuery.length * SUBSEQUENCE_MIN_PER_CHAR;
168
174
  const scored = [];
169
175
  // Preserve slice's legacy coercion behavior for non-integer public callers;
@@ -185,7 +191,10 @@ export function fuzzyRank(query, items, limit = 0) {
185
191
  // Otherwise it is subsequence-only: keep it only if it clears the
186
192
  // per-char floor. Weak scattered matches (the pgAdmin-style junk that
187
193
  // merely contains the query chars in order) fall below it and drop out.
188
- if (!strong && sc < floor) continue;
194
+ // Compare the RAW subsequence score — the +40 basename rank bonus is
195
+ // for ordering only and must not lift junk over the quality floor.
196
+ const rawSc = Math.max(pathScore ?? -Infinity, baseScore ?? -Infinity);
197
+ if (!strong && rawSc < floor) continue;
189
198
  const entry = { item, score: sc };
190
199
  if (bounded) retainTopRanked(scored, entry, ordinal, limit);
191
200
  else scored.push(entry);
@@ -43,6 +43,28 @@ export function splitGrepCountPrefix(line) {
43
43
  return { path: text.slice(0, idx), pathEnd: idx, count: Number(count) };
44
44
  }
45
45
 
46
+ export function parseGrepContextHeader(line) {
47
+ const match = /^# (.+):(\d+) \[lines (\d+)-(\d+)\]$/.exec(String(line || ''));
48
+ if (!match) return null;
49
+ const matchLine = Number(match[2]);
50
+ const startLine = Number(match[3]);
51
+ const endLine = Number(match[4]);
52
+ if (!Number.isSafeInteger(matchLine)
53
+ || !Number.isSafeInteger(startLine)
54
+ || !Number.isSafeInteger(endLine)
55
+ || startLine < 1
56
+ || endLine < startLine
57
+ || matchLine < startLine
58
+ || matchLine > endLine) return null;
59
+ return {
60
+ path: match[1],
61
+ matchLine,
62
+ startLine,
63
+ endLine,
64
+ sourceLineCount: endLine - startLine + 1,
65
+ };
66
+ }
67
+
46
68
  export function normalizeGrepLine(line, pathOnly = false, outputMode = 'content', filenameOmitted = false) {
47
69
  if (process.platform !== 'win32') return line;
48
70
  // files_with_matches mode: the WHOLE line is a path, so every separator is
@@ -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 and stay out of the model-visible text.
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
+ }