sweet-search 2.4.2 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/cli.js +19 -5
- package/core/embedding/embedding-cache.js +177 -15
- package/core/embedding/embedding-service.js +18 -4
- package/core/graph/graph-expansion.js +52 -12
- package/core/graph/graph-extractor.js +30 -1
- package/core/indexing/ast-chunker.js +331 -16
- package/core/indexing/chunking/chunk-builder.js +34 -1
- package/core/indexing/index.js +6 -3
- package/core/indexing/indexer-ann.js +45 -6
- package/core/indexing/indexer-build.js +9 -1
- package/core/indexing/indexer-phases.js +6 -4
- package/core/indexing/indexing-file-policy.js +140 -0
- package/core/indexing/li-skip-policy.js +11 -220
- package/core/infrastructure/codebase-repository.js +21 -0
- package/core/infrastructure/config/embedding.js +20 -1
- package/core/infrastructure/config/graph.js +2 -2
- package/core/infrastructure/config/ranking.js +10 -0
- package/core/infrastructure/config/vector-store.js +1 -1
- package/core/infrastructure/coreml-cascade.js +236 -30
- package/core/infrastructure/coreml-cascade.json +25 -0
- package/core/infrastructure/index.js +15 -0
- package/core/infrastructure/init-config.js +78 -0
- package/core/infrastructure/language-patterns/registry-core.js +18 -0
- package/core/infrastructure/model-registry.js +12 -0
- package/core/infrastructure/native-inference.js +143 -51
- package/core/infrastructure/tree-sitter-provider.js +92 -2
- package/core/ranking/cascaded-scorer.js +6 -2
- package/core/ranking/file-kind-ranking.js +264 -0
- package/core/ranking/late-interaction-index.js +10 -4
- package/core/ranking/late-interaction-policy.js +304 -0
- package/core/search/context-expander.js +267 -28
- package/core/search/index.js +4 -0
- package/core/search/search-cli.js +3 -1
- package/core/search/search-pattern.js +4 -3
- package/core/search/search-postprocess.js +189 -8
- package/core/search/search-read-semantic.js +717 -0
- package/core/search/search-read.js +481 -0
- package/core/search/search-server.js +6 -4
- package/core/search/sweet-search.js +119 -15
- package/mcp/server.js +41 -0
- package/mcp/tool-handlers.js +117 -6
- package/package.json +9 -7
- package/scripts/init.js +386 -5
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sweet-search read-semantic — span selection by hybrid retrieval, content from disk.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline:
|
|
5
|
+
* 1. Enumerate candidate spans for the target file from the vectors index.
|
|
6
|
+
* 2. Build a candidate union from three signals:
|
|
7
|
+
* - lexical: term matches (regex over query terms) on chunk text + symbol
|
|
8
|
+
* - symbol: exact substring match against the chunk's symbol/signature
|
|
9
|
+
* - MaxSim: ColBERT-style late interaction (token-level), if the LI
|
|
10
|
+
* index is available for these chunk IDs
|
|
11
|
+
* 3. Rank by Reciprocal Rank Fusion (RRF). If MaxSim ran, do a final
|
|
12
|
+
* LI-only re-rank over the fused top-K and use the LI score as the
|
|
13
|
+
* authoritative score on returned spans.
|
|
14
|
+
* 4. Re-read the selected spans from disk (filesystem ground truth).
|
|
15
|
+
* 5. Expand by contextLines, merge adjacent/overlapping spans, enforce a
|
|
16
|
+
* character/token budget.
|
|
17
|
+
*
|
|
18
|
+
* Why hybrid: a pure single-vector dense path is known to be weaker on code
|
|
19
|
+
* than ColBERT-style late interaction, and even MaxSim alone underperforms
|
|
20
|
+
* BM25+MaxSim fusion on out-of-domain queries (AllianceCoder 2025; ECIR 2026
|
|
21
|
+
* Late Interaction workshop survey). For per-file span selection we don't
|
|
22
|
+
* have a strong corpus-level lexical index to lean on — symbol-name and
|
|
23
|
+
* regex token candidates are the cheap and effective substitutes.
|
|
24
|
+
*
|
|
25
|
+
* DDD: search/ application layer. Allowed to import infrastructure (DB,
|
|
26
|
+
* config) and ranking (LI). Never imports indexing/ or query/. Single-file
|
|
27
|
+
* scope, so no graph-domain dependency required here; the candidate union
|
|
28
|
+
* has a documented seam where graph 1-hop neighbors can plug in later
|
|
29
|
+
* (cross-file would belong in a separate corpus-level read tool).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
import { CodebaseRepository } from '../infrastructure/codebase-repository.js';
|
|
34
|
+
import { DB_PATHS, LATE_INTERACTION_CONFIG } from '../infrastructure/config/index.js';
|
|
35
|
+
import { readFile as readFileExact } from './search-read.js';
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Defaults — keep modest so a one-file call stays under ~100ms after warmup.
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const DEFAULTS = {
|
|
42
|
+
topK: 5,
|
|
43
|
+
threshold: 0.4, // MaxSim score floor when LI ranks
|
|
44
|
+
contextLines: 2, // expand selected spans by ±N lines
|
|
45
|
+
maxChars: 8000, // hard cap on returned exact text
|
|
46
|
+
rrfK: 60, // standard RRF constant
|
|
47
|
+
lexicalWeight: 1.0,
|
|
48
|
+
symbolWeight: 1.5, // symbol-name hits are stronger evidence per-file
|
|
49
|
+
maxsimWeight: 1.6, // late interaction wins ties
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const APPROX_CHARS_PER_TOKEN = 4;
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Module-level lazy singletons
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
let _repo = null;
|
|
59
|
+
function _getRepo() {
|
|
60
|
+
if (_repo === null) {
|
|
61
|
+
try { _repo = new CodebaseRepository(DB_PATHS.codebase); }
|
|
62
|
+
catch { _repo = false; }
|
|
63
|
+
}
|
|
64
|
+
return _repo || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let _liIndex = null;
|
|
68
|
+
let _liInitPromise = null;
|
|
69
|
+
async function _getLateInteractionIndex() {
|
|
70
|
+
if (_liIndex) return _liIndex;
|
|
71
|
+
if (_liInitPromise) return _liInitPromise;
|
|
72
|
+
if (!LATE_INTERACTION_CONFIG?.enabled) return null;
|
|
73
|
+
_liInitPromise = (async () => {
|
|
74
|
+
try {
|
|
75
|
+
const { LateInteractionIndex } = await import('../ranking/late-interaction-index.js');
|
|
76
|
+
const idx = new LateInteractionIndex({});
|
|
77
|
+
await idx.init();
|
|
78
|
+
// If the index is empty (no segments, no docs), treat as unavailable —
|
|
79
|
+
// saves a noisy warning later when scoreWithLateInteraction runs.
|
|
80
|
+
if (!idx.documents || idx.documents.size === 0) {
|
|
81
|
+
_liIndex = false;
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
_liIndex = idx;
|
|
85
|
+
return idx;
|
|
86
|
+
} catch {
|
|
87
|
+
_liIndex = false;
|
|
88
|
+
return null;
|
|
89
|
+
} finally {
|
|
90
|
+
_liInitPromise = null;
|
|
91
|
+
}
|
|
92
|
+
})();
|
|
93
|
+
return _liInitPromise;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let _encodeQueryFn = null;
|
|
97
|
+
async function _getEncodeQuery() {
|
|
98
|
+
if (_encodeQueryFn) return _encodeQueryFn;
|
|
99
|
+
try {
|
|
100
|
+
const mod = await import('../ranking/late-interaction-model.js');
|
|
101
|
+
_encodeQueryFn = mod.encodeQuery;
|
|
102
|
+
return _encodeQueryFn;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Helpers
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function _projectRelative(absOrRelPath, projectRoot) {
|
|
113
|
+
const root = projectRoot || process.cwd();
|
|
114
|
+
const abs = path.isAbsolute(absOrRelPath)
|
|
115
|
+
? absOrRelPath
|
|
116
|
+
: path.resolve(root, absOrRelPath);
|
|
117
|
+
const rel = path.relative(root, abs);
|
|
118
|
+
return rel.startsWith('..') || path.isAbsolute(rel) ? abs : rel;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function _parseMeta(rawMeta) {
|
|
122
|
+
if (!rawMeta) return null;
|
|
123
|
+
if (typeof rawMeta === 'object') return rawMeta;
|
|
124
|
+
try { return JSON.parse(rawMeta); } catch { return null; }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _metaSymbol(meta) {
|
|
128
|
+
return meta.name ?? meta.symbol ?? null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _metaType(meta) {
|
|
132
|
+
return meta.type ?? meta.chunk_type ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function _metaStartLine(meta) {
|
|
136
|
+
return typeof meta.startLine === 'number' ? meta.startLine
|
|
137
|
+
: typeof meta.line_start === 'number' ? meta.line_start
|
|
138
|
+
: null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function _metaEndLine(meta) {
|
|
142
|
+
return typeof meta.endLine === 'number' ? meta.endLine
|
|
143
|
+
: typeof meta.line_end === 'number' ? meta.line_end
|
|
144
|
+
: null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _tokenizeQuery(q) {
|
|
148
|
+
// Split on non-word, lowercase, drop very short tokens — close enough to
|
|
149
|
+
// BM25-grade tokenisation for per-file term hits without a full index.
|
|
150
|
+
return Array.from(new Set(
|
|
151
|
+
String(q).toLowerCase().split(/[^a-zA-Z0-9_]+/g).filter(t => t.length >= 2),
|
|
152
|
+
));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _escapeRegex(s) {
|
|
156
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Candidate enumeration — load chunk metadata + per-chunk on-disk text slice
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
async function _loadFileChunks(filePathRel, projectRoot) {
|
|
164
|
+
const repo = _getRepo();
|
|
165
|
+
if (!repo) return { chunks: [], language: null };
|
|
166
|
+
const rows = repo.getChunksByFilePath(filePathRel);
|
|
167
|
+
if (rows.length === 0) return { chunks: [], language: null };
|
|
168
|
+
|
|
169
|
+
// Read whole file once (filesystem is ground truth) — slice each span on disk.
|
|
170
|
+
let diskRead;
|
|
171
|
+
try {
|
|
172
|
+
diskRead = await readFileExact({
|
|
173
|
+
path: filePathRel,
|
|
174
|
+
projectRoot,
|
|
175
|
+
includeMetadata: false,
|
|
176
|
+
});
|
|
177
|
+
} catch {
|
|
178
|
+
return { chunks: [], language: null };
|
|
179
|
+
}
|
|
180
|
+
if (!diskRead.ok) return { chunks: [], language: null };
|
|
181
|
+
|
|
182
|
+
const fileText = diskRead.text;
|
|
183
|
+
const lineToOffset = (() => {
|
|
184
|
+
const offsets = [0];
|
|
185
|
+
for (let i = 0; i < fileText.length; i++) {
|
|
186
|
+
if (fileText.charCodeAt(i) === 10 /* \n */) offsets.push(i + 1);
|
|
187
|
+
}
|
|
188
|
+
return offsets;
|
|
189
|
+
})();
|
|
190
|
+
const totalLines = lineToOffset.length;
|
|
191
|
+
|
|
192
|
+
let language = null;
|
|
193
|
+
const chunks = [];
|
|
194
|
+
for (const row of rows) {
|
|
195
|
+
const meta = _parseMeta(row.metadata) || {};
|
|
196
|
+
if (!language && meta.language) language = meta.language;
|
|
197
|
+
const startLine = _metaStartLine(meta);
|
|
198
|
+
const endLine = _metaEndLine(meta);
|
|
199
|
+
if (startLine == null || endLine == null) continue;
|
|
200
|
+
if (startLine < 1 || startLine > totalLines) continue;
|
|
201
|
+
|
|
202
|
+
const a = Math.max(1, startLine);
|
|
203
|
+
const b = Math.min(totalLines, Math.max(a, endLine));
|
|
204
|
+
const startByte = lineToOffset[a - 1];
|
|
205
|
+
const endByte = (b < totalLines) ? lineToOffset[b] : fileText.length;
|
|
206
|
+
// Preserve disk bytes exactly (including a trailing newline if it was on
|
|
207
|
+
// disk) — chunk text is consumed by lexical scoring, not returned.
|
|
208
|
+
const exactText = fileText.slice(startByte, endByte);
|
|
209
|
+
|
|
210
|
+
chunks.push({
|
|
211
|
+
id: row.id,
|
|
212
|
+
symbol: _metaSymbol(meta),
|
|
213
|
+
type: _metaType(meta),
|
|
214
|
+
signature: meta.signature ?? null,
|
|
215
|
+
startLine: a,
|
|
216
|
+
endLine: b,
|
|
217
|
+
exactText, // re-read from disk
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
chunks.sort((c1, c2) => c1.startLine - c2.startLine);
|
|
221
|
+
return { chunks, language, totalLines, fileText };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Candidate scoring signals (per file)
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
function _scoreLexical(chunks, queryTerms) {
|
|
229
|
+
if (queryTerms.length === 0) return new Map();
|
|
230
|
+
const re = new RegExp(`\\b(?:${queryTerms.map(_escapeRegex).join('|')})\\b`, 'gi');
|
|
231
|
+
const scores = new Map();
|
|
232
|
+
for (const c of chunks) {
|
|
233
|
+
re.lastIndex = 0;
|
|
234
|
+
let hits = 0;
|
|
235
|
+
let m;
|
|
236
|
+
while ((m = re.exec(c.exactText)) !== null) {
|
|
237
|
+
hits++;
|
|
238
|
+
if (hits > 50) break; // cap runaway counters on huge chunks
|
|
239
|
+
}
|
|
240
|
+
if (hits > 0) {
|
|
241
|
+
// Diminishing returns — first hits carry more weight than the 30th.
|
|
242
|
+
scores.set(c.id, Math.log2(1 + hits));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return scores;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function _scoreSymbol(chunks, queryTerms, queryRaw) {
|
|
249
|
+
if (queryTerms.length === 0) return new Map();
|
|
250
|
+
const lowerRaw = String(queryRaw).toLowerCase();
|
|
251
|
+
const scores = new Map();
|
|
252
|
+
for (const c of chunks) {
|
|
253
|
+
const sym = (c.symbol || '').toLowerCase();
|
|
254
|
+
if (!sym) continue;
|
|
255
|
+
let s = 0;
|
|
256
|
+
if (sym && lowerRaw.includes(sym)) s += 2; // raw query mentions the symbol
|
|
257
|
+
for (const t of queryTerms) {
|
|
258
|
+
if (sym === t) s += 3; // exact name match
|
|
259
|
+
else if (sym.includes(t)) s += 1; // substring
|
|
260
|
+
}
|
|
261
|
+
if (s > 0) scores.set(c.id, s);
|
|
262
|
+
}
|
|
263
|
+
return scores;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function _scoreLateInteraction(chunks, query) {
|
|
267
|
+
if (chunks.length === 0) return { scores: new Map(), ran: false };
|
|
268
|
+
const liIndex = await _getLateInteractionIndex();
|
|
269
|
+
if (!liIndex) return { scores: new Map(), ran: false };
|
|
270
|
+
|
|
271
|
+
// Only score chunks whose IDs actually appear in the LI index.
|
|
272
|
+
const candidates = chunks
|
|
273
|
+
.filter(c => liIndex.documents.has(c.id))
|
|
274
|
+
.map(c => ({ id: c.id, score: 0 }));
|
|
275
|
+
if (candidates.length === 0) return { scores: new Map(), ran: false };
|
|
276
|
+
|
|
277
|
+
const encodeQuery = await _getEncodeQuery();
|
|
278
|
+
if (!encodeQuery) return { scores: new Map(), ran: false };
|
|
279
|
+
|
|
280
|
+
let qTokens;
|
|
281
|
+
try { qTokens = await encodeQuery(query); }
|
|
282
|
+
catch { return { scores: new Map(), ran: false }; }
|
|
283
|
+
if (!qTokens || qTokens.length === 0) return { scores: new Map(), ran: false };
|
|
284
|
+
|
|
285
|
+
let scored;
|
|
286
|
+
try {
|
|
287
|
+
scored = await liIndex.scoreWithLateInteraction(qTokens, candidates);
|
|
288
|
+
} catch {
|
|
289
|
+
return { scores: new Map(), ran: false };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const out = new Map();
|
|
293
|
+
for (const r of scored) out.set(r.id, r.lateInteractionScore ?? r.score ?? 0);
|
|
294
|
+
return { scores: out, ran: true };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Reciprocal Rank Fusion over multiple signal maps
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
function _rrfFuse(signalMaps, weights, rrfK) {
|
|
302
|
+
// signalMaps: [{ id -> score }] in same order as `weights`
|
|
303
|
+
const fused = new Map();
|
|
304
|
+
for (let i = 0; i < signalMaps.length; i++) {
|
|
305
|
+
const m = signalMaps[i];
|
|
306
|
+
if (!m || m.size === 0) continue;
|
|
307
|
+
const w = weights[i] ?? 1;
|
|
308
|
+
const sorted = [...m.entries()].sort((a, b) => b[1] - a[1]);
|
|
309
|
+
for (let r = 0; r < sorted.length; r++) {
|
|
310
|
+
const [id] = sorted[r];
|
|
311
|
+
const contribution = w / (rrfK + r + 1);
|
|
312
|
+
fused.set(id, (fused.get(id) || 0) + contribution);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return fused;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Span post-processing — context expansion, merging, budget enforcement
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
function _expandAndMergeSpans(selected, totalLines, contextLines) {
|
|
323
|
+
if (selected.length === 0) return [];
|
|
324
|
+
const padded = selected
|
|
325
|
+
.map(s => ({
|
|
326
|
+
...s,
|
|
327
|
+
startLine: Math.max(1, s.startLine - contextLines),
|
|
328
|
+
endLine: Math.min(totalLines, s.endLine + contextLines),
|
|
329
|
+
}))
|
|
330
|
+
.sort((a, b) => a.startLine - b.startLine);
|
|
331
|
+
|
|
332
|
+
const merged = [];
|
|
333
|
+
for (const span of padded) {
|
|
334
|
+
const last = merged[merged.length - 1];
|
|
335
|
+
if (last && span.startLine <= last.endLine + 1) {
|
|
336
|
+
// Overlap or touching — merge.
|
|
337
|
+
last.endLine = Math.max(last.endLine, span.endLine);
|
|
338
|
+
last.score = Math.max(last.score, span.score);
|
|
339
|
+
last.symbols = Array.from(new Set([
|
|
340
|
+
...(last.symbols || []),
|
|
341
|
+
...(span.symbol ? [span.symbol] : []),
|
|
342
|
+
]));
|
|
343
|
+
last.types = Array.from(new Set([
|
|
344
|
+
...(last.types || []),
|
|
345
|
+
...(span.type ? [span.type] : []),
|
|
346
|
+
]));
|
|
347
|
+
last.chunkIds.push(span.id);
|
|
348
|
+
} else {
|
|
349
|
+
merged.push({
|
|
350
|
+
startLine: span.startLine,
|
|
351
|
+
endLine: span.endLine,
|
|
352
|
+
score: span.score,
|
|
353
|
+
symbols: span.symbol ? [span.symbol] : [],
|
|
354
|
+
types: span.type ? [span.type] : [],
|
|
355
|
+
chunkIds: [span.id],
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return merged;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function _sliceSpanFromDisk(fileText, lineOffsets, startLine, endLine) {
|
|
363
|
+
const total = lineOffsets.length;
|
|
364
|
+
if (total === 0) return '';
|
|
365
|
+
const a = Math.max(1, startLine | 0);
|
|
366
|
+
const b = Math.min(total, Math.max(a, endLine | 0));
|
|
367
|
+
const startByte = lineOffsets[a - 1];
|
|
368
|
+
const endByte = (b < total) ? lineOffsets[b] : fileText.length;
|
|
369
|
+
// Return disk-exact bytes; never strip newlines that exist on disk.
|
|
370
|
+
return fileText.slice(startByte, endByte);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function _enforceCharBudget(spans, fileText, lineOffsets, maxChars) {
|
|
374
|
+
// Greedy: take spans by score until we'd blow the budget. The minimum
|
|
375
|
+
// span we always include is the top-1 (truncated if it alone exceeds the
|
|
376
|
+
// budget) — better to return one truncated span than nothing.
|
|
377
|
+
const ranked = [...spans].sort((a, b) => b.score - a.score);
|
|
378
|
+
const kept = [];
|
|
379
|
+
let used = 0;
|
|
380
|
+
for (const span of ranked) {
|
|
381
|
+
const text = _sliceSpanFromDisk(fileText, lineOffsets, span.startLine, span.endLine);
|
|
382
|
+
const cost = text.length;
|
|
383
|
+
if (kept.length === 0 && cost > maxChars) {
|
|
384
|
+
// Truncate the single top span; prefer head of the span (definition first).
|
|
385
|
+
const truncatedText = text.slice(0, maxChars);
|
|
386
|
+
kept.push({ ...span, text: truncatedText, truncated: true });
|
|
387
|
+
used += truncatedText.length;
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
if (used + cost > maxChars) continue;
|
|
391
|
+
kept.push({ ...span, text });
|
|
392
|
+
used += cost;
|
|
393
|
+
}
|
|
394
|
+
// Restore line order in the final output for readability.
|
|
395
|
+
kept.sort((a, b) => a.startLine - b.startLine);
|
|
396
|
+
return { spans: kept, charsUsed: used };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function _fallbackSpanFromRead(fallback, maxChars) {
|
|
400
|
+
const text = fallback.text || '';
|
|
401
|
+
const capped = text.length > maxChars ? text.slice(0, maxChars) : text;
|
|
402
|
+
return {
|
|
403
|
+
startLine: 1,
|
|
404
|
+
endLine: fallback.totalLines,
|
|
405
|
+
score: 0,
|
|
406
|
+
symbols: [],
|
|
407
|
+
types: [],
|
|
408
|
+
chunkIds: [],
|
|
409
|
+
text: capped,
|
|
410
|
+
truncated: capped.length < text.length || undefined,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function _fallbackSpanFromText(fileText, totalLines, maxChars) {
|
|
415
|
+
const capped = fileText.length > maxChars ? fileText.slice(0, maxChars) : fileText;
|
|
416
|
+
return {
|
|
417
|
+
startLine: 1,
|
|
418
|
+
endLine: totalLines,
|
|
419
|
+
score: 0,
|
|
420
|
+
symbols: [],
|
|
421
|
+
types: [],
|
|
422
|
+
chunkIds: [],
|
|
423
|
+
text: capped,
|
|
424
|
+
truncated: capped.length < fileText.length || undefined,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// Public API
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* @param {Object} req
|
|
434
|
+
* @param {string} req.path - File path (project-relative or absolute)
|
|
435
|
+
* @param {string} req.query - Natural language query
|
|
436
|
+
* @param {number} [req.topK=5]
|
|
437
|
+
* @param {number} [req.threshold=0.4] - MaxSim score floor when LI runs
|
|
438
|
+
* @param {number} [req.contextLines=2]
|
|
439
|
+
* @param {number} [req.maxChars=8000]
|
|
440
|
+
* @param {number} [req.maxTokens] - Convenience: ~maxChars / 4
|
|
441
|
+
* @param {string} [req.projectRoot]
|
|
442
|
+
* @param {boolean} [req.verbose=false] - include timings + signal contributions
|
|
443
|
+
* @returns {Promise<Object>}
|
|
444
|
+
*/
|
|
445
|
+
export async function readSemantic(req) {
|
|
446
|
+
const t0 = performance.now();
|
|
447
|
+
if (!req || !req.path) throw new Error('path is required');
|
|
448
|
+
if (!req.query || !String(req.query).trim()) throw new Error('query is required');
|
|
449
|
+
|
|
450
|
+
const projectRoot = req.projectRoot || process.cwd();
|
|
451
|
+
const filePathRel = _projectRelative(req.path, projectRoot);
|
|
452
|
+
|
|
453
|
+
const topK = req.topK ?? DEFAULTS.topK;
|
|
454
|
+
const threshold = req.threshold ?? DEFAULTS.threshold;
|
|
455
|
+
const contextLines = req.contextLines ?? DEFAULTS.contextLines;
|
|
456
|
+
const maxChars = req.maxChars
|
|
457
|
+
?? (req.maxTokens != null ? req.maxTokens * APPROX_CHARS_PER_TOKEN : DEFAULTS.maxChars);
|
|
458
|
+
const verbose = !!req.verbose;
|
|
459
|
+
|
|
460
|
+
const tLoad0 = performance.now();
|
|
461
|
+
const { chunks, language, totalLines, fileText } = await _loadFileChunks(filePathRel, projectRoot);
|
|
462
|
+
const tLoad1 = performance.now();
|
|
463
|
+
|
|
464
|
+
// No chunks at all → fall back to plain read so the caller still gets
|
|
465
|
+
// exact text. Document the fallback in the response.
|
|
466
|
+
if (!chunks || chunks.length === 0) {
|
|
467
|
+
const fallback = await readFileExact({ path: req.path, projectRoot });
|
|
468
|
+
return {
|
|
469
|
+
file: filePathRel,
|
|
470
|
+
query: req.query,
|
|
471
|
+
ok: fallback.ok,
|
|
472
|
+
indexed: false,
|
|
473
|
+
fellBack: true,
|
|
474
|
+
reason: 'file not indexed for semantic span selection — returning whole file via plain read',
|
|
475
|
+
language: fallback.language,
|
|
476
|
+
totalLines: fallback.totalLines,
|
|
477
|
+
spans: fallback.ok ? [_fallbackSpanFromRead(fallback, maxChars)] : [],
|
|
478
|
+
charsReturned: fallback.ok ? Math.min((fallback.text || '').length, maxChars) : 0,
|
|
479
|
+
approxTokensReturned: fallback.ok ? Math.ceil(Math.min((fallback.text || '').length, maxChars) / APPROX_CHARS_PER_TOKEN) : 0,
|
|
480
|
+
timings: { totalMs: +(performance.now() - t0).toFixed(2) },
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Build line-offset table over the disk text once for span re-reads.
|
|
485
|
+
const lineOffsets = (() => {
|
|
486
|
+
const offsets = [0];
|
|
487
|
+
for (let i = 0; i < fileText.length; i++) {
|
|
488
|
+
if (fileText.charCodeAt(i) === 10) offsets.push(i + 1);
|
|
489
|
+
}
|
|
490
|
+
return offsets;
|
|
491
|
+
})();
|
|
492
|
+
|
|
493
|
+
const queryTerms = _tokenizeQuery(req.query);
|
|
494
|
+
|
|
495
|
+
const tLex0 = performance.now();
|
|
496
|
+
const lexicalScores = _scoreLexical(chunks, queryTerms);
|
|
497
|
+
const symbolScores = _scoreSymbol(chunks, queryTerms, req.query);
|
|
498
|
+
const tLex1 = performance.now();
|
|
499
|
+
|
|
500
|
+
const tLi0 = performance.now();
|
|
501
|
+
const { scores: maxsimScores, ran: liRan } = await _scoreLateInteraction(chunks, req.query);
|
|
502
|
+
const tLi1 = performance.now();
|
|
503
|
+
|
|
504
|
+
// Threshold gate on MaxSim — drop chunks whose LI score is too low. This
|
|
505
|
+
// is purely a score-floor: chunks still surviving via lexical/symbol can
|
|
506
|
+
// be retained downstream, since the floor is a MaxSim-specific quality
|
|
507
|
+
// signal.
|
|
508
|
+
if (liRan && threshold > 0) {
|
|
509
|
+
for (const [id, s] of [...maxsimScores]) {
|
|
510
|
+
if (s < threshold) maxsimScores.delete(id);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Fuse — all three signals contribute via RRF.
|
|
515
|
+
const fused = _rrfFuse(
|
|
516
|
+
[lexicalScores, symbolScores, maxsimScores],
|
|
517
|
+
[DEFAULTS.lexicalWeight, DEFAULTS.symbolWeight, DEFAULTS.maxsimWeight],
|
|
518
|
+
DEFAULTS.rrfK,
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
// If everything is empty, return the whole file as a graceful fallback
|
|
522
|
+
// with a low confidence marker rather than nothing.
|
|
523
|
+
if (fused.size === 0) {
|
|
524
|
+
return {
|
|
525
|
+
file: filePathRel,
|
|
526
|
+
query: req.query,
|
|
527
|
+
ok: true,
|
|
528
|
+
indexed: true,
|
|
529
|
+
fellBack: true,
|
|
530
|
+
reason: 'no chunk matched query signals — returning whole file',
|
|
531
|
+
language,
|
|
532
|
+
totalLines,
|
|
533
|
+
spans: [_fallbackSpanFromText(fileText, totalLines, maxChars)],
|
|
534
|
+
charsReturned: Math.min(fileText.length, maxChars),
|
|
535
|
+
approxTokensReturned: Math.ceil(Math.min(fileText.length, maxChars) / APPROX_CHARS_PER_TOKEN),
|
|
536
|
+
signals: verbose ? { liRan, lexicalHits: 0, symbolHits: 0, maxsimHits: 0 } : undefined,
|
|
537
|
+
timings: verbose ? {
|
|
538
|
+
loadMs: +(tLoad1 - tLoad0).toFixed(2),
|
|
539
|
+
lexicalMs: +(tLex1 - tLex0).toFixed(2),
|
|
540
|
+
liMs: +(tLi1 - tLi0).toFixed(2),
|
|
541
|
+
totalMs: +(performance.now() - t0).toFixed(2),
|
|
542
|
+
} : { totalMs: +(performance.now() - t0).toFixed(2) },
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Take top-K by fused score, then pull the actual chunk records.
|
|
547
|
+
const fusedTop = [...fused.entries()]
|
|
548
|
+
.sort((a, b) => b[1] - a[1])
|
|
549
|
+
.slice(0, Math.max(topK * 2, topK)); // overshoot a bit before LI re-rank
|
|
550
|
+
const idToChunk = new Map(chunks.map(c => [c.id, c]));
|
|
551
|
+
|
|
552
|
+
// Final re-rank: prefer late-interaction score when LI ran; otherwise the
|
|
553
|
+
// RRF score is the authority. This mirrors the SOTA pattern (cheap candidate
|
|
554
|
+
// pool → expensive LI re-rank on the survivors).
|
|
555
|
+
const ranked = fusedTop
|
|
556
|
+
.map(([id, fusedScore]) => {
|
|
557
|
+
const c = idToChunk.get(id);
|
|
558
|
+
if (!c) return null;
|
|
559
|
+
const li = maxsimScores.get(id);
|
|
560
|
+
const finalScore = liRan && li != null ? li : fusedScore;
|
|
561
|
+
return {
|
|
562
|
+
id,
|
|
563
|
+
symbol: c.symbol,
|
|
564
|
+
type: c.type,
|
|
565
|
+
startLine: c.startLine,
|
|
566
|
+
endLine: c.endLine,
|
|
567
|
+
score: finalScore,
|
|
568
|
+
signals: {
|
|
569
|
+
lexical: lexicalScores.get(id) || 0,
|
|
570
|
+
symbol: symbolScores.get(id) || 0,
|
|
571
|
+
maxsim: liRan ? (maxsimScores.get(id) ?? null) : null,
|
|
572
|
+
fused: fusedScore,
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
})
|
|
576
|
+
.filter(Boolean)
|
|
577
|
+
.sort((a, b) => b.score - a.score)
|
|
578
|
+
.slice(0, topK);
|
|
579
|
+
|
|
580
|
+
const merged = _expandAndMergeSpans(ranked, totalLines, contextLines);
|
|
581
|
+
const { spans, charsUsed } = _enforceCharBudget(merged, fileText, lineOffsets, maxChars);
|
|
582
|
+
|
|
583
|
+
return {
|
|
584
|
+
file: filePathRel,
|
|
585
|
+
query: req.query,
|
|
586
|
+
ok: true,
|
|
587
|
+
indexed: true,
|
|
588
|
+
fellBack: false,
|
|
589
|
+
language,
|
|
590
|
+
totalLines,
|
|
591
|
+
spans,
|
|
592
|
+
charsReturned: charsUsed,
|
|
593
|
+
approxTokensReturned: Math.ceil(charsUsed / APPROX_CHARS_PER_TOKEN),
|
|
594
|
+
signals: verbose ? {
|
|
595
|
+
liRan,
|
|
596
|
+
lexicalHits: lexicalScores.size,
|
|
597
|
+
symbolHits: symbolScores.size,
|
|
598
|
+
maxsimHits: maxsimScores.size,
|
|
599
|
+
fusedCandidates: fused.size,
|
|
600
|
+
preMergeRanked: ranked,
|
|
601
|
+
} : undefined,
|
|
602
|
+
timings: verbose ? {
|
|
603
|
+
loadMs: +(tLoad1 - tLoad0).toFixed(2),
|
|
604
|
+
lexicalMs: +(tLex1 - tLex0).toFixed(2),
|
|
605
|
+
liMs: +(tLi1 - tLi0).toFixed(2),
|
|
606
|
+
totalMs: +(performance.now() - t0).toFixed(2),
|
|
607
|
+
} : { totalMs: +(performance.now() - t0).toFixed(2) },
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ---------------------------------------------------------------------------
|
|
612
|
+
// Formatting
|
|
613
|
+
// ---------------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
export function formatReadSemanticResult(result, format = 'agent') {
|
|
616
|
+
if (format === 'json') return JSON.stringify(result, null, 2);
|
|
617
|
+
|
|
618
|
+
const fence = result.language ? '```' + result.language : '```';
|
|
619
|
+
const header = result.fellBack
|
|
620
|
+
? `### ${result.file} — full file (${result.reason || 'fallback'})`
|
|
621
|
+
: `### ${result.file} — top spans for: ${JSON.stringify(result.query)}`;
|
|
622
|
+
const lines = [header];
|
|
623
|
+
if (!result.ok) {
|
|
624
|
+
lines.push(`[error]`);
|
|
625
|
+
return lines.join('\n');
|
|
626
|
+
}
|
|
627
|
+
for (const span of result.spans) {
|
|
628
|
+
const label = span.symbols && span.symbols.length
|
|
629
|
+
? `${span.symbols.join(', ')} (lines ${span.startLine}-${span.endLine})`
|
|
630
|
+
: `lines ${span.startLine}-${span.endLine}`;
|
|
631
|
+
lines.push(`-- ${label}${typeof span.score === 'number' ? ` — score=${span.score.toFixed(3)}` : ''}`);
|
|
632
|
+
lines.push(fence);
|
|
633
|
+
lines.push(span.text);
|
|
634
|
+
lines.push('```');
|
|
635
|
+
}
|
|
636
|
+
return lines.join('\n');
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
// CLI handler
|
|
641
|
+
// sweet-search read-semantic path/to/file.ts "how does X work"
|
|
642
|
+
// sweet-search read-semantic path/to/file.ts "..." --top 5 --threshold 0.4
|
|
643
|
+
// sweet-search read-semantic path/to/file.ts "..." --json --verbose
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
function _parseArgs(args) {
|
|
647
|
+
const positional = [];
|
|
648
|
+
let format = 'agent';
|
|
649
|
+
let topK; let threshold; let contextLines; let maxChars; let maxTokens; let verbose = false;
|
|
650
|
+
for (let i = 0; i < args.length; i++) {
|
|
651
|
+
const a = args[i];
|
|
652
|
+
if (a === '--json') format = 'json';
|
|
653
|
+
else if (a === '--agent') format = 'agent';
|
|
654
|
+
else if (a === '--verbose') verbose = true;
|
|
655
|
+
else if (a === '--top' || a === '--top-k' || a === '-k') topK = +args[++i];
|
|
656
|
+
else if (a === '--threshold') threshold = +args[++i];
|
|
657
|
+
else if (a === '--context') contextLines = +args[++i];
|
|
658
|
+
else if (a === '--max-chars') maxChars = +args[++i];
|
|
659
|
+
else if (a === '--max-tokens') maxTokens = +args[++i];
|
|
660
|
+
else if (a === '--help' || a === '-h') return { help: true };
|
|
661
|
+
else if (a.startsWith('--')) throw new Error(`unknown flag: ${a}`);
|
|
662
|
+
else positional.push(a);
|
|
663
|
+
}
|
|
664
|
+
return { positional, format, topK, threshold, contextLines, maxChars, maxTokens, verbose };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function _printHelp() {
|
|
668
|
+
process.stdout.write([
|
|
669
|
+
'sweet-search read-semantic — return only the file spans relevant to a query',
|
|
670
|
+
'',
|
|
671
|
+
'Usage:',
|
|
672
|
+
' sweet-search read-semantic <file> "<query>"',
|
|
673
|
+
'',
|
|
674
|
+
'Options:',
|
|
675
|
+
' --top, -k <n> Max ranked spans before merging (default: 5)',
|
|
676
|
+
' --threshold <f> MaxSim score floor when LI runs (default: 0.4)',
|
|
677
|
+
' --context <n> Lines of pre/post context per selected span (default: 2)',
|
|
678
|
+
' --max-chars <n> Hard cap on returned text (default: 8000)',
|
|
679
|
+
' --max-tokens <n> Convenience cap (~chars/4)',
|
|
680
|
+
' --json Emit JSON',
|
|
681
|
+
' --verbose Include timings + per-signal scores',
|
|
682
|
+
'',
|
|
683
|
+
].join('\n'));
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
export async function handleReadSemanticCli(args) {
|
|
687
|
+
let parsed;
|
|
688
|
+
try { parsed = _parseArgs(args); }
|
|
689
|
+
catch (err) { process.stderr.write(`[sweet-search read-semantic] ${err.message}\n`); process.exit(2); }
|
|
690
|
+
if (parsed.help || !parsed.positional || parsed.positional.length < 2) {
|
|
691
|
+
_printHelp();
|
|
692
|
+
process.exit(parsed.help ? 0 : 2);
|
|
693
|
+
}
|
|
694
|
+
const [file, ...queryParts] = parsed.positional;
|
|
695
|
+
const query = queryParts.join(' ');
|
|
696
|
+
const result = await readSemantic({
|
|
697
|
+
path: file,
|
|
698
|
+
query,
|
|
699
|
+
topK: parsed.topK,
|
|
700
|
+
threshold: parsed.threshold,
|
|
701
|
+
contextLines: parsed.contextLines,
|
|
702
|
+
maxChars: parsed.maxChars,
|
|
703
|
+
maxTokens: parsed.maxTokens,
|
|
704
|
+
verbose: parsed.verbose,
|
|
705
|
+
});
|
|
706
|
+
process.stdout.write(formatReadSemanticResult(result, parsed.format));
|
|
707
|
+
if (parsed.format !== 'json') process.stdout.write('\n');
|
|
708
|
+
process.exit(result.ok ? 0 : 1);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Test-only export — clears caches between unit tests.
|
|
712
|
+
export function __resetReadSemanticCachesForTests() {
|
|
713
|
+
_repo = null;
|
|
714
|
+
_liIndex = null;
|
|
715
|
+
_liInitPromise = null;
|
|
716
|
+
_encodeQueryFn = null;
|
|
717
|
+
}
|