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.
- package/core/cli.js +24 -3
- package/core/graph/graph-expansion.js +215 -36
- package/core/graph/graph-extractor.js +196 -11
- package/core/graph/graph-search.js +395 -92
- package/core/graph/hcgs-generator.js +2 -1
- package/core/graph/index.js +2 -0
- package/core/graph/repo-map.js +28 -6
- package/core/graph/structural-answer-cues.js +168 -0
- package/core/graph/structural-callsite-hints.js +40 -0
- package/core/graph/structural-context-format.js +40 -0
- package/core/graph/structural-context.js +450 -0
- package/core/graph/structural-forward-push.js +156 -0
- package/core/graph/structural-header-context.js +19 -0
- package/core/graph/structural-importance.js +148 -0
- package/core/graph/structural-pagerank.js +197 -0
- package/core/graph/summary-manager.js +13 -9
- package/core/incremental-indexing/application/dirty-scan.mjs +236 -0
- package/core/incremental-indexing/application/file-watcher.mjs +197 -0
- package/core/incremental-indexing/application/maintenance-handlers.mjs +519 -0
- package/core/incremental-indexing/application/maintenance-worker.mjs +380 -0
- package/core/incremental-indexing/application/operator-cli.mjs +554 -0
- package/core/incremental-indexing/application/production-li-delta.mjs +192 -0
- package/core/incremental-indexing/application/production-reconciler-helpers.mjs +107 -0
- package/core/incremental-indexing/application/production-reconciler.mjs +583 -0
- package/core/incremental-indexing/application/reconciler.mjs +477 -0
- package/core/incremental-indexing/application/tombstone-injector.mjs +148 -0
- package/core/incremental-indexing/domain/chunk-identity.mjs +260 -0
- package/core/incremental-indexing/domain/encoder-deps.mjs +193 -0
- package/core/incremental-indexing/domain/encoder-input.mjs +225 -0
- package/core/incremental-indexing/domain/interval-autotune.mjs +255 -0
- package/core/incremental-indexing/domain/reconcile-counters.mjs +149 -0
- package/core/incremental-indexing/domain/watermark-scheduler.mjs +239 -0
- package/core/incremental-indexing/infrastructure/artifact-temp-sweep.mjs +163 -0
- package/core/incremental-indexing/infrastructure/baseline-readiness.mjs +121 -0
- package/core/incremental-indexing/infrastructure/dirty-set.mjs +233 -0
- package/core/incremental-indexing/infrastructure/graph-gc.mjs +314 -0
- package/core/incremental-indexing/infrastructure/hashing.mjs +298 -0
- package/core/incremental-indexing/infrastructure/hcgs-invalidation.mjs +182 -0
- package/core/incremental-indexing/infrastructure/li-segment-merge.mjs +278 -0
- package/core/incremental-indexing/infrastructure/li-segment-state.mjs +173 -0
- package/core/incremental-indexing/infrastructure/lockfile.mjs +119 -0
- package/core/incremental-indexing/infrastructure/maintenance-state-reader.mjs +283 -0
- package/core/incremental-indexing/infrastructure/manifest.mjs +194 -0
- package/core/incremental-indexing/infrastructure/path-filter.mjs +190 -0
- package/core/incremental-indexing/infrastructure/reader-heartbeat.mjs +201 -0
- package/core/incremental-indexing/infrastructure/schema-migrations.mjs +257 -0
- package/core/incremental-indexing/infrastructure/sparse-gram-delta.mjs +335 -0
- package/core/incremental-indexing/infrastructure/sqlite-fts5.mjs +176 -0
- package/core/incremental-indexing/infrastructure/staleness-display.mjs +105 -0
- package/core/incremental-indexing/infrastructure/tombstone-bitmap.mjs +234 -0
- package/core/incremental-indexing/infrastructure/vector-delta-writer.mjs +359 -0
- package/core/incremental-indexing/infrastructure/vector-gc.mjs +133 -0
- package/core/incremental-indexing/infrastructure/worktree-stamp.mjs +155 -0
- package/core/incremental-indexing/infrastructure/wsl2-detect.mjs +115 -0
- package/core/indexing/admission-policy.js +139 -0
- package/core/indexing/artifact-builder.js +29 -12
- package/core/indexing/ast-chunker.js +107 -30
- package/core/indexing/dedup/exemplar-selector.js +19 -1
- package/core/indexing/gitignore-filter.js +223 -0
- package/core/indexing/incremental-tracker.js +99 -30
- package/core/indexing/index-codebase-v21.js +6 -5
- package/core/indexing/index-maintainer.mjs +698 -6
- package/core/indexing/indexer-ann.js +99 -15
- package/core/indexing/indexer-build.js +158 -45
- package/core/indexing/indexer-empty-baseline.js +80 -0
- package/core/indexing/indexer-manifest.js +66 -0
- package/core/indexing/indexer-phases.js +56 -23
- package/core/indexing/indexer-sparse-gram.js +54 -13
- package/core/indexing/indexer-utils.js +26 -208
- package/core/indexing/indexing-file-policy.js +32 -7
- package/core/indexing/maintainer-launcher.mjs +137 -0
- package/core/indexing/merkle-tracker.js +251 -244
- package/core/indexing/model-pool.js +46 -5
- package/core/infrastructure/code-graph-repository.js +758 -6
- package/core/infrastructure/code-graph-visibility.js +157 -0
- package/core/infrastructure/codebase-repository.js +100 -13
- package/core/infrastructure/config/search.js +1 -1
- package/core/infrastructure/db-utils.js +118 -0
- package/core/infrastructure/dedup-hashing.js +10 -13
- package/core/infrastructure/hardware-capability.js +17 -7
- package/core/infrastructure/index.js +8 -2
- package/core/infrastructure/language-patterns/maps.js +4 -1
- package/core/infrastructure/language-patterns/registry-core.js +56 -17
- package/core/infrastructure/language-patterns/registry-object-oriented.js +12 -5
- package/core/infrastructure/language-patterns.js +69 -0
- package/core/infrastructure/model-registry.js +20 -0
- package/core/infrastructure/native-inference.js +7 -12
- package/core/infrastructure/native-resolver.js +52 -37
- package/core/infrastructure/native-sparse-gram.js +261 -20
- package/core/infrastructure/native-tokenizer.js +6 -15
- package/core/infrastructure/simd-distance.js +10 -16
- package/core/infrastructure/sparse-gram-delta-reader.js +76 -0
- package/core/infrastructure/structural-alias-resolver.js +122 -0
- package/core/infrastructure/structural-candidate-ranker.js +34 -0
- package/core/infrastructure/structural-context-repository.js +472 -0
- package/core/infrastructure/structural-context-utils.js +51 -0
- package/core/infrastructure/structural-graph-signals.js +121 -0
- package/core/infrastructure/structural-qualified-resolution.js +15 -0
- package/core/infrastructure/structural-source-definitions.js +100 -0
- package/core/infrastructure/tombstone-bitmap-reader.js +139 -0
- package/core/infrastructure/tree-sitter-provider.js +811 -37
- package/core/prompt-optimization/data/p7-final/sweet-search-system-prompt.md +50 -0
- package/core/query/query-router.js +55 -5
- package/core/ranking/file-kind-ranking.js +2192 -15
- package/core/ranking/late-interaction-index.js +87 -12
- package/core/search/cli-decoration.js +290 -0
- package/core/search/context-expander.js +988 -78
- package/core/search/index.js +1 -0
- package/core/search/output-policy.js +275 -0
- package/core/search/search-anchor.js +499 -0
- package/core/search/search-boost.js +93 -1
- package/core/search/search-cli.js +61 -204
- package/core/search/search-hybrid.js +250 -10
- package/core/search/search-pattern-chunks.js +57 -8
- package/core/search/search-pattern-planner.js +68 -9
- package/core/search/search-pattern-prefilter.js +30 -10
- package/core/search/search-pattern-ripgrep.js +40 -4
- package/core/search/search-pattern-sparse-overlay.js +256 -0
- package/core/search/search-pattern.js +117 -29
- package/core/search/search-postprocess.js +479 -5
- package/core/search/search-read-semantic.js +260 -23
- package/core/search/search-read.js +82 -64
- package/core/search/search-reader-pin.js +71 -0
- package/core/search/search-rrf.js +279 -0
- package/core/search/search-semantic.js +110 -5
- package/core/search/search-server.js +130 -57
- package/core/search/search-trace.js +107 -0
- package/core/search/server-identity.js +93 -0
- package/core/search/session-daemon-prewarm.mjs +33 -10
- package/core/search/sweet-search.js +399 -7
- package/core/skills/sweet-index/SKILL.md +8 -6
- package/core/vector-store/binary-hnsw-index.js +194 -30
- package/core/vector-store/float-vector-store.js +96 -6
- package/core/vector-store/hnsw-index.js +220 -49
- package/eval/agent-read-workflows/bin/_ss-helpers.mjs +471 -0
- package/eval/agent-read-workflows/bin/ss-find +15 -0
- package/eval/agent-read-workflows/bin/ss-grep +12 -0
- package/eval/agent-read-workflows/bin/ss-read +14 -0
- package/eval/agent-read-workflows/bin/ss-search +18 -0
- package/eval/agent-read-workflows/bin/ss-semantic +12 -0
- package/eval/agent-read-workflows/bin/ss-trace +11 -0
- package/mcp/read-tool.js +109 -0
- package/mcp/server.js +55 -15
- package/mcp/tool-handlers.js +14 -124
- package/mcp/trace-tool.js +81 -0
- package/package.json +25 -10
- package/scripts/hooks/intercept-read.mjs +55 -0
- package/scripts/hooks/remind-tools.mjs +40 -0
- package/scripts/init.js +698 -54
- package/scripts/inject-agent-instructions.js +431 -0
- package/scripts/install-prompt-reminders.js +188 -0
- package/scripts/install-tool-enforcement.js +220 -0
- package/scripts/smoke-test.js +12 -9
- package/scripts/uninstall.js +276 -18
- 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:
|
|
175
|
+
startLine: triviaStart0,
|
|
154
176
|
endLine: origEnd,
|
|
155
|
-
expanded:
|
|
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
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
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())
|
|
639
|
-
} else if (/^\s*import\s+"/.test(line)) {
|
|
640
|
-
|
|
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
|
|
793
|
+
return out;
|
|
644
794
|
}
|
|
645
795
|
|
|
646
796
|
if (lang === 'python') {
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
654
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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?
|
|
1116
|
+
* enough information to answer the query?
|
|
835
1117
|
*
|
|
836
|
-
*
|
|
837
|
-
*
|
|
838
|
-
*
|
|
839
|
-
*
|
|
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
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
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
|
-
|
|
849
|
-
const isComplete = topResult.symbol &&
|
|
1144
|
+
const isComplete = !!(topResult.symbol &&
|
|
850
1145
|
topResult.presentation === 'full' &&
|
|
851
|
-
!topResult.code?.includes('// ... (');
|
|
852
|
-
if (isComplete)
|
|
853
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
871
|
-
|
|
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
|
-
*
|
|
1073
|
-
*
|
|
1074
|
-
* '
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
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(
|
|
1137
|
-
const ri =
|
|
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(
|
|
1979
|
+
for (let j = i + 1; j < Math.min(workingResults.length, 5); j++) {
|
|
1142
1980
|
if (diversityDemotions.has(j)) continue;
|
|
1143
|
-
const rj =
|
|
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:
|
|
2000
|
+
results: workingResults,
|
|
1163
2001
|
};
|
|
1164
|
-
const allocations = allocateBudget(tokenBudget,
|
|
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(
|
|
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 <
|
|
1177
|
-
const result =
|
|
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
|
-
//
|
|
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:
|
|
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
|
};
|