opencode-rag-plugin 1.19.5 → 1.20.0

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 (44) hide show
  1. package/dist/chunker/image.d.ts +1 -1
  2. package/dist/chunker/image.js +35 -22
  3. package/dist/cli/commands/backend-detect.d.ts +61 -0
  4. package/dist/cli/commands/backend-detect.js +119 -0
  5. package/dist/cli/commands/describe-image.js +2 -1
  6. package/dist/cli/commands/index-command.js +11 -0
  7. package/dist/cli/commands/init-helpers.d.ts +4 -1
  8. package/dist/cli/commands/init-helpers.js +10 -5
  9. package/dist/cli/commands/init.js +30 -3
  10. package/dist/cli/commands/setup.js +7 -1
  11. package/dist/cli/types.d.ts +2 -0
  12. package/dist/core/config.d.ts +27 -1
  13. package/dist/core/config.js +10 -2
  14. package/dist/core/interfaces.d.ts +32 -4
  15. package/dist/core/manifest.js +1 -1
  16. package/dist/describer/describer.d.ts +20 -0
  17. package/dist/describer/describer.js +117 -14
  18. package/dist/describer/shared.d.ts +28 -0
  19. package/dist/describer/shared.js +60 -0
  20. package/dist/embedder/factory.js +1 -1
  21. package/dist/embedder/ollama.d.ts +3 -1
  22. package/dist/embedder/ollama.js +12 -2
  23. package/dist/indexer/pipeline.js +187 -99
  24. package/dist/mcp/handlers.d.ts +2 -0
  25. package/dist/mcp/handlers.js +1 -1
  26. package/dist/mcp/server.js +2 -1
  27. package/dist/opencode/system-guidance.js +4 -4
  28. package/dist/opencode/tools.js +4 -1
  29. package/dist/vectorstore/lancedb.d.ts +79 -7
  30. package/dist/vectorstore/lancedb.js +200 -72
  31. package/dist/vectorstore/memory.d.ts +8 -3
  32. package/dist/vectorstore/memory.js +22 -3
  33. package/dist/watcher.d.ts +8 -0
  34. package/dist/watcher.js +222 -96
  35. package/dist/web/api.js +24 -15
  36. package/dist/web/pca.d.ts +5 -2
  37. package/dist/web/pca.js +75 -20
  38. package/dist/web/ui/assets/ScatterPlot3D-BFWO5sAH.js +4116 -0
  39. package/dist/web/ui/assets/index-BLzCza1W.css +1 -0
  40. package/dist/web/ui/assets/index-BdPHzjQh.js +4 -0
  41. package/dist/web/ui/index.html +2 -2
  42. package/package.json +4 -1
  43. package/dist/web/ui/assets/index-BDPYdtA1.js +0 -3
  44. package/dist/web/ui/assets/index-CKdp79Tw.css +0 -1
@@ -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})`);
410
+ const idList = ids.map((id) => `'${id.replace(/'/g, "''")}'`).join(", ");
411
+ await table.delete(`filePath = '${escapedPath}' AND id NOT IN (${idList})`);
364
412
  }
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))
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
  }
@@ -684,6 +744,10 @@ export class LanceDbStore {
684
744
  const count = await table.countRows();
685
745
  if (count === 0)
686
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());
687
751
  const whereClause = buildWhereClause(filter);
688
752
  let results;
689
753
  try {
@@ -741,39 +805,103 @@ export class LanceDbStore {
741
805
  }
742
806
  }
743
807
  /**
744
- * Compact fragments and prune old version manifests to prevent the
745
- * version-manifest accumulation that causes countRows() to hang.
746
- * Should be called at the end of a successful index pass.
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).
747
818
  */
748
- async optimize() {
749
- await this.withWriteLock(async () => {
750
- try {
751
- const table = await this.getTable();
752
- // Clean up versions older than 1 hour �?" not "right now" �?" so in-flight
753
- // queries (e.g. Web UI search, background auto-index) can finish before
754
- // their data files are reclaimed. Using new Date() here caused data-file
755
- // race conditions where a reader got "Not found: �?� .lance" because the
756
- // GC deleted fragments that the current version still referenced.
757
- const threshold = new Date(Date.now() - 60 * 60 * 1000);
758
- await table.optimize({ cleanupOlderThan: threshold, deleteUnverified: false });
759
- // Build the ANN vector index without it every vectorSearch() is a
760
- // brute-force O(N) flat scan (slow at 50k+ chunks).
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++) {
761
847
  try {
762
- const count = await table.countRows().catch(() => 0);
763
- const numPartitions = count > 0 ? Math.max(16, Math.min(256, Math.floor(count / 256))) : 16;
764
- // The index metric MUST match the search metric ("cosine", see
765
- // searchInternal). The ivfFlat default is "l2", which makes LanceDB
766
- // ignore the index and fall back to brute-force on every query.
767
848
  await table.createIndex("embedding", {
768
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,
769
855
  });
856
+ return;
770
857
  }
771
- catch {
772
- // Index creation is best-effort queries still work, just slower.
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));
773
864
  }
774
865
  }
775
- catch {
776
- // Optimize is best-effort �?" must not break indexing.
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
+ }
873
+ /**
874
+ * Compact fragments and prune old version manifests to prevent the
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.
884
+ */
885
+ async optimize(options) {
886
+ await this.withWriteLock(async () => {
887
+ try {
888
+ const table = await this.getTable();
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();
900
+ }
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)}`);
777
905
  }
778
906
  });
779
907
  }
@@ -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);
@@ -78,7 +97,7 @@ export class InMemoryVectorStore {
78
97
  async close() {
79
98
  }
80
99
  /** No-op for the in-memory store. */
81
- async optimize() {
100
+ async optimize(_options) {
82
101
  }
83
102
  }
84
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