sweet-search 2.5.2 → 2.5.4

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 (155) hide show
  1. package/core/cli.js +24 -3
  2. package/core/graph/graph-expansion.js +215 -36
  3. package/core/graph/graph-extractor.js +196 -11
  4. package/core/graph/graph-search.js +395 -92
  5. package/core/graph/hcgs-generator.js +2 -1
  6. package/core/graph/index.js +2 -0
  7. package/core/graph/repo-map.js +28 -6
  8. package/core/graph/structural-answer-cues.js +168 -0
  9. package/core/graph/structural-callsite-hints.js +40 -0
  10. package/core/graph/structural-context-format.js +40 -0
  11. package/core/graph/structural-context.js +450 -0
  12. package/core/graph/structural-forward-push.js +156 -0
  13. package/core/graph/structural-header-context.js +19 -0
  14. package/core/graph/structural-importance.js +148 -0
  15. package/core/graph/structural-pagerank.js +197 -0
  16. package/core/graph/summary-manager.js +13 -9
  17. package/core/incremental-indexing/application/dirty-scan.mjs +236 -0
  18. package/core/incremental-indexing/application/file-watcher.mjs +197 -0
  19. package/core/incremental-indexing/application/maintenance-handlers.mjs +519 -0
  20. package/core/incremental-indexing/application/maintenance-worker.mjs +380 -0
  21. package/core/incremental-indexing/application/operator-cli.mjs +554 -0
  22. package/core/incremental-indexing/application/production-li-delta.mjs +192 -0
  23. package/core/incremental-indexing/application/production-reconciler-helpers.mjs +107 -0
  24. package/core/incremental-indexing/application/production-reconciler.mjs +583 -0
  25. package/core/incremental-indexing/application/reconciler.mjs +477 -0
  26. package/core/incremental-indexing/application/tombstone-injector.mjs +148 -0
  27. package/core/incremental-indexing/domain/chunk-identity.mjs +260 -0
  28. package/core/incremental-indexing/domain/encoder-deps.mjs +193 -0
  29. package/core/incremental-indexing/domain/encoder-input.mjs +225 -0
  30. package/core/incremental-indexing/domain/interval-autotune.mjs +255 -0
  31. package/core/incremental-indexing/domain/reconcile-counters.mjs +149 -0
  32. package/core/incremental-indexing/domain/watermark-scheduler.mjs +239 -0
  33. package/core/incremental-indexing/infrastructure/artifact-temp-sweep.mjs +163 -0
  34. package/core/incremental-indexing/infrastructure/baseline-readiness.mjs +121 -0
  35. package/core/incremental-indexing/infrastructure/dirty-set.mjs +233 -0
  36. package/core/incremental-indexing/infrastructure/graph-gc.mjs +314 -0
  37. package/core/incremental-indexing/infrastructure/hashing.mjs +298 -0
  38. package/core/incremental-indexing/infrastructure/hcgs-invalidation.mjs +182 -0
  39. package/core/incremental-indexing/infrastructure/li-segment-merge.mjs +278 -0
  40. package/core/incremental-indexing/infrastructure/li-segment-state.mjs +173 -0
  41. package/core/incremental-indexing/infrastructure/lockfile.mjs +119 -0
  42. package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +283 -0
  43. package/core/incremental-indexing/infrastructure/manifest.mjs +194 -0
  44. package/core/incremental-indexing/infrastructure/path-filter.mjs +190 -0
  45. package/core/incremental-indexing/infrastructure/reader-heartbeat.mjs +201 -0
  46. package/core/incremental-indexing/infrastructure/schema-migrations.mjs +257 -0
  47. package/core/incremental-indexing/infrastructure/sparse-gram-delta.mjs +335 -0
  48. package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +176 -0
  49. package/core/incremental-indexing/infrastructure/staleness-display.mjs +105 -0
  50. package/core/incremental-indexing/infrastructure/tombstone-bitmap.mjs +234 -0
  51. package/core/incremental-indexing/infrastructure/vector-delta-writer.mjs +359 -0
  52. package/core/incremental-indexing/infrastructure/vector-gc.mjs +133 -0
  53. package/core/incremental-indexing/infrastructure/worktree-stamp.mjs +155 -0
  54. package/core/incremental-indexing/infrastructure/wsl2-detect.mjs +115 -0
  55. package/core/indexing/admission-policy.js +139 -0
  56. package/core/indexing/artifact-builder.js +29 -12
  57. package/core/indexing/ast-chunker.js +107 -30
  58. package/core/indexing/dedup/exemplar-selector.js +19 -1
  59. package/core/indexing/gitignore-filter.js +223 -0
  60. package/core/indexing/incremental-tracker.js +99 -30
  61. package/core/indexing/index-codebase-v21.js +6 -5
  62. package/core/indexing/index-maintainer.mjs +698 -6
  63. package/core/indexing/indexer-ann.js +99 -15
  64. package/core/indexing/indexer-build.js +158 -45
  65. package/core/indexing/indexer-empty-baseline.js +80 -0
  66. package/core/indexing/indexer-manifest.js +66 -0
  67. package/core/indexing/indexer-phases.js +56 -23
  68. package/core/indexing/indexer-sparse-gram.js +54 -13
  69. package/core/indexing/indexer-utils.js +26 -208
  70. package/core/indexing/indexing-file-policy.js +32 -7
  71. package/core/indexing/maintainer-launcher.mjs +137 -0
  72. package/core/indexing/merkle-tracker.js +251 -244
  73. package/core/indexing/model-pool.js +46 -5
  74. package/core/infrastructure/code-graph-repository.js +758 -6
  75. package/core/infrastructure/code-graph-visibility.js +157 -0
  76. package/core/infrastructure/codebase-repository.js +100 -13
  77. package/core/infrastructure/config/search.js +1 -1
  78. package/core/infrastructure/db-utils.js +118 -0
  79. package/core/infrastructure/dedup-hashing.js +10 -13
  80. package/core/infrastructure/hardware-capability.js +17 -7
  81. package/core/infrastructure/index.js +8 -2
  82. package/core/infrastructure/language-patterns/maps.js +4 -1
  83. package/core/infrastructure/language-patterns/registry-core.js +56 -17
  84. package/core/infrastructure/language-patterns/registry-object-oriented.js +12 -5
  85. package/core/infrastructure/language-patterns.js +69 -0
  86. package/core/infrastructure/model-registry.js +20 -0
  87. package/core/infrastructure/native-inference.js +7 -12
  88. package/core/infrastructure/native-resolver.js +52 -37
  89. package/core/infrastructure/native-sparse-gram.js +261 -20
  90. package/core/infrastructure/native-tokenizer.js +6 -15
  91. package/core/infrastructure/simd-distance.js +10 -16
  92. package/core/infrastructure/sparse-gram-delta-reader.js +76 -0
  93. package/core/infrastructure/structural-alias-resolver.js +122 -0
  94. package/core/infrastructure/structural-candidate-ranker.js +34 -0
  95. package/core/infrastructure/structural-context-repository.js +472 -0
  96. package/core/infrastructure/structural-context-utils.js +51 -0
  97. package/core/infrastructure/structural-graph-signals.js +121 -0
  98. package/core/infrastructure/structural-qualified-resolution.js +15 -0
  99. package/core/infrastructure/structural-source-definitions.js +100 -0
  100. package/core/infrastructure/tombstone-bitmap-reader.js +139 -0
  101. package/core/infrastructure/tree-sitter-provider.js +811 -37
  102. package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt.md +50 -0
  103. package/core/query/query-router.js +55 -5
  104. package/core/ranking/file-kind-ranking.js +2192 -15
  105. package/core/ranking/late-interaction-index.js +87 -12
  106. package/core/search/cli-decoration.js +290 -0
  107. package/core/search/context-expander.js +988 -78
  108. package/core/search/index.js +1 -0
  109. package/core/search/output-policy.js +275 -0
  110. package/core/search/search-anchor.js +499 -0
  111. package/core/search/search-boost.js +93 -1
  112. package/core/search/search-cli.js +61 -204
  113. package/core/search/search-hybrid.js +250 -10
  114. package/core/search/search-pattern-chunks.js +57 -8
  115. package/core/search/search-pattern-planner.js +68 -9
  116. package/core/search/search-pattern-prefilter.js +30 -10
  117. package/core/search/search-pattern-ripgrep.js +40 -4
  118. package/core/search/search-pattern-sparse-overlay.js +256 -0
  119. package/core/search/search-pattern.js +117 -29
  120. package/core/search/search-postprocess.js +479 -5
  121. package/core/search/search-read-semantic.js +260 -23
  122. package/core/search/search-read.js +82 -64
  123. package/core/search/search-reader-pin.js +71 -0
  124. package/core/search/search-rrf.js +279 -0
  125. package/core/search/search-semantic.js +110 -5
  126. package/core/search/search-server.js +130 -57
  127. package/core/search/search-trace.js +107 -0
  128. package/core/search/server-identity.js +93 -0
  129. package/core/search/session-daemon-prewarm.mjs +33 -10
  130. package/core/search/sweet-search.js +399 -7
  131. package/core/skills/sweet-index/SKILL.md +8 -6
  132. package/core/vector-store/binary-hnsw-index.js +194 -30
  133. package/core/vector-store/float-vector-store.js +96 -6
  134. package/core/vector-store/hnsw-index.js +220 -49
  135. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +471 -0
  136. package/eval/agent-read-workflows/bin/ss-find +15 -0
  137. package/eval/agent-read-workflows/bin/ss-grep +12 -0
  138. package/eval/agent-read-workflows/bin/ss-read +14 -0
  139. package/eval/agent-read-workflows/bin/ss-search +18 -0
  140. package/eval/agent-read-workflows/bin/ss-semantic +12 -0
  141. package/eval/agent-read-workflows/bin/ss-trace +11 -0
  142. package/mcp/read-tool.js +109 -0
  143. package/mcp/server.js +55 -15
  144. package/mcp/tool-handlers.js +14 -124
  145. package/mcp/trace-tool.js +81 -0
  146. package/package.json +25 -10
  147. package/scripts/hooks/intercept-read.mjs +55 -0
  148. package/scripts/hooks/remind-tools.mjs +40 -0
  149. package/scripts/init.js +698 -54
  150. package/scripts/inject-agent-instructions.js +431 -0
  151. package/scripts/install-prompt-reminders.js +188 -0
  152. package/scripts/install-tool-enforcement.js +220 -0
  153. package/scripts/smoke-test.js +12 -9
  154. package/scripts/uninstall.js +276 -18
  155. package/scripts/write-claude-rules.js +110 -0
@@ -146,16 +146,39 @@ export function expandToSymbol(result, opts) {
146
146
  const origRange = `${origStart}-${origEnd}`;
147
147
  const chunkLines = (origEnd - origStart) + 1;
148
148
 
149
+
149
150
  // Check if chunk already looks like a complete symbol
150
- // (has a name/type and is > 10 lines — not just a signature fragment)
151
+ // (has a name/type and is > 10 lines — not just a signature fragment).
152
+ // Even when no expansion is needed we still:
153
+ // (1) look up the enclosing entity so callers (graph-neighbour
154
+ // reservation) can attach edges to it.
155
+ // (2) absorb leading trivia (Rust /// + #[...], JSDoc, Python decorators)
156
+ // so the agent sees attribute-driven semantics like #[non_exhaustive]
157
+ // that the judge keeps asking for.
151
158
  if (meta.name && chunkLines > 10) {
159
+ const filePath0 = meta.file || result.file;
160
+ // Try strict enclosing-range first; fall back to a single-line query at
161
+ // origStart when the chunk overshoots the entity by trailing lines (a
162
+ // common chunker artefact — observed on gin handleHTTPRequest where
163
+ // chunk=690-762 but entity=690-760, leaving the strict query empty).
164
+ let ent0 = findEnclosingEntity(codeGraphRepo, filePath0, origStart, origEnd);
165
+ if (!ent0) ent0 = findEnclosingEntity(codeGraphRepo, filePath0, origStart, origStart);
166
+ let triviaStart0 = origStart;
167
+ if (opts.fileCache && !opts.ablations?.has('no-leading-trivia') && origStart > 1) {
168
+ const lang0 = inferLanguage(filePath0);
169
+ const candidate = expandLeadingTrivia(filePath0, origStart, opts.fileCache, opts.projectRoot, lang0);
170
+ // Only commit when the absorbed trivia fits the cap (10 tok/line est).
171
+ const newLines = (origEnd - candidate) + 1;
172
+ if (newLines * 10 <= tokenCap) triviaStart0 = candidate;
173
+ }
152
174
  return {
153
- startLine: origStart,
175
+ startLine: triviaStart0,
154
176
  endLine: origEnd,
155
- expanded: false,
156
- expandedFrom: null,
177
+ expanded: triviaStart0 < origStart,
178
+ expandedFrom: triviaStart0 < origStart ? origRange : null,
157
179
  symbol: meta.name,
158
180
  symbolType: meta.type || null,
181
+ entityId: ent0?.id || null,
159
182
  kind: 'chunk',
160
183
  };
161
184
  }
@@ -169,13 +192,25 @@ export function expandToSymbol(result, opts) {
169
192
 
170
193
  // Only expand if it fits within the token cap
171
194
  if (entityTokens <= tokenCap) {
195
+ // Absorb leading trivia (doc comments, decorators, attributes) above
196
+ // the entity. This recovers context the judge keeps asking for —
197
+ // ripgrep `#[non_exhaustive]`, JSDoc, Rust /// docs, Python decorators.
198
+ const lang = inferLanguage(filePath);
199
+ const triviaStart = (opts.fileCache && !opts.ablations?.has('no-leading-trivia'))
200
+ ? expandLeadingTrivia(filePath, entity.startLine, opts.fileCache, opts.projectRoot, lang)
201
+ : entity.startLine;
202
+ const startWithTrivia = Math.max(1, Math.min(triviaStart, entity.startLine));
203
+ // Re-check budget with trivia included; fall back to symbol-only if it overflows.
204
+ const expandedLines = (entity.endLine - startWithTrivia) + 1;
205
+ const fits = expandedLines * 10 <= tokenCap;
172
206
  return {
173
- startLine: entity.startLine,
207
+ startLine: fits ? startWithTrivia : entity.startLine,
174
208
  endLine: entity.endLine,
175
209
  expanded: true,
176
210
  expandedFrom: origRange,
177
211
  symbol: entity.name,
178
212
  symbolType: entity.type,
213
+ entityId: entity.id || null,
179
214
  kind: 'full',
180
215
  };
181
216
  }
@@ -193,6 +228,7 @@ export function expandToSymbol(result, opts) {
193
228
  expandedFrom: origRange,
194
229
  symbol: entity.name,
195
230
  symbolType: entity.type,
231
+ entityId: entity.id || null,
196
232
  kind: 'sandwich',
197
233
  sandwich,
198
234
  };
@@ -206,22 +242,44 @@ export function expandToSymbol(result, opts) {
206
242
  expandedFrom: null,
207
243
  symbol: entity.name,
208
244
  symbolType: entity.type,
245
+ entityId: entity.id || null,
209
246
  kind: 'chunk',
210
247
  };
211
248
  }
212
249
 
250
+ // F5 (2026-05-07): when no enclosing entity exists for the chunk, fall back
251
+ // to the FIRST entity that starts within the chunk range. This catches cases
252
+ // like fastify lib/reply.js:64-225 where the chunk spans the Reply function
253
+ // (64-76) plus prototype methods later — no single entity contains the chunk,
254
+ // but Reply is the topmost identifier and matches the gold ("send" / "Reply"
255
+ // / "buildReply"). Only applies in fallback path where meta.name is also null.
256
+ let firstContained = null;
257
+ if (codeGraphRepo && typeof codeGraphRepo.findFirstEntityInRange === 'function' && !meta.name) {
258
+ try {
259
+ firstContained = codeGraphRepo.findFirstEntityInRange(filePath, origStart, origEnd);
260
+ } catch { firstContained = null; }
261
+ }
262
+
213
263
  // Try sibling chunk merge (contiguous chunks in the same file)
214
264
  const intervals = locationMap?.get(filePath);
215
265
  if (intervals && intervals.length > 1) {
216
266
  const merged = mergeSiblingChunks(intervals, origStart, origEnd, tokenCap);
217
267
  if (merged) {
268
+ // F5: when the merged range spans a previously-unseen entity, label it.
269
+ let mergedFirstContained = firstContained;
270
+ if (!meta.name && !mergedFirstContained && codeGraphRepo
271
+ && typeof codeGraphRepo.findFirstEntityInRange === 'function') {
272
+ try {
273
+ mergedFirstContained = codeGraphRepo.findFirstEntityInRange(filePath, merged.startLine, merged.endLine);
274
+ } catch { /* keep original firstContained */ }
275
+ }
218
276
  return {
219
277
  startLine: merged.startLine,
220
278
  endLine: merged.endLine,
221
279
  expanded: true,
222
280
  expandedFrom: origRange,
223
- symbol: meta.name || null,
224
- symbolType: meta.type || null,
281
+ symbol: meta.name || mergedFirstContained?.name || null,
282
+ symbolType: meta.type || mergedFirstContained?.type || null,
225
283
  kind: 'syntax',
226
284
  };
227
285
  }
@@ -243,13 +301,24 @@ export function expandToSymbol(result, opts) {
243
301
  fileCache, filePath, origStart, origEnd, tokenCap, projectRoot
244
302
  );
245
303
  if (syntaxExpanded) {
304
+ // F5: when syntax expansion enlarges the range, the new range may contain
305
+ // entities the raw chunk didn't. Re-lookup the first contained entity in
306
+ // the expanded range so chunks like fastify lib/reply.js:139-192 (no
307
+ // entities) → 64-225 (contains Reply at 64-76) get a meaningful symbol.
308
+ let syntaxFirstContained = firstContained;
309
+ if (!meta.name && !syntaxFirstContained && codeGraphRepo
310
+ && typeof codeGraphRepo.findFirstEntityInRange === 'function') {
311
+ try {
312
+ syntaxFirstContained = codeGraphRepo.findFirstEntityInRange(filePath, syntaxExpanded.startLine, syntaxExpanded.endLine);
313
+ } catch { /* keep firstContained */ }
314
+ }
246
315
  return {
247
316
  startLine: syntaxExpanded.startLine,
248
317
  endLine: syntaxExpanded.endLine,
249
318
  expanded: true,
250
319
  expandedFrom: origRange,
251
- symbol: meta.name || null,
252
- symbolType: meta.type || null,
320
+ symbol: meta.name || syntaxFirstContained?.name || null,
321
+ symbolType: meta.type || syntaxFirstContained?.type || null,
253
322
  kind: 'syntax',
254
323
  };
255
324
  }
@@ -260,8 +329,8 @@ export function expandToSymbol(result, opts) {
260
329
  endLine: origEnd,
261
330
  expanded: false,
262
331
  expandedFrom: null,
263
- symbol: meta.name || null,
264
- symbolType: meta.type || null,
332
+ symbol: meta.name || firstContained?.name || null,
333
+ symbolType: meta.type || firstContained?.type || null,
265
334
  kind: 'chunk',
266
335
  };
267
336
  }
@@ -550,6 +619,70 @@ export function expandBySyntax(fileCache, filePath, startLine, endLine, tokenCap
550
619
  return { startLine: expandedStart, endLine: expandedEnd };
551
620
  }
552
621
 
622
+ /**
623
+ * Walk upward from `baseStartLine` to absorb leading trivia (doc comments,
624
+ * attributes, decorators) that document the symbol. This recovers context
625
+ * the judge keeps asking for: ripgrep `#[non_exhaustive]`, JSDoc above
626
+ * the function, Python decorators, Rust `///` and `//!` doc lines.
627
+ *
628
+ * Caps at 30 lines back so we never blow the budget on accidentally
629
+ * absorbing a previous symbol's body. Returns the adjusted startLine
630
+ * (never less than 1, never above baseStartLine).
631
+ *
632
+ * @param {string} filePath
633
+ * @param {number} baseStartLine
634
+ * @param {Map} fileCache
635
+ * @param {string} projectRoot
636
+ * @param {string} lang
637
+ * @returns {number}
638
+ */
639
+ export function expandLeadingTrivia(filePath, baseStartLine, fileCache, projectRoot, lang) {
640
+ if (!filePath || !baseStartLine || baseStartLine <= 1) return baseStartLine;
641
+ const windowStart = Math.max(1, baseStartLine - 30);
642
+ const text = readFileRange(fileCache, filePath, windowStart, baseStartLine - 1, projectRoot);
643
+ if (!text) return baseStartLine;
644
+ const lines = text.split('\n');
645
+
646
+ // Walk lines BACKWARDS, classifying each:
647
+ // - trivia line (doc / attr / decorator) → mark as the new topmost
648
+ // - blank line → tolerated INSIDE a doc run,
649
+ // but not absorbed (the blank
650
+ // above the topmost trivia
651
+ // row stays attached to the
652
+ // prior code, not the symbol)
653
+ // - anything else (code / punct / close) → stop
654
+ let topmostTrivia = baseStartLine; // 1-based absolute, only moves on trivia
655
+ for (let idx = lines.length - 1; idx >= 0; idx--) {
656
+ const raw = lines[idx];
657
+ const ln = windowStart + idx;
658
+ if (ln >= baseStartLine) continue;
659
+ const trimmed = raw.trim();
660
+ if (trimmed === '') {
661
+ // Tolerate blank gaps inside the doc run but DO NOT include them: the
662
+ // returned line is always the topmost actual trivia row.
663
+ continue;
664
+ }
665
+ let isTrivia = false;
666
+ if (lang === 'rust') {
667
+ // /// and //! doc comments, #[attr] / #![attr], block comments,
668
+ // // regular comments adjacent to a doc run.
669
+ isTrivia = /^(\/\/[!/]?|\/\*\*?|\*\/?|\*\s|#\!?\[)/.test(trimmed);
670
+ } else if (lang === 'go') {
671
+ isTrivia = /^\/\//.test(trimmed);
672
+ } else if (lang === 'python') {
673
+ // Decorators, comments, and raw docstring lines (rare directly above def)
674
+ isTrivia = /^@\w/.test(trimmed) || /^#/.test(trimmed)
675
+ || /^['"]{3}/.test(trimmed);
676
+ } else {
677
+ // JS/TS/Java/C-style: //, /** ... */, *, decorators (TS @Decorator)
678
+ isTrivia = /^(\/\/|\/\*\*?|\*\/?|\*\s|@\w)/.test(trimmed);
679
+ }
680
+ if (!isTrivia) break;
681
+ topmostTrivia = ln;
682
+ }
683
+ return topmostTrivia;
684
+ }
685
+
553
686
  /** Get Python indent level (number of leading spaces, tabs=4). */
554
687
  function getIndentLevel(line) {
555
688
  let indent = 0;
@@ -618,47 +751,131 @@ export function checkStaleness(filePath, projectRoot, codeGraphRepo, cache = {})
618
751
  /**
619
752
  * Extract import lines from file header, language-aware.
620
753
  *
621
- * Handles:
622
- * - JS/TS: import/require/export statements
623
- * - Go: import (...) blocks and single imports
624
- * - Python: import/from statements
625
- * - Rust: use declarations
754
+ * Handles multi-line constructs that the previous line-by-line filter
755
+ * dropped on the floor (the dominant cause of "missing alias" judge
756
+ * complaints, e.g. fastify uses
757
+ * const { kSchemaParams: paramsSchema, ... } = require('./symbols')
758
+ * spanning 5+ lines — the body uses `paramsSchema`, but only the multi-line
759
+ * form maps it back to `kSchemaParams`):
760
+ *
761
+ * - JS/TS: ES `import { a, b } from 'x'` (multi-line)
762
+ * `const { a, b } = require('x')` (multi-line, with `kKey: alias`)
763
+ * `export { ... } from '...'`
764
+ * - Go: `import (...)` blocks (with aliases like `alias "path"`)
765
+ * - Python: `import x` / `from x import (a, b, c)` (multi-line with parens)
766
+ * - Rust: `use foo::{bar, baz}` (multi-line grouped) + `pub use` + extern crate
767
+ *
768
+ * Output is one logical statement per array entry; multi-line statements
769
+ * are joined with a space so the consumer (header rendering, identifier
770
+ * scan) sees a single string per import.
626
771
  */
627
772
  function extractImportLines(headerText, lang) {
628
773
  const lines = headerText.split('\n');
629
774
 
775
+ // ── Generic multi-line statement collector ───────────────────────────────
776
+ // Walk lines, accumulate balanced bracket levels for each "starter" we
777
+ // recognise, emit when the statement closes. Falls back to single-line
778
+ // emission for languages where a statement ends at EOL.
779
+ const out = [];
780
+
630
781
  if (lang === 'go') {
631
- // Go: capture `import (...)` block contents and single `import "..."`
632
- const result = [];
782
+ // Go: capture `import (...)` block contents (each line) and single `import "..."`
633
783
  let inBlock = false;
634
784
  for (const line of lines) {
635
785
  if (/^\s*import\s*\(/.test(line)) { inBlock = true; continue; }
636
786
  if (inBlock) {
637
787
  if (/^\s*\)/.test(line)) { inBlock = false; continue; }
638
- if (line.trim()) result.push(line);
639
- } else if (/^\s*import\s+"/.test(line)) {
640
- result.push(line);
788
+ if (line.trim()) out.push(line.trimEnd());
789
+ } else if (/^\s*import\s+(\w+\s+)?"/.test(line)) {
790
+ out.push(line.trimEnd());
641
791
  }
642
792
  }
643
- return result;
793
+ return out;
644
794
  }
645
795
 
646
796
  if (lang === 'python') {
647
- return lines.filter(line =>
648
- /^\s*(import\s+\w|from\s+\w)/.test(line)
649
- );
797
+ // Python: `import x`, `from x import y`, `from x import (a, b, ...)`
798
+ // multi-line via parens or trailing backslash.
799
+ let i = 0;
800
+ while (i < lines.length) {
801
+ const line = lines[i];
802
+ if (/^\s*(import\s+\w|from\s+[.\w]+\s+import)/.test(line)) {
803
+ let stmt = line.trimEnd();
804
+ // Continue while line ends with backslash or has unbalanced (
805
+ const opens = () => (stmt.match(/\(/g) || []).length;
806
+ const closes = () => (stmt.match(/\)/g) || []).length;
807
+ const continued = () => /\\\s*$/.test(stmt) || opens() > closes();
808
+ while (continued() && i + 1 < lines.length) {
809
+ stmt = stmt.replace(/\\\s*$/, '').trimEnd() + ' ' + lines[++i].trim();
810
+ }
811
+ out.push(stmt);
812
+ }
813
+ i++;
814
+ }
815
+ return out;
650
816
  }
651
817
 
652
818
  if (lang === 'rust') {
653
- return lines.filter(line =>
654
- /^\s*(use\s+|pub\s+use\s+|extern\s+crate\s+)/.test(line)
655
- );
819
+ // Rust: `use foo::{bar, baz}` (possibly multi-line via { ... });
820
+ // also `pub use ...;` and `extern crate ...;` (single line).
821
+ let i = 0;
822
+ while (i < lines.length) {
823
+ const line = lines[i];
824
+ if (/^\s*(pub\s+)?use\s+/.test(line) || /^\s*extern\s+crate\s+/.test(line)) {
825
+ let stmt = line.trimEnd();
826
+ // Continue until we see the terminating `;`
827
+ while (!/;\s*(\/\/.*)?$/.test(stmt) && i + 1 < lines.length) {
828
+ stmt += ' ' + lines[++i].trim();
829
+ }
830
+ out.push(stmt);
831
+ }
832
+ i++;
833
+ }
834
+ return out;
656
835
  }
657
836
 
658
- // JS/TS/default
659
- return lines.filter(line =>
660
- /^\s*(import\s|const\s+\{.*\}\s*=\s*require|from\s+['"]|export\s+\{)/.test(line)
661
- );
837
+ // JS/TS/default — handle multi-line ES imports and CommonJS destructured requires.
838
+ // Examples we want to capture as ONE logical line:
839
+ // import {
840
+ // foo,
841
+ // bar as baz,
842
+ // } from 'mod'
843
+ // const {
844
+ // kSchemaParams: paramsSchema,
845
+ // kSchemaBody: bodySchema,
846
+ // } = require('./symbols')
847
+ // const x = require('./y')
848
+ // export { a, b } from './x'
849
+ let i = 0;
850
+ while (i < lines.length) {
851
+ const line = lines[i];
852
+ const isStartES = /^\s*(import|export)\s/.test(line);
853
+ // CJS detector: start at any line that begins a const/let/var declaration.
854
+ // We accumulate continuation lines until brackets balance, THEN filter to
855
+ // only keep statements that prove themselves to be import-like (contain
856
+ // `require(...)` or destructured-assignment from a bracketed expression).
857
+ const isStartCJS = /^\s*(const|let|var)\s+/.test(line)
858
+ && (/\brequire\s*\(/.test(line) || /\{[^}]*$/.test(line));
859
+ if (isStartES || isStartCJS) {
860
+ let stmt = line.trimEnd();
861
+ const opens = () => (stmt.match(/[{(]/g) || []).length;
862
+ const closes = () => (stmt.match(/[})]/g) || []).length;
863
+ // Continue while open brackets exceed closes OR statement ends with comma
864
+ // (suggests a continuation line for object / list).
865
+ while ((opens() > closes() || /,\s*(\/\/.*)?$/.test(stmt))
866
+ && i + 1 < lines.length) {
867
+ stmt += ' ' + lines[++i].trim();
868
+ if (opens() === closes() && /;\s*$/.test(stmt)) break;
869
+ }
870
+ // Only keep statements that look like imports (drop unrelated
871
+ // const/let assignments that happened to span lines).
872
+ if (/\b(import|require\s*\(|from\s+['"]|export\s*\{)/.test(stmt)) {
873
+ out.push(stmt);
874
+ }
875
+ }
876
+ i++;
877
+ }
878
+ return out;
662
879
  }
663
880
 
664
881
  /**
@@ -829,46 +1046,349 @@ export function computeConfidence(results, stats) {
829
1046
  return { confidence, confidenceReason };
830
1047
  }
831
1048
 
1049
+ /**
1050
+ * Identifiers that look like external references the body uses but does
1051
+ * NOT define. We treat anything matching `\b[A-Za-z_][A-Za-z0-9_]{2,}\b`
1052
+ * (≥3 chars), excluding language keywords and the symbol's own name.
1053
+ * Returns a Set, lower-cased keys plus the original case for diagnostics.
1054
+ */
1055
+ function extractCodeIdentifiers(code, ownSymbolName) {
1056
+ const out = new Set();
1057
+ if (!code) return out;
1058
+ const matches = code.match(/\b[A-Za-z_][A-Za-z0-9_]{2,}\b/g) || [];
1059
+ const ownLower = (ownSymbolName || '').toLowerCase();
1060
+ for (const id of matches) {
1061
+ if (LANG_KEYWORDS.has(id)) continue;
1062
+ if (id.toLowerCase() === ownLower) continue;
1063
+ // Drop pure numerics and trivially-short tokens already filtered.
1064
+ out.add(id);
1065
+ }
1066
+ return out;
1067
+ }
1068
+
1069
+ /**
1070
+ * Decide whether the body's referenced identifiers are all locally
1071
+ * resolvable from headerContext + neighbours + the body itself
1072
+ * (i.e. the symbol introduces or imports them all). Used by the
1073
+ * stricter `computeSufficiency` rule.
1074
+ *
1075
+ * Identifiers count as resolved when they are:
1076
+ * - mentioned in `headerContext` (any kind of import/require line)
1077
+ * - mentioned in `neighborsRendered` (callees/imports we surfaced)
1078
+ * - declared inside `code` itself (e.g. const x = ..., function x ...,
1079
+ * parameters in the symbol signature) — detected as identifiers that
1080
+ * appear in lvalue positions (`const X`, `let X`, `function X`,
1081
+ * `class X`, function parameters)
1082
+ */
1083
+ function unresolvedExternalRefs(code, ownSymbolName, headerContext, neighborsRendered) {
1084
+ const externals = extractCodeIdentifiers(code, ownSymbolName);
1085
+ if (!externals.size) return new Set();
1086
+ const resolvedHaystack = (headerContext || '') + '\n' + (neighborsRendered || '');
1087
+ // Approximate "declared locally in this code block": any identifier that
1088
+ // appears in an lvalue-ish position. We only need a rough check, false
1089
+ // positives here just mean "looks self-contained" (which is fine).
1090
+ const localDecls = new Set();
1091
+ const lvalueRe = /\b(?:const|let|var|function|class|fn|def|struct|enum|trait|impl|type|interface)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
1092
+ let m;
1093
+ while ((m = lvalueRe.exec(code))) localDecls.add(m[1]);
1094
+ // Function-parameter approximation: capture top-of-block `(...)` after the symbol name.
1095
+ const sigRe = new RegExp(`\\b${(ownSymbolName || '').replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\s*\\(([^)]*)\\)`);
1096
+ if (ownSymbolName) {
1097
+ const sig = code.match(sigRe);
1098
+ if (sig) {
1099
+ for (const part of sig[1].split(/[,\s]+/)) {
1100
+ const id = part.replace(/[:=\[\]?<>]/g, '').replace(/\.\.\./, '').trim();
1101
+ if (id) localDecls.add(id);
1102
+ }
1103
+ }
1104
+ }
1105
+ const unresolved = new Set();
1106
+ for (const id of externals) {
1107
+ if (localDecls.has(id)) continue;
1108
+ if (resolvedHaystack.includes(id)) continue;
1109
+ unresolved.add(id);
1110
+ }
1111
+ return unresolved;
1112
+ }
1113
+
832
1114
  /**
833
1115
  * Compute sufficiency signal — does the returned context likely contain
834
- * enough information to answer the query? (Fix #7, plan §5)
1116
+ * enough information to answer the query?
835
1117
  *
836
- * Signals:
837
- * (a) Expanded region contains a complete symbol (not truncated)
838
- * (b) Header context resolves all referenced imports
839
- * (c) Score gap suggests the match is specific, not generic
1118
+ * Tightened rule (May 2026 — addresses the dominant agent-bench loss
1119
+ * pattern): a complete symbol on its own is NOT sufficient. We also
1120
+ * require either (a) the symbol's external references are resolved
1121
+ * (header imports + 1-hop graph neighbours), or (b) the symbol is
1122
+ * provably self-contained (no unresolved external identifiers).
840
1123
  *
841
- * @param {object} topResult - The top-1 agent result
842
- * @param {{ confidence: string }} confidenceInfo - Confidence computation result
843
- * @returns {{ sufficient: boolean, reasons: string[] }}
1124
+ * Reasons emitted (independent signals, kept for diagnostics):
1125
+ * - complete_symbol : top-1 is a full, non-truncated symbol
1126
+ * - header_resolved : top-1 has resolved import/header context
1127
+ * - neighbors_present : the package surfaced ≥1 1-hop graph neighbour
1128
+ * - self_contained_strict: every external identifier in the body is
1129
+ * either declared locally, in headerContext,
1130
+ * or in the surfaced neighbours list
1131
+ * - high_confidence : score gap puts top-1 well ahead of top-2
1132
+ *
1133
+ * sufficient := complete_symbol
1134
+ * AND (header_resolved OR neighbors_present OR self_contained_strict)
1135
+ * AND (high_confidence OR header_resolved OR neighbors_present)
1136
+ *
1137
+ * @param {object} topResult
1138
+ * @param {{ confidence: string }} confidenceInfo
1139
+ * @returns {{ sufficient: boolean, reasons: string[], unresolvedExternalCount: number }}
844
1140
  */
845
1141
  export function computeSufficiency(topResult, confidenceInfo) {
846
1142
  const reasons = [];
847
1143
 
848
- // (a) Is the result a complete symbol (not truncated)?
849
- const isComplete = topResult.symbol &&
1144
+ const isComplete = !!(topResult.symbol &&
850
1145
  topResult.presentation === 'full' &&
851
- !topResult.code?.includes('// ... (');
852
- if (isComplete) {
853
- reasons.push('complete_symbol');
1146
+ !topResult.code?.includes('// ... ('));
1147
+ if (isComplete) reasons.push('complete_symbol');
1148
+
1149
+ const hasHeader = !!topResult.headerContext;
1150
+ if (hasHeader) reasons.push('header_resolved');
1151
+
1152
+ const hasNeighbors = !!(topResult.neighbors && topResult.neighbors.count > 0);
1153
+ if (hasNeighbors) reasons.push('neighbors_present');
1154
+
1155
+ // Strict self-containment: only fires if the body has zero unresolved
1156
+ // external identifiers (after considering header + neighbours + locals).
1157
+ let unresolvedCount = 0;
1158
+ if (isComplete && topResult.code) {
1159
+ const unresolved = unresolvedExternalRefs(
1160
+ topResult.code,
1161
+ topResult.symbol,
1162
+ topResult.headerContext || '',
1163
+ topResult.neighbors?.rendered || ''
1164
+ );
1165
+ unresolvedCount = unresolved.size;
1166
+ if (unresolvedCount === 0) reasons.push('self_contained_strict');
1167
+ }
1168
+
1169
+ if (confidenceInfo?.confidence === 'high') reasons.push('high_confidence');
1170
+
1171
+ // Tightened rule: complete symbol is necessary but NOT sufficient.
1172
+ // We require at least one resolution reason AND at least one specificity
1173
+ // reason. This stops a bare "complete + high_confidence" from claiming
1174
+ // sufficient when the body still references a dozen helpers we never
1175
+ // surfaced (the validation-pipeline failure mode).
1176
+ const hasResolution = hasHeader || hasNeighbors || reasons.includes('self_contained_strict');
1177
+ const hasSpecificity = confidenceInfo?.confidence === 'high' || hasHeader || hasNeighbors;
1178
+ const sufficient = isComplete && hasResolution && hasSpecificity;
1179
+
1180
+ return { sufficient, reasons, unresolvedExternalCount: unresolvedCount };
1181
+ }
1182
+
1183
+ // =============================================================================
1184
+ // Graph-neighbour reservation (Phase 6) — 1-hop neighbours for top-1
1185
+ // =============================================================================
1186
+
1187
+ /**
1188
+ * Extract identifier candidates from a code body that look like type names
1189
+ * (struct / interface / class / enum / trait / type). Used by the
1190
+ * graph-neighbour tier to find type definitions that the relationships
1191
+ * table didn't capture as explicit edges (the canonical failure case is
1192
+ * gin:http-dispatch — `methodTree` is an unexported Go struct referenced
1193
+ * via a field-of-field, never as a direct relationship edge).
1194
+ *
1195
+ * Heuristic: any token ≥3 chars containing at least one uppercase AND at
1196
+ * least one lowercase letter (Pascal/camelCase). This captures both
1197
+ * exported types (`Engine`, `Context`, `ErrorKind`) and unexported Go
1198
+ * types (`methodTree`, `nodeType`). It deliberately rejects:
1199
+ * - all-uppercase SCREAMING_SNAKE_CASE constants
1200
+ * - all-lowercase variables / function names
1201
+ * - language keywords
1202
+ * - the symbol's own name
1203
+ *
1204
+ * False positives (e.g. method names in camelCase) are cheap because the
1205
+ * downstream SQL lookup ALSO filters by entity type, so a camelCase
1206
+ * function name won't match a struct/interface/class entity.
1207
+ *
1208
+ * @param {string} code
1209
+ * @param {string|null} ownName - the symbol's own name, excluded from results
1210
+ * @returns {string[]}
1211
+ */
1212
+ function extractTypeCandidates(code, ownName) {
1213
+ if (!code) return [];
1214
+ const matches = code.match(/\b[A-Za-z_][A-Za-z0-9_]{2,}\b/g) || [];
1215
+ const own = (ownName || '').toLowerCase();
1216
+ const seen = new Set();
1217
+ const out = [];
1218
+ for (const id of matches) {
1219
+ if (seen.has(id)) continue;
1220
+ seen.add(id);
1221
+ if (LANG_KEYWORDS.has(id)) continue;
1222
+ if (id.toLowerCase() === own) continue;
1223
+ // Require BOTH upper and lower (Pascal/camelCase). Filters out
1224
+ // SCREAMING_SNAKE constants AND all-lowercase variables. A pure
1225
+ // single-word lowercase token like `trees` is rejected — most
1226
+ // unexported Go types are camelCase like `methodTree` and survive.
1227
+ if (!/[A-Z]/.test(id)) continue;
1228
+ if (!/[a-z]/.test(id)) continue;
1229
+ out.push(id);
854
1230
  }
1231
+ // Cap the candidate list — DB lookup is bounded but we still pay query cost.
1232
+ return out.slice(0, 32);
1233
+ }
855
1234
 
856
- // (b) Does header context exist, OR is the symbol self-contained?
857
- // Self-contained: complete symbol with code but no header needed (no imports referenced).
858
- // This prevents false negatives for utility functions that don't use imports.
859
- if (topResult.headerContext) {
860
- reasons.push('header_resolved');
861
- } else if (isComplete && topResult.code && topResult.codeTokens > 0) {
862
- reasons.push('self_contained');
1235
+ /**
1236
+ * Render a one-hop graph-neighbour tier for the top-1 result. This addresses
1237
+ * the dominant loss pattern in the agent benchmark: ss-search returned a
1238
+ * tight, "complete" symbol, the agent stopped at one tool call, and the
1239
+ * judge then complained the answer didn't surface CALLERS, IMPORTED
1240
+ * SYMBOLS, or HELPER FUNCTIONS that the chunk plainly references.
1241
+ *
1242
+ * The renderer:
1243
+ * - asks the code graph for outgoing relationships (calls / imports / uses /
1244
+ * extends / implements / overrides / throws) from top-1's entity,
1245
+ * - asks for incoming callers / users (top-K by weight),
1246
+ * - dedupes anything whose target file:line range overlaps a result that
1247
+ * is already in the ranked pack (no point spending budget on a row the
1248
+ * agent already sees),
1249
+ * - groups by edge family and renders compact one-liners that include the
1250
+ * target's `file:line` so the agent can cite the neighbour directly,
1251
+ * - hard-caps the rendered text at `tokenCap`. The result is fully
1252
+ * elidable — when the cap is 0, the function returns null.
1253
+ *
1254
+ * @param {object} opts
1255
+ * @param {object} opts.codeGraphRepo - CodeGraphRepository instance
1256
+ * @param {object} opts.entity - { id, filePath, startLine, endLine, name, type }
1257
+ * @param {Set<string>} opts.skipKeys - "file|startLine|endLine" of results already in the pack
1258
+ * @param {number} opts.tokenCap - max tokens for the rendered tier
1259
+ * @param {string} [opts.body] - top-1 code body, used to discover
1260
+ * referenced TYPE names (struct/interface/class/...) that the
1261
+ * relationships table doesn't capture as explicit edges
1262
+ * @returns {{ rendered: string, count: number, tokens: number,
1263
+ * outgoingCount: number, incomingCount: number,
1264
+ * typeRefCount: number }|null}
1265
+ */
1266
+ export function renderGraphNeighbors(opts) {
1267
+ const { codeGraphRepo, entity, skipKeys, tokenCap = 0, body = '' } = opts;
1268
+ if (!codeGraphRepo || !entity || !entity.id || tokenCap <= 0) return null;
1269
+
1270
+ const OUT_TYPES = ['imports', 'calls', 'uses', 'extends', 'implements', 'overrides', 'throws'];
1271
+ const IN_TYPES = ['calls', 'uses', 'extends', 'implements'];
1272
+ // typeAlias is what Go's graph extractor stores for struct/interface/type
1273
+ // declarations; the others cover JS/TS/Java/Rust/Python conventions.
1274
+ const TYPE_KINDS = ['struct', 'class', 'interface', 'enum', 'trait', 'type', 'typeAlias'];
1275
+
1276
+ let outgoing = [];
1277
+ let incoming = [];
1278
+ let typeRefs = [];
1279
+ try { outgoing = codeGraphRepo.getOutgoingRelationships(entity.id, { types: OUT_TYPES, limit: 16 }) || []; }
1280
+ catch { outgoing = []; }
1281
+ try { incoming = codeGraphRepo.getIncomingRelationships(entity.id, { types: IN_TYPES, limit: 8 }) || []; }
1282
+ catch { incoming = []; }
1283
+
1284
+ // Type-reference discovery: extract identifiers from the body and ask the
1285
+ // graph for entities of struct/interface/class/enum/trait/type/typeAlias
1286
+ // with that name. This recovers the case the relationships table misses —
1287
+ // e.g. a Go method whose receiver field has type `methodTrees []methodTree`
1288
+ // never gets a 'calls/imports/uses' edge to `methodTree`, yet the agent
1289
+ // needs that type's defining file:line to give a correct answer
1290
+ // (gin:http-dispatch was the canonical failure).
1291
+ //
1292
+ // Dedup uses range-based skipKeys (NOT excludeFile) — same-file types
1293
+ // matter when top-1 is a method and the receiver struct lives next to
1294
+ // it (e.g. Engine in gin.go vs handleHTTPRequest in gin.go).
1295
+ if (body && typeof codeGraphRepo.findEntitiesByNames === 'function') {
1296
+ try {
1297
+ const ids = extractTypeCandidates(body, entity.name);
1298
+ if (ids.length) {
1299
+ typeRefs = codeGraphRepo.findEntitiesByNames(ids, {
1300
+ types: TYPE_KINDS,
1301
+ limit: 8,
1302
+ }) || [];
1303
+ }
1304
+ } catch { typeRefs = []; }
863
1305
  }
864
1306
 
865
- // (c) Is the confidence high (specific match)?
866
- if (confidenceInfo.confidence === 'high') {
867
- reasons.push('high_confidence');
1307
+ if (outgoing.length === 0 && incoming.length === 0 && typeRefs.length === 0) return null;
1308
+
1309
+ // Group by edge family for stable rendering. Each row is a one-liner.
1310
+ // Format:
1311
+ // - imports paramsSchema → lib/symbols.js:14 [Symbol]
1312
+ // - calls validateParam → lib/validation.js:118-144 [function]
1313
+ // - caller handleRequest → lib/handle-request.js:88-104 [function]
1314
+ // - imports module './x' (unresolved)
1315
+ const ownKey = `${entity.filePath}|${entity.startLine}|${entity.endLine}`;
1316
+ const seen = new Set([ownKey, ...(skipKeys || [])]);
1317
+
1318
+ const formatLineRange = (a, b) => (a && b && b > a) ? `${a}-${b}` : `${a || '?'}`;
1319
+
1320
+ const lines = [];
1321
+ // OUTGOING — group resolved targets first, then unresolved imports
1322
+ const grouped = new Map();
1323
+ for (const r of outgoing) {
1324
+ const fam = r.type;
1325
+ if (!grouped.has(fam)) grouped.set(fam, []);
1326
+ grouped.get(fam).push(r);
1327
+ }
1328
+ // Render order: imports, calls, uses, extends, implements, overrides, throws
1329
+ for (const fam of OUT_TYPES) {
1330
+ const rows = grouped.get(fam) || [];
1331
+ for (const r of rows) {
1332
+ let rendered;
1333
+ if (r.target && r.target.filePath) {
1334
+ const k = `${r.target.filePath}|${r.target.startLine}|${r.target.endLine}`;
1335
+ if (seen.has(k)) continue; // already in the pack — skip
1336
+ seen.add(k);
1337
+ const range = formatLineRange(r.target.startLine, r.target.endLine);
1338
+ rendered = `- ${fam} ${r.target.name} → ${r.target.filePath}:${range} [${r.target.type}]`;
1339
+ } else if (r.fullImportPath) {
1340
+ rendered = `- ${fam} ${r.targetName} ← '${r.fullImportPath}' (unresolved)`;
1341
+ } else if (r.targetName && r.contextLine) {
1342
+ rendered = `- ${fam} ${r.targetName} (referenced at line ${r.contextLine})`;
1343
+ } else if (r.targetName) {
1344
+ rendered = `- ${fam} ${r.targetName}`;
1345
+ } else {
1346
+ continue;
1347
+ }
1348
+ lines.push(rendered);
1349
+ }
1350
+ }
1351
+ // INCOMING — flag as "caller" or "user"
1352
+ for (const r of incoming) {
1353
+ const s = r.source;
1354
+ if (!s) continue;
1355
+ const k = `${s.filePath}|${s.startLine}|${s.endLine}`;
1356
+ if (seen.has(k)) continue;
1357
+ seen.add(k);
1358
+ const range = formatLineRange(s.startLine, s.endLine);
1359
+ const kind = r.type === 'calls' ? 'caller' : (r.type === 'uses' ? 'user' : r.type);
1360
+ lines.push(`- ${kind} ${s.name} ← ${s.filePath}:${range} [${s.type}]`);
1361
+ }
1362
+
1363
+ // TYPE-REFERENCES — entities discovered by name from the body. Renders as
1364
+ // "type" prefix to disambiguate from the relationship-driven rows above.
1365
+ // Same dedup against skipKeys.
1366
+ for (const t of typeRefs) {
1367
+ const k = `${t.filePath}|${t.startLine}|${t.endLine}`;
1368
+ if (seen.has(k)) continue;
1369
+ seen.add(k);
1370
+ const range = formatLineRange(t.startLine, t.endLine);
1371
+ lines.push(`- type ${t.name} → ${t.filePath}:${range} [${t.type}]`);
1372
+ }
1373
+
1374
+ if (lines.length === 0) return null;
1375
+
1376
+ // Hard-cap to tokenCap. Drop tail lines until it fits.
1377
+ let combined = lines.join('\n');
1378
+ while (estimateTokens(combined) > tokenCap && lines.length > 1) {
1379
+ lines.pop();
1380
+ combined = lines.join('\n');
868
1381
  }
1382
+ if (estimateTokens(combined) > tokenCap) return null;
869
1383
 
870
- const sufficient = reasons.length >= 2;
871
- return { sufficient, reasons };
1384
+ return {
1385
+ rendered: combined,
1386
+ count: lines.length,
1387
+ tokens: estimateTokens(combined),
1388
+ outgoingCount: outgoing.length,
1389
+ incomingCount: incoming.length,
1390
+ typeRefCount: typeRefs.length,
1391
+ };
872
1392
  }
873
1393
 
874
1394
  // =============================================================================
@@ -1069,11 +1589,21 @@ function compressToPreview(code, tokenCap) {
1069
1589
 
1070
1590
  /**
1071
1591
  * Resolve the effective sub-mode from the format string.
1072
- * 'agent' / 'agent_preview' → 'agent_preview' (compact 4k budget)
1073
- * 'agent_full' → 'agent_full' (8k budget)
1074
- * 'agent_full_xl' → 'agent_full_xl' (12k budget, opt-in only;
1075
- * falls back to agent_full at allocation time
1076
- * when the dominance gate fails)
1592
+ *
1593
+ * EXPLICIT TIERS (caller picks):
1594
+ * 'agent_preview' → 'agent_preview' (compact 4k budget)
1595
+ * 'agent_full' → 'agent_full' (8k budget)
1596
+ * 'agent_full_xl' 'agent_full_xl' (12k budget, opt-in only;
1597
+ * falls back to per-result baseline cap
1598
+ * at allocation time when the top-1
1599
+ * dominance gate fails)
1600
+ *
1601
+ * AUTO-PICK (default for the bare 'agent' format):
1602
+ * 'agent' → tier chosen by selectAgentBudget(); see that fn.
1603
+ *
1604
+ * Used as a fallback when caller bypasses the auto-pick path (e.g. unit tests
1605
+ * that call resolveSubMode directly). Production code goes through
1606
+ * selectAgentBudget(format, signals) which understands auto-pick.
1077
1607
  */
1078
1608
  function resolveSubMode(format) {
1079
1609
  if (format === 'agent_full_xl') return 'agent_full_xl';
@@ -1081,6 +1611,256 @@ function resolveSubMode(format) {
1081
1611
  return 'agent_preview'; // 'agent' and 'agent_preview' both map here
1082
1612
  }
1083
1613
 
1614
+ // =============================================================================
1615
+ // Auto-tier selection — selectAgentBudget
1616
+ // =============================================================================
1617
+ //
1618
+ // Picks the agent-mode budget tier (preview 4k / full 8k / xl 12k) from
1619
+ // post-ranking signals when callers pass the bare format='agent'.
1620
+ //
1621
+ // DESIGN PRINCIPLE: 4k is enough for ~99% of queries.
1622
+ //
1623
+ // Lost-in-the-middle and RAG-vs-long-context studies (Liu et al., Xu et al.)
1624
+ // consistently show that smaller, focused context outperforms bigger context
1625
+ // for retrieval tasks. The preview tier already renders top-1 fully (up to
1626
+ // 2000 tokens) and gives ranks 2-3 a signature + 5-line snippet — that's
1627
+ // enough for the agent to either answer or to escalate via an explicit
1628
+ // `format='agent_full'` re-query. Auto should NOT silently pay an 8k or 12k
1629
+ // token bill on every borderline query.
1630
+ //
1631
+ // When does extra budget STRICTLY beat preview?
1632
+ //
1633
+ // XL: top-1 itself needs >2k tokens. The dominance gate at allocation
1634
+ // (allocateBudget L1448-1454) raises top-1's per-result cap from 2k
1635
+ // → 8k IFF top-1 >= 2 * top-2. Without the gate, XL caps top-1 at
1636
+ // 2k anyway — identical to preview for the top-1 case. So XL only
1637
+ // pays off when BOTH conditions hold: chunk really is big, AND
1638
+ // dominance gate will fire.
1639
+ //
1640
+ // FULL: rank 2/3 need full body (each up to 2000 tokens) instead of a
1641
+ // signature + 5 lines. That's only useful when several results
1642
+ // are TIGHTLY tied — i.e. when there's no single answer, just a
1643
+ // set the agent must compare. A query with moderate dominance
1644
+ // (top-1 = 0.9, top-2 = 0.6) doesn't qualify; the agent can read
1645
+ // top-1 fully and re-query if needed.
1646
+ //
1647
+ // Decision tree (only fires when format='agent'; explicit tiers are pass-thru):
1648
+ //
1649
+ // numResults == 0 → preview ('auto_empty')
1650
+ // top1Tokens >= 2400 AND (numResults == 1 OR D >= 2.5) → xl ('auto_xl_*')
1651
+ // numResults >= 10 AND D < 1.05 AND top1Tokens >= 600 → full ('auto_full_tight_cluster')
1652
+ // default → preview ('auto_preview_default')
1653
+ //
1654
+ // Thresholds are deliberately tight — designed so XL+FULL combined fire on
1655
+ // roughly 1-5% of queries. On a fastify spot-check (NL queries with k=10),
1656
+ // all 6 representative queries land on preview under this rule. Single
1657
+ // dominant answers stay on preview unless the chunk is genuinely huge
1658
+ // (200+ lines × 9 tokens/line ≥ 2400). Multi-result clusters need 10+ items
1659
+ // within 5% of the top score before auto goes to full.
1660
+ //
1661
+ // Crucially: dominance answers "is top-1 the answer?", NOT "is top-1 big?".
1662
+ // We require both signals (and a high-bar threshold on each) before paying
1663
+ // the XL token cost. Likewise, "many results" alone is not a reason to
1664
+ // upgrade — the cluster has to be tight (D < 1.05) AND deep (≥ 10 items).
1665
+ //
1666
+ // Signals (computeBudgetSignals, all post-ranking — pure):
1667
+ // - numResults : ranked-results length
1668
+ // - dominance : top1.score / top2.score (sentinel 99 for single result)
1669
+ // - top1Tokens : estimated tokens of top-1 chunk (lineCount * 9, the
1670
+ // same per-line conversion the rest of the packager uses).
1671
+ // Equals 0 when start/end lines are unavailable — those
1672
+ // results stay on preview.
1673
+ //
1674
+ // `breadth` (grepMatches / candidatePoolSize) and `entropy` are still
1675
+ // computed and surfaced in `budgetSignals` for diagnostics, but neither
1676
+ // drives the decision: a broad pool is not a reason to give top-1 more
1677
+ // space, and small-N entropy is dominated by the 1/log(n) denominator and
1678
+ // stops being a reliable distribution-width signal.
1679
+
1680
+ const BUDGET_TIERS = {
1681
+ preview: { subMode: 'agent_preview', budget: 4000 },
1682
+ full: { subMode: 'agent_full', budget: 8000 },
1683
+ xl: { subMode: 'agent_full_xl', budget: 12000 },
1684
+ };
1685
+
1686
+ /**
1687
+ * Compute auto-pick signals from ranked results + searchStats.
1688
+ * Pure: does not look at file content or call into expensive code paths.
1689
+ *
1690
+ * @param {Array} rankedResults - Results after ranking pipeline (PRE-packaging)
1691
+ * @param {object} searchStats - Stats from the retrieval pipeline
1692
+ * @returns {{
1693
+ * numResults: number, breadth: number, dominance: number,
1694
+ * entropy: number, top1Tokens: number, top1LineCount: number
1695
+ * }}
1696
+ */
1697
+ export function computeBudgetSignals(rankedResults, searchStats = {}) {
1698
+ const numResults = Array.isArray(rankedResults) ? rankedResults.length : 0;
1699
+ if (numResults === 0) {
1700
+ return { numResults: 0, breadth: 0, dominance: 0, entropy: 0, top1Tokens: 0, top1LineCount: 0 };
1701
+ }
1702
+
1703
+ // Use whichever score field the ranker emitted. Pattern (colgrep) emits
1704
+ // `lateInteractionScore`; hybrid/semantic/lexical emit `score`. Keep both
1705
+ // paths working without renaming.
1706
+ const scores = rankedResults
1707
+ .map(r => Number(r?.score ?? r?.lateInteractionScore ?? 0))
1708
+ .filter(s => Number.isFinite(s) && s > 0);
1709
+
1710
+ // Top-1 size proxy — derived from the chunk's start/end lines. Uses the
1711
+ // same 9 tokens/line conversion the rest of the packager applies (see
1712
+ // expandToSymbol / renderCode in structural-context.js). Returns 0 when
1713
+ // either bound is missing, in which case the auto-pick falls back to the
1714
+ // preview branch instead of guessing.
1715
+ const top1 = rankedResults[0] || {};
1716
+ const top1Start = top1.metadata?.startLine ?? top1.startLine ?? null;
1717
+ const top1End = top1.metadata?.endLine ?? top1.endLine ?? null;
1718
+ const top1LineCount = (top1Start != null && top1End != null && top1End >= top1Start)
1719
+ ? (top1End - top1Start + 1)
1720
+ : 0;
1721
+ const top1Tokens = top1LineCount * 9;
1722
+
1723
+ if (scores.length === 0) {
1724
+ return { numResults, breadth: 0, dominance: 0, entropy: 0, top1Tokens, top1LineCount };
1725
+ }
1726
+
1727
+ const topScore = scores[0];
1728
+ const secondScore = scores[1] ?? 0;
1729
+ // Sentinel "very high" dominance when there's only one positive-score result
1730
+ // — keeps single-answer queries on the preview path.
1731
+ const dominance = secondScore > 0 ? topScore / secondScore : 99;
1732
+
1733
+ // Normalised Shannon entropy — KEPT FOR DIAGNOSTIC OUTPUT only. Not used
1734
+ // by selectAgentBudget after the small-N flaw was identified (2-result
1735
+ // entropy is forced into [0.7, 1.0] by the 1/log(n) denominator).
1736
+ let entropy = 0;
1737
+ if (scores.length > 1) {
1738
+ const sum = scores.reduce((a, b) => a + b, 0);
1739
+ if (sum > 0) {
1740
+ let H = 0;
1741
+ for (const s of scores) {
1742
+ const p = s / sum;
1743
+ if (p > 0) H -= p * Math.log(p);
1744
+ }
1745
+ entropy = H / Math.log(scores.length);
1746
+ }
1747
+ }
1748
+
1749
+ // Breadth — KEPT FOR DIAGNOSTIC OUTPUT only. Not used by tier selection
1750
+ // (broad candidate pools aren't a reason to give top-1 more tokens; only
1751
+ // top-1 actually being big is). `allocateBudget` still uses breadth for
1752
+ // its within-tier top-1-share sharpening (its job, not ours).
1753
+ const breadth = Number(
1754
+ searchStats?.grepMatches
1755
+ ?? searchStats?.candidatePoolSize
1756
+ ?? 0
1757
+ ) || 0;
1758
+
1759
+ return { numResults, breadth, dominance, entropy, top1Tokens, top1LineCount };
1760
+ }
1761
+
1762
+ /**
1763
+ * Pick the agent-mode tier for a request.
1764
+ *
1765
+ * Explicit tier formats (agent_preview / agent_full / agent_full_xl) are
1766
+ * pass-through. The bare 'agent' format triggers the auto-pick decision
1767
+ * tree using the signals above.
1768
+ *
1769
+ * Format-gating note: all return values keep `format='agent_*'` semantics,
1770
+ * so the `_isAgentFormat` ranking flag (file-kind-ranking.js:1443) remains
1771
+ * TRUE regardless of which tier we land on. Ranking is unchanged.
1772
+ *
1773
+ * @param {string} format - 'agent' | 'agent_preview' | 'agent_full' | 'agent_full_xl'
1774
+ * @param {object} signals - From computeBudgetSignals()
1775
+ * @param {object} [opts]
1776
+ * @param {number} [opts.explicitBudget] - Caller-supplied tokenBudget; if set,
1777
+ * bypass auto-pick and infer the tier from the value (matches trace's
1778
+ * selectBudget contract).
1779
+ * @returns {{ tier: 'preview'|'full'|'xl', subMode: string, tokenBudget: number, reason: string }}
1780
+ */
1781
+ export function selectAgentBudget(format, signals, opts = {}) {
1782
+ // Explicit numeric budget always wins. Pass the value through unchanged
1783
+ // (callers that pass tiny budgets — e.g. `tokenBudget: 1` for hard-ceiling
1784
+ // tests — expect the packager to honour them as a strict cap). We only
1785
+ // clamp the value used for tier inference, so the subMode label stays sane.
1786
+ if (opts.explicitBudget != null && Number.isFinite(opts.explicitBudget)) {
1787
+ const n = Math.floor(opts.explicitBudget);
1788
+ const tierBound = Math.max(1000, Math.min(16000, n));
1789
+ const tier = tierBound >= 11000 ? 'xl' : tierBound >= 7000 ? 'full' : 'preview';
1790
+ return { tier, subMode: BUDGET_TIERS[tier].subMode, tokenBudget: n, reason: 'explicit_budget' };
1791
+ }
1792
+
1793
+ // Explicit tier flags — caller is asking for a specific budget.
1794
+ if (format === 'agent_preview') {
1795
+ return { tier: 'preview', ...BUDGET_TIERS.preview, tokenBudget: BUDGET_TIERS.preview.budget, reason: 'explicit_preview' };
1796
+ }
1797
+ if (format === 'agent_full') {
1798
+ return { tier: 'full', ...BUDGET_TIERS.full, tokenBudget: BUDGET_TIERS.full.budget, reason: 'explicit_full' };
1799
+ }
1800
+ if (format === 'agent_full_xl') {
1801
+ return { tier: 'xl', ...BUDGET_TIERS.xl, tokenBudget: BUDGET_TIERS.xl.budget, reason: 'explicit_xl' };
1802
+ }
1803
+
1804
+ // Auto-pick: format === 'agent' (or anything unrecognised — defensive).
1805
+ // Design target: preview fires on ~99% of queries; XL+FULL combined ~1-5%.
1806
+ const { numResults, dominance, top1Tokens } = signals || {};
1807
+ const N = Number(numResults) || 0;
1808
+ const D = Number.isFinite(dominance) ? dominance : 0;
1809
+ const T1 = Number(top1Tokens) || 0;
1810
+
1811
+ const pick = (tier, reason) => ({
1812
+ tier,
1813
+ subMode: BUDGET_TIERS[tier].subMode,
1814
+ tokenBudget: BUDGET_TIERS[tier].budget,
1815
+ reason,
1816
+ });
1817
+
1818
+ // Tight thresholds. Both upgrade paths require strong, hard-to-fake signals.
1819
+ // XL_TOP1_TOKENS = ~267 lines × 9 t/line. Below this, top-1's render fits
1820
+ // inside the 2000-token per-result preview cap and XL
1821
+ // adds no usable budget.
1822
+ // XL_DOMINANCE = 2.5×. We need the dominance gate to FIRE at
1823
+ // allocation time (allocateBudget L1448-1454 needs
1824
+ // top1 ≥ 2 × top2); we add headroom (2.5 vs 2.0) so
1825
+ // we don't pick XL on borderline cases that the gate
1826
+ // might miss.
1827
+ // FULL_MIN_N = 10 results. Fewer than this and the agent can
1828
+ // re-read rank 2 (which is shown as a preview anyway).
1829
+ // FULL_MAX_DOM = 1.05. Strictly tied cluster — top-1 is at most 5%
1830
+ // ahead of top-2. Anything wider and the agent
1831
+ // can treat top-1 as the answer.
1832
+ // FULL_MIN_TOP1 = 600 t (~67 lines). If top-1 is tiny, ranks 2-3
1833
+ // being expanded buys nothing — preview's signature
1834
+ // already shows everything.
1835
+ const XL_TOP1_TOKENS = 2400;
1836
+ const XL_DOMINANCE = 2.5;
1837
+ const FULL_MIN_N = 10;
1838
+ const FULL_MAX_DOM = 1.05;
1839
+ const FULL_MIN_TOP1 = 600;
1840
+
1841
+ if (N === 0) return pick('preview', 'auto_empty');
1842
+
1843
+ // XL path: huge top-1 that dominates. Single-result counts as "dominates"
1844
+ // (no top-2 to compete). Both branches gate on T1 >= XL_TOP1_TOKENS so we
1845
+ // never pick XL when extra space would go unused.
1846
+ if (T1 >= XL_TOP1_TOKENS) {
1847
+ if (N === 1) return pick('xl', 'auto_xl_single_huge');
1848
+ if (D >= XL_DOMINANCE) return pick('xl', 'auto_xl_dominant_huge_top1');
1849
+ }
1850
+
1851
+ // FULL path: tightly-clustered multi-result set with non-trivial chunks.
1852
+ // All three conditions must hold — comparison-shaped queries with many
1853
+ // tied alternatives are the narrow profile where rank 2/3 full bodies
1854
+ // pay off. Single dominant answers and small clusters stay on preview.
1855
+ if (N >= FULL_MIN_N && D < FULL_MAX_DOM && T1 >= FULL_MIN_TOP1) {
1856
+ return pick('full', 'auto_full_tight_cluster');
1857
+ }
1858
+
1859
+ // PREVIEW: default for ~99% of queries. The agent can always escalate
1860
+ // to full or xl with an explicit format flag if the answer needs more.
1861
+ return pick('preview', 'auto_preview_default');
1862
+ }
1863
+
1084
1864
  /**
1085
1865
  * Package ranked results into agent-mode context blocks.
1086
1866
  *
@@ -1119,28 +1899,86 @@ export function packageForAgent(rankedResults, searchStats, opts) {
1119
1899
  } = opts;
1120
1900
  const ablations = opts.ablations || new Set();
1121
1901
 
1122
- const subMode = resolveSubMode(formatOpt);
1123
- const defaultBudget = subMode === 'agent_full_xl' ? AGENT_FULL_XL_TOKEN_BUDGET
1124
- : subMode === 'agent_full' ? AGENT_FULL_TOKEN_BUDGET
1125
- : DEFAULT_TOKEN_BUDGET;
1126
- const tokenBudget = opts.tokenBudget ?? defaultBudget;
1902
+ // Auto-tier selection: pick preview / full / xl based on score-distribution
1903
+ // signals when format='agent'. Explicit format=='agent_preview|full|full_xl'
1904
+ // and explicit numeric tokenBudget remain as overrides. Mirrors trace's
1905
+ // adaptive selectBudget (core/graph/structural-context.js:37). See
1906
+ // selectAgentBudget() above for the decision tree.
1907
+ //
1908
+ // Disabled by 'no-auto-budget' ablation — falls back to the legacy
1909
+ // resolveSubMode mapping (which treats 'agent' as 'agent_preview').
1910
+ let subMode, tokenBudget, budgetReason, budgetSignals;
1911
+ if (ablations.has('no-auto-budget')) {
1912
+ subMode = resolveSubMode(formatOpt);
1913
+ const defaultBudget = subMode === 'agent_full_xl' ? AGENT_FULL_XL_TOKEN_BUDGET
1914
+ : subMode === 'agent_full' ? AGENT_FULL_TOKEN_BUDGET
1915
+ : DEFAULT_TOKEN_BUDGET;
1916
+ tokenBudget = opts.tokenBudget ?? defaultBudget;
1917
+ budgetReason = 'ablation_no_auto_budget';
1918
+ budgetSignals = null;
1919
+ } else {
1920
+ budgetSignals = computeBudgetSignals(rankedResults, searchStats);
1921
+ const pick = selectAgentBudget(formatOpt, budgetSignals, {
1922
+ explicitBudget: opts.tokenBudget,
1923
+ });
1924
+ subMode = pick.subMode;
1925
+ tokenBudget = pick.tokenBudget;
1926
+ budgetReason = pick.reason;
1927
+ }
1127
1928
 
1128
1929
  const start = performance.now();
1129
1930
  const fileCache = new Map();
1130
1931
 
1932
+ // Locality clustering: pull up to two non-overlapping companion results
1933
+ // from the SAME file as top-1 (when their score is competitive, ≥ top-1/3)
1934
+ // ahead of unrelated higher-scoring distractors. This addresses the
1935
+ // dominant agent-bench loss pattern where the right helper symbol existed
1936
+ // in the ranked list at rank 4 but got demoted to summary because a
1937
+ // tangential file scored slightly higher. Top-1 is never moved.
1938
+ // Disabled by 'no-locality-cluster' ablation.
1939
+ let workingResults = rankedResults;
1940
+ if (!ablations.has('no-locality-cluster') && rankedResults.length >= 3) {
1941
+ const top = rankedResults[0];
1942
+ const topFile = top?.metadata?.file || top?.file;
1943
+ const topScore = top?.score || top?.lateInteractionScore || 0;
1944
+ if (topFile && topScore > 0) {
1945
+ const sameFile = [];
1946
+ const other = [];
1947
+ for (let i = 1; i < rankedResults.length; i++) {
1948
+ const r = rankedResults[i];
1949
+ const f = r.metadata?.file || r.file;
1950
+ const s = r.score || r.lateInteractionScore || 0;
1951
+ // Don't pull up overlapping same-file ranges (those are diversity dups).
1952
+ const ts = top.metadata?.startLine || top.startLine;
1953
+ const te = top.metadata?.endLine || top.endLine;
1954
+ const rs = r.metadata?.startLine || r.startLine;
1955
+ const re = r.metadata?.endLine || r.endLine;
1956
+ const overlapsTop = (rs != null && re != null && ts != null && te != null)
1957
+ && rs <= te + 10 && re >= ts - 10;
1958
+ if (f === topFile && s >= topScore / 3 && !overlapsTop) sameFile.push(r);
1959
+ else other.push(r);
1960
+ }
1961
+ // Promote up to 2 companion results into ranks 2 and 3, push the
1962
+ // rest behind. We deliberately keep `score` untouched so callers
1963
+ // can still inspect the original ranking signal.
1964
+ workingResults = [top, ...sameFile.slice(0, 2), ...other, ...sameFile.slice(2)]
1965
+ .map((r, idx) => ({ ...r, rank: idx + 1 }));
1966
+ }
1967
+ }
1968
+
1131
1969
  // Diversity: demote results that cluster in same file+region as a higher-ranked result.
1132
1970
  // Skipped when 'no-diversity' ablation is active.
1133
1971
  // This prevents wasting preview/full budget on near-duplicate chunks from the same symbol.
1134
1972
  const diversityDemotions = new Set();
1135
1973
  if (ablations.has('no-diversity')) { /* skip diversity check */ }
1136
- else for (let i = 0; i < Math.min(rankedResults.length, 5); i++) {
1137
- const ri = rankedResults[i];
1974
+ else for (let i = 0; i < Math.min(workingResults.length, 5); i++) {
1975
+ const ri = workingResults[i];
1138
1976
  const fi = ri.metadata?.file || ri.file;
1139
1977
  const si = ri.metadata?.startLine || ri.startLine;
1140
1978
  const ei = ri.metadata?.endLine || ri.endLine;
1141
- for (let j = i + 1; j < Math.min(rankedResults.length, 5); j++) {
1979
+ for (let j = i + 1; j < Math.min(workingResults.length, 5); j++) {
1142
1980
  if (diversityDemotions.has(j)) continue;
1143
- const rj = rankedResults[j];
1981
+ const rj = workingResults[j];
1144
1982
  const fj = rj.metadata?.file || rj.file;
1145
1983
  if (fi !== fj) continue;
1146
1984
  const sj = rj.metadata?.startLine || rj.startLine;
@@ -1159,12 +1997,12 @@ export function packageForAgent(rankedResults, searchStats, opts) {
1159
1997
  : {
1160
1998
  ...(searchStats?.grepMatches != null ? { grepMatches: searchStats.grepMatches } : {}),
1161
1999
  ...(searchStats?.candidatePoolSize != null ? { candidatePoolSize: searchStats.candidatePoolSize } : {}),
1162
- results: rankedResults,
2000
+ results: workingResults,
1163
2001
  };
1164
- const allocations = allocateBudget(tokenBudget, rankedResults.length, subMode, budgetContext);
2002
+ const allocations = allocateBudget(tokenBudget, workingResults.length, subMode, budgetContext);
1165
2003
 
1166
2004
  // Compute confidence from ranked results (Fix #4: regex selectivity included)
1167
- const confidenceInfo = computeConfidence(rankedResults, searchStats);
2005
+ const confidenceInfo = computeConfidence(workingResults, searchStats);
1168
2006
 
1169
2007
  // Shared staleness cache — db mtime is the same for all results in one search.
1170
2008
  // Avoids repeated statSync calls (Fix D: perf).
@@ -1173,8 +2011,8 @@ export function packageForAgent(rankedResults, searchStats, opts) {
1173
2011
  let tokensUsed = 0;
1174
2012
  const agentResults = [];
1175
2013
 
1176
- for (let i = 0; i < rankedResults.length; i++) {
1177
- const result = rankedResults[i];
2014
+ for (let i = 0; i < workingResults.length; i++) {
2015
+ const result = workingResults[i];
1178
2016
  const allocation = allocations[i] || { presentation: 'summary', tokenCap: 0 };
1179
2017
  const meta = result.metadata || {};
1180
2018
  const filePath = meta.file || result.file;
@@ -1370,36 +2208,108 @@ export function packageForAgent(rankedResults, searchStats, opts) {
1370
2208
  }
1371
2209
  }
1372
2210
 
2211
+ // Phase 6: Graph-neighbour reservation (top-1 only). The pack reserves
2212
+ // up to 20% of the budget (capped at 1000 tokens, floored at 600 when
2213
+ // the budget allows) for a dedicated 1-hop neighbours tier. Surfaced
2214
+ // as `agentResult.neighbors`; rendered for the agent by the CLI shim.
2215
+ // Disabled by 'no-graph-neighbors' ablation. Always opt-OUT, never
2216
+ // model-specific — the rendering is plain text.
2217
+ if (i === 0
2218
+ && !ablations.has('no-graph-neighbors')
2219
+ && expansion.entityId
2220
+ && codeGraphRepo) {
2221
+ // Reserve fraction depends on subMode but never above 20% / 1000 toks.
2222
+ // Stretches the floor for full+xl so the top-1 actually gets useful
2223
+ // neighbour evidence even when the chunk consumed most of the budget.
2224
+ const reserveFraction = subMode === 'agent_full_xl' ? 0.20
2225
+ : subMode === 'agent_full' ? 0.18
2226
+ : 0.15;
2227
+ const headroom = Math.max(0, tokenBudget - tokensUsed);
2228
+ const desired = Math.min(1000, Math.floor(tokenBudget * reserveFraction));
2229
+ const tokenCap = Math.min(headroom, desired);
2230
+ if (tokenCap >= 80) {
2231
+ // Build skip set from ALL ranked locations that will be shown
2232
+ // with code (full / preview tiers). Summary-tier rows are not
2233
+ // skipped — they convey no code and the neighbour tier still
2234
+ // adds value (edge type + direction). This avoids the
2235
+ // pathological case (validation-pipeline) where every caller is
2236
+ // already in the pack as a summary row, leaving the agent with
2237
+ // file:line refs but no edge attribution.
2238
+ const skipKeys = new Set();
2239
+ for (let j = 0; j < workingResults.length; j++) {
2240
+ const tier = allocations[j]?.presentation;
2241
+ if (tier !== 'full' && tier !== 'preview') continue;
2242
+ const r = workingResults[j];
2243
+ const f = r.metadata?.file || r.file;
2244
+ const s = r.metadata?.startLine || r.startLine;
2245
+ const e = r.metadata?.endLine || r.endLine;
2246
+ if (f && s != null && e != null) {
2247
+ skipKeys.add(`${f}|${s}|${e}`);
2248
+ }
2249
+ }
2250
+ skipKeys.add(`${filePath}|${expansion.startLine}|${expansion.endLine}`);
2251
+ const neighbours = renderGraphNeighbors({
2252
+ codeGraphRepo,
2253
+ entity: {
2254
+ id: expansion.entityId,
2255
+ filePath,
2256
+ startLine: expansion.startLine,
2257
+ endLine: expansion.endLine,
2258
+ name: expansion.symbol,
2259
+ type: expansion.symbolType,
2260
+ },
2261
+ skipKeys,
2262
+ tokenCap,
2263
+ // Pass the loaded code so the neighbour tier can also surface
2264
+ // referenced TYPE definitions (struct/enum/...) discovered by
2265
+ // name from the body — fills the gap left by relationship-only
2266
+ // edges (e.g. Go method receiver fields with custom types).
2267
+ body: code,
2268
+ });
2269
+ if (neighbours) {
2270
+ agentResult.neighbors = neighbours;
2271
+ tokensUsed += neighbours.tokens;
2272
+ }
2273
+ }
2274
+ }
2275
+
1373
2276
  agentResults.push(agentResult);
1374
2277
  }
1375
2278
 
1376
2279
  const packagingMs = Math.round(performance.now() - start);
1377
2280
 
1378
- // Fix #7: Sufficiency signal for top-1 result
2281
+ // Sufficiency signal for top-1. Tightened in 2026-05: requires resolution
2282
+ // (header_resolved OR neighbors_present OR self_contained_strict) instead
2283
+ // of the old "complete_symbol + high_confidence" rule.
1379
2284
  let sufficient = false;
1380
2285
  let sufficiencyReasons = [];
2286
+ let unresolvedExternalCount = 0;
1381
2287
  if (agentResults.length > 0 && agentResults[0].code) {
1382
2288
  const sufficiency = computeSufficiency(agentResults[0], confidenceInfo);
1383
2289
  sufficient = sufficiency.sufficient;
1384
2290
  sufficiencyReasons = sufficiency.reasons;
2291
+ unresolvedExternalCount = sufficiency.unresolvedExternalCount || 0;
1385
2292
  }
1386
2293
 
1387
2294
  return {
1388
2295
  query,
1389
2296
  regex,
1390
2297
  mode: modeOpt || searchStats?.path || 'pattern',
1391
- totalResults: rankedResults.length,
2298
+ totalResults: workingResults.length,
1392
2299
  latencyMs: searchStats?.total_ms || 0,
1393
2300
  packagingMs,
1394
2301
 
1395
2302
  format: 'agent',
1396
2303
  subMode,
1397
2304
  tokenBudget,
2305
+ budgetReason,
2306
+ budgetSignals,
1398
2307
  tokensUsed,
1399
2308
  confidence: confidenceInfo.confidence,
1400
2309
  confidenceReason: confidenceInfo.confidenceReason,
1401
2310
  sufficient,
1402
2311
  sufficiencyReasons,
2312
+ unresolvedExternalCount,
1403
2313
 
1404
2314
  results: agentResults,
1405
2315
  };