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.
@@ -2351,7 +2351,28 @@ function resolveThrows(exceptionName, entityByName) {
2351
2351
  * Insert entities and relationships into database
2352
2352
  * Uses better-sqlite3 (sync API, no .free() needed)
2353
2353
  */
2354
- export function insertGraph(db, entities, relationships, hasFts5 = false) {
2354
+ /**
2355
+ * Rebuild + optimize the external-content FTS5 mirrors from the entities
2356
+ * table. 'rebuild' reconstructs the whole FTS index from current content, so
2357
+ * only the LAST rebuild before any FTS read matters — batch loops should
2358
+ * pass { syncFts: false } to insertGraph and call this once at the end.
2359
+ */
2360
+ export function rebuildGraphFts(db) {
2361
+ try {
2362
+ db.exec(`INSERT INTO entities_fts(entities_fts) VALUES('rebuild')`);
2363
+ db.exec(`INSERT INTO entities_trigram(entities_trigram) VALUES('rebuild')`);
2364
+ console.log(' FTS5 indexes rebuilt (porter + trigram)');
2365
+
2366
+ // Best-effort post-build compaction for faster reads.
2367
+ db.exec(`INSERT INTO entities_fts(entities_fts) VALUES('optimize')`);
2368
+ db.exec(`INSERT INTO entities_trigram(entities_trigram) VALUES('optimize')`);
2369
+ console.log(' FTS5 indexes optimized (segments merged)');
2370
+ } catch (err) {
2371
+ // FTS5 rebuild/optimize failed, ignore
2372
+ }
2373
+ }
2374
+
2375
+ export function insertGraph(db, entities, relationships, hasFts5 = false, { syncFts = true } = {}) {
2355
2376
  // Insert entities with HCGS hierarchy support
2356
2377
  // Includes signature_hash for collision-proof backup/restore
2357
2378
  const entityStmt = db.prepare(`
@@ -2481,19 +2502,8 @@ export function insertGraph(db, entities, relationships, hasFts5 = false) {
2481
2502
  // }
2482
2503
 
2483
2504
  // Rebuild FTS indexes if available
2484
- if (hasFts5) {
2485
- try {
2486
- db.exec(`INSERT INTO entities_fts(entities_fts) VALUES('rebuild')`);
2487
- db.exec(`INSERT INTO entities_trigram(entities_trigram) VALUES('rebuild')`);
2488
- console.log(' FTS5 indexes rebuilt (porter + trigram)');
2489
-
2490
- // Best-effort post-build compaction for faster reads.
2491
- db.exec(`INSERT INTO entities_fts(entities_fts) VALUES('optimize')`);
2492
- db.exec(`INSERT INTO entities_trigram(entities_trigram) VALUES('optimize')`);
2493
- console.log(' FTS5 indexes optimized (segments merged)');
2494
- } catch (err) {
2495
- // FTS5 rebuild/optimize failed, ignore
2496
- }
2505
+ if (hasFts5 && syncFts) {
2506
+ rebuildGraphFts(db);
2497
2507
  }
2498
2508
  }
2499
2509
 
@@ -428,9 +428,10 @@ async function generateSummariesForEntities(entityIds, options = {}) {
428
428
  const dbWrite = new Database(dbPath);
429
429
  const stmt = dbWrite.prepare(`UPDATE entities SET summary = NULL WHERE id = ?`);
430
430
 
431
- for (const entity of entities) {
432
- stmt.run(entity.id);
433
- }
431
+ const clearSummaries = dbWrite.transaction((rows) => {
432
+ for (const entity of rows) stmt.run(entity.id);
433
+ });
434
+ clearSummaries(entities);
434
435
 
435
436
  dbWrite.close();
436
437
 
@@ -34,6 +34,7 @@ import { floatToBinary, normalizedFloatToInt8, truncateForHNSW } from '../../inf
34
34
  import { extractSparseGramDeltaRecord } from '../../infrastructure/native-sparse-gram.js';
35
35
  import { migrateEntitiesSchema, migrateRelationshipsSchema } from '../infrastructure/schema-migrations.mjs';
36
36
  import { readMaintenanceState as readMaintenanceStateFromArtifacts } from '../infrastructure/maintenance-state-reader.mjs';
37
+ import { prepareCached } from '../../infrastructure/db-utils.js';
37
38
 
38
39
  const DIRTY_QUEUE = 'index-maintainer-queue.jsonl';
39
40
  const PROCESSING_QUEUE = 'index-maintainer-queue.processing.jsonl';
@@ -270,9 +271,9 @@ async function enrichChunksFromGraph(chunks, stateDir, tickCtx = null) {
270
271
  ownConn = true;
271
272
  }
272
273
  try {
273
- const entityStmt = db.prepare('SELECT type, name, start_line, end_line FROM entities WHERE file_path = ? AND epoch_retired IS NULL ORDER BY start_line ASC');
274
- const fileEntityStmt = db.prepare('SELECT id FROM entities WHERE file_path = ? AND logical_entity_id = ? AND epoch_retired IS NULL ORDER BY epoch_written DESC LIMIT 1');
275
- const importStmt = db.prepare("SELECT DISTINCT target_name FROM relationships WHERE source_id = ? AND type IN ('imports', 'plainImport') AND epoch_retired IS NULL ORDER BY target_name");
274
+ const entityStmt = prepareCached(db, 'SELECT type, name, start_line, end_line FROM entities WHERE file_path = ? AND epoch_retired IS NULL ORDER BY start_line ASC');
275
+ const fileEntityStmt = prepareCached(db, 'SELECT id FROM entities WHERE file_path = ? AND logical_entity_id = ? AND epoch_retired IS NULL ORDER BY epoch_written DESC LIMIT 1');
276
+ const importStmt = prepareCached(db, "SELECT DISTINCT target_name FROM relationships WHERE source_id = ? AND type IN ('imports', 'plainImport') AND epoch_retired IS NULL ORDER BY target_name");
276
277
  for (const chunk of chunks) {
277
278
  const file = chunk.file || chunk.metadata?.relative_path;
278
279
  const symbol = chunk.metadata?.symbol;
@@ -376,6 +377,23 @@ class ProductionReconcileAdapter {
376
377
  // when the flag is on; null when disabled.
377
378
  this._cutoffCache = null;
378
379
  this._cutoffDirty = false;
380
+ // merkle-state.json is written exactly once per tick (persistManifest);
381
+ // every other reader gets one shared parse. `undefined` = not read yet,
382
+ // `null` = missing/corrupt file.
383
+ this._merkleCache = undefined;
384
+ }
385
+
386
+ /**
387
+ * Shared read of merkle-state.json — parsed once per adapter lifetime and
388
+ * invalidated by persistManifest (the only writer). All consumers treat the
389
+ * object as read-only.
390
+ * @returns {object|null}
391
+ */
392
+ _merkleRead() {
393
+ if (this._merkleCache === undefined) {
394
+ this._merkleCache = readJson(path.join(this.stateDir, MERKLE_STATE), null);
395
+ }
396
+ return this._merkleCache;
379
397
  }
380
398
 
381
399
  progress(phase) {
@@ -697,7 +715,7 @@ class ProductionReconcileAdapter {
697
715
  // dropped if it was never indexed, and retired if it was (so the index
698
716
  // converges to a fresh full rebuild). Existence + shape + size are sync;
699
717
  // gitignore is ONE batched check over the admissible candidates.
700
- const merkle = readJson(path.join(this.stateDir, MERKLE_STATE), { files: {} }).files || {};
718
+ const merkle = (this._merkleRead() ?? { files: {} }).files || {};
701
719
  const info = rels.map((rel) => {
702
720
  const abs = path.join(this.projectRoot, rel);
703
721
  const exists = fs.existsSync(abs);
@@ -736,7 +754,7 @@ class ProductionReconcileAdapter {
736
754
  async hashFile(file) {
737
755
  const rel = typeof file === 'string' ? file : file.path;
738
756
  const abs = path.join(this.projectRoot, rel);
739
- const merkle = readJson(path.join(this.stateDir, MERKLE_STATE), { files: {} });
757
+ const merkle = this._merkleRead() ?? { files: {} };
740
758
  // Deleted on disk, or flagged inadmissible by readDirtySet (a previously
741
759
  // indexed file that became excluded/oversized/gitignored): retire it so all
742
760
  // tiers tombstone and the merkle entry is dropped.
@@ -784,7 +802,7 @@ class ProductionReconcileAdapter {
784
802
  ownConn = true;
785
803
  }
786
804
  try {
787
- const oldRows = db.prepare('SELECT rowid, id, logical_entity_id, signature_hash FROM entities WHERE file_path = ? AND epoch_retired IS NULL').all(rel);
805
+ const oldRows = prepareCached(db, 'SELECT rowid, id, logical_entity_id, signature_hash FROM entities WHERE file_path = ? AND epoch_retired IS NULL').all(rel);
788
806
  const oldByLogical = new Map(oldRows.map((r) => [r.logical_entity_id || r.id, r]));
789
807
  const oldIds = oldRows.map((r) => r.id);
790
808
  const extractor = new GraphExtractor();
@@ -811,7 +829,7 @@ class ProductionReconcileAdapter {
811
829
  let tombstone = 0;
812
830
  const liveIdFor = new Map();
813
831
  const tx = db.transaction(() => {
814
- const retireEntity = db.prepare('UPDATE entities SET epoch_retired = ?, stale_since = COALESCE(stale_since, ?) WHERE id = ? AND epoch_retired IS NULL');
832
+ const retireEntity = prepareCached(db, 'UPDATE entities SET epoch_retired = ?, stale_since = COALESCE(stale_since, ?) WHERE id = ? AND epoch_retired IS NULL');
815
833
  const retireRel = oldIds.length > 0
816
834
  ? db.prepare(`UPDATE relationships SET epoch_retired = ? WHERE source_id IN (${oldIds.map(() => '?').join(',')}) AND epoch_retired IS NULL`)
817
835
  : null;
@@ -913,7 +931,7 @@ class ProductionReconcileAdapter {
913
931
  // tier write. The merkle still records the new content hash so the
914
932
  // file is not re-queued forever.
915
933
  const prevTouched = this.touched.get(rel) || {};
916
- const prevChunkIds = readJson(path.join(this.stateDir, MERKLE_STATE), { files: {} }).files?.[rel]?.chunkIds || prevTouched.chunkIds || [];
934
+ const prevChunkIds = (this._merkleRead() ?? { files: {} }).files?.[rel]?.chunkIds || prevTouched.chunkIds || [];
917
935
  this.touched.set(rel, { ...prevTouched, hash: hashes, chunkIds: prevChunkIds, content: hashes.content });
918
936
  if (ctx) ctx.persistedFiles.add(rel);
919
937
  return {
@@ -1170,7 +1188,7 @@ class ProductionReconcileAdapter {
1170
1188
  * @returns {number}
1171
1189
  */
1172
1190
  _publishedEpoch() {
1173
- const merkle = readJson(path.join(this.stateDir, MERKLE_STATE), {});
1191
+ const merkle = this._merkleRead() ?? {};
1174
1192
  return Number.isInteger(merkle?.epoch) ? merkle.epoch : -1;
1175
1193
  }
1176
1194
 
@@ -1225,6 +1243,8 @@ class ProductionReconcileAdapter {
1225
1243
  merkle.epoch = manifest.epoch;
1226
1244
  merkle.stats = { ...(merkle.stats || {}), totalFiles: Object.keys(merkle.files).length };
1227
1245
  safeWriteJson(merklePath, merkle);
1246
+ // Invalidate the shared read cache — this is the one merkle writer.
1247
+ this._merkleCache = undefined;
1228
1248
  // E.6: persist the updated chunk-cutoff cache once per tick (after the
1229
1249
  // merkle advances). Best-effort; a failure only costs a redundant re-embed.
1230
1250
  if (this._cutoffCache && this._cutoffDirty) {
@@ -192,13 +192,17 @@ function firstSafeRelativePath(...candidates) {
192
192
  * carried inside the encoder-input dependency registry; see
193
193
  * `core/incremental-indexing/domain/encoder-deps.mjs`).
194
194
  *
195
+ * Pass `{ includeDedup: false }` when the caller never persists the dedup
196
+ * fingerprint (the full-indexing insert path) to skip that hash entirely.
197
+ *
195
198
  * @param {object} chunk
196
- * @returns {{chunk_text_hash:string, embedding_input_hash:string, li_input_hash:string, metadata_fingerprint:string, dedup_fingerprint:string}}
199
+ * @param {{includeDedup?: boolean}} [opts]
200
+ * @returns {{chunk_text_hash:string, embedding_input_hash:string, li_input_hash:string, metadata_fingerprint:string, dedup_fingerprint?:string}}
197
201
  */
198
- export function chunkInputHashes(chunk) {
202
+ export function chunkInputHashes(chunk, { includeDedup = true } = {}) {
199
203
  const text = chunk?.content || chunk?.text || '';
200
204
  const meta = chunk?.metadata || {};
201
- return {
205
+ const hashes = {
202
206
  chunk_text_hash: contentHashSync(text),
203
207
  embedding_input_hash: denseInputHash(chunk),
204
208
  li_input_hash: liInputHash(chunk),
@@ -214,8 +218,9 @@ export function chunkInputHashes(chunk) {
214
218
  policy_embed: EMBED_TEXT_POLICY_VERSION,
215
219
  policy_li: LI_INPUT_POLICY_VERSION,
216
220
  })),
217
- dedup_fingerprint: dedupFingerprint(chunk),
218
221
  };
222
+ if (includeDedup) hashes.dedup_fingerprint = dedupFingerprint(chunk);
223
+ return hashes;
219
224
  }
220
225
 
221
226
  /**
@@ -151,12 +151,12 @@ function buildEmbeddingText({ variant: variantOverride, content, relativePath, l
151
151
  const pathLine = relativePath ? `# ${relativePath}` : null;
152
152
  const parentLine = hierarchyInfo?.parentSymbol
153
153
  ? `# Parent: ${hierarchyInfo.parentType} ${hierarchyInfo.parentSymbol}` : null;
154
- // Ruby method chunks keep metadata anchors, but omit method-name header
155
- // text so top-level Ruby snippets stay aligned with the pre-method-boundary
156
- // embedding surface.
157
- const isRubyMethodChunk = language === 'ruby'
158
- && (chunkType === 'method' || chunkType === 'singleton_method');
159
- const symbolLine = (symbol && symbol !== 'unknown' && !isRubyMethodChunk)
154
+ // Ruby method chunks keep metadata anchors, but omit method-name header
155
+ // text so top-level Ruby snippets stay aligned with the pre-method-boundary
156
+ // embedding surface.
157
+ const isRubyMethodChunk = language === 'ruby'
158
+ && (chunkType === 'method' || chunkType === 'singleton_method');
159
+ const symbolLine = (symbol && symbol !== 'unknown' && !isRubyMethodChunk)
160
160
  ? `# ${chunkType}: ${symbol}` : null;
161
161
  const langLine = (language && language !== 'text')
162
162
  ? `# Language: ${language}` : null;
@@ -293,10 +293,10 @@ function buildLiText({ content, relativePath, language, chunkType, symbol, hiera
293
293
  if (hierarchyInfo?.parentSymbol) {
294
294
  lines.push(`# Parent: ${hierarchyInfo.parentType} ${hierarchyInfo.parentSymbol}`);
295
295
  }
296
- // Mirror the Ruby method header carve-out used by embedding_text.
297
- const isRubyMethodChunk = language === 'ruby'
298
- && (chunkType === 'method' || chunkType === 'singleton_method');
299
- if (symbol && symbol !== 'unknown' && !isRubyMethodChunk) {
296
+ // Mirror the Ruby method header carve-out used by embedding_text.
297
+ const isRubyMethodChunk = language === 'ruby'
298
+ && (chunkType === 'method' || chunkType === 'singleton_method');
299
+ if (symbol && symbol !== 'unknown' && !isRubyMethodChunk) {
300
300
  lines.push(`# ${chunkType}: ${symbol}`);
301
301
  }
302
302
  // Mirror embedding_text: surface sibling symbol names so the LI MaxSim
@@ -391,27 +391,27 @@ export class ASTChunker {
391
391
  const tsChunks = await provider.parseFileToChunks(content, langInfo.id);
392
392
  if (!tsChunks || tsChunks.length === 0) return null;
393
393
 
394
- return tsChunks.map(chunk => {
395
- const isTopLevelRubyMethod = langInfo.id === 'ruby'
396
- && (chunk.type === 'method' || chunk.type === 'singleton_method')
397
- && !chunk.parentSymbol;
398
-
399
- return this.buildChunk(
400
- chunk.text, filePath, langInfo.id,
401
- isTopLevelRubyMethod ? 'code' : chunk.type,
402
- isTopLevelRubyMethod ? null : chunk.name,
403
- chunk.startLine, chunk.endLine,
404
- {
405
- chunkId: chunk.chunkId,
406
- parentChunkId: chunk.parentChunkId,
407
- parentSymbol: chunk.parentSymbol,
408
- parentType: chunk.parentType,
409
- signature: chunk.signature || null,
410
- additionalSymbols: chunk.additionalSymbols || null,
411
- }
412
- );
413
- });
414
- }
394
+ return tsChunks.map(chunk => {
395
+ const isTopLevelRubyMethod = langInfo.id === 'ruby'
396
+ && (chunk.type === 'method' || chunk.type === 'singleton_method')
397
+ && !chunk.parentSymbol;
398
+
399
+ return this.buildChunk(
400
+ chunk.text, filePath, langInfo.id,
401
+ isTopLevelRubyMethod ? 'code' : chunk.type,
402
+ isTopLevelRubyMethod ? null : chunk.name,
403
+ chunk.startLine, chunk.endLine,
404
+ {
405
+ chunkId: chunk.chunkId,
406
+ parentChunkId: chunk.parentChunkId,
407
+ parentSymbol: chunk.parentSymbol,
408
+ parentType: chunk.parentType,
409
+ signature: chunk.signature || null,
410
+ additionalSymbols: chunk.additionalSymbols || null,
411
+ }
412
+ );
413
+ });
414
+ }
415
415
 
416
416
  parseBraceBasedFile(filePath, content, language, patterns, comment, multiLine) {
417
417
  const chunks = [];
@@ -942,6 +942,7 @@ export class ASTChunker {
942
942
  // Production embedding text. With the default variant (current) this
943
943
  // is byte-identical to shipped v6.2; under a research-only variant
944
944
  // (SWEET_SEARCH_EMBED_TEXT_VARIANT) it produces the experimental form.
945
+ const activeVariant = getEmbedTextVariant();
945
946
  const embedding_text = buildEmbeddingText({
946
947
  content,
947
948
  relativePath,
@@ -954,17 +955,19 @@ export class ASTChunker {
954
955
  // Research isolation: always build the shipped (current-variant)
955
956
  // form alongside, so the LI stage can read a canonical input even
956
957
  // when an R1 experiment is active on embedding_text. In production
957
- // (variant=current) this is the same content as embedding_text and
958
- // pickLiInput's preference for li_greedy_text changes nothing.
959
- const li_greedy_text = buildEmbeddingText({
960
- variant: 'current',
961
- content,
962
- relativePath,
963
- language,
964
- chunkType,
965
- symbol,
966
- hierarchyInfo,
967
- });
958
+ // (variant=current) it IS the embedding_text reuse the string
959
+ // instead of rebuilding it.
960
+ const li_greedy_text = activeVariant === 'current'
961
+ ? embedding_text
962
+ : buildEmbeddingText({
963
+ variant: 'current',
964
+ content,
965
+ relativePath,
966
+ language,
967
+ chunkType,
968
+ symbol,
969
+ hierarchyInfo,
970
+ });
968
971
 
969
972
  // Build LI text via the v6.2 builder (language-conditioned path
970
973
  // policy). See buildLiText() docstring for rationale.
@@ -1034,26 +1037,26 @@ export class ASTChunker {
1034
1037
  const parts = [];
1035
1038
  parts.push(`# ${chunk.metadata.path}`);
1036
1039
 
1037
- const isRubyMethodChunk = chunk.metadata.language === 'ruby'
1038
- && (chunk.metadata.chunk_type === 'method'
1039
- || chunk.metadata.chunk_type === 'singleton_method');
1040
- const hasOnlySelfScope = scopeChain
1041
- && scopeChain.length === 1
1042
- && scopeChain[0] === chunk.metadata.symbol
1043
- && !chunk.metadata.parent_symbol;
1044
-
1045
- if (scopeChain && scopeChain.length > 0 && !(isRubyMethodChunk && hasOnlySelfScope)) {
1046
- parts.push(`# Scope: ${scopeChain.join(' > ')}`);
1047
- } else if (chunk.metadata.parent_symbol) {
1048
- // Preserve cAST parent context when no scope chain from code graph
1049
- parts.push(`# Parent: ${chunk.metadata.parent_type} ${chunk.metadata.parent_symbol}`);
1050
- }
1051
- // Keep Ruby method metadata, but avoid injecting the method name into
1052
- // the production embedding/LI text. Top-level Ruby method chunks also
1053
- // skip self-only scope above.
1054
- if (chunk.metadata.symbol && chunk.metadata.symbol !== 'unknown' && !isRubyMethodChunk) {
1055
- parts.push(`# Defines: ${chunk.metadata.chunk_type} ${chunk.metadata.symbol}`);
1056
- }
1040
+ const isRubyMethodChunk = chunk.metadata.language === 'ruby'
1041
+ && (chunk.metadata.chunk_type === 'method'
1042
+ || chunk.metadata.chunk_type === 'singleton_method');
1043
+ const hasOnlySelfScope = scopeChain
1044
+ && scopeChain.length === 1
1045
+ && scopeChain[0] === chunk.metadata.symbol
1046
+ && !chunk.metadata.parent_symbol;
1047
+
1048
+ if (scopeChain && scopeChain.length > 0 && !(isRubyMethodChunk && hasOnlySelfScope)) {
1049
+ parts.push(`# Scope: ${scopeChain.join(' > ')}`);
1050
+ } else if (chunk.metadata.parent_symbol) {
1051
+ // Preserve cAST parent context when no scope chain from code graph
1052
+ parts.push(`# Parent: ${chunk.metadata.parent_type} ${chunk.metadata.parent_symbol}`);
1053
+ }
1054
+ // Keep Ruby method metadata, but avoid injecting the method name into
1055
+ // the production embedding/LI text. Top-level Ruby method chunks also
1056
+ // skip self-only scope above.
1057
+ if (chunk.metadata.symbol && chunk.metadata.symbol !== 'unknown' && !isRubyMethodChunk) {
1058
+ parts.push(`# Defines: ${chunk.metadata.chunk_type} ${chunk.metadata.symbol}`);
1059
+ }
1057
1060
 
1058
1061
  if (chunk.metadata.additional_symbols && chunk.metadata.additional_symbols.length > 0) {
1059
1062
  parts.push(`# Additional: ${chunk.metadata.additional_symbols.join(', ')}`);
@@ -9,7 +9,7 @@ import { existsSync } from 'fs';
9
9
  import path from 'path';
10
10
 
11
11
  import { DB_PATHS, EMBEDDING_CONFIG, PROJECT_ROOT } from '../infrastructure/config/index.js';
12
- import { GraphExtractor, createGraphSchema, insertGraph } from '../graph/graph-extractor.js';
12
+ import { GraphExtractor, createGraphSchema, insertGraph, rebuildGraphFts } from '../graph/graph-extractor.js';
13
13
  import { resolveRelationshipTargets } from '../graph/relationship-resolver.js';
14
14
  import { populatePageRankColumn } from '../graph/structural-pagerank.js';
15
15
  import { getEmbeddings, getModelInfo } from '../embedding/embedding-service.js';
@@ -153,6 +153,7 @@ export async function buildCodeGraph(files, dryRun = false) {
153
153
  let errors = 0;
154
154
  let totalEntities = 0;
155
155
  let totalRelationships = 0;
156
+ let graphFlushed = false;
156
157
 
157
158
  for (let i = 0; i < files.length; i++) {
158
159
  try {
@@ -170,10 +171,14 @@ export async function buildCodeGraph(files, dryRun = false) {
170
171
  errors++;
171
172
  }
172
173
 
173
- // Flush batch every GRAPH_BATCH_SIZE files or at the end
174
+ // Flush batch every GRAPH_BATCH_SIZE files or at the end. FTS5 sync is
175
+ // deferred to ONE rebuild after the loop — 'rebuild' reconstructs the
176
+ // whole index from the entities table, so per-batch rebuilds were
177
+ // O(entities × batches) work discarded by the next batch.
174
178
  if ((i + 1) % GRAPH_BATCH_SIZE === 0 || i === files.length - 1) {
175
179
  if (entityBatch.length > 0 || relBatch.length > 0) {
176
- insertGraph(db, entityBatch, relBatch, hasFts5);
180
+ insertGraph(db, entityBatch, relBatch, hasFts5, { syncFts: false });
181
+ graphFlushed = true;
177
182
  totalEntities += entityBatch.length;
178
183
  totalRelationships += relBatch.length;
179
184
  entityBatch = [];
@@ -186,6 +191,13 @@ export async function buildCodeGraph(files, dryRun = false) {
186
191
  }
187
192
  }
188
193
 
194
+ // Single FTS5 rebuild at the exact point the last per-batch rebuild used to
195
+ // run (before relationship resolution, whose entity updates were never
196
+ // reflected in FTS).
197
+ if (graphFlushed && hasFts5) {
198
+ rebuildGraphFts(db);
199
+ }
200
+
189
201
  log(`\n✓ Extracted ${totalEntities} entities, ${totalRelationships} relationships`, 'green');
190
202
  if (errors > 0) {
191
203
  log(`⚠ ${errors} files had errors`, 'yellow');
@@ -360,7 +372,9 @@ function annotateChunksForVectorInsert(chunks) {
360
372
  const idx = indices[i];
361
373
  annotations[idx] = {
362
374
  ...ids[i],
363
- hashes: chunkInputHashes(chunks[idx]),
375
+ // Full indexing never persists dedup_fingerprint (only the reconcile
376
+ // delta writer does) — skip that hash.
377
+ hashes: chunkInputHashes(chunks[idx], { includeDedup: false }),
364
378
  };
365
379
  }
366
380
  }
@@ -10,7 +10,7 @@ import path from 'path';
10
10
  import { DB_PATHS, PROJECT_ROOT, EMBEDDING_CONFIG, HCGS_CONFIG } from '../infrastructure/config/index.js';
11
11
  import { getChangedFiles, updateState, getStats as getIncrementalStats, updatePhaseProgress, markPhaseComplete, clearPhaseProgress } from './incremental-tracker.js';
12
12
  import { backupSummaries, restoreSummaries, markForRegeneration } from '../graph/summary-manager.js';
13
- import { colors, log, logProgress, logError, discoverFiles, readFilesFromStdin, atomicSwapDatabase } from './indexer-utils.js';
13
+ import { colors, log, logProgress, logError, discoverFiles, readFilesFromStdin, atomicSwapDatabase, shouldStreamVectors } from './indexer-utils.js';
14
14
  import { buildCodeGraph, buildVectorIndex, chunkFiles } from './indexer-build.js';
15
15
  import { runDedupPhase, formatDedupSummary } from './dedup/dedup-phase.js';
16
16
  import { DEDUP_CONFIG } from '../infrastructure/config/index.js';
@@ -420,15 +420,24 @@ export async function buildVectorsAndArtifactsPhase(options = {}) {
420
420
  // model. For large full rebuilds we instead spill chunks to disk and embed/LI
421
421
  // in bounded windows (see streaming-vectors.js) so peak heap is O(window).
422
422
  //
423
- // Gated by file count so small repos + incremental runs keep the original
424
- // in-memory path byte-for-byte (benchmark indexes unaffected). Auto-selected,
425
- // no opt-in flag; SWEET_SEARCH_STREAM_VECTORS=0 forces the legacy path and
426
- // SWEET_SEARCH_STREAM_MIN_FILES tunes the threshold.
427
- const streamMinFiles = Number(process.env.SWEET_SEARCH_STREAM_MIN_FILES) || 5000;
428
- const useStreaming = !dryRun
429
- && fullReindex
430
- && filesToIndex.length >= streamMinFiles
431
- && process.env.SWEET_SEARCH_STREAM_VECTORS !== '0';
423
+ // Gated by file count OR total admitted source bytes (see shouldStreamVectors
424
+ // in indexer-utils.js) so small repos + incremental runs keep the original
425
+ // in-memory path byte-for-byte (benchmark indexes unaffected). The byte
426
+ // trigger catches few-files-huge-bytes repos (amalgamations, vendored blobs)
427
+ // that OOM the in-memory path while staying under the file gate.
428
+ // Auto-selected, no opt-in flag; SWEET_SEARCH_STREAM_VECTORS=0 forces the
429
+ // legacy path, SWEET_SEARCH_STREAM_MIN_FILES / SWEET_SEARCH_STREAM_MIN_BYTES
430
+ // tune the thresholds.
431
+ const streamingDecision = await shouldStreamVectors({ filesToIndex, dryRun, fullReindex });
432
+ const useStreaming = streamingDecision.useStreaming;
433
+ if (streamingDecision.reason === 'bytes') {
434
+ log(
435
+ ` Streaming vectors: ${filesToIndex.length} files total ` +
436
+ `${(streamingDecision.totalBytes / 1048576).toFixed(0)}+ MB >= ` +
437
+ `${(streamingDecision.thresholdBytes / 1048576).toFixed(0)} MB (bounded memory)`,
438
+ 'dim'
439
+ );
440
+ }
432
441
 
433
442
  // The in-memory path pre-chunks up front so both vector + LI encoders share
434
443
  // one chunk list. The streaming path does its own windowed chunking + dedup,
@@ -814,20 +823,32 @@ export async function updateIncrementalStatePhase(options = {}) {
814
823
  log('\nIncremental state updated', 'green');
815
824
  } else if (fullReindex) {
816
825
  const hashes = {};
817
- for (const file of allFiles) {
818
- try {
819
- const fullPath = path.join(PROJECT_ROOT, file);
820
- const [content, stat] = await Promise.all([
821
- fs.readFile(fullPath),
822
- fs.stat(fullPath, { bigint: true }).catch(() => null),
823
- ]);
824
- hashes[file] = {
825
- hash: contentHashSync(content),
826
- size: stat ? stat.size.toString() : null,
827
- mtime_ns: stat ? stat.mtimeNs.toString() : null,
828
- inode: stat ? stat.ino.toString() : null,
829
- };
830
- } catch (e) { /* skip */ }
826
+ // Batched read+stat (order-preserving assignment key insertion order,
827
+ // and therefore the serialized state file, matches the sequential loop).
828
+ // Mirrors the H6 batching in incremental-tracker.getChangedFiles.
829
+ const HASH_BATCH = 100;
830
+ for (let i = 0; i < allFiles.length; i += HASH_BATCH) {
831
+ const batch = allFiles.slice(i, i + HASH_BATCH);
832
+ const results = await Promise.all(batch.map(async (file) => {
833
+ try {
834
+ const fullPath = path.join(PROJECT_ROOT, file);
835
+ const [content, stat] = await Promise.all([
836
+ fs.readFile(fullPath),
837
+ fs.stat(fullPath, { bigint: true }).catch(() => null),
838
+ ]);
839
+ return {
840
+ hash: contentHashSync(content),
841
+ size: stat ? stat.size.toString() : null,
842
+ mtime_ns: stat ? stat.mtimeNs.toString() : null,
843
+ inode: stat ? stat.ino.toString() : null,
844
+ };
845
+ } catch (e) {
846
+ return null; // skip
847
+ }
848
+ }));
849
+ for (let j = 0; j < batch.length; j++) {
850
+ if (results[j]) hashes[batch[j]] = results[j];
851
+ }
831
852
  }
832
853
  await updateState(hashes, {
833
854
  totalChunks: vectorStats.chunks,
@@ -395,16 +395,22 @@ export async function discoverFiles(options = {}) {
395
395
 
396
396
  const files = [];
397
397
  let oversized = 0;
398
- for (const file of allFiles) {
399
- try {
400
- const stat = await fs.stat(path.join(projectRoot, file));
398
+ // Stat in batches (order-preserving) instead of one serialized await per
399
+ // file; results are consumed in the original allFiles order.
400
+ const STAT_BATCH = 200;
401
+ for (let i = 0; i < allFiles.length; i += STAT_BATCH) {
402
+ const batch = allFiles.slice(i, i + STAT_BATCH);
403
+ const stats = await Promise.all(
404
+ batch.map((file) => fs.stat(path.join(projectRoot, file)).catch(() => null)),
405
+ );
406
+ for (let j = 0; j < batch.length; j++) {
407
+ const stat = stats[j];
408
+ if (!stat) continue; // File disappeared between glob and stat
401
409
  if (stat.size > maxFileSize) {
402
410
  oversized++;
403
411
  } else {
404
- files.push(file);
412
+ files.push(batch[j]);
405
413
  }
406
- } catch {
407
- // File disappeared between glob and stat
408
414
  }
409
415
  }
410
416
 
@@ -426,3 +432,87 @@ export async function discoverFiles(options = {}) {
426
432
 
427
433
  return files;
428
434
  }
435
+
436
+ // =============================================================================
437
+ // STREAMING-VECTORS GATE
438
+ // =============================================================================
439
+
440
+ /**
441
+ * Sum on-disk sizes of `files` (relative to `projectRoot`), stopping as soon
442
+ * as the running total crosses `stopAt`. The streaming gate only needs to
443
+ * know whether the total crosses the threshold, not the exact figure, so on
444
+ * large-byte repos this exits after a fraction of the stats.
445
+ */
446
+ export async function sumFileSizesUpTo(files, stopAt, projectRoot = PROJECT_ROOT) {
447
+ let total = 0;
448
+ for (const file of files) {
449
+ try {
450
+ const stat = await fs.stat(path.isAbsolute(file) ? file : path.join(projectRoot, file));
451
+ total += stat.size;
452
+ if (total >= stopAt) return total;
453
+ } catch {
454
+ // File disappeared between discovery and this gate — skip it.
455
+ }
456
+ }
457
+ return total;
458
+ }
459
+
460
+ /**
461
+ * Decide whether a full rebuild should take the bounded-memory streaming
462
+ * vectors path (see streaming-vectors.js) instead of the in-memory path.
463
+ *
464
+ * Two independent triggers, either is sufficient:
465
+ * - file count ≥ SWEET_SEARCH_STREAM_MIN_FILES (default 5000)
466
+ * - total admitted source bytes ≥ SWEET_SEARCH_STREAM_MIN_BYTES
467
+ * (default 512 MB; set to 0 to disable the byte trigger)
468
+ *
469
+ * The byte trigger exists because peak heap on the in-memory path scales
470
+ * with the chunk corpus (≈ source bytes), not file count: a repo with
471
+ * few-but-huge files (amalgamations, vendored/generated blobs, extreme
472
+ * duplication) can OOM the default ~4 GB heap while staying far under the
473
+ * file gate.
474
+ *
475
+ * The 512 MB default is deliberately conservative so the trigger only fires
476
+ * where the in-memory path would fail outright, never where it would merely
477
+ * be tight. The one MEASURED failure at this scale is libsql (596 MB
478
+ * admitted source; the in-memory path needed a 9.6+ GB heap); the wider
479
+ * crash zone is ESTIMATED — not measured — to start around ~200 MB of
480
+ * admitted source on a default ~4 GB heap. A repo the byte trigger newly
481
+ * moves to streaming was therefore not getting a usable in-memory index at
482
+ * all, so streaming strictly improves on a crash; every repo below the
483
+ * threshold keeps the byte-for-byte-identical in-memory path and identical
484
+ * retrieval behaviour.
485
+ *
486
+ * Sizes are only stat'd when the (free) count trigger hasn't already fired,
487
+ * so the byte check costs at most one stat per file on sub-threshold repos.
488
+ * The re-stat duplicates work discoverFiles already did for its size cap —
489
+ * accepted deliberately: threading sizes through would change discoverFiles'
490
+ * public return shape, and ≤5000 extra stats on a full rebuild is noise next
491
+ * to chunking + embedding.
492
+ *
493
+ * @returns {Promise<{useStreaming: boolean, reason?: 'files'|'bytes', totalBytes?: number, thresholdBytes?: number}>}
494
+ */
495
+ export async function shouldStreamVectors({ filesToIndex, dryRun, fullReindex, projectRoot = PROJECT_ROOT, env = process.env }) {
496
+ if (dryRun || !fullReindex || env.SWEET_SEARCH_STREAM_VECTORS === '0') {
497
+ return { useStreaming: false };
498
+ }
499
+ const streamMinFiles = Number(env.SWEET_SEARCH_STREAM_MIN_FILES) || 5000;
500
+ if (filesToIndex.length >= streamMinFiles) {
501
+ return { useStreaming: true, reason: 'files' };
502
+ }
503
+ // Unset/empty/invalid → 512 MB default; an explicit 0 (or negative)
504
+ // disables the byte trigger alone, leaving the count trigger active.
505
+ const rawMinBytes = env.SWEET_SEARCH_STREAM_MIN_BYTES;
506
+ const parsedMinBytes = (rawMinBytes === undefined || rawMinBytes === '') ? NaN : Number(rawMinBytes);
507
+ const streamMinBytes = Number.isFinite(parsedMinBytes)
508
+ ? (parsedMinBytes > 0 ? parsedMinBytes : Infinity)
509
+ : 512 * 1024 * 1024;
510
+ if (!Number.isFinite(streamMinBytes)) {
511
+ return { useStreaming: false };
512
+ }
513
+ const totalBytes = await sumFileSizesUpTo(filesToIndex, streamMinBytes, projectRoot);
514
+ if (totalBytes >= streamMinBytes) {
515
+ return { useStreaming: true, reason: 'bytes', totalBytes, thresholdBytes: streamMinBytes };
516
+ }
517
+ return { useStreaming: false };
518
+ }