sweet-search 2.6.8 → 2.6.9

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.
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { existsSync, readFileSync } from 'fs';
11
- import { minimatch } from 'minimatch';
11
+ import { Minimatch } from 'minimatch';
12
12
  import { loadProjectConfig } from '../infrastructure/config/index.js';
13
13
 
14
14
  const MM_OPTS = { dot: true, nocase: false };
@@ -54,6 +54,31 @@ function getExcludes(projectRoot) {
54
54
  function resetCache() {
55
55
  _excludesByRoot.clear();
56
56
  _cachedExtraPatterns = null;
57
+ _excludeMatchersByRoot.clear();
58
+ _cachedExtraMatchers = null;
59
+ }
60
+
61
+ // Precompiled Minimatch instances — `minimatch(p, g, opts)` recompiles the
62
+ // glob to a regex on every call; `new Minimatch(g, opts).match(p)` is the
63
+ // documented equivalent with the compile amortized.
64
+ const _excludeMatchersByRoot = new Map();
65
+
66
+ function getExcludeMatchers(projectRoot) {
67
+ const key = projectRoot || '__cwd__';
68
+ let cached = _excludeMatchersByRoot.get(key);
69
+ if (cached) return cached;
70
+ cached = getExcludes(projectRoot)
71
+ .filter((g) => typeof g === 'string')
72
+ .map((g) => new Minimatch(g, MM_OPTS));
73
+ _excludeMatchersByRoot.set(key, cached);
74
+ return cached;
75
+ }
76
+
77
+ let _cachedExtraMatchers = null;
78
+ function getExtraMatchers() {
79
+ if (_cachedExtraMatchers !== null) return _cachedExtraMatchers;
80
+ _cachedExtraMatchers = loadExtraPatternsFromFile().map((g) => new Minimatch(g, MM_OPTS));
81
+ return _cachedExtraMatchers;
57
82
  }
58
83
 
59
84
  const GENERATED_MARKERS = [
@@ -87,13 +112,11 @@ function loadExtraPatternsFromFile() {
87
112
  export function isExcludedByConfig(filePath, projectRoot) {
88
113
  if (!filePath) return false;
89
114
  const p = normalizePath(filePath);
90
- const excludes = getExcludes(projectRoot);
91
- for (const g of excludes) {
92
- if (typeof g === 'string' && minimatch(p, g, MM_OPTS)) return true;
115
+ for (const m of getExcludeMatchers(projectRoot)) {
116
+ if (m.match(p)) return true;
93
117
  }
94
- const extras = loadExtraPatternsFromFile();
95
- for (const g of extras) {
96
- if (minimatch(p, g, MM_OPTS)) return true;
118
+ for (const m of getExtraMatchers()) {
119
+ if (m.match(p)) return true;
97
120
  }
98
121
  return false;
99
122
  }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import Database from 'better-sqlite3';
12
12
  import { existsSync, statSync } from 'fs';
13
- import { applyReadPragmas } from './db-utils.js';
13
+ import { applyReadPragmas, prepareCached } from './db-utils.js';
14
14
  import { readAdjacentManifest, resolveManifestCodeGraphPath, sqlAliasPrefix } from './code-graph-visibility.js';
15
15
 
16
16
  export class CodeGraphRepository {
@@ -72,7 +72,7 @@ export class CodeGraphRepository {
72
72
  }
73
73
 
74
74
  _hasColumns(db, table, columns) {
75
- const rows = db.prepare(`PRAGMA table_info(${table})`).all();
75
+ const rows = prepareCached(db, `PRAGMA table_info(${table})`).all();
76
76
  const names = new Set(rows.map((row) => row.name));
77
77
  return columns.every((column) => names.has(column));
78
78
  }
@@ -138,7 +138,7 @@ export class CodeGraphRepository {
138
138
  const db = this._open();
139
139
  if (!db) return null;
140
140
  try {
141
- const row = db.prepare(`
141
+ const row = prepareCached(db, `
142
142
  SELECT id, name, type, start_line, end_line, parent_class
143
143
  FROM entities
144
144
  WHERE file_path = ?
@@ -207,7 +207,7 @@ export class CodeGraphRepository {
207
207
  // in the chunk range (catches entities like a multi-chunk struct whose
208
208
  // declaration starts in this chunk but body extends beyond — e.g. gin
209
209
  // Engine struct at gin.go:92-189 split across chunks 92-133 and 134-189).
210
- const containedRows = db.prepare(`
210
+ const containedRows = prepareCached(db, `
211
211
  SELECT name, type, start_line, end_line FROM entities
212
212
  WHERE file_path = ? AND start_line >= ? AND end_line <= ? AND ${this._entityVisibilitySql(db)}
213
213
  ORDER BY (end_line - start_line) DESC
@@ -219,7 +219,7 @@ export class CodeGraphRepository {
219
219
  return { name: row.name, type: row.type, startLine: row.start_line, endLine: row.end_line };
220
220
  }
221
221
  }
222
- const startsInRows = db.prepare(`
222
+ const startsInRows = prepareCached(db, `
223
223
  SELECT name, type, start_line, end_line FROM entities
224
224
  WHERE file_path = ? AND start_line >= ? AND start_line <= ? AND ${this._entityVisibilitySql(db)}
225
225
  ORDER BY (end_line - start_line) DESC
@@ -245,7 +245,7 @@ export class CodeGraphRepository {
245
245
  const db = this._open();
246
246
  if (!db) return null;
247
247
  try {
248
- const row = db.prepare(`
248
+ const row = prepareCached(db, `
249
249
  SELECT id, name, type, start_line, end_line, parent_class
250
250
  FROM entities
251
251
  WHERE file_path = ?
@@ -289,7 +289,7 @@ export class CodeGraphRepository {
289
289
  const db = this._open();
290
290
  if (!db) return [];
291
291
  try {
292
- const rows = db.prepare(`
292
+ const rows = prepareCached(db, `
293
293
  SELECT id, name, type, start_line, end_line, parent_class
294
294
  FROM entities
295
295
  WHERE file_path = ?
@@ -322,7 +322,7 @@ export class CodeGraphRepository {
322
322
  const db = this._open();
323
323
  if (!db) return null;
324
324
  try {
325
- const row = db.prepare(`
325
+ const row = prepareCached(db, `
326
326
  SELECT id, name, type, file_path, start_line, end_line, parent_class
327
327
  FROM entities
328
328
  WHERE id = ? AND ${this._entityVisibilitySql(db)}
@@ -379,7 +379,7 @@ export class CodeGraphRepository {
379
379
  const args = types
380
380
  ? [...this._entityVisibilityParams(db), sourceId, ...this._relationshipVisibilityParams(db), ...types, limit]
381
381
  : [...this._entityVisibilityParams(db), sourceId, ...this._relationshipVisibilityParams(db), limit];
382
- const rows = db.prepare(baseSql).all(...args);
382
+ const rows = prepareCached(db, baseSql).all(...args);
383
383
  return rows.map(r => ({
384
384
  type: r.rel_type,
385
385
  targetName: r.target_name,
@@ -445,7 +445,7 @@ export class CodeGraphRepository {
445
445
  const args = excludeFile
446
446
  ? [...uniq, ...types, ...this._entityVisibilityParams(db), excludeFile, limit]
447
447
  : [...uniq, ...types, ...this._entityVisibilityParams(db), limit];
448
- const rows = db.prepare(sql).all(...args);
448
+ const rows = prepareCached(db, sql).all(...args);
449
449
  // De-dup by name+type, keeping the first (smallest body).
450
450
  const seen = new Set();
451
451
  const out = [];
@@ -493,7 +493,7 @@ export class CodeGraphRepository {
493
493
  ORDER BY (end_line - start_line) ASC
494
494
  LIMIT ?
495
495
  `;
496
- const rows = db.prepare(sql).all(...uniq, ...types, ...this._entityVisibilityParams(db), limit);
496
+ const rows = prepareCached(db, sql).all(...uniq, ...types, ...this._entityVisibilityParams(db), limit);
497
497
  return rows.map(row => ({
498
498
  id: row.id,
499
499
  name: row.name,
@@ -549,7 +549,7 @@ export class CodeGraphRepository {
549
549
  ORDER BY (end_line - start_line) ASC
550
550
  LIMIT ?
551
551
  `;
552
- const rows = db.prepare(sql).all(...uniq, ...exclude, ...this._entityVisibilityParams(db), limit);
552
+ const rows = prepareCached(db, sql).all(...uniq, ...exclude, ...this._entityVisibilityParams(db), limit);
553
553
  return rows.map(row => ({
554
554
  id: row.id,
555
555
  name: row.name,
@@ -598,7 +598,7 @@ export class CodeGraphRepository {
598
598
  AND ${this._entityVisibilitySql(db)}
599
599
  GROUP BY lname
600
600
  `;
601
- const rows = db.prepare(sql).all(...uniq, ...exclude, ...this._entityVisibilityParams(db));
601
+ const rows = prepareCached(db, sql).all(...uniq, ...exclude, ...this._entityVisibilityParams(db));
602
602
  const map = new Map();
603
603
  for (const r of rows) map.set(r.lname, r.count);
604
604
  // Names with no row are absent — caller treats absent as 0.
@@ -640,7 +640,7 @@ export class CodeGraphRepository {
640
640
  const args = types
641
641
  ? [...this._entityVisibilityParams(db), targetId, ...this._relationshipVisibilityParams(db), ...types, limit]
642
642
  : [...this._entityVisibilityParams(db), targetId, ...this._relationshipVisibilityParams(db), limit];
643
- const rows = db.prepare(baseSql).all(...args);
643
+ const rows = prepareCached(db, baseSql).all(...args);
644
644
  return rows.map(r => ({
645
645
  type: r.rel_type,
646
646
  contextLine: r.context_line || null,
@@ -689,7 +689,7 @@ export class CodeGraphRepository {
689
689
  return Number.isFinite(n) && n > 0 ? n : 12;
690
690
  })();
691
691
  try {
692
- const rows = db.prepare(`
692
+ const rows = prepareCached(db, `
693
693
  SELECT r.target_name, COUNT(*) as cnt
694
694
  FROM relationships r
695
695
  LEFT JOIN entities e ON e.id = r.target_id AND ${this._entityVisibilitySql(db, 'e')}
@@ -798,7 +798,7 @@ export class CodeGraphRepository {
798
798
  if (!db) return null;
799
799
  try {
800
800
  if (this._hasColumns(db, 'entities', ['epoch_written', 'epoch_retired'])) {
801
- const visible = db.prepare(`
801
+ const visible = prepareCached(db, `
802
802
  SELECT 1 FROM entities
803
803
  WHERE file_path = ? AND ${this._entityVisibilitySql(db)}
804
804
  LIMIT 1
@@ -827,10 +827,10 @@ export class CodeGraphRepository {
827
827
  const staleArgs = this._manifestEpoch !== null
828
828
  ? [filePath, this._manifestEpoch, this._manifestEpoch]
829
829
  : [filePath];
830
- const staleRow = db.prepare(staleSql).get(...staleArgs);
830
+ const staleRow = prepareCached(db, staleSql).get(...staleArgs);
831
831
  return staleRow ? { staleSince: staleRow.stale_since || null } : null;
832
832
  }
833
- const row = db.prepare(`
833
+ const row = prepareCached(db, `
834
834
  SELECT stale_since, MIN(ROWID) as first_row
835
835
  FROM entities
836
836
  WHERE file_path = ?
@@ -17,6 +17,35 @@ import Database from 'better-sqlite3';
17
17
  */
18
18
  export const SAFE_IN_CLAUSE_BATCH = 999;
19
19
 
20
+ // Per-connection prepared-statement cache. Statements belong to their
21
+ // Database object, so the WeakMap self-invalidates when a connection object
22
+ // is dropped (closed connections are never passed back in by callers).
23
+ // Only use for .get/.all/.run statements — never .iterate (a cached statement
24
+ // mid-iteration cannot be re-entered).
25
+ const _stmtCaches = new WeakMap();
26
+
27
+ /**
28
+ * db.prepare with a per-connection cache — compiles each distinct SQL string
29
+ * once per Database object instead of once per call.
30
+ *
31
+ * @param {import('better-sqlite3').Database} db
32
+ * @param {string} sql
33
+ * @returns {import('better-sqlite3').Statement}
34
+ */
35
+ export function prepareCached(db, sql) {
36
+ let cache = _stmtCaches.get(db);
37
+ if (!cache) {
38
+ cache = new Map();
39
+ _stmtCaches.set(db, cache);
40
+ }
41
+ let stmt = cache.get(sql);
42
+ if (!stmt) {
43
+ stmt = db.prepare(sql);
44
+ cache.set(sql, stmt);
45
+ }
46
+ return stmt;
47
+ }
48
+
20
49
  /**
21
50
  * Apply read-path PRAGMA optimizations to a read-only database connection.
22
51
  *
@@ -98,6 +98,23 @@ async function initWasm() {
98
98
  // Eager non-blocking init
99
99
  initWasm().catch(() => {});
100
100
 
101
+ // WASM memory views are cached; both modules declare fixed-size memory (no
102
+ // memory.grow anywhere), so the buffer identity check only fires if that
103
+ // ever changes. Same pattern as _wasmPerTokenLayout.
104
+ function wasmMemView() {
105
+ if (wasmMem === null || wasmMem.buffer !== wasmExports.memory.buffer) {
106
+ wasmMem = new Uint8Array(wasmExports.memory.buffer);
107
+ }
108
+ return wasmMem;
109
+ }
110
+
111
+ function maxsimMemView() {
112
+ if (maxsimMem === null || maxsimMem.buffer !== maxsimExports.memory.buffer) {
113
+ maxsimMem = new Uint8Array(maxsimExports.memory.buffer);
114
+ }
115
+ return maxsimMem;
116
+ }
117
+
101
118
  // =============================================================================
102
119
  // POPCOUNT LUT (JS fallback)
103
120
  // =============================================================================
@@ -115,10 +132,9 @@ export function wasmHammingDistance(a, b) {
115
132
  if (wasmExports) {
116
133
  const aPtr = DATA_OFFSET;
117
134
  const bPtr = DATA_OFFSET + a.length;
118
- // Re-acquire view in case memory grew
119
- wasmMem = new Uint8Array(wasmExports.memory.buffer);
120
- wasmMem.set(a, aPtr);
121
- wasmMem.set(b, bPtr);
135
+ const mem = wasmMemView();
136
+ mem.set(a, aPtr);
137
+ mem.set(b, bPtr);
122
138
  return wasmExports.hamming_distance(aPtr, bPtr, a.length);
123
139
  }
124
140
  // JS fallback
@@ -137,9 +153,9 @@ export function wasmInt8Cosine(a, b) {
137
153
  if (wasmExports) {
138
154
  const aPtr = DATA_OFFSET;
139
155
  const bPtr = DATA_OFFSET + a.length;
140
- wasmMem = new Uint8Array(wasmExports.memory.buffer);
141
- wasmMem.set(new Uint8Array(a.buffer, a.byteOffset, a.byteLength), aPtr);
142
- wasmMem.set(new Uint8Array(b.buffer, b.byteOffset, b.byteLength), bPtr);
156
+ const mem = wasmMemView();
157
+ mem.set(new Uint8Array(a.buffer, a.byteOffset, a.byteLength), aPtr);
158
+ mem.set(new Uint8Array(b.buffer, b.byteOffset, b.byteLength), bPtr);
143
159
 
144
160
  const dot = wasmExports.int8_dot(aPtr, bPtr, a.length);
145
161
  const normA = wasmExports.int8_norm_sq(aPtr, a.length);
@@ -193,18 +209,18 @@ export function wasmInt8BatchDot(query, candidates) {
193
209
  const alignedScoresPtr = (scoresPtr + 3) & ~3;
194
210
  const needed = alignedScoresPtr + count * 4;
195
211
 
196
- wasmMem = new Uint8Array(wasmExports.memory.buffer);
197
- if (needed > wasmMem.length) {
212
+ const mem = wasmMemView();
213
+ if (needed > mem.length) {
198
214
  return _jsInt8BatchDot(query, candidates);
199
215
  }
200
216
 
201
217
  // Copy query once
202
- wasmMem.set(new Uint8Array(query.buffer, query.byteOffset, query.byteLength), queryPtr);
218
+ mem.set(new Uint8Array(query.buffer, query.byteOffset, query.byteLength), queryPtr);
203
219
 
204
220
  // Copy all candidates as contiguous slab
205
221
  for (let i = 0; i < count; i++) {
206
222
  const v = candidates[i];
207
- wasmMem.set(new Uint8Array(v.buffer, v.byteOffset, v.byteLength), slabPtr + i * dim);
223
+ mem.set(new Uint8Array(v.buffer, v.byteOffset, v.byteLength), slabPtr + i * dim);
208
224
  }
209
225
 
210
226
  // Single WASM call: scores all candidates internally, writes to output buffer
@@ -267,9 +283,9 @@ export function wasmInt8Dot(a, b) {
267
283
  if (wasmExports) {
268
284
  const aPtr = DATA_OFFSET;
269
285
  const bPtr = DATA_OFFSET + a.length;
270
- wasmMem = new Uint8Array(wasmExports.memory.buffer);
271
- wasmMem.set(new Uint8Array(a.buffer, a.byteOffset, a.byteLength), aPtr);
272
- wasmMem.set(new Uint8Array(b.buffer, b.byteOffset, b.byteLength), bPtr);
286
+ const mem = wasmMemView();
287
+ mem.set(new Uint8Array(a.buffer, a.byteOffset, a.byteLength), aPtr);
288
+ mem.set(new Uint8Array(b.buffer, b.byteOffset, b.byteLength), bPtr);
273
289
  return wasmExports.int8_dot(aPtr, bPtr, a.length);
274
290
  }
275
291
  // JS fallback
@@ -290,9 +306,9 @@ export function wasmAsymmetricDistance(docBinary, queryInt4, queryNormScaled) {
290
306
  if (wasmExports) {
291
307
  const docPtr = DATA_OFFSET;
292
308
  const queryPtr = DATA_OFFSET + docBinary.length;
293
- wasmMem = new Uint8Array(wasmExports.memory.buffer);
294
- wasmMem.set(docBinary, docPtr);
295
- wasmMem.set(new Uint8Array(queryInt4.buffer, queryInt4.byteOffset, queryInt4.byteLength), queryPtr);
309
+ const mem = wasmMemView();
310
+ mem.set(docBinary, docPtr);
311
+ mem.set(new Uint8Array(queryInt4.buffer, queryInt4.byteOffset, queryInt4.byteLength), queryPtr);
296
312
  return wasmExports.asymmetric_distance(docPtr, queryPtr, queryInt4.length, queryNormScaled);
297
313
  }
298
314
  // JS fallback
@@ -336,9 +352,7 @@ export function wasmMaxSimF32(queryFlat, docFlat, numQ, numD, dim) {
336
352
  if (!maxsimExports?.maxsim_f32) return null;
337
353
  const exports = maxsimExports;
338
354
 
339
- const mem = maxsimExports
340
- ? new Uint8Array(maxsimExports.memory.buffer)
341
- : (wasmMem = new Uint8Array(wasmExports.memory.buffer));
355
+ const mem = maxsimMemView();
342
356
 
343
357
  const qBytes = numQ * dim * 4;
344
358
  const dBytes = numD * dim * 4;
@@ -375,7 +389,7 @@ export function wasmMaxSimDequant(queryFlat, docInt8, numQ, numD, dim, min, scal
375
389
  const qBytes = numQ * dim * 4; // float32
376
390
  const dBytes = numD * dim; // int8 (4x smaller!)
377
391
 
378
- const mem = new Uint8Array(maxsimExports.memory.buffer);
392
+ const mem = maxsimMemView();
379
393
  if (qBytes + dBytes + 1024 > mem.length) return null;
380
394
 
381
395
  const qPtr = DATA_OFFSET;
@@ -49,8 +49,10 @@ export function loadBitmap(filePath) {
49
49
  if (raw.length < expectedLength) {
50
50
  throw new Error(`loadBitmap: ${filePath} truncated payload (${raw.length} bytes, expected ${expectedLength})`);
51
51
  }
52
+ // View, not copy: `raw` is otherwise unreferenced, so this only keeps the
53
+ // 64-byte header alive alongside the payload bytes.
52
54
  const payload = raw.subarray(HEADER_RESERVED, HEADER_RESERVED + payloadByteLength(capacity));
53
- return { capacity, payload: Buffer.from(payload) };
55
+ return { capacity, payload };
54
56
  }
55
57
 
56
58
  export function saveBitmap(filePath, bitmap) {
@@ -533,11 +533,23 @@ function envFloat(name, dflt) {
533
533
  return Number.isFinite(n) && n > 0 ? n : dflt;
534
534
  }
535
535
 
536
+ // Pure function of `preferred` (static ENTITY_KIND_KEYWORDS table) — memoize
537
+ // the last kind so the per-result demotion loop reuses one Set.
538
+ let _kindKeywordSetKey;
539
+ let _kindKeywordSet;
540
+ function kindKeywordSet(preferred) {
541
+ if (preferred !== _kindKeywordSetKey) {
542
+ _kindKeywordSetKey = preferred;
543
+ _kindKeywordSet = new Set((ENTITY_KIND_KEYWORDS[preferred] || []).map(normalizeType));
544
+ }
545
+ return _kindKeywordSet;
546
+ }
547
+
536
548
  function entityKindMultiplier(r, preferred, opts = {}) {
537
549
  if (!preferred) return 1;
538
550
  const kindBoost = envFloat('SWEET_SEARCH_KIND_BOOST', 1.10);
539
551
  const kindDemote = envFloat('SWEET_SEARCH_KIND_DEMOTE', 0.90);
540
- const wantSet = new Set((ENTITY_KIND_KEYWORDS[preferred] || []).map(normalizeType));
552
+ const wantSet = kindKeywordSet(preferred);
541
553
  const inferred = resolveEntityKindInfo(r, opts)?.type || '';
542
554
  const recorded = normalizeType(resolveResultType(r));
543
555
  const type = recorded && recorded !== 'code' && recorded !== 'chunk' ? recorded : normalizeType(inferred);
@@ -550,7 +562,7 @@ function namePrecisionMultiplier(r, preferred, nameHintsLower, opts = {}) {
550
562
  if (!preferred || nameHintsLower.size === 0) return 1;
551
563
  const exactBoost = envFloat('SWEET_SEARCH_NAME_EXACT_BOOST', 1.10);
552
564
  const substrBoost = envFloat('SWEET_SEARCH_NAME_SUBSTR_BOOST', 1.03);
553
- const wantSet = new Set((ENTITY_KIND_KEYWORDS[preferred] || []).map(normalizeType));
565
+ const wantSet = kindKeywordSet(preferred);
554
566
  const entityInfo = resolveEntityKindInfo(r, opts);
555
567
  const recorded = normalizeType(resolveResultType(r));
556
568
  const type = recorded && recorded !== 'code' && recorded !== 'chunk'
@@ -12,6 +12,7 @@
12
12
 
13
13
  import fs from 'fs/promises';
14
14
  import { existsSync, createWriteStream, createReadStream, statSync } from 'fs';
15
+ import readline from 'readline';
15
16
  import path from 'path';
16
17
  import { DB_PATHS, LATE_INTERACTION_CONFIG } from '../infrastructure/config/index.js';
17
18
  import { wasmMaxSimF32, wasmMaxSimDequantPerToken, wasmMaxSimDequant4Bit, nativeMaxSimBatch, nativeMaxSimBatchPerToken, nativeMaxSimBatch4Bit, initWasm, isNativeMaxSimAvailable, isNativePerTokenAvailable, isNative4BitAvailable } from '../infrastructure/simd-distance.js';
@@ -938,33 +939,90 @@ export class LateInteractionIndex {
938
939
  }
939
940
 
940
941
  async _saveAliasSidecar(indexPath = this.indexPath) {
942
+ const sidecarPath = this._aliasSidecarPath(indexPath);
941
943
  if (this.aliasPointers.size === 0) {
942
944
  // Remove any stale sidecar from a previous build.
943
- try { await fs.unlink(this._aliasSidecarPath(indexPath)); } catch (_e) { /* not present */ }
945
+ try { await fs.unlink(sidecarPath); } catch (_e) { /* not present */ }
944
946
  return;
945
947
  }
946
- const entries = [];
947
- for (const [aliasId, ptr] of this.aliasPointers) {
948
- entries.push({
949
- aliasId,
950
- exemplarId: ptr.exemplarId,
951
- clusterId: ptr.clusterId,
952
- metadata: ptr.metadata || {},
953
- });
948
+ // NDJSON (version 2): one header line, then one alias pointer per line,
949
+ // flushed through a write stream in bounded batches. The previous
950
+ // monolithic JSON.stringify of the whole payload hits V8's ~512 MB string
951
+ // ceiling ("Invalid string length") on extreme-dedup repos, and the
952
+ // matching readFile on load had the same ceiling.
953
+ const ws = createWriteStream(sidecarPath, { encoding: 'utf8' });
954
+ const finished = new Promise((resolve, reject) => {
955
+ ws.on('error', reject);
956
+ ws.on('finish', resolve);
957
+ });
958
+ const flush = (str) => new Promise((resolve, reject) => {
959
+ ws.write(str, (err) => (err ? reject(err) : resolve()));
960
+ });
961
+ try {
962
+ let lines = [JSON.stringify({ version: 2, count: this.aliasPointers.size })];
963
+ for (const [aliasId, ptr] of this.aliasPointers) {
964
+ lines.push(JSON.stringify({
965
+ aliasId,
966
+ exemplarId: ptr.exemplarId,
967
+ clusterId: ptr.clusterId,
968
+ metadata: ptr.metadata || {},
969
+ }));
970
+ if (lines.length >= 20000) {
971
+ await flush(lines.join('\n') + '\n');
972
+ lines = [];
973
+ }
974
+ }
975
+ if (lines.length > 0) await flush(lines.join('\n') + '\n');
976
+ ws.end();
977
+ await finished;
978
+ } catch (err) {
979
+ finished.catch(() => { /* surfaced via throw below */ });
980
+ ws.destroy();
981
+ throw err;
954
982
  }
955
- const payload = { version: 1, count: entries.length, aliases: entries };
956
- await fs.writeFile(this._aliasSidecarPath(indexPath), JSON.stringify(payload));
957
983
  }
958
984
 
959
985
  async _loadAliasSidecar(indexPath = this.indexPath) {
960
986
  const p = this._aliasSidecarPath(indexPath);
961
987
  if (!existsSync(p)) return;
988
+ // Streamed line-by-line so no single string approaches V8's ~512 MB
989
+ // ceiling. Two on-disk formats:
990
+ // v1 — one JSON.stringify'd { version: 1, aliases: [...] } line
991
+ // v2 — NDJSON: { version: 2, count } header, then one alias per line
992
+ //
993
+ // The input stream is destroyed in `finally`: early returns abandon the
994
+ // readline iterator, and rl.close() does NOT destroy its input — an
995
+ // fs read stream only auto-closes on 'end'/'error', so without the
996
+ // explicit destroy every early return leaks an fd (fatal only in
997
+ // long-lived processes like the daemon, but a leak everywhere).
998
+ const input = createReadStream(p, { encoding: 'utf8' });
999
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
962
1000
  try {
963
- const raw = await fs.readFile(p, 'utf-8');
964
- const payload = JSON.parse(raw);
965
- if (!payload || !Array.isArray(payload.aliases)) return;
966
1001
  this.aliasPointers.clear();
967
- for (const { aliasId, exemplarId, clusterId, metadata } of payload.aliases) {
1002
+ let first = true;
1003
+ let expected = 0;
1004
+ let parsed = 0;
1005
+ for await (const line of rl) {
1006
+ if (!line) continue;
1007
+ if (first) {
1008
+ first = false;
1009
+ const head = JSON.parse(line);
1010
+ if (head && Array.isArray(head.aliases)) {
1011
+ // v1 monolithic payload — the whole sidecar is this one line,
1012
+ // so JSON.parse succeeding IS the integrity check (any
1013
+ // truncation makes it unparseable).
1014
+ for (const { aliasId, exemplarId, clusterId, metadata } of head.aliases) {
1015
+ if (!this.documents.has(exemplarId)) continue; // orphan guard, see below
1016
+ this.aliasPointers.set(aliasId, { exemplarId, clusterId, metadata: metadata || {} });
1017
+ }
1018
+ return;
1019
+ }
1020
+ if (!head || head.version !== 2 || !Number.isFinite(head.count)) return;
1021
+ expected = head.count;
1022
+ continue;
1023
+ }
1024
+ const { aliasId, exemplarId, clusterId, metadata } = JSON.parse(line);
1025
+ parsed++;
968
1026
  // Orphan guard: drop aliases whose exemplar is no longer in documents.
969
1027
  // Happens if the file containing the exemplar was removed between
970
1028
  // save and load (incremental re-index removed the exemplar file
@@ -972,8 +1030,21 @@ export class LateInteractionIndex {
972
1030
  if (!this.documents.has(exemplarId)) continue;
973
1031
  this.aliasPointers.set(aliasId, { exemplarId, clusterId, metadata: metadata || {} });
974
1032
  }
1033
+ // Truncation guard: NDJSON has no whole-file parse to fail, so a file
1034
+ // cut exactly at a line boundary (crash mid-save, disk full) is valid
1035
+ // prefix NDJSON and would otherwise load a silent subset. The header's
1036
+ // `count` restores v1's all-or-nothing semantics. Compared against
1037
+ // PARSED lines, not kept aliases — the orphan guard intentionally
1038
+ // drops entries and must not trip this.
1039
+ if (parsed !== expected) {
1040
+ this.aliasPointers.clear();
1041
+ }
975
1042
  } catch (_e) {
976
1043
  // Malformed sidecar — treat as absent; aliases will be skipped at query time.
1044
+ this.aliasPointers.clear();
1045
+ } finally {
1046
+ rl.close();
1047
+ input.destroy();
977
1048
  }
978
1049
  }
979
1050
 
@@ -52,6 +52,11 @@ export const MMR_CONFIG = {
52
52
  * @param {Object} weights - Feature weights
53
53
  * @returns {number} Similarity score [0, 1]
54
54
  */
55
+ // Static type groupings used by computeSimilarity — hoisted out of the
56
+ // O(candidates × selected) MMR inner loop.
57
+ const SIMILARITY_DEF_TYPES = new Set(['class', 'interface', 'struct', 'enum', 'trait']);
58
+ const SIMILARITY_METHOD_TYPES = new Set(['method', 'function', 'constructor']);
59
+
55
60
  export function computeSimilarity(a, b, weights = MMR_CONFIG.weights) {
56
61
  let totalSim = 0;
57
62
  let totalWeight = 0;
@@ -83,11 +88,8 @@ export function computeSimilarity(a, b, weights = MMR_CONFIG.weights) {
83
88
  totalSim += weights.type * 1.0;
84
89
  } else {
85
90
  // Partial similarity for related types
86
- const defTypes = new Set(['class', 'interface', 'struct', 'enum', 'trait']);
87
- const methodTypes = new Set(['method', 'function', 'constructor']);
88
-
89
- if ((defTypes.has(typeA) && defTypes.has(typeB)) ||
90
- (methodTypes.has(typeA) && methodTypes.has(typeB))) {
91
+ if ((SIMILARITY_DEF_TYPES.has(typeA) && SIMILARITY_DEF_TYPES.has(typeB)) ||
92
+ (SIMILARITY_METHOD_TYPES.has(typeA) && SIMILARITY_METHOD_TYPES.has(typeB))) {
91
93
  totalSim += weights.type * 0.5;
92
94
  }
93
95
  }
@@ -237,11 +239,19 @@ export function applyMMR(results, options = {}) {
237
239
  while (selected.length < k && remaining.size > 0) {
238
240
  let bestIdx = -1;
239
241
  let bestMMR = -Infinity;
242
+ // Highest-relevance tracking shares the same pass (same iteration order
243
+ // and strict-> comparison as the reduce it replaces).
244
+ let highestRelevanceIdx = -1;
240
245
 
241
246
  // Find the candidate with highest MMR score
242
247
  for (const idx of remaining) {
243
248
  const candidate = normalized[idx];
244
249
 
250
+ if (highestRelevanceIdx === -1
251
+ || candidate._normalizedRelevance > normalized[highestRelevanceIdx]._normalizedRelevance) {
252
+ highestRelevanceIdx = idx;
253
+ }
254
+
245
255
  // Compute max similarity to already selected results
246
256
  let maxSim = 0;
247
257
  for (const selectedResult of selected) {
@@ -261,11 +271,6 @@ export function applyMMR(results, options = {}) {
261
271
  if (bestIdx >= 0) {
262
272
  const selectedCandidate = normalized[bestIdx];
263
273
 
264
- // Track if this selection was reordered (not the highest relevance remaining)
265
- const highestRelevanceIdx = [...remaining].reduce((maxIdx, idx) =>
266
- normalized[idx]._normalizedRelevance > normalized[maxIdx]._normalizedRelevance ? idx : maxIdx
267
- );
268
-
269
274
  if (bestIdx !== highestRelevanceIdx) {
270
275
  reorderCount++;
271
276
  totalDiversityPenalty += normalized[highestRelevanceIdx]._normalizedRelevance - selectedCandidate._normalizedRelevance;
@@ -129,7 +129,23 @@ function readUniquenessCeil(opts) {
129
129
  * @param {{ filePath: string, startLine: number, endLine: number }} entity
130
130
  * @returns {{ id: string, metadata: object, content?: string, text?: string }|null}
131
131
  */
132
- function findChunkForEntity(liIndex, entity) {
132
+ // Bucket the LI documents Map by file in ONE pass, preserving per-file
133
+ // iteration order, so findChunkForEntity scans only entity.filePath's chunks
134
+ // instead of the whole index per entity.
135
+ function bucketDocsByFile(liIndex) {
136
+ const byFile = new Map();
137
+ if (!liIndex?.documents) return byFile;
138
+ for (const [id, doc] of liIndex.documents) {
139
+ const file = doc?.metadata?.file;
140
+ if (!file) continue;
141
+ let arr = byFile.get(file);
142
+ if (!arr) byFile.set(file, arr = []);
143
+ arr.push([id, doc]);
144
+ }
145
+ return byFile;
146
+ }
147
+
148
+ function findChunkForEntity(liIndex, entity, docsByFile = null) {
133
149
  if (!liIndex || !entity) return null;
134
150
  let best = null;
135
151
  let bestSize = Infinity;
@@ -145,7 +161,8 @@ function findChunkForEntity(liIndex, entity) {
145
161
  let headerBest = null;
146
162
  let headerBestSize = Infinity;
147
163
  const entityNameLc = String(entity.name || '').toLowerCase();
148
- for (const [id, doc] of liIndex.documents) {
164
+ const candidates = docsByFile ? (docsByFile.get(entity.filePath) || []) : liIndex.documents;
165
+ for (const [id, doc] of candidates) {
149
166
  const m = doc?.metadata;
150
167
  if (!m || m.file !== entity.filePath) continue;
151
168
  const cs = m.startLine, ce = m.endLine;
@@ -402,10 +419,11 @@ export function injectAnchorCandidates(fused, query, opts = {}) {
402
419
  let existingBoosted = 0;
403
420
  const out = fused.slice(); // copy — we'll append injections
404
421
  const seenInjected = new Set();
422
+ const docsByFile = bucketDocsByFile(liIndex);
405
423
 
406
424
  for (const entity of entities) {
407
425
  if (!entityMatchesAnchorHint(entity, hints)) continue;
408
- const chunk = findChunkForEntity(liIndex, entity);
426
+ const chunk = findChunkForEntity(liIndex, entity, docsByFile);
409
427
  if (!chunk) continue;
410
428
  const key = chunkKey({ metadata: chunk.metadata });
411
429
  if (seenInjected.has(key)) continue;