opencode-rag-plugin 1.19.5 → 1.19.8

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.
@@ -382,19 +382,176 @@ async function runIndexPassInner(options, logger) {
382
382
  options.logger?.warn?.(`Failed to save manifest: ${err.message}`);
383
383
  }));
384
384
  }
385
+ /**
386
+ * Store one window's prepared files into the vector store and update the
387
+ * manifest. Runs as a standalone async step so the caller can overlap it
388
+ * with the next window's prepare/describe/embed phases (the store phase is
389
+ * I/O-bound; the embed phase is GPU/API-bound).
390
+ *
391
+ * All chunks in the window are written in ONE bulk transaction (a single
392
+ * table.add plus one delete per modified file) instead of per-file adds
393
+ * plus per-startLine deletes. Per-file transactions caused LanceDB
394
+ * version-manifest accumulation (K+2 versions per file) that made the
395
+ * store phase degrade quadratically as the index grew.
396
+ */
397
+ async function storeWindow(prepared, windowEarlyResults) {
398
+ const filesToStore = prepared.filter((p) => !windowEarlyResults.has(prepared.indexOf(p)) && p.chunks && (p.textToEmbed?.length ?? 0) > 0).length;
399
+ const storePhaseStart = Date.now();
400
+ logger.info(`Store phase: storing ${filesToStore} file(s) into vector database...`);
401
+ let storedFiles = 0;
402
+ // Compute per-file store payloads first (pure computation, no I/O).
403
+ const storePayloads = [];
404
+ for (const prep of prepared) {
405
+ if (aborted())
406
+ break;
407
+ if (windowEarlyResults.has(prepared.indexOf(prep)))
408
+ continue;
409
+ if (!prep.chunks || prep.textToEmbed?.length === 0)
410
+ continue;
411
+ const validChunks = (prep.chunks ?? []).filter((c) => c.embedding && c.embedding.length > 0);
412
+ // Partial embed failure: some chunks have no vector. Do NOT record the
413
+ // file as complete — keep the previous manifest entry (or none, for
414
+ // new files) so the next pass retries the missing chunks.
415
+ const allEmbedded = validChunks.length === (prep.chunks?.length ?? 0);
416
+ if (!allEmbedded && (prep.chunks?.length ?? 0) > 0) {
417
+ options.logger?.warn?.(` ${prep.fileLabel}: ${validChunks.length}/${prep.chunks?.length} chunks embedded — marking for retry on next pass`);
418
+ }
419
+ storePayloads.push({ prep, validChunks, allEmbedded });
420
+ }
421
+ // One bulk write per window. Dedup (per-modified-file deletes) is skipped
422
+ // when the store provably has no prior rows: during a full rebuild the
423
+ // temp store starts empty, and on a first-time index the store counted 0
424
+ // chunks at the start of the pass. Nothing can collide there, so the
425
+ // delete transactions (which would each scan the table) are pure waste.
426
+ const skipDedup = tempStorePath !== undefined || existingCount === 0;
427
+ if (storePayloads.length > 0) {
428
+ const bulkItems = storePayloads.map(({ validChunks }) => ({
429
+ chunks: validChunks,
430
+ dedup: !skipDedup,
431
+ }));
432
+ if (effectiveStore.addChunksBulk) {
433
+ await effectiveStore.addChunksBulk(bulkItems);
434
+ }
435
+ else {
436
+ // Fallback for stores without bulk-write support
437
+ for (const item of bulkItems) {
438
+ await effectiveStore.addChunks(item.chunks, { dedup: item.dedup });
439
+ }
440
+ }
441
+ }
442
+ // Manifest updates + progress reporting (per file, in-memory only).
443
+ const storeResults = [];
444
+ for (let fi = 0; fi < prepared.length; fi++) {
445
+ if (aborted()) {
446
+ storeResults.push({ normalizedPath: prepared[fi].normalizedPath, skipped: true });
447
+ break;
448
+ }
449
+ // Return early results from phase 1
450
+ const earlyResult = windowEarlyResults.get(fi);
451
+ if (earlyResult) {
452
+ storeResults.push(earlyResult);
453
+ continue;
454
+ }
455
+ const prep = prepared[fi];
456
+ // No-embed path (shouldn't reach here but guard anyway)
457
+ if (!prep.chunks || prep.textToEmbed?.length === 0) {
458
+ options.progress?.finishFile(prep.fileLabel);
459
+ storeResults.push({
460
+ normalizedPath: prep.normalizedPath, hash: prep.hash, chunkCount: 0,
461
+ fileLabel: prep.fileLabel,
462
+ isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
463
+ isTooSmall: false, isRemoved: true, hadChunks: false,
464
+ descriptionFailed: prep.descriptionFailed,
465
+ });
466
+ continue;
467
+ }
468
+ const payload = storePayloads.find((p) => p.prep === prep);
469
+ const validChunks = payload?.validChunks ?? [];
470
+ const allEmbedded = payload?.allEmbedded ?? false;
471
+ const result = {
472
+ normalizedPath: prep.normalizedPath,
473
+ hash: prep.hash,
474
+ chunkCount: validChunks.length,
475
+ fileLabel: prep.fileLabel,
476
+ isNew: !prep.isModified,
477
+ isModified: prep.isModified,
478
+ isUnchanged: false,
479
+ isEmpty: false,
480
+ isTooSmall: false,
481
+ isRemoved: (prep.chunks?.length ?? 0) > 0 && validChunks.length === 0 ? false : validChunks.length === 0,
482
+ hadChunks: (prep.chunks?.length ?? 0) > 0,
483
+ descriptionFailed: prep.descriptionFailed,
484
+ descHash: prep.descHash,
485
+ };
486
+ // Update manifest — only when ALL chunks were embedded. A partial
487
+ // write must not bump the hash/chunkCount, otherwise the missing
488
+ // chunks would never be retried (hash match on the next pass).
489
+ if (result.chunkCount > 0 && !result.isRemoved && allEmbedded) {
490
+ const meta = fileMeta.get(result.normalizedPath);
491
+ const entry = {
492
+ hash: result.hash,
493
+ chunkCount: result.chunkCount,
494
+ indexedAt: Date.now(),
495
+ mtime: meta?.mtime,
496
+ size: meta?.size,
497
+ descriptionFailed: result.descriptionFailed,
498
+ };
499
+ if (result.descHash) {
500
+ entry.descHash = result.descHash;
501
+ }
502
+ manifest.files[result.normalizedPath] = entry;
503
+ enqueueManifestSave();
504
+ }
505
+ else if (result.isRemoved) {
506
+ delete manifest.files[result.normalizedPath];
507
+ enqueueManifestSave();
508
+ }
509
+ options.progress?.finishFile(prep.fileLabel);
510
+ storedFiles++;
511
+ logChunkProgress("Storing", prep.fileLabel, storedFiles, filesToStore, storedFiles, filesToStore);
512
+ storeResults.push(result);
513
+ }
514
+ const workerResults = storeResults;
515
+ for (const r of workerResults) {
516
+ if (r.skipped) {
517
+ abortedInWindow = true;
518
+ break;
519
+ }
520
+ allFinalResults.push(r);
521
+ }
522
+ aggregateStats(stats, allFinalResults.slice(-workerResults.length));
523
+ const storePhaseSec = ((Date.now() - storePhaseStart) / 1000).toFixed(1);
524
+ logger.info(`Store phase: ${storedFiles} files stored in ${storePhaseSec}s (running total ${stats.totalChunks} chunks)`);
525
+ // Free this window's chunk payloads so GC can reclaim them before the
526
+ // next window is prepared.
527
+ for (const prep of prepared) {
528
+ prep.chunks = undefined;
529
+ prep.textToEmbed = undefined;
530
+ }
531
+ }
385
532
  // ── Windowed pipeline ────────────────────────────────────────────────────
386
533
  // The prepare→describe→embed→store chain used to materialize ALL chunks,
387
534
  // embed texts, and vectors of the whole workspace simultaneously (hundreds
388
535
  // of MB on large repos). Processing bounded windows of files keeps peak
389
536
  // memory proportional to the window size instead of the workspace size.
537
+ //
538
+ // The store phase is launched as a non-awaited promise per window so it
539
+ // overlaps the next window's prepare/describe/embed (the store is I/O-bound,
540
+ // the embed is GPU/API-bound). Stores themselves stay serialized: the next
541
+ // window's store only starts after the previous one resolves.
390
542
  const WINDOW_SIZE = Math.max(50, (options.config.indexing.concurrency ?? 4) * 10);
391
543
  const allFinalResults = [];
392
544
  let abortedInWindow = false;
545
+ const earlyWorkerResults = new Map();
546
+ let prevStore = null;
547
+ let windowCount = 0;
548
+ const optimizeInterval = options.config.indexing.optimizeIntervalWindows ?? 8;
393
549
  for (let windowStart = 0; windowStart < workspaceFiles.length; windowStart += WINDOW_SIZE) {
394
550
  if (aborted()) {
395
551
  abortedInWindow = true;
396
552
  break;
397
553
  }
554
+ windowCount++;
398
555
  const windowFiles = workspaceFiles.slice(windowStart, windowStart + WINDOW_SIZE);
399
556
  const prepared = await prepareWindow(windowFiles);
400
557
  if (windowStart === 0) {
@@ -590,7 +747,7 @@ async function runIndexPassInner(options, logger) {
590
747
  // ── Phase 1: Collect embed queue + handle early results ────────────────
591
748
  const embedQueue = [];
592
749
  let totalEmbedChunks = 0;
593
- const earlyWorkerResults = new Map();
750
+ earlyWorkerResults.clear();
594
751
  for (let fi = 0; fi < prepared.length; fi++) {
595
752
  const prep = prepared[fi];
596
753
  if (prep.earlyResult) {
@@ -662,106 +819,33 @@ async function runIndexPassInner(options, logger) {
662
819
  prep.chunks[chunkIdx].embedding = emb;
663
820
  }
664
821
  }
665
- // ── Phase 3: Store + manifest update per file (parallel) ──────────────
666
- const filesToStore = prepared.filter((p) => !earlyWorkerResults.has(prepared.indexOf(p)) && p.chunks && (p.textToEmbed?.length ?? 0) > 0).length;
667
- const storePhaseStart = Date.now();
668
- logger.info(`Store phase: storing ${filesToStore} file(s) into vector database...`);
669
- let storedFiles = 0;
670
- const storeLimit = pLimit(options.config.indexing.concurrency);
671
- const storeResults = await Promise.all(prepared.map((prep, fi) => storeLimit(async () => {
672
- if (aborted()) {
673
- return { normalizedPath: prep.normalizedPath, skipped: true };
674
- }
675
- // Return early results from phase 1
676
- const earlyResult = earlyWorkerResults.get(fi);
677
- if (earlyResult)
678
- return earlyResult;
679
- // No-embed path (shouldn't reach here but guard anyway)
680
- if (!prep.chunks || prep.textToEmbed?.length === 0) {
681
- options.progress?.finishFile(prep.fileLabel);
682
- return {
683
- normalizedPath: prep.normalizedPath, hash: prep.hash, chunkCount: 0,
684
- fileLabel: prep.fileLabel,
685
- isNew: false, isModified: false, isUnchanged: false, isEmpty: false,
686
- isTooSmall: false, isRemoved: true, hadChunks: false,
687
- descriptionFailed: prep.descriptionFailed,
688
- };
689
- }
690
- // Store chunks with pre-attached embeddings
691
- const validChunks = (prep.chunks ?? []).filter((c) => c.embedding && c.embedding.length > 0);
692
- // Partial embed failure: some chunks have no vector. Do NOT record the
693
- // file as complete — keep the previous manifest entry (or none, for
694
- // new files) so the next pass retries the missing chunks.
695
- const allEmbedded = validChunks.length === (prep.chunks?.length ?? 0);
696
- if (!allEmbedded && (prep.chunks?.length ?? 0) > 0) {
697
- options.logger?.warn?.(` ${prep.fileLabel}: ${validChunks.length}/${prep.chunks?.length} chunks embedded — marking for retry on next pass`);
698
- }
699
- if (validChunks.length > 0) {
700
- await effectiveStore.addChunks(validChunks);
701
- }
702
- const result = {
703
- normalizedPath: prep.normalizedPath,
704
- hash: prep.hash,
705
- chunkCount: validChunks.length,
706
- fileLabel: prep.fileLabel,
707
- isNew: !prep.isModified,
708
- isModified: prep.isModified,
709
- isUnchanged: false,
710
- isEmpty: false,
711
- isTooSmall: false,
712
- isRemoved: (prep.chunks?.length ?? 0) > 0 && validChunks.length === 0 ? false : validChunks.length === 0,
713
- hadChunks: (prep.chunks?.length ?? 0) > 0,
714
- descriptionFailed: prep.descriptionFailed,
715
- descHash: prep.descHash,
716
- };
717
- // Update manifest — only when ALL chunks were embedded. A partial
718
- // write must not bump the hash/chunkCount, otherwise the missing
719
- // chunks would never be retried (hash match on the next pass).
720
- if (result.chunkCount > 0 && !result.isRemoved && allEmbedded) {
721
- const meta = fileMeta.get(result.normalizedPath);
722
- const entry = {
723
- hash: result.hash,
724
- chunkCount: result.chunkCount,
725
- indexedAt: Date.now(),
726
- mtime: meta?.mtime,
727
- size: meta?.size,
728
- descriptionFailed: result.descriptionFailed,
729
- };
730
- if (result.descHash) {
731
- entry.descHash = result.descHash;
732
- }
733
- manifest.files[result.normalizedPath] = entry;
734
- enqueueManifestSave();
735
- }
736
- else if (result.isRemoved) {
737
- delete manifest.files[result.normalizedPath];
738
- enqueueManifestSave();
739
- }
740
- options.progress?.finishFile(prep.fileLabel);
741
- storedFiles++;
742
- logChunkProgress("Storing", prep.fileLabel, storedFiles, filesToStore, storedFiles, filesToStore);
743
- return result;
744
- })));
745
- const workerResults = storeResults;
746
- for (const r of workerResults) {
747
- if (r.skipped) {
748
- abortedInWindow = true;
749
- break;
750
- }
751
- allFinalResults.push(r);
752
- }
753
- aggregateStats(stats, allFinalResults.slice(-workerResults.length));
754
- const storePhaseSec = ((Date.now() - storePhaseStart) / 1000).toFixed(1);
755
- logger.info(`Store phase: ${storedFiles} files stored in ${storePhaseSec}s (running total ${stats.totalChunks} chunks)`);
756
- // Free this window's chunk payloads so GC can reclaim them before the
757
- // next window is prepared.
758
- for (const prep of prepared) {
759
- prep.chunks = undefined;
760
- prep.textToEmbed = undefined;
822
+ // ── Phase 3: Store + manifest update per file ─────────────────────────
823
+ // The store phase is I/O-bound, so it is launched without awaiting and
824
+ // overlaps the next window's prepare/describe/embed (GPU/API-bound)
825
+ // phases. Stores stay serialized: the previous window's store is drained
826
+ // before the next one starts.
827
+ if (prevStore)
828
+ await prevStore;
829
+ // Snapshot the current window's early results BEFORE the next window's
830
+ // embed phase clears/repopulates the shared map — storeWindow runs
831
+ // asynchronously (overlapped with the next window's prepare/embed), so a
832
+ // shared mutable map would make its per-file loop read the WRONG window's
833
+ // entries and skip every file ("Store phase: storing N → 0 files stored").
834
+ prevStore = storeWindow(prepared, new Map(earlyWorkerResults));
835
+ // Periodically compact fragments and prune old versions so the store
836
+ // phase doesn't slow down as the index grows during long runs.
837
+ if (optimizeInterval > 0 && windowCount % optimizeInterval === 0) {
838
+ await prevStore;
839
+ prevStore = null;
840
+ logger.info("Optimizing vector store (mid-run compaction, pruning old versions)...");
841
+ await effectiveStore.optimize?.(tempStorePath ? { aggressive: true } : undefined);
761
842
  }
762
843
  if (abortedInWindow)
763
844
  break;
764
845
  } // end windowed pipeline loop
846
+ // Drain the final window's store before finishing.
847
+ if (prevStore)
848
+ await prevStore;
765
849
  const finalResults = allFinalResults;
766
850
  // Drain any in-flight manifest saves so all file entries are durable
767
851
  await manifestSaveChain;
@@ -822,12 +906,16 @@ async function runIndexPassInner(options, logger) {
822
906
  await saveManifest(options.storePath, manifest);
823
907
  await options.keywordIndex?.save(options.storePath);
824
908
  // Compact fragments and prune old version manifests so countRows() can't
825
- // hang on accumulated versions from many add/delete cycles.
909
+ // hang on accumulated versions from many add/delete cycles. After a temp
910
+ // store rebuild, optimize the reopened real store handle (the temp handle
911
+ // was closed and its directory moved); use aggressive pruning since the
912
+ // swapped-in store is private to this process.
826
913
  if (!aborted()) {
827
914
  logger.info("Optimizing vector store (compacting fragments, pruning old versions)...");
828
915
  const optimizeStart = Date.now();
829
916
  try {
830
- await effectiveStore.optimize?.();
917
+ const optimizeTarget = tempStorePath ? options.store : effectiveStore;
918
+ await optimizeTarget.optimize?.(tempStorePath ? { aggressive: true } : undefined);
831
919
  logger.info(`Vector store optimized in ${((Date.now() - optimizeStart) / 1000).toFixed(1)}s`);
832
920
  }
833
921
  catch (err) {
@@ -32,6 +32,16 @@ export declare function isTransientConflictError(err: unknown): boolean;
32
32
  * @param realPath - Path to the current store (destination, will be replaced).
33
33
  */
34
34
  export declare function swapStoreDirectories(tempPath: string, realPath: string): Promise<void>;
35
+ /**
36
+ * A single file's chunk payload for a bulk store write.
37
+ * `dedup: true` removes prior-revision rows for the same file path that are
38
+ * not part of this write; `dedup: false` appends only (safe when writing into
39
+ * a freshly-created store where no rows can collide).
40
+ */
41
+ export interface BulkChunkWrite {
42
+ chunks: Chunk[];
43
+ dedup: boolean;
44
+ }
35
45
  /**
36
46
  * A LanceDB-backed vector store with persistent on-disk storage, vector search,
37
47
  * and chunk metadata queries. Supports automatic corruption recovery by falling
@@ -44,6 +54,14 @@ export declare class LanceDbStore implements VectorStore {
44
54
  private table;
45
55
  private tableInit;
46
56
  private writeLock;
57
+ /**
58
+ * Memoized once-per-process index-metric repair. Stores built by versions
59
+ * before the cosine search switch carry an IVF index trained with the
60
+ * default L2 metric, which makes every cosine query log
61
+ * "Requested metric Cosine is incompatible with index metric L2" and fall
62
+ * back to brute-force. This repairs that stale index on first search.
63
+ */
64
+ private indexRepairPromise;
47
65
  /**
48
66
  * Execute an async function under an exclusive write lock.
49
67
  *
@@ -81,13 +99,36 @@ export declare class LanceDbStore implements VectorStore {
81
99
  private ensureColumnsNullable;
82
100
  /**
83
101
  * Store chunks in the LanceDB table. New rows are inserted first, then
84
- * any old rows at the same (filePath, startLine) with different IDs are
85
- * removed. This ensures no data is lost if the process aborts between
86
- * insert and cleanup. Automatically attempts repair on corruption errors.
102
+ * old rows for the same file that are not part of this write are removed
103
+ * in a single delete per file. This ensures no data is lost if the process
104
+ * aborts between insert and cleanup. Automatically attempts repair on
105
+ * corruption errors.
106
+ *
107
+ * When `options.dedup` is `false` the cleanup step is skipped entirely —
108
+ * a pure append. Use this when writing into a store that provably has no
109
+ * prior rows for these files (e.g. a freshly-created rebuild store).
110
+ *
87
111
  * @param chunks - The chunks to add.
112
+ * @param options - Optional write options (`dedup`, default `true`).
88
113
  */
89
- addChunks(chunks: Chunk[]): Promise<void>;
114
+ addChunks(chunks: Chunk[], options?: {
115
+ dedup?: boolean;
116
+ }): Promise<void>;
117
+ /**
118
+ * Store chunks for many files in a single transaction: one `table.add`
119
+ * across all items, then one `table.delete` per item that needs dedup.
120
+ * This collapses what used to be a per-file add + per-startLine deletes
121
+ * (K+2 LanceDB versions per file) into ~1 + M versions per batch.
122
+ *
123
+ * @param items - Per-file chunk payloads with their dedup flags.
124
+ */
125
+ addChunksBulk(items: BulkChunkWrite[]): Promise<void>;
126
+ /** Map a chunk to its internal row shape, or null if it has no embedding. */
127
+ private chunkToRow;
128
+ /** Group new rows by file path for dedup deletes. */
129
+ private rowsByFilePath;
90
130
  private addChunksInternal;
131
+ private addChunksBulkInternal;
91
132
  /**
92
133
  * Perform ANN (approximate nearest neighbor) search using LanceDB's native vector index.
93
134
  * Returns results scored as cosine similarity (0-1). Falls back to repair on corruption.
@@ -168,12 +209,43 @@ export declare class LanceDbStore implements VectorStore {
168
209
  * @returns The chunk count, or 0 if the table does not exist or times out.
169
210
  */
170
211
  count(): Promise<number>;
212
+ /**
213
+ * Ensure the ANN index on the `embedding` column uses the cosine metric,
214
+ * matching the `distanceType` requested by searchInternal.
215
+ *
216
+ * Older stores built the IVF index with the ivfFlat default (L2), so every
217
+ * cosine query hit "Requested metric Cosine is incompatible with index
218
+ * metric L2" and silently fell back to brute-force O(N) scans. This lazily
219
+ * replaces such an index with a cosine one — once per process.
220
+ *
221
+ * Callers must hold the write lock (searchInternal wraps the call in
222
+ * withWriteLock; optimize runs under it already).
223
+ */
224
+ private ensureCosineIndex;
225
+ /**
226
+ * Perform a single index-metric repair pass. Skips stores that have no
227
+ * index and fewer than 1000 rows (brute-force is optimal there). Uses a
228
+ * single `createIndex` with `replace: true` — a dropIndex + createIndex
229
+ * sequence races in LanceDB ("Retryable commit conflict") and leaves the
230
+ * stale index in place. On failure the memo is cleared so the next
231
+ * search/optimize retries.
232
+ */
233
+ private repairIndexMetricOnce;
171
234
  /**
172
235
  * Compact fragments and prune old version manifests to prevent the
173
- * version-manifest accumulation that causes countRows() to hang.
174
- * Should be called at the end of a successful index pass.
236
+ * version-manifest accumulation that causes countRows() to hang and the
237
+ * store phase to slow down as the index grows. Should be called at the
238
+ * end of a successful index pass, and periodically during long passes.
239
+ *
240
+ * @param options - `aggressive: true` prunes every version but the current
241
+ * one with `deleteUnverified`. Only safe for a private store that no other
242
+ * process reads (e.g. a temporary rebuild store). For the shared store,
243
+ * versions newer than 1 hour are retained so in-flight queries (Web UI,
244
+ * background auto-index) can finish before their data files are reclaimed.
175
245
  */
176
- optimize(): Promise<void>;
246
+ optimize(options?: {
247
+ aggressive?: boolean;
248
+ }): Promise<void>;
177
249
  /**
178
250
  * Return all unique file paths currently stored in the index.
179
251
  * @returns An array of normalized file paths.