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.
@@ -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
- * sufficient := complete_symbol
1134
- * AND (header_resolved OR neighbors_present OR self_contained_strict)
1135
- * AND (high_confidence OR header_resolved OR neighbors_present)
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
- * @returns {{ sufficient: boolean, reasons: string[], unresolvedExternalCount: number }}
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
- return { sufficient, reasons, unresolvedExternalCount: unresolvedCount };
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. Tightened in 2026-05: requires resolution
2291
- // (header_resolved OR neighbors_present OR self_contained_strict) instead
2292
- // of the old "complete_symbol + high_confidence" rule.
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 && agentResults[0].code) {
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
- return `### ${result.file}${range}${symbolHint}\n${fence}\n${result.text}\n\`\`\`\n`;
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.sufficient ? ' sufficient=YES' : ' sufficient=no'}\n`);
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
- process.stdout.write(`# ss-read ${r.file}${range}\n${fence}\n${r.text}\n\`\`\`\n`);
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.sufficient ? ' sufficient=YES' : ' sufficient=no'}\n`);
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
  });
@@ -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
- return header + symbolInfo + code;
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.13",
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.13",
171
- "@sweet-search/native-darwin-x64": "2.6.13",
172
- "@sweet-search/native-linux-arm64-gnu": "2.6.13",
173
- "@sweet-search/native-linux-arm64-gnu-cuda": "2.6.13",
174
- "@sweet-search/native-linux-x64-gnu": "2.6.13",
175
- "@sweet-search/native-linux-x64-gnu-cuda": "2.6.13",
176
- "@sweet-search/bg-priority": "2.6.13"
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"