sweet-search 2.6.13 → 2.6.15
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/infrastructure/code-graph-repository.js +71 -0
- package/core/search/context-expander.js +148 -18
- package/core/search/query-sufficiency.js +341 -0
- package/core/search/search-read.js +108 -1
- package/eval/agent-read-workflows/bin/_ss-argparse.mjs +15 -0
- package/eval/agent-read-workflows/bin/_ss-helpers.mjs +23 -5
- package/mcp/read-tool.js +10 -0
- package/mcp/tool-handlers.js +12 -1
- package/package.json +8 -8
|
@@ -312,6 +312,77 @@ export class CodeGraphRepository {
|
|
|
312
312
|
}
|
|
313
313
|
}
|
|
314
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Find the named entities immediately ADJACENT to a shown line window in
|
|
317
|
+
* the same file: the nearest ones lying fully ABOVE the window
|
|
318
|
+
* (end_line < startLine) and the nearest ones starting BELOW it
|
|
319
|
+
* (start_line > endLine). Powers the agent-format same-file span map —
|
|
320
|
+
* the pointer that tells an agent which sibling symbols sit just outside
|
|
321
|
+
* the chunk window it was shown.
|
|
322
|
+
*
|
|
323
|
+
* Entities enclosing the window (e.g. the surrounding class) never match
|
|
324
|
+
* either predicate, so results are true siblings. Inner entities contained
|
|
325
|
+
* in an already-kept row (closures inside the adjacent function) are
|
|
326
|
+
* filtered so the map names outermost neighbors only.
|
|
327
|
+
*
|
|
328
|
+
* @param {string} filePath
|
|
329
|
+
* @param {number} startLine - first shown line (1-based)
|
|
330
|
+
* @param {number} endLine - last shown line (1-based)
|
|
331
|
+
* @param {{ perSide?: number }} [opts] - max entities per side (default 2)
|
|
332
|
+
* @returns {{ above: Array<{id,name,type,startLine,endLine,parentClass}>,
|
|
333
|
+
* below: Array<{id,name,type,startLine,endLine,parentClass}> }}
|
|
334
|
+
*/
|
|
335
|
+
findAdjacentEntities(filePath, startLine, endLine, opts = {}) {
|
|
336
|
+
const perSide = opts.perSide ?? 2;
|
|
337
|
+
const empty = { above: [], below: [] };
|
|
338
|
+
if (!filePath || !Number.isFinite(startLine) || !Number.isFinite(endLine)) return empty;
|
|
339
|
+
const db = this._open();
|
|
340
|
+
if (!db) return empty;
|
|
341
|
+
const mapRow = row => ({
|
|
342
|
+
id: row.id,
|
|
343
|
+
name: row.name,
|
|
344
|
+
type: row.type,
|
|
345
|
+
startLine: row.start_line,
|
|
346
|
+
endLine: row.end_line,
|
|
347
|
+
parentClass: row.parent_class || null,
|
|
348
|
+
});
|
|
349
|
+
// Keep outermost neighbors: drop rows contained in an already-kept row.
|
|
350
|
+
const keepOutermost = rows => {
|
|
351
|
+
const kept = [];
|
|
352
|
+
for (const row of rows) {
|
|
353
|
+
if (kept.some(k => row.start_line >= k.start_line && row.end_line <= k.end_line)) continue;
|
|
354
|
+
kept.push(row);
|
|
355
|
+
if (kept.length >= perSide) break;
|
|
356
|
+
}
|
|
357
|
+
return kept.map(mapRow);
|
|
358
|
+
};
|
|
359
|
+
try {
|
|
360
|
+
const aboveRows = prepareCached(db, `
|
|
361
|
+
SELECT id, name, type, start_line, end_line, parent_class
|
|
362
|
+
FROM entities
|
|
363
|
+
WHERE file_path = ?
|
|
364
|
+
AND end_line < ?
|
|
365
|
+
AND name IS NOT NULL AND name != ''
|
|
366
|
+
AND ${this._entityVisibilitySql(db)}
|
|
367
|
+
ORDER BY end_line DESC, start_line ASC
|
|
368
|
+
LIMIT 8
|
|
369
|
+
`).all(filePath, startLine, ...this._entityVisibilityParams(db));
|
|
370
|
+
const belowRows = prepareCached(db, `
|
|
371
|
+
SELECT id, name, type, start_line, end_line, parent_class
|
|
372
|
+
FROM entities
|
|
373
|
+
WHERE file_path = ?
|
|
374
|
+
AND start_line > ?
|
|
375
|
+
AND name IS NOT NULL AND name != ''
|
|
376
|
+
AND ${this._entityVisibilitySql(db)}
|
|
377
|
+
ORDER BY start_line ASC, end_line DESC
|
|
378
|
+
LIMIT 8
|
|
379
|
+
`).all(filePath, endLine, ...this._entityVisibilityParams(db));
|
|
380
|
+
return { above: keepOutermost(aboveRows), below: keepOutermost(belowRows) };
|
|
381
|
+
} catch {
|
|
382
|
+
return empty;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
315
386
|
/**
|
|
316
387
|
* Get a single entity by id, with file:line metadata.
|
|
317
388
|
*
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { readFileRange } from './search-pattern-chunks.js';
|
|
22
|
+
import { computeSufficiencyVerdict } from './query-sufficiency.js';
|
|
22
23
|
import { statSync } from 'fs';
|
|
23
24
|
import path from 'path';
|
|
24
25
|
|
|
@@ -1130,15 +1131,34 @@ function unresolvedExternalRefs(code, ownSymbolName, headerContext, neighborsRen
|
|
|
1130
1131
|
* or in the surfaced neighbours list
|
|
1131
1132
|
* - high_confidence : score gap puts top-1 well ahead of top-2
|
|
1132
1133
|
*
|
|
1133
|
-
*
|
|
1134
|
-
*
|
|
1135
|
-
*
|
|
1134
|
+
* Query-conditioned rule (July 2026 — full200 bench forensics showed the
|
|
1135
|
+
* structural rule mislabels systemically: `confidence=low sufficient=YES`
|
|
1136
|
+
* was the modal trailer, 156×, and reasons were structural in ~all cases):
|
|
1137
|
+
* the verdict is now 3-valued and REQUIRES positive query-match evidence
|
|
1138
|
+
* in top-1 (see core/search/query-sufficiency.js):
|
|
1139
|
+
*
|
|
1140
|
+
* yes := strong query evidence (exact anchor hit or high subtoken
|
|
1141
|
+
* overlap) AND (confidence=high OR (confidence=medium AND
|
|
1142
|
+
* structural resolution))
|
|
1143
|
+
* no := no results, or a query with real anchors finding nothing
|
|
1144
|
+
* in top-1 (confirmed absence)
|
|
1145
|
+
* unknown := everything ambiguous (incl. the old structural-only YES,
|
|
1146
|
+
* now reported as reason=well_formed_only)
|
|
1147
|
+
*
|
|
1148
|
+
* Structural facts (complete_symbol / header_resolved / neighbors_present /
|
|
1149
|
+
* self_contained_strict) are kept as diagnostic reasons and as the
|
|
1150
|
+
* resolution gate for medium-confidence YES — they can no longer produce
|
|
1151
|
+
* YES on their own. `sufficient` (boolean) stays = (verdict === 'yes') for
|
|
1152
|
+
* every structured consumer (MCP zod schema, bench counters).
|
|
1136
1153
|
*
|
|
1137
1154
|
* @param {object} topResult
|
|
1138
1155
|
* @param {{ confidence: string }} confidenceInfo
|
|
1139
|
-
* @
|
|
1156
|
+
* @param {{ query?: string, regex?: string, lowerResults?: object[] }} [queryContext]
|
|
1157
|
+
* @returns {{ sufficient: boolean, verdict: 'yes'|'no'|'unknown',
|
|
1158
|
+
* sufficiencyReason: string, reasons: string[],
|
|
1159
|
+
* unresolvedExternalCount: number, evidence: object|null }}
|
|
1140
1160
|
*/
|
|
1141
|
-
export function computeSufficiency(topResult, confidenceInfo) {
|
|
1161
|
+
export function computeSufficiency(topResult, confidenceInfo, queryContext = {}) {
|
|
1142
1162
|
const reasons = [];
|
|
1143
1163
|
|
|
1144
1164
|
const isComplete = !!(topResult.symbol &&
|
|
@@ -1168,16 +1188,31 @@ export function computeSufficiency(topResult, confidenceInfo) {
|
|
|
1168
1188
|
|
|
1169
1189
|
if (confidenceInfo?.confidence === 'high') reasons.push('high_confidence');
|
|
1170
1190
|
|
|
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
1191
|
const hasResolution = hasHeader || hasNeighbors || reasons.includes('self_contained_strict');
|
|
1177
|
-
const hasSpecificity = confidenceInfo?.confidence === 'high' || hasHeader || hasNeighbors;
|
|
1178
|
-
const sufficient = isComplete && hasResolution && hasSpecificity;
|
|
1179
1192
|
|
|
1180
|
-
|
|
1193
|
+
// Query-conditioned verdict: structural facts are the resolution gate,
|
|
1194
|
+
// never the whole answer. See query-sufficiency.js for the fusion rule.
|
|
1195
|
+
const { verdict, reason: sufficiencyReason, evidence } = computeSufficiencyVerdict({
|
|
1196
|
+
topResult,
|
|
1197
|
+
confidenceInfo,
|
|
1198
|
+
query: queryContext.query || '',
|
|
1199
|
+
regex: queryContext.regex || '',
|
|
1200
|
+
structural: { isComplete, hasResolution },
|
|
1201
|
+
lowerResults: queryContext.lowerResults || [],
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
if (evidence?.exactHit) reasons.push('query_literal_matched');
|
|
1205
|
+
else if (evidence?.strength === 'strong') reasons.push('query_token_overlap');
|
|
1206
|
+
else if (evidence?.strength === 'none') reasons.push('no_query_evidence');
|
|
1207
|
+
|
|
1208
|
+
return {
|
|
1209
|
+
sufficient: verdict === 'yes',
|
|
1210
|
+
verdict,
|
|
1211
|
+
sufficiencyReason,
|
|
1212
|
+
reasons,
|
|
1213
|
+
unresolvedExternalCount: unresolvedCount,
|
|
1214
|
+
evidence,
|
|
1215
|
+
};
|
|
1181
1216
|
}
|
|
1182
1217
|
|
|
1183
1218
|
// =============================================================================
|
|
@@ -1896,6 +1931,40 @@ export function selectAgentBudget(format, signals, opts = {}) {
|
|
|
1896
1931
|
* 'no-syntax-expansion', 'no-header', 'no-diversity', 'no-adaptive-budget'
|
|
1897
1932
|
* @returns {object} Agent mode response
|
|
1898
1933
|
*/
|
|
1934
|
+
/**
|
|
1935
|
+
* Same-file span map (2026-07, within-file blind-spot fix). When the top-1
|
|
1936
|
+
* pack entry is a WINDOWED view of a file (kind chunk/sandwich/syntax — not
|
|
1937
|
+
* a fully-shown symbol) and the sufficiency verdict is not a clear YES, one
|
|
1938
|
+
* compact line names the sibling symbols immediately above/below the shown
|
|
1939
|
+
* window so the agent can sweep the fix surface instead of leaving the file
|
|
1940
|
+
* (the fhir/sushi-1175 miss shape: right file, bug 30 lines above the
|
|
1941
|
+
* window). Names+kinds+lines only — never bodies; ~25-45 tokens.
|
|
1942
|
+
*
|
|
1943
|
+
* @param {{ file:string, startLine:number, endLine:number }} top - top-1 agent result
|
|
1944
|
+
* @param {{ above:Array, below:Array }} adjacent - findAdjacentEntities() result
|
|
1945
|
+
* @returns {{ rendered:string, tokens:number, neighbors:Array }|null}
|
|
1946
|
+
*/
|
|
1947
|
+
export function buildSameFileMap(top, adjacent) {
|
|
1948
|
+
const above = adjacent?.above || [];
|
|
1949
|
+
const below = adjacent?.below || [];
|
|
1950
|
+
if (above.length === 0 && below.length === 0) return null;
|
|
1951
|
+
const shortType = t => (t === 'function' ? 'fn' : (t || 'sym'));
|
|
1952
|
+
const fmt = (e, pos) => `${e.name} (${shortType(e.type)} ${e.startLine}-${e.endLine} ${pos})`;
|
|
1953
|
+
const neighbors = [
|
|
1954
|
+
...above.map(e => ({ ...e, position: 'above' })),
|
|
1955
|
+
...below.map(e => ({ ...e, position: 'below' })),
|
|
1956
|
+
];
|
|
1957
|
+
const parts = [
|
|
1958
|
+
...above.map(e => fmt(e, 'above')),
|
|
1959
|
+
...below.map(e => fmt(e, 'below')),
|
|
1960
|
+
];
|
|
1961
|
+
// Placeholder-style drill-in hint (the v2.6.13 `ss-grep "<regex>" --in
|
|
1962
|
+
// <file>` convention, which agents demonstrably follow) — embedding the
|
|
1963
|
+
// live query costs ~25-30 tokens per pack for no extra signal.
|
|
1964
|
+
const rendered = `# same file: ${parts.join(' · ')} — sweep: ss-semantic ${top.file} "<query>"`;
|
|
1965
|
+
return { rendered, tokens: estimateTokens(rendered), neighbors };
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1899
1968
|
export function packageForAgent(rankedResults, searchStats, opts) {
|
|
1900
1969
|
const {
|
|
1901
1970
|
query,
|
|
@@ -2287,19 +2356,78 @@ export function packageForAgent(rankedResults, searchStats, opts) {
|
|
|
2287
2356
|
|
|
2288
2357
|
const packagingMs = Math.round(performance.now() - start);
|
|
2289
2358
|
|
|
2290
|
-
// Sufficiency signal for top-1.
|
|
2291
|
-
//
|
|
2292
|
-
//
|
|
2359
|
+
// Sufficiency signal for top-1. Query-conditioned since 2026-07: the
|
|
2360
|
+
// 3-valued verdict requires positive query-match evidence; structural
|
|
2361
|
+
// packaging facts remain as reasons + the medium-confidence resolution
|
|
2362
|
+
// gate. Runs for summary-only top-1 too (verdict can be 'no'/'unknown'
|
|
2363
|
+
// there; never 'yes' without code).
|
|
2293
2364
|
let sufficient = false;
|
|
2365
|
+
let sufficiencyVerdict = 'no';
|
|
2366
|
+
let sufficiencyReason = 'no_results';
|
|
2294
2367
|
let sufficiencyReasons = [];
|
|
2295
2368
|
let unresolvedExternalCount = 0;
|
|
2296
|
-
if (agentResults.length > 0
|
|
2297
|
-
const sufficiency = computeSufficiency(agentResults[0], confidenceInfo
|
|
2369
|
+
if (agentResults.length > 0) {
|
|
2370
|
+
const sufficiency = computeSufficiency(agentResults[0], confidenceInfo, {
|
|
2371
|
+
query,
|
|
2372
|
+
regex,
|
|
2373
|
+
// Code-bearing lower ranks (full/preview) — soften a false 'no' when
|
|
2374
|
+
// the pack's answer sits below top-1; never used to grant YES.
|
|
2375
|
+
lowerResults: agentResults.slice(1, 4).filter(r => r.code),
|
|
2376
|
+
});
|
|
2298
2377
|
sufficient = sufficiency.sufficient;
|
|
2378
|
+
sufficiencyVerdict = sufficiency.verdict;
|
|
2379
|
+
sufficiencyReason = sufficiency.sufficiencyReason;
|
|
2299
2380
|
sufficiencyReasons = sufficiency.reasons;
|
|
2300
2381
|
unresolvedExternalCount = sufficiency.unresolvedExternalCount || 0;
|
|
2301
2382
|
}
|
|
2302
2383
|
|
|
2384
|
+
// Phase 7: same-file span map (top-1 only). Emitted ONLY when the verdict
|
|
2385
|
+
// is not a clear YES (composes with the query-conditioned verdict: the map
|
|
2386
|
+
// supplies the "where to look next" exactly when the engine says "keep
|
|
2387
|
+
// looking") AND top-1 is a windowed view (kind != 'full' — fully-shown
|
|
2388
|
+
// symbols stay byte-identical) AND the line fits the remaining tier
|
|
2389
|
+
// budget (dropped on overflow, never a truncated pack). Its tokens are
|
|
2390
|
+
// counted inside tokensUsed. Disabled by 'no-same-file-map' ablation.
|
|
2391
|
+
if (!ablations.has('no-same-file-map')
|
|
2392
|
+
&& agentResults.length > 0
|
|
2393
|
+
&& sufficiencyVerdict !== 'yes'
|
|
2394
|
+
&& codeGraphRepo
|
|
2395
|
+
&& typeof codeGraphRepo.findAdjacentEntities === 'function') {
|
|
2396
|
+
const top = agentResults[0];
|
|
2397
|
+
const windowed = top.code
|
|
2398
|
+
&& top.presentation !== 'summary'
|
|
2399
|
+
&& top.expansionKind
|
|
2400
|
+
&& top.expansionKind !== 'full'
|
|
2401
|
+
&& top.file
|
|
2402
|
+
&& Number.isFinite(top.startLine)
|
|
2403
|
+
&& Number.isFinite(top.endLine);
|
|
2404
|
+
if (windowed) {
|
|
2405
|
+
let adjacent = null;
|
|
2406
|
+
try {
|
|
2407
|
+
adjacent = codeGraphRepo.findAdjacentEntities(top.file, top.startLine, top.endLine, { perSide: 2 });
|
|
2408
|
+
} catch { adjacent = null; }
|
|
2409
|
+
// Don't name neighbors whose code is ALREADY visible in the pack —
|
|
2410
|
+
// locality clustering pulls same-file companions to ranks 2-3; a map
|
|
2411
|
+
// entry for a shown span is pure noise.
|
|
2412
|
+
if (adjacent) {
|
|
2413
|
+
const shownSameFile = agentResults.filter(r =>
|
|
2414
|
+
r !== top && r.code && r.file === top.file
|
|
2415
|
+
&& Number.isFinite(r.startLine) && Number.isFinite(r.endLine));
|
|
2416
|
+
const overlapsShown = e => shownSameFile.some(r =>
|
|
2417
|
+
e.startLine <= r.endLine && e.endLine >= r.startLine);
|
|
2418
|
+
adjacent = {
|
|
2419
|
+
above: adjacent.above.filter(e => !overlapsShown(e)),
|
|
2420
|
+
below: adjacent.below.filter(e => !overlapsShown(e)),
|
|
2421
|
+
};
|
|
2422
|
+
}
|
|
2423
|
+
const map = adjacent ? buildSameFileMap(top, adjacent) : null;
|
|
2424
|
+
if (map && map.tokens <= Math.max(0, tokenBudget - tokensUsed)) {
|
|
2425
|
+
top.sameFile = map;
|
|
2426
|
+
tokensUsed += map.tokens;
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2303
2431
|
return {
|
|
2304
2432
|
query,
|
|
2305
2433
|
regex,
|
|
@@ -2317,6 +2445,8 @@ export function packageForAgent(rankedResults, searchStats, opts) {
|
|
|
2317
2445
|
confidence: confidenceInfo.confidence,
|
|
2318
2446
|
confidenceReason: confidenceInfo.confidenceReason,
|
|
2319
2447
|
sufficient,
|
|
2448
|
+
sufficiencyVerdict,
|
|
2449
|
+
sufficiencyReason,
|
|
2320
2450
|
sufficiencyReasons,
|
|
2321
2451
|
unresolvedExternalCount,
|
|
2322
2452
|
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query-conditioned sufficiency evidence (2026-07).
|
|
3
|
+
*
|
|
4
|
+
* Answers ONE question, cheaply: does the top-1 packaged hit contain
|
|
5
|
+
* positive evidence that it matches what the QUERY asked for? This is the
|
|
6
|
+
* signal the structural sufficiency rule (complete_symbol / header_resolved /
|
|
7
|
+
* neighbors_present) never looked at — a perfectly well-formed function that
|
|
8
|
+
* has nothing to do with the query must not earn `sufficient=YES`.
|
|
9
|
+
*
|
|
10
|
+
* Literature anchor: Sufficient Context (Joren et al., ICLR 2025) — a naive
|
|
11
|
+
* lexical answer-presence check alone reaches F1 0.81 / precision 0.87 on
|
|
12
|
+
* sufficiency; fusing it (AND, not weighted sum) with an independent
|
|
13
|
+
* confidence signal is the high-precision pattern. See
|
|
14
|
+
* eval/task-completion-bench/analysis/sufficiency-redesign-2026-07-06.md.
|
|
15
|
+
*
|
|
16
|
+
* Constraints honoured here:
|
|
17
|
+
* - engine-local, query-time only: pure string work over the already-
|
|
18
|
+
* retrieved top-1 (haystack capped), no I/O, no LLM, sub-millisecond;
|
|
19
|
+
* - only ever invoked from packageForAgent (agent formats) — NL/GCSN
|
|
20
|
+
* ranking paths never reach this module;
|
|
21
|
+
* - identifier-SHAPE heuristics, not capture-filtering stopword lists
|
|
22
|
+
* (CLAUDE.md rule): the only stopword set below is a stable ~30-entry
|
|
23
|
+
* English query-tokenization list.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Query-tokenization stopwords (standard English IR practice — the "OK to
|
|
27
|
+
// keep" category). Filters function words from the subtoken-overlap signal;
|
|
28
|
+
// never used to filter identifier captures.
|
|
29
|
+
const EVIDENCE_STOPWORDS = new Set([
|
|
30
|
+
'a', 'an', 'and', 'are', 'can', 'could', 'did', 'do', 'does', 'for',
|
|
31
|
+
'from', 'how', 'in', 'into', 'is', 'it', 'its', 'not', 'of', 'on', 'or',
|
|
32
|
+
'should', 'that', 'the', 'this', 'to', 'was', 'were', 'what', 'when',
|
|
33
|
+
'where', 'which', 'with',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
function envFloat(name, fallback) {
|
|
37
|
+
const raw = process.env[name];
|
|
38
|
+
if (raw == null || raw === '') return fallback;
|
|
39
|
+
const parsed = Number.parseFloat(raw);
|
|
40
|
+
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Thresholds are parameterized (env-overridable for offline calibration
|
|
44
|
+
// sweeps) — tuned on the task-level dev split of the full200 replay, never
|
|
45
|
+
// hand-picked against held-out. See the design note for the discipline.
|
|
46
|
+
export function sufficiencyThresholds() {
|
|
47
|
+
return {
|
|
48
|
+
overlapStrong: envFloat('SS_SUFF_OVERLAP_STRONG', 0.6),
|
|
49
|
+
overlapNone: envFloat('SS_SUFF_OVERLAP_NONE', 0.2),
|
|
50
|
+
minInformativeSubtokens: 2,
|
|
51
|
+
haystackCap: 32768,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const WORD_CHAR = /[A-Za-z0-9_$]/;
|
|
56
|
+
|
|
57
|
+
function isWordChar(ch) {
|
|
58
|
+
return ch != null && WORD_CHAR.test(ch);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Word-boundary substring check without compiling a regex per token.
|
|
63
|
+
* A match counts only when the char before and after the occurrence are
|
|
64
|
+
* not identifier chars ("Chunker" must not match inside "ChunkerParams"
|
|
65
|
+
* unless the query token IS ChunkerParams).
|
|
66
|
+
*/
|
|
67
|
+
export function containsToken(haystack, token, { caseSensitive = true } = {}) {
|
|
68
|
+
if (!haystack || !token) return false;
|
|
69
|
+
const hay = caseSensitive ? haystack : haystack.toLowerCase();
|
|
70
|
+
const needle = caseSensitive ? token : token.toLowerCase();
|
|
71
|
+
let from = 0;
|
|
72
|
+
while (from <= hay.length - needle.length) {
|
|
73
|
+
const idx = hay.indexOf(needle, from);
|
|
74
|
+
if (idx === -1) return false;
|
|
75
|
+
const before = idx > 0 ? hay[idx - 1] : null;
|
|
76
|
+
const after = idx + needle.length < hay.length ? hay[idx + needle.length] : null;
|
|
77
|
+
if (!isWordChar(before) && !isWordChar(after)) return true;
|
|
78
|
+
from = idx + 1;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Identifier-shape heuristic (shape, not a word list): a token reads as a
|
|
85
|
+
* code identifier when it carries structure natural language words don't —
|
|
86
|
+
* camelCase humps, underscores/$, digits mixed into letters, dotted/pathy
|
|
87
|
+
* compounds, or ALL_CAPS constants. Pure lowercase single words ("parse",
|
|
88
|
+
* "config") are NOT identifier-shaped; they flow into the softer
|
|
89
|
+
* subtoken-overlap signal instead.
|
|
90
|
+
*/
|
|
91
|
+
export function looksLikeIdentifierToken(token) {
|
|
92
|
+
if (!token || token.length < 3) return false;
|
|
93
|
+
if (/[_$]/.test(token)) return true;
|
|
94
|
+
if (/[a-z][A-Z]/.test(token)) return true; // camelCase hump
|
|
95
|
+
if (/[A-Za-z]\d|\d[A-Za-z]/.test(token)) return true; // letters+digits
|
|
96
|
+
if (/[.:/\\-]/.test(token) && /[A-Za-z]{2,}/.test(token)) return true; // dotted/pathy
|
|
97
|
+
if (/^[A-Z][A-Z0-9]{2,}$/.test(token)) return true; // SCREAMING caps
|
|
98
|
+
if (/^[A-Z][a-z]+[A-Z]/.test(token)) return true; // PascalCase
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pull literal runs out of a user regex (ss-find / ss-grep patterns):
|
|
104
|
+
* unescape metachar escapes, drop anchors, split on regex syntax, keep
|
|
105
|
+
* word-ish runs of length ≥ 3. "\\bChunkerParams\\b" → ["ChunkerParams"].
|
|
106
|
+
*/
|
|
107
|
+
export function regexLiteralRuns(regexSource) {
|
|
108
|
+
if (!regexSource) return [];
|
|
109
|
+
const unescaped = String(regexSource)
|
|
110
|
+
.replace(/\\[bBdDsSwWAZz]/g, ' ') // char-class/anchor escapes → separator
|
|
111
|
+
.replace(/\\([^A-Za-z0-9])/g, '$1'); // \. \( … → literal char
|
|
112
|
+
const runs = unescaped.split(/[\^$.|?*+()[\]{}<>=!,\s]+/);
|
|
113
|
+
const out = [];
|
|
114
|
+
for (const run of runs) {
|
|
115
|
+
const trimmed = run.replace(/^[-:/\\]+|[-:/\\]+$/g, '');
|
|
116
|
+
if (trimmed.length >= 3 && /[A-Za-z]/.test(trimmed)) out.push(trimmed);
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Split text into normalized informative subtokens: identifier chunks are
|
|
123
|
+
* split on case humps / non-alnum, lowercased, stopword- and length-filtered.
|
|
124
|
+
* Single-pass character scanner — this runs over the top-1 haystack on every
|
|
125
|
+
* agent-format search, so it must stay well under a millisecond at 32KB.
|
|
126
|
+
*/
|
|
127
|
+
export function informativeSubtokens(text) {
|
|
128
|
+
const out = new Set();
|
|
129
|
+
if (!text) return out;
|
|
130
|
+
const s = String(text);
|
|
131
|
+
const n = s.length;
|
|
132
|
+
let start = -1; // current subtoken start
|
|
133
|
+
let hasAlpha = false; // subtoken contains a letter (pure digits dropped)
|
|
134
|
+
const flush = (end) => {
|
|
135
|
+
if (start === -1) return;
|
|
136
|
+
if (hasAlpha && end - start >= 3) {
|
|
137
|
+
const norm = s.slice(start, end).toLowerCase();
|
|
138
|
+
if (!EVIDENCE_STOPWORDS.has(norm)) out.add(norm);
|
|
139
|
+
}
|
|
140
|
+
start = -1;
|
|
141
|
+
hasAlpha = false;
|
|
142
|
+
};
|
|
143
|
+
let prevLower = false; // previous char was a lowercase letter or digit
|
|
144
|
+
let prevUpper = false; // previous char was uppercase
|
|
145
|
+
for (let i = 0; i < n; i++) {
|
|
146
|
+
const c = s.charCodeAt(i);
|
|
147
|
+
const isLower = c >= 97 && c <= 122;
|
|
148
|
+
const isUpper = c >= 65 && c <= 90;
|
|
149
|
+
const isDigit = c >= 48 && c <= 57;
|
|
150
|
+
if (isLower || isUpper || isDigit) {
|
|
151
|
+
// camelCase hump: aB starts a new subtoken; ABc splits before the 'Bc'.
|
|
152
|
+
if (start !== -1 && isUpper && prevLower) {
|
|
153
|
+
flush(i);
|
|
154
|
+
} else if (start !== -1 && isLower && prevUpper && i - start >= 2) {
|
|
155
|
+
const split = i - 1; // "HTTPServer" → "http" + "server"
|
|
156
|
+
if (split - start >= 3) {
|
|
157
|
+
const norm = s.slice(start, split).toLowerCase();
|
|
158
|
+
if (!EVIDENCE_STOPWORDS.has(norm)) out.add(norm);
|
|
159
|
+
}
|
|
160
|
+
start = split;
|
|
161
|
+
hasAlpha = true;
|
|
162
|
+
}
|
|
163
|
+
if (start === -1) { start = i; hasAlpha = false; }
|
|
164
|
+
if (isLower || isUpper) hasAlpha = true;
|
|
165
|
+
prevLower = isLower || isDigit;
|
|
166
|
+
prevUpper = isUpper;
|
|
167
|
+
} else {
|
|
168
|
+
flush(i);
|
|
169
|
+
prevLower = false;
|
|
170
|
+
prevUpper = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
flush(n);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Extract the query's evidence anchors.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} query natural-language or symbol query
|
|
181
|
+
* @param {string} [regex] optional user regex (ss-find)
|
|
182
|
+
* @returns {{ anchors: string[], subtokens: Set<string> }}
|
|
183
|
+
* anchors: exact-match candidates (quoted literals, identifier-shaped
|
|
184
|
+
* tokens, regex literal runs) — checked verbatim with word boundaries;
|
|
185
|
+
* subtokens: normalized informative subtokens for the overlap signal.
|
|
186
|
+
*/
|
|
187
|
+
export function extractQueryEvidence(query, regex) {
|
|
188
|
+
const q = String(query || '');
|
|
189
|
+
const anchors = [];
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
const push = (tok) => {
|
|
192
|
+
if (tok && tok.length >= 3 && !seen.has(tok)) { seen.add(tok); anchors.push(tok); }
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Quoted literals (error strings, config keys) — strongest anchors.
|
|
196
|
+
const quoteRe = /"([^"\n]{3,120})"|'([^'\n]{3,120})'|`([^`\n]{3,120})`/g;
|
|
197
|
+
let m;
|
|
198
|
+
while ((m = quoteRe.exec(q)) !== null) push(m[1] || m[2] || m[3]);
|
|
199
|
+
|
|
200
|
+
// Identifier-shaped tokens from the query text.
|
|
201
|
+
for (const raw of q.split(/[\s,;()"'`]+/)) {
|
|
202
|
+
const tok = raw.replace(/^[^A-Za-z0-9_$]+|[^A-Za-z0-9_$)]+$/g, '');
|
|
203
|
+
if (looksLikeIdentifierToken(tok)) push(tok);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Literal runs from the regex (the exact thing the caller greps for).
|
|
207
|
+
for (const run of regexLiteralRuns(regex)) push(run);
|
|
208
|
+
|
|
209
|
+
return { anchors, subtokens: informativeSubtokens(q) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Assess query-match evidence in the top-1 packaged result.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} query
|
|
216
|
+
* @param {string} regex
|
|
217
|
+
* @param {object} topResult packaged agent result (symbol, file, code,
|
|
218
|
+
* headerContext, summary)
|
|
219
|
+
* @returns {{ strength: 'strong'|'partial'|'none', exactHit: boolean,
|
|
220
|
+
* matchedAnchor: string|null, overlap: number,
|
|
221
|
+
* informativeCount: number }}
|
|
222
|
+
*/
|
|
223
|
+
export function assessQueryEvidence(query, regex, topResult) {
|
|
224
|
+
const t = sufficiencyThresholds();
|
|
225
|
+
const { anchors, subtokens } = extractQueryEvidence(query, regex);
|
|
226
|
+
|
|
227
|
+
const haystackParts = [
|
|
228
|
+
topResult?.symbol || '',
|
|
229
|
+
topResult?.file || '',
|
|
230
|
+
topResult?.headerContext || '',
|
|
231
|
+
topResult?.summary || '',
|
|
232
|
+
topResult?.code || '',
|
|
233
|
+
];
|
|
234
|
+
const haystack = haystackParts.join('\n').slice(0, t.haystackCap);
|
|
235
|
+
|
|
236
|
+
let exactHit = false;
|
|
237
|
+
let matchedAnchor = null;
|
|
238
|
+
for (const anchor of anchors) {
|
|
239
|
+
// Case-sensitive when the anchor carries case information; boundary-
|
|
240
|
+
// checked either way so "Chunker" can't match inside "ChunkerParams".
|
|
241
|
+
const caseSensitive = /[A-Z]/.test(anchor);
|
|
242
|
+
if (containsToken(haystack, anchor, { caseSensitive })) {
|
|
243
|
+
exactHit = true;
|
|
244
|
+
matchedAnchor = anchor;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const informativeCount = subtokens.size;
|
|
250
|
+
let overlap = 0;
|
|
251
|
+
if (informativeCount > 0) {
|
|
252
|
+
const hayTokens = informativeSubtokens(haystack);
|
|
253
|
+
let matched = 0;
|
|
254
|
+
for (const tok of subtokens) if (hayTokens.has(tok)) matched++;
|
|
255
|
+
overlap = matched / informativeCount;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let strength = 'partial';
|
|
259
|
+
if (exactHit || (overlap >= t.overlapStrong && informativeCount >= t.minInformativeSubtokens)) {
|
|
260
|
+
strength = 'strong';
|
|
261
|
+
} else if (informativeCount >= t.minInformativeSubtokens && overlap <= t.overlapNone) {
|
|
262
|
+
// Only a query with enough informative tokens can CONFIRM absence —
|
|
263
|
+
// a 1-token NL query never earns a discouraging 'none'.
|
|
264
|
+
strength = 'none';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return { strength, exactHit, matchedAnchor, overlap, informativeCount };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Fuse query-match evidence with the engine's confidence bucket and the
|
|
272
|
+
* structural packaging facts into a 3-valued verdict.
|
|
273
|
+
*
|
|
274
|
+
* Rule (design note §2; stricter-YES direction):
|
|
275
|
+
* - YES requires strong evidence AND (confidence=high, or confidence=medium
|
|
276
|
+
* with resolved structural context). Packaging alone can never say YES;
|
|
277
|
+
* confidence=low can never say YES.
|
|
278
|
+
* - 'no' is reserved for confirmed absence (no results, or a query with
|
|
279
|
+
* real anchors/subtokens finding nothing in top-1). The borg-style
|
|
280
|
+
* false-no — literal present with a clear margin — becomes YES.
|
|
281
|
+
* - everything ambiguous is 'unknown', never a false binary.
|
|
282
|
+
*
|
|
283
|
+
* @param {object} args
|
|
284
|
+
* @param {object|null} args.topResult
|
|
285
|
+
* @param {{confidence: string}|null} args.confidenceInfo
|
|
286
|
+
* @param {string} args.query
|
|
287
|
+
* @param {string} [args.regex]
|
|
288
|
+
* @param {{isComplete: boolean, hasResolution: boolean}} args.structural
|
|
289
|
+
* @param {object[]} [args.lowerResults] code-bearing results below top-1
|
|
290
|
+
* (full/preview tiers) — consulted ONLY to soften a would-be 'no': when
|
|
291
|
+
* the pack's answer sits at rank 2-3, a flat 'no' would falsely push the
|
|
292
|
+
* agent away from a pack that contains it. YES stays top-1-strict.
|
|
293
|
+
* @returns {{ verdict: 'yes'|'no'|'unknown', reason: string,
|
|
294
|
+
* evidence: object|null }}
|
|
295
|
+
*/
|
|
296
|
+
export function computeSufficiencyVerdict({ topResult, confidenceInfo, query, regex, structural, lowerResults = [] }) {
|
|
297
|
+
if (!topResult) {
|
|
298
|
+
return { verdict: 'no', reason: 'no_results', evidence: null };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const evidence = assessQueryEvidence(query, regex, topResult);
|
|
302
|
+
const conf = confidenceInfo?.confidence || 'low';
|
|
303
|
+
const structuralOk = !!(structural?.isComplete && structural?.hasResolution);
|
|
304
|
+
|
|
305
|
+
if (evidence.strength === 'none') {
|
|
306
|
+
for (const r of lowerResults) {
|
|
307
|
+
if (!r || (!r.code && !r.symbol)) continue;
|
|
308
|
+
const lower = assessQueryEvidence(query, regex, r);
|
|
309
|
+
if (lower.strength === 'strong') {
|
|
310
|
+
return { verdict: 'unknown', reason: 'evidence_below_top1', evidence };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { verdict: 'no', reason: 'no_query_evidence', evidence };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!topResult.code) {
|
|
317
|
+
// Summary-only top-1: the agent holds no code to answer from.
|
|
318
|
+
return { verdict: 'unknown', reason: 'top_summary_only', evidence };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (evidence.strength === 'strong') {
|
|
322
|
+
if (conf === 'high') {
|
|
323
|
+
return { verdict: 'yes', reason: evidence.exactHit ? 'query_evidence_clear_margin' : 'query_overlap_clear_margin', evidence };
|
|
324
|
+
}
|
|
325
|
+
if (conf === 'medium') {
|
|
326
|
+
// Dev-split calibration (full200 replay): gating medium on structural
|
|
327
|
+
// resolution cost recall (0.35 vs 0.41) for no precision gain (0.706
|
|
328
|
+
// vs 0.700) — strong evidence + a medium margin is enough. Low never
|
|
329
|
+
// reaches yes: re-admitting low+exactHit dropped precision to 0.61.
|
|
330
|
+
return { verdict: 'yes', reason: 'query_evidence_moderate_margin', evidence };
|
|
331
|
+
}
|
|
332
|
+
return { verdict: 'unknown', reason: 'evidence_without_margin', evidence };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Partial evidence — the old structural-YES shape lands here.
|
|
336
|
+
return {
|
|
337
|
+
verdict: 'unknown',
|
|
338
|
+
reason: structuralOk ? 'well_formed_only' : 'partial_query_evidence',
|
|
339
|
+
evidence,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
@@ -239,6 +239,48 @@ function _attachIndexMetadata(filePathRel, projectRoot) {
|
|
|
239
239
|
return { indexed: true, chunks, language };
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Remainder definition sniffing — fallback symbol names for the "what
|
|
244
|
+
// remains" trailer when the index has no named chunks in the unread span
|
|
245
|
+
// (e.g. C++ files where the chunker recorded `name: null`). Scans ONLY the
|
|
246
|
+
// remainder lines of the buffer already in memory: zero I/O, capped.
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
const SNIFF_MAX_LINES = 4000;
|
|
250
|
+
const UNREAD_SYMBOLS_MAX = 5; // hard cap on named symbols in the trailer
|
|
251
|
+
const UNREAD_SYMBOLS_MIN_LINES = 20; // smaller remainders get the short form
|
|
252
|
+
const C_FAMILY_EXTS = new Set(['.c', '.h', '.cc', '.cpp', '.cxx', '.hpp', '.hh', '.hxx', '.java', '.cs', '.m', '.mm']);
|
|
253
|
+
|
|
254
|
+
// Keyword-introduced definitions (Python/Ruby/JS/TS/Go/Rust/Kotlin/PHP/...).
|
|
255
|
+
const KEYWORD_DEF_RE = /^\s*(?:export\s+|default\s+|pub(?:\([^)]*\))?\s+|static\s+|async\s+|abstract\s+|final\s+|public\s+|private\s+|protected\s+|inline\s+|constexpr\s+|unsafe\s+|override\s+|open\s+|sealed\s+)*(?:def|fn|func|function\*?|class|struct|enum|trait|interface|impl|object|module|proc)\s+(?:\([^)]*\)\s*)?([A-Za-z_][\w]*(?:(?:::|\.)[A-Za-z_][\w]*)*)/;
|
|
256
|
+
// C-family definitions at low indent: `[return-type] Qualified::name(args...`
|
|
257
|
+
// with no trailing `;` (declarations) — captures the identifier before the
|
|
258
|
+
// first `(`. The return-type prefix is lazy so qualification stays intact.
|
|
259
|
+
const C_DEF_RE = /^(?:[A-Za-z_][\w:<>,*&~\s]*?[\s*&]+)?((?:[A-Za-z_~][\w]*::)*(?:~?[A-Za-z_][\w]*|operator\s*[^\s(]{1,3}))\s*\(/;
|
|
260
|
+
const C_CONTROL_RE = /^\s*(?:if|for|while|switch|return|else|do|catch|case|sizeof|new|delete|throw|goto|using|typedef)\b/;
|
|
261
|
+
|
|
262
|
+
function _sniffRemainderDefinitions(text, isCFamily) {
|
|
263
|
+
const names = [];
|
|
264
|
+
const seen = new Set();
|
|
265
|
+
const lines = text.split('\n', SNIFF_MAX_LINES);
|
|
266
|
+
for (let i = 0; i < lines.length; i++) {
|
|
267
|
+
const line = lines[i];
|
|
268
|
+
if (!/\S/.test(line)) continue;
|
|
269
|
+
let name = null;
|
|
270
|
+
const kw = line.match(KEYWORD_DEF_RE);
|
|
271
|
+
if (kw) name = kw[1];
|
|
272
|
+
else if (isCFamily && /^[A-Za-z_]/.test(line) && !line.trimEnd().endsWith(';') && !C_CONTROL_RE.test(line)) {
|
|
273
|
+
const m = line.match(C_DEF_RE);
|
|
274
|
+
if (m) name = m[1].replace(/\s+/g, '');
|
|
275
|
+
}
|
|
276
|
+
if (name && !seen.has(name)) {
|
|
277
|
+
seen.add(name);
|
|
278
|
+
names.push({ symbol: name, type: null, startLine: i + 1 }); // startLine relative; caller offsets
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return names;
|
|
282
|
+
}
|
|
283
|
+
|
|
242
284
|
// ---------------------------------------------------------------------------
|
|
243
285
|
// Public API — single read
|
|
244
286
|
// ---------------------------------------------------------------------------
|
|
@@ -293,6 +335,47 @@ async function _readFileUnpinned(req) {
|
|
|
293
335
|
language = meta.language;
|
|
294
336
|
}
|
|
295
337
|
|
|
338
|
+
// "What remains" trailer data (2026-07, within-file blind-spot fix): when
|
|
339
|
+
// a range read stops before EOF, record what the UNREAD remainder below
|
|
340
|
+
// the window contains — computed from the full chunk table BEFORE the
|
|
341
|
+
// overlap-narrowing just below. A bare "(lines a-b of N)" marker is
|
|
342
|
+
// provably ignored by agents (the botan-2738 shape: three reads, never
|
|
343
|
+
// past line 205 of 272, fix surface below); naming the symbols is what
|
|
344
|
+
// makes the remainder actionable. Whole-file reads and read-to-EOF stay
|
|
345
|
+
// byte-identical (unreadBelow stays null).
|
|
346
|
+
let unreadBelow = null;
|
|
347
|
+
if (wantsRange && sliced.totalLines > 0 && sliced.endLine < sliced.totalLines) {
|
|
348
|
+
// Token diet: a tiny remainder needs no symbol list — the range plus the
|
|
349
|
+
// continue command IS the affordance; names only earn their tokens when
|
|
350
|
+
// the unread span is big enough to hide a sibling branch.
|
|
351
|
+
const remainderLines = sliced.totalLines - sliced.endLine;
|
|
352
|
+
const seen = new Set();
|
|
353
|
+
let symbols = [];
|
|
354
|
+
if (remainderLines >= UNREAD_SYMBOLS_MIN_LINES) {
|
|
355
|
+
for (const c of chunks) {
|
|
356
|
+
if (c.startLine == null || c.startLine <= sliced.endLine) continue;
|
|
357
|
+
if (!c.symbol || seen.has(c.symbol)) continue;
|
|
358
|
+
seen.add(c.symbol);
|
|
359
|
+
symbols.push({ symbol: c.symbol, type: c.type ?? null, startLine: c.startLine });
|
|
360
|
+
}
|
|
361
|
+
// Index had no named chunks in the remainder (common for C/C++ where the
|
|
362
|
+
// chunker stores name:null) — sniff definition lines from the in-memory
|
|
363
|
+
// buffer instead. Zero I/O; capped at SNIFF_MAX_LINES.
|
|
364
|
+
if (symbols.length === 0 && disk.text != null) {
|
|
365
|
+
const remainder = _sliceLines(disk.text, disk.lineOffsets, sliced.endLine + 1, sliced.totalLines);
|
|
366
|
+
const isCFamily = C_FAMILY_EXTS.has(path.extname(absPath).toLowerCase());
|
|
367
|
+
symbols = _sniffRemainderDefinitions(remainder.text, isCFamily)
|
|
368
|
+
.map(s => ({ ...s, startLine: sliced.endLine + s.startLine }));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
unreadBelow = {
|
|
372
|
+
startLine: sliced.endLine + 1,
|
|
373
|
+
endLine: sliced.totalLines,
|
|
374
|
+
symbols: symbols.slice(0, UNREAD_SYMBOLS_MAX),
|
|
375
|
+
moreCount: Math.max(0, symbols.length - UNREAD_SYMBOLS_MAX),
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
296
379
|
// If a line range was requested, narrow attached chunks to the overlap.
|
|
297
380
|
if (wantsRange && chunks.length) {
|
|
298
381
|
chunks = chunks.filter(c =>
|
|
@@ -315,6 +398,7 @@ async function _readFileUnpinned(req) {
|
|
|
315
398
|
range: wantsRange ? { startLine: sliced.startLine, endLine: sliced.endLine } : null,
|
|
316
399
|
text: sliced.text,
|
|
317
400
|
chunks,
|
|
401
|
+
unreadBelow,
|
|
318
402
|
timings: { totalMs: +(performance.now() - t0).toFixed(2) },
|
|
319
403
|
};
|
|
320
404
|
}
|
|
@@ -362,6 +446,28 @@ export async function readFiles(files, opts = {}) {
|
|
|
362
446
|
// Formatting
|
|
363
447
|
// ---------------------------------------------------------------------------
|
|
364
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Render the "what remains" trailer for a range read that stopped before
|
|
451
|
+
* EOF. Names the symbols in the unread remainder plus the exact continue
|
|
452
|
+
* command — the actionable form (a bare truncation marker is ignored;
|
|
453
|
+
* see the 2026-07 within-file design note). Returns '' when the read
|
|
454
|
+
* covered the whole file / reached EOF.
|
|
455
|
+
*
|
|
456
|
+
* @param {Object} result - readFile() result
|
|
457
|
+
* @param {{ command?: 'read'|'ss-read' }} [opts] - continue-command surface
|
|
458
|
+
* @returns {string} one line without trailing newline, or ''
|
|
459
|
+
*/
|
|
460
|
+
export function renderUnreadBelow(result, { command = 'read' } = {}) {
|
|
461
|
+
const u = result?.unreadBelow;
|
|
462
|
+
if (!u) return '';
|
|
463
|
+
const names = (u.symbols || []).map(s => s.symbol).join(', ');
|
|
464
|
+
const more = u.moreCount > 0 ? ` +${u.moreCount} more` : '';
|
|
465
|
+
const cont = command === 'ss-read'
|
|
466
|
+
? `ss-read ${result.file} ${u.startLine} ${u.endLine}`
|
|
467
|
+
: `read ${result.file} ${u.startLine}-${u.endLine}`;
|
|
468
|
+
return `# unread below (${u.startLine}-${u.endLine})${names ? ': ' + names + more : ''} — continue: ${cont}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
365
471
|
function _formatAgent(result) {
|
|
366
472
|
if (!result.ok) {
|
|
367
473
|
return `### ${result.file}\n[error] ${result.error}\n`;
|
|
@@ -377,7 +483,8 @@ function _formatAgent(result) {
|
|
|
377
483
|
.filter(Boolean);
|
|
378
484
|
if (names.length) symbolHint = `\nsymbols: ${names.join(', ')}`;
|
|
379
485
|
}
|
|
380
|
-
|
|
486
|
+
const remainder = renderUnreadBelow(result);
|
|
487
|
+
return `### ${result.file}${range}${symbolHint}\n${fence}\n${result.text}\n\`\`\`${remainder ? '\n' + remainder : ''}\n`;
|
|
381
488
|
}
|
|
382
489
|
|
|
383
490
|
export function formatReadResults(results, format = 'agent') {
|
|
@@ -192,3 +192,18 @@ export function extractPositional(args) {
|
|
|
192
192
|
if (bad) return { pattern: undefined, unknownFlag: bad };
|
|
193
193
|
return { pattern: args[0], unknownFlag: null };
|
|
194
194
|
}
|
|
195
|
+
|
|
196
|
+
// --- trailer rendering --------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
// Sufficiency trailer segment: 3-valued verdict (YES / no / unknown) + a
|
|
199
|
+
// compact why-token, e.g. ` sufficient=YES (query_evidence_clear_margin)` vs
|
|
200
|
+
// ` sufficient=unknown (well_formed_only)`. Falls back to the legacy boolean
|
|
201
|
+
// when the engine predates sufficiencyVerdict. The full line shape stays
|
|
202
|
+
// `# confidence=<bucket> (<reason>) sufficient=<verdict> (<why>)`.
|
|
203
|
+
export function renderSufficiency(response) {
|
|
204
|
+
const verdict = response.sufficiencyVerdict
|
|
205
|
+
? (response.sufficiencyVerdict === 'yes' ? 'YES' : response.sufficiencyVerdict)
|
|
206
|
+
: (response.sufficient ? 'YES' : 'no');
|
|
207
|
+
const why = response.sufficiencyReason ? ` (${response.sufficiencyReason})` : '';
|
|
208
|
+
return ` sufficient=${verdict}${why}`;
|
|
209
|
+
}
|
|
@@ -16,7 +16,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
16
16
|
import {
|
|
17
17
|
parseBoolFlag, parseValueFlag, parsePositiveIntFlag,
|
|
18
18
|
buildGrepPattern, stripInertFlags, normalizeArgs, extractPositional,
|
|
19
|
-
parseLineRange, looksLikeOption,
|
|
19
|
+
parseLineRange, looksLikeOption, renderSufficiency,
|
|
20
20
|
} from './_ss-argparse.mjs';
|
|
21
21
|
import { renderGrepBody } from '../../../core/search/grep-output-shaping.js';
|
|
22
22
|
|
|
@@ -222,7 +222,7 @@ async function cmdFind(rawArgs) {
|
|
|
222
222
|
` budget=${response.tokenBudget} used=${response.tokensUsed} subMode=${response.subMode ?? format}\n`);
|
|
223
223
|
if (response.confidence) {
|
|
224
224
|
process.stdout.write(`# confidence=${response.confidence}${response.confidenceReason ? ' (' + response.confidenceReason + ')' : ''}` +
|
|
225
|
-
`${response
|
|
225
|
+
`${renderSufficiency(response)}\n`);
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
// Per-result blocks — identical shape to ss-search's agent packaging.
|
|
@@ -242,6 +242,11 @@ async function cmdFind(rawArgs) {
|
|
|
242
242
|
if (r.neighbors && r.neighbors.rendered) {
|
|
243
243
|
process.stdout.write(`### related (1-hop graph, ~${r.neighbors.tokens} tok)\n${r.neighbors.rendered}\n`);
|
|
244
244
|
}
|
|
245
|
+
// Same-file span map (top-1, windowed chunk, verdict != YES): sibling
|
|
246
|
+
// symbols just outside the shown window + a copy-paste drill-in command.
|
|
247
|
+
if (r.sameFile && r.sameFile.rendered) {
|
|
248
|
+
process.stdout.write(`${r.sameFile.rendered}\n`);
|
|
249
|
+
}
|
|
245
250
|
}
|
|
246
251
|
if (!response.results || response.results.length === 0) process.stdout.write('(no matches)\n');
|
|
247
252
|
process.exit(0);
|
|
@@ -297,7 +302,7 @@ async function cmdRead(args) {
|
|
|
297
302
|
}
|
|
298
303
|
}
|
|
299
304
|
}
|
|
300
|
-
const { readFile } = await import(path.join(REPO_ROOT, 'core/search/search-read.js'));
|
|
305
|
+
const { readFile, renderUnreadBelow } = await import(path.join(REPO_ROOT, 'core/search/search-read.js'));
|
|
301
306
|
const r = await readFile({ path: file, projectRoot: PROJECT_ROOT, startLine: start ?? undefined, endLine: end ?? undefined });
|
|
302
307
|
if (!r.ok) {
|
|
303
308
|
process.stderr.write(`[ss-read] error: ${r.error}\n`);
|
|
@@ -305,7 +310,11 @@ async function cmdRead(args) {
|
|
|
305
310
|
}
|
|
306
311
|
const range = r.range ? ` (lines ${r.range.startLine}-${r.range.endLine} of ${r.totalLines})` : ` (${r.totalLines} lines)`;
|
|
307
312
|
const fence = r.language ? '```' + r.language : '```';
|
|
308
|
-
|
|
313
|
+
// "What remains" trailer: on a range read that stops before EOF, one final
|
|
314
|
+
// line names the symbols in the unread remainder + the exact continue
|
|
315
|
+
// command (last line for recency — the actionable form of truncation).
|
|
316
|
+
const remainder = renderUnreadBelow(r, { command: 'ss-read' });
|
|
317
|
+
process.stdout.write(`# ss-read ${r.file}${range}\n${fence}\n${r.text}\n\`\`\`${remainder ? '\n' + remainder : ''}\n`);
|
|
309
318
|
process.exit(0);
|
|
310
319
|
}
|
|
311
320
|
|
|
@@ -417,7 +426,7 @@ async function cmdAgentSearch(rawArgs) {
|
|
|
417
426
|
` results=${response.results.length} subMode=${response.subMode}\n`);
|
|
418
427
|
if (response.confidence) {
|
|
419
428
|
process.stdout.write(`# confidence=${response.confidence}${response.confidenceReason ? ' (' + response.confidenceReason + ')' : ''}` +
|
|
420
|
-
`${response
|
|
429
|
+
`${renderSufficiency(response)}\n`);
|
|
421
430
|
}
|
|
422
431
|
|
|
423
432
|
// Per-result blocks
|
|
@@ -441,6 +450,11 @@ async function cmdAgentSearch(rawArgs) {
|
|
|
441
450
|
if (r.neighbors && r.neighbors.rendered) {
|
|
442
451
|
process.stdout.write(`### related (1-hop graph, ~${r.neighbors.tokens} tok)\n${r.neighbors.rendered}\n`);
|
|
443
452
|
}
|
|
453
|
+
// Same-file span map (top-1, windowed chunk, verdict != YES): sibling
|
|
454
|
+
// symbols just outside the shown window + a copy-paste drill-in command.
|
|
455
|
+
if (r.sameFile && r.sameFile.rendered) {
|
|
456
|
+
process.stdout.write(`${r.sameFile.rendered}\n`);
|
|
457
|
+
}
|
|
444
458
|
}
|
|
445
459
|
|
|
446
460
|
if (!response.results || response.results.length === 0) {
|
|
@@ -474,9 +488,13 @@ async function cmdAgentSearch(rawArgs) {
|
|
|
474
488
|
headerCount,
|
|
475
489
|
confidence: response.confidence || null,
|
|
476
490
|
sufficient: response.sufficient ?? null,
|
|
491
|
+
sufficiencyVerdict: response.sufficiencyVerdict ?? null,
|
|
492
|
+
sufficiencyReason: response.sufficiencyReason ?? null,
|
|
477
493
|
sufficiencyReasons: Array.isArray(response.sufficiencyReasons) ? response.sufficiencyReasons : null,
|
|
478
494
|
unresolvedExternalCount: typeof response.unresolvedExternalCount === 'number'
|
|
479
495
|
? response.unresolvedExternalCount : null,
|
|
496
|
+
sameFileMapTokens: response.results?.[0]?.sameFile?.tokens ?? null,
|
|
497
|
+
sameFileNeighborCount: response.results?.[0]?.sameFile?.neighbors?.length ?? null,
|
|
480
498
|
};
|
|
481
499
|
process.stdout.write(`\n<<SS_ROUTE_META>>${JSON.stringify(meta)}\n`);
|
|
482
500
|
process.exit(0);
|
package/mcp/read-tool.js
CHANGED
|
@@ -23,6 +23,16 @@ const ReadFileResultSchema = z.object({
|
|
|
23
23
|
endLine: z.number().int().nullable().optional(),
|
|
24
24
|
signature: z.string().nullable().optional(),
|
|
25
25
|
})).optional(),
|
|
26
|
+
unreadBelow: z.object({
|
|
27
|
+
startLine: z.number().int(),
|
|
28
|
+
endLine: z.number().int(),
|
|
29
|
+
symbols: z.array(z.object({
|
|
30
|
+
symbol: z.string(),
|
|
31
|
+
type: z.string().nullable().optional(),
|
|
32
|
+
startLine: z.number().int().nullable().optional(),
|
|
33
|
+
})),
|
|
34
|
+
moreCount: z.number().int(),
|
|
35
|
+
}).nullable().optional(),
|
|
26
36
|
error: z.string().optional(),
|
|
27
37
|
timings: z.object({ totalMs: z.number() }).optional(),
|
|
28
38
|
});
|
package/mcp/tool-handlers.js
CHANGED
|
@@ -42,6 +42,8 @@ export const SearchOutputSchema = z.object({
|
|
|
42
42
|
confidence: z.enum(['high', 'medium', 'low']).optional(),
|
|
43
43
|
confidenceReason: z.string().optional(),
|
|
44
44
|
sufficient: z.boolean().optional(),
|
|
45
|
+
sufficiencyVerdict: z.enum(['yes', 'no', 'unknown']).optional(),
|
|
46
|
+
sufficiencyReason: z.string().optional(),
|
|
45
47
|
sufficiencyReasons: z.array(z.string()).optional(),
|
|
46
48
|
packagingMs: z.number().optional(),
|
|
47
49
|
latencyMs: z.number().optional(),
|
|
@@ -125,7 +127,14 @@ export async function handleSearch({ query, k, mode, structural, regex, format,
|
|
|
125
127
|
const header = `${r.rank}. ${r.file}:${r.startLine}-${r.endLine} (score: ${r.score.toFixed(3)}, ${r.presentation})`;
|
|
126
128
|
const symbolInfo = r.symbol ? `\n ${r.symbolType || 'symbol'}: ${r.symbol}` : '';
|
|
127
129
|
const code = r.code ? `\n\`\`\`\n${r.code}\n\`\`\`` : '';
|
|
128
|
-
|
|
130
|
+
// Same-file span map (top-1, windowed chunk, verdict != YES) —
|
|
131
|
+
// MCP phrasing references the MCP drill-in tool name.
|
|
132
|
+
const sameFile = (r.sameFile && r.sameFile.neighbors?.length)
|
|
133
|
+
? `\n Same file: ${r.sameFile.neighbors
|
|
134
|
+
.map(n => `${n.name} (${n.type === 'function' ? 'fn' : (n.type || 'sym')} ${n.startLine}-${n.endLine} ${n.position})`)
|
|
135
|
+
.join(' · ')} — sweep: read-semantic ${r.file}`
|
|
136
|
+
: '';
|
|
137
|
+
return header + symbolInfo + code + sameFile;
|
|
129
138
|
});
|
|
130
139
|
|
|
131
140
|
const summaries = agentResults
|
|
@@ -166,6 +175,8 @@ export async function handleSearch({ query, k, mode, structural, regex, format,
|
|
|
166
175
|
confidence: searchResult.confidence,
|
|
167
176
|
confidenceReason: searchResult.confidenceReason,
|
|
168
177
|
sufficient: searchResult.sufficient,
|
|
178
|
+
...(searchResult.sufficiencyVerdict ? { sufficiencyVerdict: searchResult.sufficiencyVerdict } : {}),
|
|
179
|
+
...(searchResult.sufficiencyReason ? { sufficiencyReason: searchResult.sufficiencyReason } : {}),
|
|
169
180
|
sufficiencyReasons: searchResult.sufficiencyReasons || [],
|
|
170
181
|
packagingMs: searchResult.packagingMs,
|
|
171
182
|
latencyMs: searchResult.latencyMs,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sweet-search",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.15",
|
|
4
4
|
"description": "Sweet Search - SOTA Hybrid Code Search Engine with WASM CatBoost Query Router, Semantic/Lexical/Structural Search, and Multilingual Support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "core/search/sweet-search.js",
|
|
@@ -167,13 +167,13 @@
|
|
|
167
167
|
"vitest": "^4.0.16"
|
|
168
168
|
},
|
|
169
169
|
"optionalDependencies": {
|
|
170
|
-
"@sweet-search/native-darwin-arm64": "2.6.
|
|
171
|
-
"@sweet-search/native-darwin-x64": "2.6.
|
|
172
|
-
"@sweet-search/native-linux-arm64-gnu": "2.6.
|
|
173
|
-
"@sweet-search/native-linux-arm64-gnu-cuda": "2.6.
|
|
174
|
-
"@sweet-search/native-linux-x64-gnu": "2.6.
|
|
175
|
-
"@sweet-search/native-linux-x64-gnu-cuda": "2.6.
|
|
176
|
-
"@sweet-search/bg-priority": "2.6.
|
|
170
|
+
"@sweet-search/native-darwin-arm64": "2.6.15",
|
|
171
|
+
"@sweet-search/native-darwin-x64": "2.6.15",
|
|
172
|
+
"@sweet-search/native-linux-arm64-gnu": "2.6.15",
|
|
173
|
+
"@sweet-search/native-linux-arm64-gnu-cuda": "2.6.15",
|
|
174
|
+
"@sweet-search/native-linux-x64-gnu": "2.6.15",
|
|
175
|
+
"@sweet-search/native-linux-x64-gnu-cuda": "2.6.15",
|
|
176
|
+
"@sweet-search/bg-priority": "2.6.15"
|
|
177
177
|
},
|
|
178
178
|
"engines": {
|
|
179
179
|
"node": ">=18.0.0"
|