opencode-rag-plugin 1.19.4 → 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.
Files changed (87) hide show
  1. package/dist/chunker/base.js +19 -5
  2. package/dist/chunker/factory.js +27 -9
  3. package/dist/chunker/grammar.d.ts +18 -1
  4. package/dist/chunker/grammar.js +48 -10
  5. package/dist/chunker/image.js +5 -0
  6. package/dist/chunker/pdf.js +30 -14
  7. package/dist/cli/commands/backend-detect.d.ts +61 -0
  8. package/dist/cli/commands/backend-detect.js +119 -0
  9. package/dist/cli/commands/index-command.js +11 -0
  10. package/dist/cli/commands/init-helpers.d.ts +4 -1
  11. package/dist/cli/commands/init-helpers.js +21 -4
  12. package/dist/cli/commands/init.js +61 -24
  13. package/dist/cli/commands/quirk.js +9 -3
  14. package/dist/cli/commands/setup.js +12 -3
  15. package/dist/cli/commands/status.js +14 -5
  16. package/dist/cli/commands/ui.js +23 -9
  17. package/dist/cli/commands/update.js +4 -5
  18. package/dist/cli/format.d.ts +5 -2
  19. package/dist/cli/format.js +14 -5
  20. package/dist/content/image.js +33 -11
  21. package/dist/content/reader.js +74 -13
  22. package/dist/core/bootstrap.js +10 -3
  23. package/dist/core/config.d.ts +27 -1
  24. package/dist/core/config.js +41 -2
  25. package/dist/core/desc-cache.d.ts +8 -2
  26. package/dist/core/desc-cache.js +10 -3
  27. package/dist/core/doc-progress.js +5 -2
  28. package/dist/core/interfaces.d.ts +32 -4
  29. package/dist/core/manifest.js +1 -1
  30. package/dist/core/provider-defaults.d.ts +2 -0
  31. package/dist/core/provider-defaults.js +19 -4
  32. package/dist/core/runtime-overrides.d.ts +0 -6
  33. package/dist/core/version-check.d.ts +5 -0
  34. package/dist/core/version-check.js +8 -2
  35. package/dist/describer/anthropic.d.ts +2 -2
  36. package/dist/describer/anthropic.js +19 -5
  37. package/dist/describer/describer.d.ts +20 -0
  38. package/dist/describer/describer.js +132 -16
  39. package/dist/describer/gemini.js +25 -10
  40. package/dist/describer/shared.d.ts +28 -0
  41. package/dist/describer/shared.js +60 -0
  42. package/dist/embedder/factory.d.ts +5 -3
  43. package/dist/embedder/factory.js +42 -9
  44. package/dist/embedder/health.js +19 -19
  45. package/dist/embedder/http.d.ts +14 -1
  46. package/dist/embedder/http.js +60 -6
  47. package/dist/embedder/ollama.d.ts +3 -1
  48. package/dist/embedder/ollama.js +12 -2
  49. package/dist/eval/session-logger.js +7 -0
  50. package/dist/eval/storage.js +8 -0
  51. package/dist/indexer/git-diff.d.ts +1 -1
  52. package/dist/indexer/git-diff.js +5 -1
  53. package/dist/indexer/pipeline.js +511 -346
  54. package/dist/indexer/stats.d.ts +2 -0
  55. package/dist/indexer/stats.js +1 -0
  56. package/dist/indexer/watch.js +8 -1
  57. package/dist/indexer/worker.js +21 -0
  58. package/dist/mcp/cli.js +4 -0
  59. package/dist/mcp/handlers.js +16 -5
  60. package/dist/mcp/server.js +3 -0
  61. package/dist/opencode/create-read-tool.js +14 -3
  62. package/dist/opencode/tool-args.js +23 -1
  63. package/dist/plugin.js +66 -152
  64. package/dist/quirks/auto-capture.js +5 -0
  65. package/dist/quirks/quirk-store.d.ts +1 -1
  66. package/dist/quirks/quirk-store.js +56 -17
  67. package/dist/retriever/context-optimizer.js +18 -4
  68. package/dist/retriever/keyword-index.d.ts +2 -0
  69. package/dist/retriever/keyword-index.js +38 -4
  70. package/dist/retriever/retriever.js +6 -1
  71. package/dist/tui.js +41 -4
  72. package/dist/vectorstore/lancedb.d.ts +104 -8
  73. package/dist/vectorstore/lancedb.js +345 -71
  74. package/dist/vectorstore/memory.d.ts +8 -3
  75. package/dist/vectorstore/memory.js +27 -4
  76. package/dist/watcher.d.ts +8 -0
  77. package/dist/watcher.js +237 -85
  78. package/dist/web/api.d.ts +5 -1
  79. package/dist/web/api.js +195 -69
  80. package/dist/web/server.d.ts +2 -0
  81. package/dist/web/server.js +66 -28
  82. package/dist/web/static.d.ts +5 -2
  83. package/dist/web/static.js +9 -5
  84. package/dist/web/ui/assets/index-BDPYdtA1.js +3 -0
  85. package/dist/web/ui/index.html +1 -1
  86. package/package.json +1 -1
  87. package/dist/web/ui/assets/index-CJBvt6e0.js +0 -3
@@ -99,6 +99,14 @@ export class LanceDbStore {
99
99
  table = null;
100
100
  tableInit = null;
101
101
  writeLock = Promise.resolve(void 0);
102
+ /**
103
+ * Memoized once-per-process index-metric repair. Stores built by versions
104
+ * before the cosine search switch carry an IVF index trained with the
105
+ * default L2 metric, which makes every cosine query log
106
+ * "Requested metric Cosine is incompatible with index metric L2" and fall
107
+ * back to brute-force. This repairs that stale index on first search.
108
+ */
109
+ indexRepairPromise = null;
102
110
  /**
103
111
  * Execute an async function under an exclusive write lock.
104
112
  *
@@ -294,32 +302,65 @@ export class LanceDbStore {
294
302
  }
295
303
  /**
296
304
  * Store chunks in the LanceDB table. New rows are inserted first, then
297
- * any old rows at the same (filePath, startLine) with different IDs are
298
- * removed. This ensures no data is lost if the process aborts between
299
- * insert and cleanup. Automatically attempts repair on corruption errors.
305
+ * old rows for the same file that are not part of this write are removed
306
+ * in a single delete per file. This ensures no data is lost if the process
307
+ * aborts between insert and cleanup. Automatically attempts repair on
308
+ * corruption errors.
309
+ *
310
+ * When `options.dedup` is `false` the cleanup step is skipped entirely —
311
+ * a pure append. Use this when writing into a store that provably has no
312
+ * prior rows for these files (e.g. a freshly-created rebuild store).
313
+ *
300
314
  * @param chunks - The chunks to add.
315
+ * @param options - Optional write options (`dedup`, default `true`).
301
316
  */
302
- async addChunks(chunks) {
317
+ async addChunks(chunks, options) {
303
318
  if (chunks.length === 0)
304
319
  return;
320
+ const dedup = options?.dedup ?? true;
305
321
  await this.withWriteLock(async () => {
306
322
  try {
307
- await this.addChunksInternal(chunks);
323
+ await this.addChunksInternal(chunks, dedup);
308
324
  }
309
325
  catch (err) {
310
326
  if (isCorruptionError(err) && await this.tryRepair()) {
311
- await this.addChunksInternal(chunks);
327
+ await this.addChunksInternal(chunks, dedup);
312
328
  return;
313
329
  }
314
330
  throw err;
315
331
  }
316
332
  });
317
333
  }
318
- async addChunksInternal(chunks) {
319
- const table = await this.getTable();
320
- const rows = chunks
321
- .filter((c) => c.embedding && c.embedding.length > 0)
322
- .map((c) => ({
334
+ /**
335
+ * Store chunks for many files in a single transaction: one `table.add`
336
+ * across all items, then one `table.delete` per item that needs dedup.
337
+ * This collapses what used to be a per-file add + per-startLine deletes
338
+ * (K+2 LanceDB versions per file) into ~1 + M versions per batch.
339
+ *
340
+ * @param items - Per-file chunk payloads with their dedup flags.
341
+ */
342
+ async addChunksBulk(items) {
343
+ const active = items.filter((item) => item.chunks.length > 0);
344
+ if (active.length === 0)
345
+ return;
346
+ await this.withWriteLock(async () => {
347
+ try {
348
+ await this.addChunksBulkInternal(active);
349
+ }
350
+ catch (err) {
351
+ if (isCorruptionError(err) && await this.tryRepair()) {
352
+ await this.addChunksBulkInternal(active);
353
+ return;
354
+ }
355
+ throw err;
356
+ }
357
+ });
358
+ }
359
+ /** Map a chunk to its internal row shape, or null if it has no embedding. */
360
+ chunkToRow(c) {
361
+ if (!c.embedding || c.embedding.length === 0)
362
+ return null;
363
+ return {
323
364
  id: c.id,
324
365
  content: c.content,
325
366
  description: c.description ?? "",
@@ -331,56 +372,75 @@ export class LanceDbStore {
331
372
  kind: c.metadata.kind ?? "",
332
373
  quirkType: c.metadata.quirkType ?? "",
333
374
  tags: c.metadata.tags ? JSON.stringify(c.metadata.tags) : "",
334
- }));
335
- if (rows.length === 0)
336
- return;
337
- // Build a map: (filePath, startLine) → set of new IDs for dedup after insert
338
- const newIdsByLine = new Map();
375
+ };
376
+ }
377
+ /** Group new rows by file path for dedup deletes. */
378
+ rowsByFilePath(rows) {
379
+ const byFile = new Map();
339
380
  for (const row of rows) {
340
- const key = `${row.filePath}:${row.startLine}`;
341
- const ids = newIdsByLine.get(key);
381
+ const ids = byFile.get(row.filePath);
342
382
  if (ids) {
343
- ids.add(row.id);
383
+ ids.push(row.id);
344
384
  }
345
385
  else {
346
- newIdsByLine.set(key, new Set([row.id]));
386
+ byFile.set(row.filePath, [row.id]);
347
387
  }
348
388
  }
389
+ return byFile;
390
+ }
391
+ async addChunksInternal(chunks, dedup = true) {
392
+ const table = await this.getTable();
393
+ const rows = chunks
394
+ .map((c) => this.chunkToRow(c))
395
+ .filter((r) => r !== null);
396
+ if (rows.length === 0)
397
+ return;
349
398
  // INSERT FIRST: data is safely stored before any delete
350
399
  await table.add(rows);
351
- // THEN DEDUP: remove old rows at the same (filePath, startLine) positions,
352
- // but preserve the newly inserted rows by filtering out their IDs.
353
- // IMPORTANT: use a single NOT IN clause per position so that when multiple
354
- // new IDs share the same startLine they don't delete each other.
355
- for (const [key, newIds] of newIdsByLine) {
356
- const colonIdx = key.lastIndexOf(":");
357
- const filePath = key.slice(0, colonIdx);
358
- const startLine = parseInt(key.slice(colonIdx + 1), 10);
400
+ if (!dedup)
401
+ return;
402
+ // THEN DEDUP: one delete per file removes prior-revision rows at the
403
+ // same (filePath, startLine) positions AND stale startLines, while the
404
+ // NOT IN clause preserves the newly inserted rows (so multiple new IDs
405
+ // sharing a startLine never delete each other). Insert-first ordering
406
+ // keeps an abort between insert and delete from losing data.
407
+ const byFile = this.rowsByFilePath(rows);
408
+ for (const [filePath, ids] of byFile) {
359
409
  const escapedPath = filePath.replace(/'/g, "''");
360
- const idList = [...newIds]
361
- .map((id) => `'${id.replace(/'/g, "''")}'`)
362
- .join(", ");
363
- await table.delete(`filePath = '${escapedPath}' AND startLine = ${startLine} AND id NOT IN (${idList})`);
364
- }
365
- // FINALLY: remove stale chunks for the same file that belong to a
366
- // previous revision (different startLines). Exclude the new inserts
367
- // so an abort never orphans data.
368
- const filePathsDone = new Set();
369
- for (const [key] of newIdsByLine) {
370
- const colonIdx = key.lastIndexOf(":");
371
- const filePath = key.slice(0, colonIdx);
372
- if (filePathsDone.has(filePath))
410
+ const idList = ids.map((id) => `'${id.replace(/'/g, "''")}'`).join(", ");
411
+ await table.delete(`filePath = '${escapedPath}' AND id NOT IN (${idList})`);
412
+ }
413
+ }
414
+ async addChunksBulkInternal(items) {
415
+ const table = await this.getTable();
416
+ const allRows = [];
417
+ const dedupByFile = new Map();
418
+ for (const item of items) {
419
+ const rows = item.chunks
420
+ .map((c) => this.chunkToRow(c))
421
+ .filter((r) => r !== null);
422
+ if (rows.length === 0)
373
423
  continue;
374
- filePathsDone.add(filePath);
375
- // Collect all new IDs inserted for this file
376
- const fileNewIds = [];
377
- for (const [k, ids] of newIdsByLine) {
378
- if (k.startsWith(filePath + ":")) {
379
- fileNewIds.push(...ids);
424
+ allRows.push(...rows);
425
+ if (item.dedup) {
426
+ for (const [filePath, ids] of this.rowsByFilePath(rows)) {
427
+ const existing = dedupByFile.get(filePath);
428
+ if (existing) {
429
+ existing.push(...ids);
430
+ }
431
+ else {
432
+ dedupByFile.set(filePath, [...ids]);
433
+ }
380
434
  }
381
435
  }
436
+ }
437
+ if (allRows.length === 0)
438
+ return;
439
+ // INSERT FIRST (single add for the whole batch), then per-file dedup
440
+ await table.add(allRows);
441
+ for (const [filePath, ids] of dedupByFile) {
382
442
  const escapedPath = filePath.replace(/'/g, "''");
383
- const idList = fileNewIds.map((id) => `'${id.replace(/'/g, "''")}'`).join(", ");
443
+ const idList = ids.map((id) => `'${id.replace(/'/g, "''")}'`).join(", ");
384
444
  await table.delete(`filePath = '${escapedPath}' AND id NOT IN (${idList})`);
385
445
  }
386
446
  }
@@ -396,6 +456,12 @@ export class LanceDbStore {
396
456
  }
397
457
  async searchWithFilter(embedding, topK, filter) {
398
458
  try {
459
+ // Guard against dimension mismatch BEFORE the native call — LanceDB
460
+ // throws a cryptic error that used to be swallowed into "no results".
461
+ if (embedding.length !== this.vectorDimension) {
462
+ console.warn(`[lancedb] searchWithFilter: query embedding dimension ${embedding.length} != store dimension ${this.vectorDimension} — returning empty`);
463
+ return [];
464
+ }
399
465
  return await this.searchInternal(embedding, topK, filter);
400
466
  }
401
467
  catch (err) {
@@ -404,7 +470,12 @@ export class LanceDbStore {
404
470
  if (repaired) {
405
471
  return this.searchInternal(embedding, topK, filter);
406
472
  }
473
+ console.warn(`[lancedb] searchWithFilter: repair failed: ${err.message}`);
474
+ return [];
407
475
  }
476
+ // Never silently mask non-corruption failures as "no results" —
477
+ // that hides provider outages and store bugs from every caller.
478
+ console.warn(`[lancedb] searchWithFilter failed (returning []): ${err.message}`);
408
479
  return [];
409
480
  }
410
481
  }
@@ -534,6 +605,104 @@ export class LanceDbStore {
534
605
  }));
535
606
  });
536
607
  }
608
+ /**
609
+ * Retrieve a paginated list of chunks without embeddings, with optional
610
+ * language and file-path filters pushed into the SQL WHERE clause.
611
+ *
612
+ * @param offset - Number of rows to skip (for pagination).
613
+ * @param limit - Maximum number of rows to return.
614
+ * @param lang - Optional exact language filter.
615
+ * @param filePrefix - Optional filePath prefix filter.
616
+ * @returns The page of chunks plus the total count of matching rows.
617
+ */
618
+ async getChunksFiltered(offset, limit, lang, filePrefix) {
619
+ const conditions = [];
620
+ if (lang)
621
+ conditions.push(`language = '${lang.replace(/'/g, "''")}'`);
622
+ if (filePrefix)
623
+ conditions.push(`filePath LIKE '${filePrefix.replace(/'/g, "''")}%'`);
624
+ const where = conditions.length > 0 ? conditions.join(" AND ") : undefined;
625
+ return this.withCorruptionRecovery(async () => {
626
+ const table = await this.getTable();
627
+ let query = table.query().select(QUERY_COLUMNS);
628
+ let countQuery = table.query().select(["id"]);
629
+ if (where) {
630
+ query = query.where(where);
631
+ countQuery = countQuery.where(where);
632
+ }
633
+ const [rows, countRows] = await Promise.all([
634
+ query.offset(Math.max(0, offset)).limit(Math.max(1, Math.min(limit, 1000))).toArray(),
635
+ countQuery.toArray(),
636
+ ]);
637
+ return {
638
+ chunks: rows.map((row) => ({
639
+ id: row.id,
640
+ filePath: row.filePath,
641
+ language: row.language,
642
+ startLine: row.startLine,
643
+ endLine: row.endLine,
644
+ content: row.content,
645
+ description: row.description ?? "",
646
+ kind: row.kind ?? "",
647
+ quirkType: row.quirkType ?? "",
648
+ tags: row.tags ?? "",
649
+ })),
650
+ total: countRows.length,
651
+ };
652
+ });
653
+ }
654
+ /** Look up a single chunk by its ID. Returns undefined when not found. */
655
+ async getChunkById(id) {
656
+ return this.withCorruptionRecovery(async () => {
657
+ const table = await this.getTable();
658
+ const rows = await table.query()
659
+ .select(QUERY_COLUMNS)
660
+ .where(`id = '${id.replace(/'/g, "''")}'`)
661
+ .limit(1)
662
+ .toArray();
663
+ const row = rows[0];
664
+ if (!row)
665
+ return undefined;
666
+ return {
667
+ id: row.id,
668
+ filePath: row.filePath,
669
+ language: row.language,
670
+ startLine: row.startLine,
671
+ endLine: row.endLine,
672
+ content: row.content,
673
+ description: row.description ?? "",
674
+ kind: row.kind ?? "",
675
+ quirkType: row.quirkType ?? "",
676
+ tags: row.tags ?? "",
677
+ };
678
+ });
679
+ }
680
+ /** Fetch chunks by their IDs (for the compare view). Bounded to 100 ids. */
681
+ async getChunksByIds(ids) {
682
+ const bounded = ids.slice(0, 100);
683
+ if (bounded.length === 0)
684
+ return [];
685
+ return this.withCorruptionRecovery(async () => {
686
+ const table = await this.getTable();
687
+ const idList = bounded.map((id) => `'${id.replace(/'/g, "''")}'`).join(", ");
688
+ const rows = await table.query()
689
+ .select(QUERY_COLUMNS)
690
+ .where(`id IN (${idList})`)
691
+ .toArray();
692
+ return rows.map((row) => ({
693
+ id: row.id,
694
+ filePath: row.filePath,
695
+ language: row.language,
696
+ startLine: row.startLine,
697
+ endLine: row.endLine,
698
+ content: row.content,
699
+ description: row.description ?? "",
700
+ kind: row.kind ?? "",
701
+ quirkType: row.quirkType ?? "",
702
+ tags: row.tags ?? "",
703
+ }));
704
+ });
705
+ }
537
706
  /**
538
707
  * Fetch chunks with their embedding vectors included (for embedding projection).
539
708
  * @param limit - Maximum number of rows to return.
@@ -575,6 +744,10 @@ export class LanceDbStore {
575
744
  const count = await table.countRows();
576
745
  if (count === 0)
577
746
  return [];
747
+ // One-time per-process repair: drop any stale L2 index so the cosine query
748
+ // below doesn't log "Requested metric Cosine is incompatible" and degrade
749
+ // to brute-force. Await it so the repair completes before this search.
750
+ await this.withWriteLock(() => this.ensureCosineIndex());
578
751
  const whereClause = buildWhereClause(filter);
579
752
  let results;
580
753
  try {
@@ -614,8 +787,11 @@ export class LanceDbStore {
614
787
  return 0;
615
788
  const table = await this.getTable();
616
789
  const COUNT_TIMEOUT_MS = 30_000;
790
+ // Attach a no-op catch to the race loser so a late rejection (after the
791
+ // timeout resolved `null`) cannot become an unhandled rejection.
792
+ const countPromise = table.countRows().catch(() => 0);
617
793
  const result = await Promise.race([
618
- table.countRows(),
794
+ countPromise,
619
795
  new Promise((resolve) => setTimeout(() => resolve(null), COUNT_TIMEOUT_MS)),
620
796
  ]);
621
797
  if (result === null) {
@@ -628,25 +804,104 @@ export class LanceDbStore {
628
804
  return 0;
629
805
  }
630
806
  }
807
+ /**
808
+ * Ensure the ANN index on the `embedding` column uses the cosine metric,
809
+ * matching the `distanceType` requested by searchInternal.
810
+ *
811
+ * Older stores built the IVF index with the ivfFlat default (L2), so every
812
+ * cosine query hit "Requested metric Cosine is incompatible with index
813
+ * metric L2" and silently fell back to brute-force O(N) scans. This lazily
814
+ * replaces such an index with a cosine one — once per process.
815
+ *
816
+ * Callers must hold the write lock (searchInternal wraps the call in
817
+ * withWriteLock; optimize runs under it already).
818
+ */
819
+ ensureCosineIndex() {
820
+ if (!this.indexRepairPromise) {
821
+ this.indexRepairPromise = this.repairIndexMetricOnce();
822
+ }
823
+ return this.indexRepairPromise;
824
+ }
825
+ /**
826
+ * Perform a single index-metric repair pass. Skips stores that have no
827
+ * index and fewer than 1000 rows (brute-force is optimal there). Uses a
828
+ * single `createIndex` with `replace: true` — a dropIndex + createIndex
829
+ * sequence races in LanceDB ("Retryable commit conflict") and leaves the
830
+ * stale index in place. On failure the memo is cleared so the next
831
+ * search/optimize retries.
832
+ */
833
+ async repairIndexMetricOnce() {
834
+ try {
835
+ const table = await this.getTable();
836
+ const count = await table.countRows().catch(() => 0);
837
+ const indices = await table.listIndices();
838
+ const vecIndex = indices.find((i) => i.columns.includes("embedding"));
839
+ const idxName = vecIndex?.name ?? "embedding_idx";
840
+ const stats = await table.indexStats(idxName);
841
+ if (stats && stats.distanceType === "cosine")
842
+ return; // already healthy
843
+ if (!stats && count < 1000)
844
+ return; // tiny store, no index — leave brute-force
845
+ const numPartitions = Math.max(16, Math.min(256, Math.floor(count / 256)));
846
+ for (let attempt = 1; attempt <= 3; attempt++) {
847
+ try {
848
+ await table.createIndex("embedding", {
849
+ config: lancedb.Index.ivfFlat({ numPartitions, distanceType: "cosine" }),
850
+ replace: true,
851
+ name: idxName,
852
+ // Wait for the commit so the repair is deterministic and cannot
853
+ // race a background index-creation from another process.
854
+ waitTimeoutSeconds: 120,
855
+ });
856
+ return;
857
+ }
858
+ catch (err) {
859
+ const retriable = err instanceof Error &&
860
+ (err.message.includes("Retryable commit conflict") || err.message.includes("Please retry"));
861
+ if (!retriable || attempt === 3)
862
+ throw err;
863
+ await new Promise((resolve) => setTimeout(resolve, 150 * attempt));
864
+ }
865
+ }
866
+ }
867
+ catch (err) {
868
+ this.indexRepairPromise = null; // allow retry on a later search/optimize
869
+ console.warn(`[lancedb] index metric repair failed (queries continue in brute-force): ` +
870
+ `${err instanceof Error ? err.message : String(err)}`);
871
+ }
872
+ }
631
873
  /**
632
874
  * Compact fragments and prune old version manifests to prevent the
633
- * version-manifest accumulation that causes countRows() to hang.
634
- * Should be called at the end of a successful index pass.
875
+ * version-manifest accumulation that causes countRows() to hang and the
876
+ * store phase to slow down as the index grows. Should be called at the
877
+ * end of a successful index pass, and periodically during long passes.
878
+ *
879
+ * @param options - `aggressive: true` prunes every version but the current
880
+ * one with `deleteUnverified`. Only safe for a private store that no other
881
+ * process reads (e.g. a temporary rebuild store). For the shared store,
882
+ * versions newer than 1 hour are retained so in-flight queries (Web UI,
883
+ * background auto-index) can finish before their data files are reclaimed.
635
884
  */
636
- async optimize() {
885
+ async optimize(options) {
637
886
  await this.withWriteLock(async () => {
638
887
  try {
639
888
  const table = await this.getTable();
640
- // Clean up versions older than 1 hour �?" not "right now" �?" so in-flight
641
- // queries (e.g. Web UI search, background auto-index) can finish before
642
- // their data files are reclaimed. Using new Date() here caused data-file
643
- // race conditions where a reader got "Not found: �?� .lance" because the
644
- // GC deleted fragments that the current version still referenced.
645
- const threshold = new Date(Date.now() - 60 * 60 * 1000);
646
- await table.optimize({ cleanupOlderThan: threshold, deleteUnverified: false });
889
+ if (options?.aggressive) {
890
+ await table.optimize({ cleanupOlderThan: new Date(), deleteUnverified: true });
891
+ }
892
+ else {
893
+ const threshold = new Date(Date.now() - 60 * 60 * 1000);
894
+ await table.optimize({ cleanupOlderThan: threshold, deleteUnverified: false });
895
+ }
896
+ // Build the ANN vector index — without it every vectorSearch() is a
897
+ // brute-force O(N) flat scan (slow at 50k+ chunks). Skips early when
898
+ // an index with the correct cosine metric already exists.
899
+ await this.ensureCosineIndex();
647
900
  }
648
- catch {
649
- // Optimize is best-effort �?" must not break indexing.
901
+ catch (err) {
902
+ // Optimize is best-effort must not break indexing, but surface the
903
+ // failure instead of swallowing it silently.
904
+ console.warn(`[lancedb] optimize failed: ${err instanceof Error ? err.message : String(err)}`);
650
905
  }
651
906
  });
652
907
  }
@@ -684,20 +939,39 @@ export class LanceDbStore {
684
939
  * @param newPath - Optional new filesystem path for the LanceDB database.
685
940
  */
686
941
  async reopen(newPath) {
687
- await this.table?.close();
688
- await this.db?.close();
689
- this.table = null;
690
- this.db = null;
691
- this.tableInit = null;
942
+ await this.close();
692
943
  if (newPath)
693
944
  this.dbPath = newPath;
694
945
  }
695
- /** Close the database connection and release resources. */
946
+ /**
947
+ * Close the database connection and release resources.
948
+ *
949
+ * The native LanceDB `close()` can hang indefinitely on Windows, so the
950
+ * whole close is raced against a timeout — callers (CLI commands, plugin
951
+ * reload, web server) must never block forever on shutdown.
952
+ */
696
953
  async close() {
697
- await this.table?.close();
698
- this.table = null;
699
- await this.db?.close();
700
- this.db = null;
954
+ await Promise.race([
955
+ (async () => {
956
+ try {
957
+ await this.table?.close();
958
+ }
959
+ catch {
960
+ // best-effort — the connection may already be gone
961
+ }
962
+ this.table = null;
963
+ try {
964
+ await this.db?.close();
965
+ }
966
+ catch {
967
+ // best-effort — the connection may already be gone
968
+ }
969
+ this.db = null;
970
+ })(),
971
+ new Promise((resolve) => {
972
+ setTimeout(resolve, 5000).unref();
973
+ }),
974
+ ]);
701
975
  }
702
976
  /**
703
977
  * Back up the chunks.lance directory before a destructive operation.
@@ -1,11 +1,14 @@
1
1
  /**
2
2
  * @fileoverview Ephemeral in-memory vector store using cosine similarity search.
3
3
  */
4
- import type { VectorStore, Chunk, ChunkSummary, FileSummary, SearchResult, MetadataFilter } from "../core/interfaces.js";
4
+ import type { VectorStore, Chunk, ChunkSummary, FileSummary, SearchResult, MetadataFilter, BulkChunkWrite } from "../core/interfaces.js";
5
5
  /** Ephemeral in-memory vector store using cosine similarity search. */
6
6
  export declare class InMemoryVectorStore implements VectorStore {
7
7
  private chunks;
8
- addChunks(chunks: Chunk[]): Promise<void>;
8
+ addChunks(chunks: Chunk[], options?: {
9
+ dedup?: boolean;
10
+ }): Promise<void>;
11
+ addChunksBulk(items: BulkChunkWrite[]): Promise<void>;
9
12
  search(embedding: number[], topK: number): Promise<SearchResult[]>;
10
13
  searchWithFilter(embedding: number[], topK: number, filter?: MetadataFilter): Promise<SearchResult[]>;
11
14
  count(): Promise<number>;
@@ -22,5 +25,7 @@ export declare class InMemoryVectorStore implements VectorStore {
22
25
  /** Release any held resources. No-op for the in-memory store. */
23
26
  close(): Promise<void>;
24
27
  /** No-op for the in-memory store. */
25
- optimize(): Promise<void>;
28
+ optimize(_options?: {
29
+ aggressive?: boolean;
30
+ }): Promise<void>;
26
31
  }
@@ -1,8 +1,27 @@
1
1
  /** Ephemeral in-memory vector store using cosine similarity search. */
2
2
  export class InMemoryVectorStore {
3
3
  chunks = [];
4
- async addChunks(chunks) {
5
- this.chunks.push(...chunks.filter((c) => c.embedding && c.embedding.length > 0));
4
+ async addChunks(chunks, options) {
5
+ const valid = chunks.filter((c) => c.embedding && c.embedding.length > 0);
6
+ if (valid.length === 0)
7
+ return;
8
+ if (options?.dedup === false) {
9
+ this.chunks.push(...valid);
10
+ return;
11
+ }
12
+ // Mirror LanceDB dedup semantics: keep existing rows for touched files
13
+ // that are part of this write; drop prior-revision rows that aren't.
14
+ const newIds = new Set(valid.map((c) => c.id));
15
+ const newPaths = new Set(valid.map((c) => c.metadata.filePath));
16
+ this.chunks = [
17
+ ...this.chunks.filter((c) => !newPaths.has(c.metadata.filePath) || newIds.has(c.id)),
18
+ ...valid,
19
+ ];
20
+ }
21
+ async addChunksBulk(items) {
22
+ for (const item of items) {
23
+ await this.addChunks(item.chunks, { dedup: item.dedup });
24
+ }
6
25
  }
7
26
  async search(embedding, topK) {
8
27
  return this.searchWithFilter(embedding, topK);
@@ -13,7 +32,11 @@ export class InMemoryVectorStore {
13
32
  .filter((c) => matchesFilter(c, filter))
14
33
  .map((chunk) => {
15
34
  const sim = cosineSimilarity(embedding, chunk.embedding);
16
- return { chunk, score: sim };
35
+ // Normalize cosine [-1,1] into [0,1] so scores are comparable with
36
+ // the LanceDB store's clamped `1 - distance/2` domain (minScore and
37
+ // displayed scores mean the same thing across both stores).
38
+ const normalized = Math.min(1, Math.max(0, (sim + 1) / 2));
39
+ return { chunk, score: normalized };
17
40
  })
18
41
  .sort((a, b) => b.score - a.score)
19
42
  .slice(0, topK);
@@ -74,7 +97,7 @@ export class InMemoryVectorStore {
74
97
  async close() {
75
98
  }
76
99
  /** No-op for the in-memory store. */
77
- async optimize() {
100
+ async optimize(_options) {
78
101
  }
79
102
  }
80
103
  /**
package/dist/watcher.d.ts CHANGED
@@ -40,6 +40,14 @@ export type WatcherStatus = {
40
40
  /** Timestamp (ms since epoch) of the last completed run, or undefined. */
41
41
  lastRunAt: number | undefined;
42
42
  };
43
+ /**
44
+ * Atomically claim the watcher lock for a workspace. Returns true only if
45
+ * this process is now the active watcher. A stale lock (dead PID) or an
46
+ * unreadable/corrupt lock file is cleared and re-claimed.
47
+ */
48
+ export declare function tryAcquireWatcherLock(storePath: string): boolean;
49
+ /** Release the watcher lock — only if this process owns it. */
50
+ export declare function releaseWatcherLock(storePath: string): void;
43
51
  /**
44
52
  * Create a background file watcher that automatically re-indexes the
45
53
  * workspace when files change. Uses chokidar for file system events and