sweet-search 2.6.8 → 2.6.10

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.
Files changed (34) hide show
  1. package/README.md +15 -0
  2. package/core/cli.js +7 -0
  3. package/core/graph/graph-extractor.js +24 -14
  4. package/core/graph/hcgs-generator.js +4 -3
  5. package/core/incremental-indexing/application/production-li-delta.mjs +28 -3
  6. package/core/incremental-indexing/application/production-reconciler.mjs +40 -11
  7. package/core/incremental-indexing/application/reconciler.mjs +1 -1
  8. package/core/incremental-indexing/domain/encoder-input.mjs +9 -4
  9. package/core/indexing/artifact-builder.js +5 -3
  10. package/core/indexing/ast-chunker.js +65 -62
  11. package/core/indexing/index-maintainer.mjs +49 -6
  12. package/core/indexing/indexer-build.js +18 -4
  13. package/core/indexing/indexer-phases.js +45 -24
  14. package/core/indexing/indexer-utils.js +96 -6
  15. package/core/indexing/indexing-file-policy.js +30 -7
  16. package/core/indexing/model-pool.js +26 -0
  17. package/core/indexing/rss-budget.mjs +65 -0
  18. package/core/infrastructure/code-graph-repository.js +18 -18
  19. package/core/infrastructure/db-utils.js +29 -0
  20. package/core/infrastructure/simd-distance.js +35 -21
  21. package/core/infrastructure/tombstone-bitmap-reader.js +3 -1
  22. package/core/ranking/file-kind-ranking.js +14 -2
  23. package/core/ranking/late-interaction-index.js +86 -15
  24. package/core/ranking/late-interaction-model.js +25 -4
  25. package/core/ranking/mmr.js +15 -10
  26. package/core/search/search-anchor.js +21 -3
  27. package/core/search/search-boost.js +25 -13
  28. package/core/search/search-fusion.js +13 -5
  29. package/core/search/search-read-semantic.js +13 -12
  30. package/core/search/search-server.js +2 -1
  31. package/core/vector-store/binary-hnsw-index.js +338 -33
  32. package/core/vector-store/float-vector-store.js +9 -5
  33. package/eval/agent-read-workflows/bin/_ss-helpers.mjs +11 -0
  34. package/package.json +8 -8
@@ -155,8 +155,7 @@ function _lateInteractionIndexPath(projectRoot, manifest) {
155
155
  return path.join(_stateDirForProject(root), path.basename(DB_PATHS.lateInteraction));
156
156
  }
157
157
 
158
- function _sourceStaleness(projectRoot, filePathRel) {
159
- const manifest = _readReconcileManifest(projectRoot);
158
+ function _sourceStaleness(projectRoot, filePathRel, manifest = _readReconcileManifest(projectRoot)) {
160
159
  const publishedMs = Date.parse(manifest?.publishedAt || '');
161
160
  if (!Number.isFinite(publishedMs)) return null;
162
161
  try {
@@ -178,9 +177,8 @@ function _sourceStaleness(projectRoot, filePathRel) {
178
177
  }
179
178
 
180
179
  const _repos = new Map();
181
- function _getRepo(projectRoot) {
180
+ function _getRepo(projectRoot, manifest = _readReconcileManifest(projectRoot)) {
182
181
  const key = _projectKey(projectRoot);
183
- const manifest = _readReconcileManifest(projectRoot);
184
182
  const dbPath = _codebasePathForProject(projectRoot, manifest);
185
183
  const baseDbPath = _defaultCodebasePathForProject(projectRoot);
186
184
  let entry = _repos.get(key);
@@ -202,9 +200,8 @@ let _liIndex = null;
202
200
  let _liInitPromise = null;
203
201
  let _liProjectKey = null;
204
202
  let _liManifestEpoch = null;
205
- async function _getLateInteractionIndex(projectRoot) {
203
+ async function _getLateInteractionIndex(projectRoot, manifest = _readReconcileManifest(projectRoot)) {
206
204
  const projectKey = _projectKey(projectRoot);
207
- const manifest = _readReconcileManifest(projectRoot);
208
205
  const manifestEpoch = Number.isInteger(manifest?.epoch) ? manifest.epoch : null;
209
206
  const samePin = _liProjectKey === projectKey && _liManifestEpoch === manifestEpoch;
210
207
  if (_liIndex !== null && samePin) return _liIndex || null;
@@ -331,8 +328,8 @@ function _escapeRegex(s) {
331
328
  // Candidate enumeration — load chunk metadata + per-chunk on-disk text slice
332
329
  // ---------------------------------------------------------------------------
333
330
 
334
- async function _loadFileChunks(filePathRel, projectRoot) {
335
- const repo = _getRepo(projectRoot);
331
+ async function _loadFileChunks(filePathRel, projectRoot, manifest = _readReconcileManifest(projectRoot)) {
332
+ const repo = _getRepo(projectRoot, manifest);
336
333
  if (!repo) return { chunks: [], language: null };
337
334
  const rows = repo.getChunksByFilePath(filePathRel);
338
335
  if (rows.length === 0) return { chunks: [], language: null };
@@ -445,9 +442,9 @@ function _scoreSymbol(chunks, queryTerms, queryRaw) {
445
442
  return scores;
446
443
  }
447
444
 
448
- async function _scoreLateInteraction(chunks, query, projectRoot, lateInteractionIndexOverride = null) {
445
+ async function _scoreLateInteraction(chunks, query, projectRoot, lateInteractionIndexOverride = null, manifest = undefined) {
449
446
  if (chunks.length === 0) return { scores: new Map(), ran: false };
450
- const liIndex = lateInteractionIndexOverride || await _getLateInteractionIndex(projectRoot);
447
+ const liIndex = lateInteractionIndexOverride || await _getLateInteractionIndex(projectRoot, manifest);
451
448
  if (!liIndex) return { scores: new Map(), ran: false };
452
449
 
453
450
  // Only score chunks whose IDs actually appear in the LI index. Use the
@@ -636,7 +633,10 @@ async function _readSemanticUnpinned(req) {
636
633
  const projectRoot = req.projectRoot || process.cwd();
637
634
  _ensurePersistedLiModelApplied(projectRoot);
638
635
  const filePathRel = _projectRelative(req.path, projectRoot);
639
- const staleness = _sourceStaleness(projectRoot, filePathRel);
636
+ // One manifest read per request — _sourceStaleness/_getRepo/_getLateInteractionIndex
637
+ // each re-read the same file otherwise (it cannot change mid-request usefully).
638
+ const reconcileManifest = _readReconcileManifest(projectRoot);
639
+ const staleness = _sourceStaleness(projectRoot, filePathRel, reconcileManifest);
640
640
 
641
641
  const topK = req.topK ?? DEFAULTS.topK;
642
642
  const threshold = req.threshold ?? DEFAULTS.threshold;
@@ -646,7 +646,7 @@ async function _readSemanticUnpinned(req) {
646
646
  const verbose = !!req.verbose;
647
647
 
648
648
  const tLoad0 = performance.now();
649
- const { chunks, language, totalLines, fileText } = await _loadFileChunks(filePathRel, projectRoot);
649
+ const { chunks, language, totalLines, fileText } = await _loadFileChunks(filePathRel, projectRoot, reconcileManifest);
650
650
  const tLoad1 = performance.now();
651
651
 
652
652
  // No chunks at all → fall back to plain read so the caller still gets
@@ -692,6 +692,7 @@ async function _readSemanticUnpinned(req) {
692
692
  req.query,
693
693
  projectRoot,
694
694
  req._lateInteractionIndex || null,
695
+ reconcileManifest,
695
696
  );
696
697
  const tLi1 = performance.now();
697
698
 
@@ -37,6 +37,8 @@ export const SEARCH_SERVER_MAX_URL_LENGTH = 16_384;
37
37
  export const SEARCH_SERVER_MAX_QUERY_LENGTH = 2_000;
38
38
  export const SEARCH_SERVER_MAX_READ_PATH_LENGTH = 8_192;
39
39
 
40
+ const AGENT_FORMATS = new Set(['agent', 'agent_preview', 'agent_full', 'agent_full_xl']);
41
+
40
42
  function canonicalProjectRoot(root) {
41
43
  const resolved = path.resolve(root || process.cwd());
42
44
  try {
@@ -686,7 +688,6 @@ export async function startServer() {
686
688
 
687
689
  // Agent mode: context packaging (ColGrep agent format)
688
690
  const rawFormat = url.searchParams.get('format');
689
- const AGENT_FORMATS = new Set(['agent', 'agent_preview', 'agent_full', 'agent_full_xl']);
690
691
  const agentFormat = AGENT_FORMATS.has(rawFormat) ? rawFormat : undefined;
691
692
  const tokenBudget = url.searchParams.has('budget')
692
693
  ? parseInt(url.searchParams.get('budget'), 10)
@@ -11,8 +11,8 @@
11
11
  * CANONICAL INT8 STORAGE (Workstream H resolution):
12
12
  * Int8 vectors for stage-2 rescoring are stored in this index's .int8.json sidecar.
13
13
  * This file is saved/loaded alongside the binary HNSW index artifacts.
14
- * - save(): Writes .int8.json with { id: Int8Array[], ... } format
15
- * - load(): Populates this.int8Vectors Map from .int8.json
14
+ * - save(): Writes .int8.json (NDJSON v2: header line + one {id, v} per line)
15
+ * - load(): Populates this.int8Vectors Map from .int8.json (v1 back-compat)
16
16
  * - getInt8Vector(id): O(1) lookup used by sweet-search.js during stage-2
17
17
  *
18
18
  * This is the ONLY source of int8 vectors. The SQLite approach (codebase-int8.db)
@@ -28,9 +28,11 @@
28
28
 
29
29
  import fs from 'fs/promises';
30
30
  import {
31
- existsSync, statSync,
31
+ existsSync, statSync, readFileSync,
32
32
  openSync, readSync, closeSync, fstatSync,
33
+ createWriteStream, createReadStream,
33
34
  } from 'fs';
35
+ import readline from 'readline';
34
36
  import path from 'path';
35
37
  import { BINARY_HNSW_CONFIG, DB_PATHS } from '../infrastructure/config/index.js';
36
38
  import {
@@ -53,11 +55,12 @@ const PIPELINE_VERSION = 2;
53
55
  //
54
56
  // The default on-disk format is the JSON sidecar tuple
55
57
  // (.meta.json / .vectors.json / .graph.json / .int8.json / .calibration.json),
56
- // written by save() and read by load(). That format is preserved BYTE-FOR-BYTE
57
- // when SWEET_SEARCH_HNSW_MMAP !== '1' this whole subsystem is inert by
58
- // default. Existing on-disk indexes (the 200 bench repos) keep loading via the
59
- // JSON path regardless of the flag: format is detected by inspecting the .idx
60
- // file's magic header (`_indexFormatOnDisk`), never by the flag.
58
+ // written by save() and read by load() the big sidecars as NDJSON v2 with
59
+ // v1 read back-compat (see the sidecar-format block below). This whole mmap
60
+ // subsystem is inert when SWEET_SEARCH_HNSW_MMAP !== '1'. Existing on-disk
61
+ // indexes (the 200 bench repos) keep loading via the JSON path regardless of
62
+ // the flag: format is detected by inspecting the .idx file's magic header
63
+ // (`_indexFormatOnDisk`), never by the flag.
61
64
  //
62
65
  // When SWEET_SEARCH_HNSW_MMAP === '1', NEWLY written indexes are packed into a
63
66
  // single flat-binary `.idx` file (magic-prefixed) whose vectors / graph
@@ -202,6 +205,276 @@ function makeMmapVectorProxy(store) {
202
205
  });
203
206
  }
204
207
 
208
+ // =============================================================================
209
+ // NDJSON SIDECAR FORMAT (v2)
210
+ // =============================================================================
211
+ //
212
+ // The big JSON sidecars (.vectors.json / .graph.json / .int8.json) used to be
213
+ // written with one monolithic JSON.stringify. Past ~500k vectors that single
214
+ // string crosses V8's ~512 MB ceiling and save() dies with "Invalid string
215
+ // length" (libsql: 637,550 vectors) — the same failure mode the LI alias
216
+ // sidecar hit, fixed the same way in 2.6.9. Cure: NDJSON v2 — one
217
+ // {version:2,...} header line, then one record per line, flushed through a
218
+ // write stream in bounded batches so no serialized string ever approaches the
219
+ // ceiling. .meta.json / .calibration.json stay monolithic JSON (O(1)-sized).
220
+ //
221
+ // Back-compat: a v1 sidecar is a single line holding the whole payload, and
222
+ // any v1 file on disk was produced by a JSON.stringify that SUCCEEDED — so it
223
+ // fits back into one string, and the line-streaming readers handle it as
224
+ // "first line IS the payload". Format is detected from the first line, never
225
+ // from config, so old and new indexes coexist with zero migration.
226
+ //
227
+ // Truncation guard: NDJSON cut at a line boundary (crash mid-write, disk
228
+ // full) is valid prefix NDJSON and would otherwise load a silent subset, so
229
+ // every v2 reader enforces the header's record count and throws on mismatch —
230
+ // callers route that to the rebuild path like any other corrupt artifact.
231
+ // (The tmp+rename publish in save() makes this unreachable in normal
232
+ // operation; the guard covers torn tmp files that somehow got renamed.)
233
+
234
+ const NDJSON_LINE_BATCH = 20000;
235
+ // Graph chunk lines are ~MB-sized, so batch flushes are bounded by chars too.
236
+ const NDJSON_CHAR_BATCH = 8 * 1024 * 1024;
237
+ const GRAPH_CHUNK_NODES = 10000;
238
+
239
+ async function writeNdjsonSidecar(filePath, header, records) {
240
+ const ws = createWriteStream(filePath, { encoding: 'utf8' });
241
+ const finished = new Promise((resolve, reject) => {
242
+ ws.on('error', reject);
243
+ ws.on('finish', resolve);
244
+ });
245
+ const flush = (str) => new Promise((resolve, reject) => {
246
+ ws.write(str, (err) => (err ? reject(err) : resolve()));
247
+ });
248
+ try {
249
+ let lines = [JSON.stringify(header)];
250
+ let chars = lines[0].length;
251
+ for (const record of records) {
252
+ const line = JSON.stringify(record);
253
+ lines.push(line);
254
+ chars += line.length;
255
+ if (lines.length >= NDJSON_LINE_BATCH || chars >= NDJSON_CHAR_BATCH) {
256
+ await flush(lines.join('\n') + '\n');
257
+ lines = [];
258
+ chars = 0;
259
+ }
260
+ }
261
+ if (lines.length > 0) await flush(lines.join('\n') + '\n');
262
+ ws.end();
263
+ await finished;
264
+ } catch (err) {
265
+ finished.catch(() => { /* surfaced via throw below */ });
266
+ ws.destroy();
267
+ throw err;
268
+ }
269
+ }
270
+
271
+ // Yields one parsed JSON document per non-empty line. The input stream is
272
+ // destroyed in `finally`: early exits abandon the readline iterator, and
273
+ // rl.close() does NOT destroy its input — without the explicit destroy every
274
+ // early return leaks an fd (fatal only in long-lived processes like the
275
+ // daemon, but a leak everywhere).
276
+ async function* iterateJsonLines(filePath) {
277
+ const input = createReadStream(filePath, { encoding: 'utf8' });
278
+ const rl = readline.createInterface({ input, crlfDelay: Infinity });
279
+ try {
280
+ for await (const line of rl) {
281
+ if (!line) continue;
282
+ yield JSON.parse(line);
283
+ }
284
+ } finally {
285
+ rl.close();
286
+ input.destroy();
287
+ }
288
+ }
289
+
290
+ function isV2Header(doc) {
291
+ return !!doc && !Array.isArray(doc) && doc.version === 2 && Number.isFinite(doc.count);
292
+ }
293
+
294
+ export async function readVectorsSidecar(vectorsPath) {
295
+ let header = null;
296
+ let first = true;
297
+ const entries = [];
298
+ for await (const doc of iterateJsonLines(vectorsPath)) {
299
+ if (first) {
300
+ first = false;
301
+ if (Array.isArray(doc)) return doc; // v1 — the whole payload is this line
302
+ if (!isV2Header(doc)) {
303
+ throw new Error(`BinaryHNSW: unrecognized vectors sidecar format: ${vectorsPath}`);
304
+ }
305
+ header = doc;
306
+ continue;
307
+ }
308
+ entries.push(doc);
309
+ }
310
+ if (!header) {
311
+ throw new Error(`BinaryHNSW: empty vectors sidecar: ${vectorsPath}`);
312
+ }
313
+ if (entries.length !== header.count) {
314
+ throw new Error(
315
+ `BinaryHNSW: truncated vectors sidecar (${entries.length}/${header.count} records): ${vectorsPath}`
316
+ );
317
+ }
318
+ return entries;
319
+ }
320
+
321
+ // Graph v2 records tile each level's node array in fixed-size chunks. Holes in
322
+ // the (sparse) upper-level arrays serialize to null inside a chunk, matching
323
+ // what JSON.parse produced for the v1 whole-array format.
324
+ function* graphChunkRecords(graph) {
325
+ for (let level = 0; level < graph.length; level++) {
326
+ const nodes = graph[level];
327
+ for (let start = 0; start < nodes.length; start += GRAPH_CHUNK_NODES) {
328
+ yield { level, start, nodes: nodes.slice(start, start + GRAPH_CHUNK_NODES) };
329
+ }
330
+ }
331
+ }
332
+
333
+ function graphChunkCount(graph) {
334
+ let count = 0;
335
+ for (const nodes of graph) count += Math.ceil(nodes.length / GRAPH_CHUNK_NODES);
336
+ return count;
337
+ }
338
+
339
+ export async function readGraphSidecar(graphPath) {
340
+ let header = null;
341
+ let first = true;
342
+ let graph = null;
343
+ let parsed = 0;
344
+ for await (const doc of iterateJsonLines(graphPath)) {
345
+ if (first) {
346
+ first = false;
347
+ if (Array.isArray(doc)) return doc; // v1 — the whole payload is this line
348
+ if (!doc || doc.version !== 2 || !Array.isArray(doc.lengths) || !Number.isFinite(doc.chunks)) {
349
+ throw new Error(`BinaryHNSW: unrecognized graph sidecar format: ${graphPath}`);
350
+ }
351
+ header = doc;
352
+ graph = doc.lengths.map((len) => new Array(len).fill(null));
353
+ continue;
354
+ }
355
+ const target = graph[doc.level];
356
+ for (let i = 0; i < doc.nodes.length; i++) {
357
+ target[doc.start + i] = doc.nodes[i];
358
+ }
359
+ parsed++;
360
+ }
361
+ if (!header) {
362
+ throw new Error(`BinaryHNSW: empty graph sidecar: ${graphPath}`);
363
+ }
364
+ if (parsed !== header.chunks) {
365
+ throw new Error(
366
+ `BinaryHNSW: truncated graph sidecar (${parsed}/${header.chunks} chunks): ${graphPath}`
367
+ );
368
+ }
369
+ return graph;
370
+ }
371
+
372
+ export async function readInt8Sidecar(int8Path, out) {
373
+ let header = null;
374
+ let first = true;
375
+ let parsed = 0;
376
+ for await (const doc of iterateJsonLines(int8Path)) {
377
+ if (first) {
378
+ first = false;
379
+ if (!isV2Header(doc)) {
380
+ // v1 — the whole { id: number[] } map is this line. A v1 map can never
381
+ // look like a v2 header: its values are arrays, so doc.version is
382
+ // either undefined or a number[] and fails isV2Header.
383
+ for (const [id, vec] of Object.entries(doc || {})) {
384
+ out.set(id, new Int8Array(vec));
385
+ }
386
+ return;
387
+ }
388
+ header = doc;
389
+ continue;
390
+ }
391
+ out.set(doc.id, new Int8Array(doc.v));
392
+ parsed++;
393
+ }
394
+ if (header && parsed !== header.count) {
395
+ throw new Error(
396
+ `BinaryHNSW: truncated int8 sidecar (${parsed}/${header.count} records): ${int8Path}`
397
+ );
398
+ }
399
+ }
400
+
401
+ // Entry count without materializing the payload: v2 reads only the header
402
+ // line; v1 (single line = whole map) is parsed from that same first line.
403
+ // Used by diagnostics (getArtifactStats) so stats never re-load the index.
404
+ export async function int8SidecarCount(int8Path) {
405
+ for await (const doc of iterateJsonLines(int8Path)) {
406
+ return isV2Header(doc) ? doc.count : Object.keys(doc || {}).length;
407
+ }
408
+ return 0;
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Sync sidecar readers — TOOLING ONLY (soak probes, determinism dumps).
413
+ // These readFileSync the whole file, so they inherit the very string ceiling
414
+ // the streaming readers exist to avoid; fine at tool-corpus scale, never for
415
+ // production-scale indexes. Same v1/v2 detection and truncation guards.
416
+ // ---------------------------------------------------------------------------
417
+
418
+ function parseSidecarLinesSync(filePath) {
419
+ return readFileSync(filePath, 'utf-8')
420
+ .split('\n')
421
+ .filter((l) => l.length > 0)
422
+ .map((l) => JSON.parse(l));
423
+ }
424
+
425
+ export function readVectorsSidecarSync(vectorsPath) {
426
+ const docs = parseSidecarLinesSync(vectorsPath);
427
+ if (Array.isArray(docs[0])) return docs[0]; // v1
428
+ if (!isV2Header(docs[0])) {
429
+ throw new Error(`BinaryHNSW: unrecognized vectors sidecar format: ${vectorsPath}`);
430
+ }
431
+ const entries = docs.slice(1);
432
+ if (entries.length !== docs[0].count) {
433
+ throw new Error(
434
+ `BinaryHNSW: truncated vectors sidecar (${entries.length}/${docs[0].count} records): ${vectorsPath}`
435
+ );
436
+ }
437
+ return entries;
438
+ }
439
+
440
+ export function readGraphSidecarSync(graphPath) {
441
+ const docs = parseSidecarLinesSync(graphPath);
442
+ if (Array.isArray(docs[0])) return docs[0]; // v1
443
+ const header = docs[0];
444
+ if (!header || header.version !== 2 || !Array.isArray(header.lengths) || !Number.isFinite(header.chunks)) {
445
+ throw new Error(`BinaryHNSW: unrecognized graph sidecar format: ${graphPath}`);
446
+ }
447
+ if (docs.length - 1 !== header.chunks) {
448
+ throw new Error(
449
+ `BinaryHNSW: truncated graph sidecar (${docs.length - 1}/${header.chunks} chunks): ${graphPath}`
450
+ );
451
+ }
452
+ const graph = header.lengths.map((len) => new Array(len).fill(null));
453
+ for (const rec of docs.slice(1)) {
454
+ const target = graph[rec.level];
455
+ for (let i = 0; i < rec.nodes.length; i++) {
456
+ target[rec.start + i] = rec.nodes[i];
457
+ }
458
+ }
459
+ return graph;
460
+ }
461
+
462
+ // Returns the v1-shaped plain object { id: number[] } for both formats.
463
+ export function readInt8SidecarSync(int8Path) {
464
+ const docs = parseSidecarLinesSync(int8Path);
465
+ if (docs.length === 0) return {};
466
+ if (!isV2Header(docs[0])) return docs[0] || {}; // v1 — whole map on one line
467
+ const records = docs.slice(1);
468
+ if (records.length !== docs[0].count) {
469
+ throw new Error(
470
+ `BinaryHNSW: truncated int8 sidecar (${records.length}/${docs[0].count} records): ${int8Path}`
471
+ );
472
+ }
473
+ const out = {};
474
+ for (const rec of records) out[rec.id] = rec.v;
475
+ return out;
476
+ }
477
+
205
478
  // =============================================================================
206
479
  // BINARY HNSW INDEX CLASS
207
480
  // =============================================================================
@@ -236,6 +509,11 @@ export class BinaryHNSWIndex {
236
509
 
237
510
  // Pre-allocated visited list (generation-stamped, reused across searches)
238
511
  this._visitedList = new VisitedList(this.maxElements);
512
+ // Pooled search heaps — searchLayer/searchLayerQuery are synchronous and
513
+ // never re-entered, so one pair (auto-growing) serves all calls. Same
514
+ // single-flight contract as _visitedList.
515
+ this._candHeap = new TypedMinHeap(64);
516
+ this._resHeap = new TypedMaxHeap(64);
239
517
 
240
518
  // Asymmetric binary quantization calibration data
241
519
  this.centroid = null; // Float32Array — dataset centroid
@@ -652,11 +930,13 @@ export class BinaryHNSWIndex {
652
930
  const startDist = hammingDistance(this.vectors[startNode].binary, targetBinary);
653
931
 
654
932
  // candidates = min-heap (explore closest first)
655
- const candidates = new TypedMinHeap(ef * 4);
933
+ const candidates = this._candHeap;
934
+ candidates.clear();
656
935
  candidates.insert(startNode, startDist);
657
936
 
658
937
  // results = max-heap of size ef (peek furthest, replaceMax when closer found)
659
- const results = new TypedMaxHeap(ef + 1);
938
+ const results = this._resHeap;
939
+ results.clear();
660
940
  results.insert(startNode, startDist);
661
941
 
662
942
  while (candidates.size > 0) {
@@ -894,8 +1174,10 @@ export class BinaryHNSWIndex {
894
1174
  visited.mark(startNode);
895
1175
 
896
1176
  const startDist = hammingDistance(this.vectors[startNode].binary, queryBinary);
897
- const candidates = new TypedMinHeap(ef * 4);
898
- const results = new TypedMaxHeap(ef + 1);
1177
+ const candidates = this._candHeap;
1178
+ candidates.clear();
1179
+ const results = this._resHeap;
1180
+ results.clear();
899
1181
  candidates.insert(startNode, startDist);
900
1182
  results.insert(startNode, startDist);
901
1183
 
@@ -1061,12 +1343,6 @@ export class BinaryHNSWIndex {
1061
1343
  savedAt: new Date().toISOString(),
1062
1344
  };
1063
1345
 
1064
- const vectorsData = this.vectors.map(v => ({
1065
- id: v.id,
1066
- binary: Array.from(v.binary),
1067
- metadata: v.metadata,
1068
- }));
1069
-
1070
1346
  const metaPath = indexPath.replace('.idx', '.meta.json');
1071
1347
  const vectorsPath = indexPath.replace('.idx', '.vectors.json');
1072
1348
  const graphPath = indexPath.replace('.idx', '.graph.json');
@@ -1074,21 +1350,53 @@ export class BinaryHNSWIndex {
1074
1350
  const calibPath = indexPath.replace('.idx', '.calibration.json');
1075
1351
  const pidSuffix = `.tmp.${process.pid}`;
1076
1352
 
1077
- // Stage all sidecars to sibling temp paths.
1353
+ // Stage all sidecars to sibling temp paths. The big three are NDJSON v2
1354
+ // (see the sidecar-format block above the class) — one record per line
1355
+ // through a write stream — so no serialized string can cross V8's ceiling
1356
+ // on large indexes. Records are yielded lazily so no whole-index
1357
+ // intermediate array/object is materialized either.
1078
1358
  await fs.writeFile(metaPath + pidSuffix, JSON.stringify(meta, null, 2));
1079
- await fs.writeFile(vectorsPath + pidSuffix, JSON.stringify(vectorsData));
1080
- await fs.writeFile(graphPath + pidSuffix, JSON.stringify(this.graph));
1359
+
1360
+ const vectors = this.vectors;
1361
+ await writeNdjsonSidecar(
1362
+ vectorsPath + pidSuffix,
1363
+ { version: 2, count: vectors.length },
1364
+ (function* () {
1365
+ for (const v of vectors) {
1366
+ yield { id: v.id, binary: Array.from(v.binary), metadata: v.metadata };
1367
+ }
1368
+ })()
1369
+ );
1370
+
1371
+ await writeNdjsonSidecar(
1372
+ graphPath + pidSuffix,
1373
+ {
1374
+ version: 2,
1375
+ lengths: this.graph.map((nodes) => nodes.length),
1376
+ chunks: graphChunkCount(this.graph),
1377
+ },
1378
+ graphChunkRecords(this.graph)
1379
+ );
1081
1380
 
1082
1381
  let stagedInt8 = false;
1083
1382
  if (this.int8Vectors.size > 0) {
1084
1383
  const liveIds = new Set(this.vectors.map(v => v.id));
1085
- const int8Data = {};
1086
- for (const [id, vec] of this.int8Vectors) {
1087
- if (!liveIds.has(id)) continue;
1088
- int8Data[id] = Array.from(vec);
1384
+ let liveCount = 0;
1385
+ for (const id of this.int8Vectors.keys()) {
1386
+ if (liveIds.has(id)) liveCount++;
1089
1387
  }
1090
- if (Object.keys(int8Data).length > 0) {
1091
- await fs.writeFile(int8Path + pidSuffix, JSON.stringify(int8Data));
1388
+ if (liveCount > 0) {
1389
+ const int8Vectors = this.int8Vectors;
1390
+ await writeNdjsonSidecar(
1391
+ int8Path + pidSuffix,
1392
+ { version: 2, count: liveCount },
1393
+ (function* () {
1394
+ for (const [id, vec] of int8Vectors) {
1395
+ if (!liveIds.has(id)) continue;
1396
+ yield { id, v: Array.from(vec) };
1397
+ }
1398
+ })()
1399
+ );
1092
1400
  stagedInt8 = true;
1093
1401
  }
1094
1402
  }
@@ -1174,7 +1482,7 @@ export class BinaryHNSWIndex {
1174
1482
  let attempt = 0;
1175
1483
  while (true) {
1176
1484
  meta = attempt === 0 ? initialMeta : JSON.parse(await fs.readFile(metaPath, 'utf-8'));
1177
- vectorsData = JSON.parse(await fs.readFile(vectorsPath, 'utf-8'));
1485
+ vectorsData = await readVectorsSidecar(vectorsPath);
1178
1486
  const consistent = meta.vectorCount === vectorsData.length
1179
1487
  && (meta.entryPoint === -1 || meta.entryPoint < vectorsData.length);
1180
1488
  if (consistent) break;
@@ -1215,15 +1523,12 @@ export class BinaryHNSWIndex {
1215
1523
  }
1216
1524
 
1217
1525
  // Load graph
1218
- this.graph = JSON.parse(await fs.readFile(graphPath, 'utf-8'));
1526
+ this.graph = await readGraphSidecar(graphPath);
1219
1527
 
1220
1528
  // Load int8 vectors if available
1221
1529
  this.int8Vectors.clear();
1222
1530
  if (existsSync(int8Path)) {
1223
- const int8Data = JSON.parse(await fs.readFile(int8Path, 'utf-8'));
1224
- for (const [id, vec] of Object.entries(int8Data)) {
1225
- this.int8Vectors.set(id, new Int8Array(vec));
1226
- }
1531
+ await readInt8Sidecar(int8Path, this.int8Vectors);
1227
1532
  }
1228
1533
 
1229
1534
  // Load asymmetric calibration data
@@ -117,12 +117,16 @@ export class FloatVectorStore {
117
117
  const idsPath = binPath.replace(/\.bin$/, '.ids.json');
118
118
  if (!existsSync(idsPath)) return false;
119
119
 
120
- // Read binary
120
+ // Read binary. Large reads return an unpooled Buffer that owns its whole
121
+ // ArrayBuffer — adopt it directly; only pooled (offset/short) buffers need
122
+ // the defensive copy.
121
123
  const fileBuffer = await readFile(binPath);
122
- this.buffer = fileBuffer.buffer.slice(
123
- fileBuffer.byteOffset,
124
- fileBuffer.byteOffset + fileBuffer.byteLength
125
- );
124
+ this.buffer = (fileBuffer.byteOffset === 0 && fileBuffer.byteLength === fileBuffer.buffer.byteLength)
125
+ ? fileBuffer.buffer
126
+ : fileBuffer.buffer.slice(
127
+ fileBuffer.byteOffset,
128
+ fileBuffer.byteOffset + fileBuffer.byteLength
129
+ );
126
130
 
127
131
  // Parse header
128
132
  const header = new DataView(this.buffer, 0, HEADER_SIZE);
@@ -19,6 +19,17 @@ import {
19
19
  parseLineRange, looksLikeOption,
20
20
  } from './_ss-argparse.mjs';
21
21
 
22
+ // Diagnostic-log isolation (agent-facing tools). The Sweet Search engine emits
23
+ // model/index load banners via console.log → stdout ("LateInteraction: Loaded…",
24
+ // "BinaryHNSW: Loaded…", "Warming up embedding…", "✓ Vocabulary loaded…"). When the
25
+ // engine runs IN-PROCESS in this wrapper, that stdout IS the agent's tool result, so a
26
+ // cold start crowds out the actual hits and the agent falls back to native tools
27
+ // (diagnosed root cause of the sweet≈native tie). The wrappers emit REAL results only
28
+ // via process.stdout.write, so redirect every console.log to stderr; the ss-* shell
29
+ // wrapper's `2>` suppresses it on success → the agent sees clean results, warm or cold.
30
+ // (Production search/grep/find already avoid this: the daemon is spawned stdio:'ignore'.)
31
+ console.log = (...args) => process.stderr.write(args.map(a => (typeof a === 'string' ? a : String(a))).join(' ') + '\n');
32
+
22
33
  // 8-char SHA1 prefix is enough for grouping identical queries across
23
34
  // benchmark runs without bloating artifacts.
24
35
  function shortQueryHash(q) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sweet-search",
3
- "version": "2.6.8",
3
+ "version": "2.6.10",
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.8",
171
- "@sweet-search/native-darwin-x64": "2.6.8",
172
- "@sweet-search/native-linux-arm64-gnu": "2.6.8",
173
- "@sweet-search/native-linux-arm64-gnu-cuda": "2.6.8",
174
- "@sweet-search/native-linux-x64-gnu": "2.6.8",
175
- "@sweet-search/native-linux-x64-gnu-cuda": "2.6.8",
176
- "@sweet-search/bg-priority": "2.6.8"
170
+ "@sweet-search/native-darwin-arm64": "2.6.10",
171
+ "@sweet-search/native-darwin-x64": "2.6.10",
172
+ "@sweet-search/native-linux-arm64-gnu": "2.6.10",
173
+ "@sweet-search/native-linux-arm64-gnu-cuda": "2.6.10",
174
+ "@sweet-search/native-linux-x64-gnu": "2.6.10",
175
+ "@sweet-search/native-linux-x64-gnu-cuda": "2.6.10",
176
+ "@sweet-search/bg-priority": "2.6.10"
177
177
  },
178
178
  "engines": {
179
179
  "node": ">=18.0.0"