sweet-search 2.6.7 → 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.
- package/core/graph/graph-extractor.js +24 -14
- package/core/graph/hcgs-generator.js +4 -3
- package/core/incremental-indexing/application/production-reconciler.mjs +29 -9
- package/core/incremental-indexing/domain/encoder-input.mjs +9 -4
- package/core/indexing/ast-chunker.js +65 -62
- package/core/indexing/indexer-build.js +18 -4
- package/core/indexing/indexer-phases.js +45 -24
- package/core/indexing/indexer-utils.js +96 -6
- package/core/indexing/indexing-file-policy.js +30 -7
- package/core/infrastructure/code-graph-repository.js +18 -18
- package/core/infrastructure/config/search.js +18 -1
- package/core/infrastructure/db-utils.js +29 -0
- package/core/infrastructure/language-patterns/maps.js +37 -0
- package/core/infrastructure/simd-distance.js +35 -21
- package/core/infrastructure/tombstone-bitmap-reader.js +3 -1
- package/core/ranking/file-kind-ranking.js +14 -2
- package/core/ranking/late-interaction-index.js +86 -15
- package/core/ranking/mmr.js +15 -10
- package/core/search/search-anchor.js +21 -3
- package/core/search/search-boost.js +25 -13
- package/core/search/search-fusion.js +13 -5
- package/core/search/search-read-semantic.js +13 -12
- package/core/search/search-server.js +2 -1
- package/core/vector-store/binary-hnsw-index.js +13 -4
- package/core/vector-store/float-vector-store.js +9 -5
- package/eval/agent-read-workflows/bin/_ss-helpers.mjs +11 -0
- package/package.json +8 -8
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
274
|
-
const fileEntityStmt = db
|
|
275
|
-
const importStmt = db
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
* @
|
|
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
|
-
|
|
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)
|
|
958
|
-
//
|
|
959
|
-
const li_greedy_text =
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
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
|
|
424
|
-
// in-
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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(
|
|
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
|
+
}
|