opencode-rag-plugin 1.21.0 → 1.21.2

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.
@@ -21,8 +21,11 @@ export declare function buildWorkspacePackageJson(existing: Record<string, unkno
21
21
  /**
22
22
  * Build the `.opencode/opencode.json` config object.
23
23
  *
24
- * Ensures the `$schema` key is present and removes any stale `plugin`
25
- * entries that would trigger erroneous npm installs.
24
+ * Ensures the `$schema` key is present and removes stale `plugin` entries
25
+ * (which would trigger erroneous npm installs) plus stale MCP entries that
26
+ * are not valid local/remote server configs (e.g. a leftover
27
+ * `mcp.<name>: { enabled: true }` that OpenCode ignores with
28
+ * "Ignoring MCP config entry without type").
26
29
  *
27
30
  * @param existing - The existing opencode.json content (if any).
28
31
  * @returns The normalized config object.
@@ -49,8 +49,11 @@ export function buildWorkspacePackageJson(existing, packageMetadata) {
49
49
  /**
50
50
  * Build the `.opencode/opencode.json` config object.
51
51
  *
52
- * Ensures the `$schema` key is present and removes any stale `plugin`
53
- * entries that would trigger erroneous npm installs.
52
+ * Ensures the `$schema` key is present and removes stale `plugin` entries
53
+ * (which would trigger erroneous npm installs) plus stale MCP entries that
54
+ * are not valid local/remote server configs (e.g. a leftover
55
+ * `mcp.<name>: { enabled: true }` that OpenCode ignores with
56
+ * "Ignoring MCP config entry without type").
54
57
  *
55
58
  * @param existing - The existing opencode.json content (if any).
56
59
  * @returns The normalized config object.
@@ -65,6 +68,27 @@ export function buildOpencodeConfig(existing) {
65
68
  // init versions would trigger npm install (which fails due to native
66
69
  // dependencies like sharp) and produce "Plugin export is not a function".
67
70
  delete next.plugin;
71
+ // Keep only MCP entries that look like real server configs (have a
72
+ // "type" of "local" or "remote"). Drop shorthand entries such as
73
+ // `{ enabled: true }` that OpenCode ignores at startup.
74
+ if (next.mcp && typeof next.mcp === "object" && !Array.isArray(next.mcp)) {
75
+ const mcp = next.mcp;
76
+ const valid = {};
77
+ for (const [name, value] of Object.entries(mcp)) {
78
+ if (value && typeof value === "object" && !Array.isArray(value)) {
79
+ const entry = value;
80
+ if (entry.type === "local" || entry.type === "remote") {
81
+ valid[name] = value;
82
+ }
83
+ }
84
+ }
85
+ if (Object.keys(valid).length === 0) {
86
+ delete next.mcp;
87
+ }
88
+ else {
89
+ next.mcp = valid;
90
+ }
91
+ }
68
92
  return next;
69
93
  }
70
94
  /**
@@ -209,9 +209,14 @@ export interface VectorStore {
209
209
  * accumulation.
210
210
  * @param options - `aggressive: true` prunes all but the current version
211
211
  * (only safe when no other process reads the store, e.g. temp rebuilds).
212
+ * `skipIndex: true` skips ANN index creation/repair (private temp stores
213
+ * are never searched — the rebuild pipeline builds the index once at the
214
+ * end). `logger` receives diagnostics instead of the console.
212
215
  */
213
216
  optimize?(options?: {
214
217
  aggressive?: boolean;
218
+ skipIndex?: boolean;
219
+ logger?: (message: string) => void;
215
220
  }): Promise<void>;
216
221
  /**
217
222
  * Verify that the store's data is actually readable.
@@ -528,6 +528,11 @@ async function runIndexPassInner(options, logger) {
528
528
  prep.chunks = undefined;
529
529
  prep.textToEmbed = undefined;
530
530
  }
531
+ // Number of files whose chunks were actually bulk-written this window.
532
+ // The caller uses this to skip mid-run compaction on idle passes that
533
+ // wrote nothing (no fragments were added, so nothing needs compacting,
534
+ // and the store avoids unnecessary index-repair churn).
535
+ return storePayloads.length;
531
536
  }
532
537
  // ── Windowed pipeline ────────────────────────────────────────────────────
533
538
  // The prepare→describe→embed→store chain used to materialize ALL chunks,
@@ -833,12 +838,18 @@ async function runIndexPassInner(options, logger) {
833
838
  // entries and skip every file ("Store phase: storing N → 0 files stored").
834
839
  prevStore = storeWindow(prepared, new Map(earlyWorkerResults));
835
840
  // Periodically compact fragments and prune old versions so the store
836
- // phase doesn't slow down as the index grows during long runs.
841
+ // phase doesn't slow down as the index grows during long runs. Skipped on
842
+ // windows that wrote nothing — an idle pass (no changed files) adds no
843
+ // fragments, so compaction would only churn the index-repair path.
837
844
  if (optimizeInterval > 0 && windowCount % optimizeInterval === 0) {
838
- await prevStore;
845
+ const storedInWindow = await prevStore;
839
846
  prevStore = null;
840
- logger.info("Optimizing vector store (mid-run compaction, pruning old versions)...");
841
- await effectiveStore.optimize?.(tempStorePath ? { aggressive: true } : undefined);
847
+ if (storedInWindow > 0) {
848
+ logger.info("Optimizing vector store (mid-run compaction, pruning old versions)...");
849
+ await effectiveStore.optimize?.(tempStorePath
850
+ ? { aggressive: true, skipIndex: true, logger: logger.warn }
851
+ : { logger: logger.warn });
852
+ }
842
853
  }
843
854
  if (abortedInWindow)
844
855
  break;
@@ -915,7 +926,7 @@ async function runIndexPassInner(options, logger) {
915
926
  const optimizeStart = Date.now();
916
927
  try {
917
928
  const optimizeTarget = tempStorePath ? options.store : effectiveStore;
918
- await optimizeTarget.optimize?.(tempStorePath ? { aggressive: true } : undefined);
929
+ await optimizeTarget.optimize?.(tempStorePath ? { aggressive: true, logger: logger.warn } : { logger: logger.warn });
919
930
  logger.info(`Vector store optimized in ${((Date.now() - optimizeStart) / 1000).toFixed(1)}s`);
920
931
  }
921
932
  catch (err) {
@@ -1,4 +1,34 @@
1
1
  import type { VectorStore, Chunk, ChunkSummary, SearchResult, MetadataFilter } from "../core/interfaces.js";
2
+ /** Minimal warning sink for store diagnostics (defaults to console.warn). */
3
+ export type StoreWarn = (message: string) => void;
4
+ /**
5
+ * Count index-version directories in a LanceDB table directory. Each
6
+ * `createIndex` writes a new `<uuid>/` directory under `_indices`; a healthy
7
+ * store holds one per built index, while a store whose index commits fail
8
+ * accumulates one per attempt (and eventually degrades / corrupts).
9
+ *
10
+ * Empty directories are the husks of pruned versions (Lance removes the files
11
+ * but leaves the directory behind) and are harmless — they must not count
12
+ * toward the corruption threshold.
13
+ *
14
+ * @param tablePath - Filesystem path of the table directory (e.g. `.../chunks.lance`).
15
+ * @param options - `withFiles: true` counts only directories that still contain index files.
16
+ * @returns The number of index-version directories, or 0 when unavailable.
17
+ */
18
+ export declare function countIndexVersionDirs(tablePath: string, options?: {
19
+ withFiles?: boolean;
20
+ }): number;
21
+ /**
22
+ * Remove stale empty index-version directories (husks of versions whose files
23
+ * Lance already pruned). Empty directories are never referenced by the index,
24
+ * so removing them is safe; only husks older than `MIN_HUSK_AGE_MS` are swept
25
+ * so a concurrent createIndex from another process can never lose its
26
+ * freshly-created (and momentarily empty) version directory.
27
+ *
28
+ * @param tablePath - Filesystem path of the table directory (e.g. `.../chunks.lance`).
29
+ * @returns The number of removed directories.
30
+ */
31
+ export declare function sweepEmptyIndexVersionDirs(tablePath: string): number;
2
32
  /**
3
33
  * L2-normalize a vector to unit length. Cosine models require unit vectors
4
34
  * for the dot product to equal cosine similarity.
@@ -62,6 +92,8 @@ export declare class LanceDbStore implements VectorStore {
62
92
  * back to brute-force. This repairs that stale index on first search.
63
93
  */
64
94
  private indexRepairPromise;
95
+ /** Consecutive failed repair attempts — bounded so a broken store cannot retrain forever. */
96
+ private indexRepairFailures;
65
97
  /**
66
98
  * Execute an async function under an exclusive write lock.
67
99
  *
@@ -245,6 +277,8 @@ export declare class LanceDbStore implements VectorStore {
245
277
  */
246
278
  optimize(options?: {
247
279
  aggressive?: boolean;
280
+ skipIndex?: boolean;
281
+ logger?: StoreWarn;
248
282
  }): Promise<void>;
249
283
  /**
250
284
  * Return all unique file paths currently stored in the index.
@@ -3,11 +3,102 @@
3
3
  */
4
4
  import * as lancedb from "@lancedb/lancedb";
5
5
  import fs from "node:fs/promises";
6
+ import fsSync from "node:fs";
6
7
  import path from "node:path";
7
8
  import { normalizeFilePath, manifestPathFor } from "../core/manifest.js";
8
9
  import { normalizeFileExtensions, matchesFileExtension } from "../core/filters.js";
9
10
  const TABLE_NAME = "chunks";
10
11
  const QUERY_COLUMNS = ["id", "content", "description", "filePath", "startLine", "endLine", "language", "kind", "quirkType", "tags"];
12
+ /**
13
+ * Upper bound for the number of failed index-creation attempts per process.
14
+ * Beyond this the repair gives up for the process lifetime instead of
15
+ * retraining the IVF index on every optimize/search call (each attempt runs
16
+ * a full KMeans training pass and logs "partition N is empty, skipping").
17
+ */
18
+ const MAX_INDEX_REPAIR_ATTEMPTS = 3;
19
+ /**
20
+ * Upper bound for stale index-version directories under `chunks.lance/_indices`.
21
+ * Healthy stores have one per built index; a store whose index registration
22
+ * keeps failing accumulates one directory per attempt and never converges.
23
+ * Above this threshold the repair refuses to create yet another version and
24
+ * instead tells the user to rebuild the store.
25
+ */
26
+ const MAX_STALE_INDEX_VERSIONS = 40;
27
+ /**
28
+ * Count index-version directories in a LanceDB table directory. Each
29
+ * `createIndex` writes a new `<uuid>/` directory under `_indices`; a healthy
30
+ * store holds one per built index, while a store whose index commits fail
31
+ * accumulates one per attempt (and eventually degrades / corrupts).
32
+ *
33
+ * Empty directories are the husks of pruned versions (Lance removes the files
34
+ * but leaves the directory behind) and are harmless — they must not count
35
+ * toward the corruption threshold.
36
+ *
37
+ * @param tablePath - Filesystem path of the table directory (e.g. `.../chunks.lance`).
38
+ * @param options - `withFiles: true` counts only directories that still contain index files.
39
+ * @returns The number of index-version directories, or 0 when unavailable.
40
+ */
41
+ export function countIndexVersionDirs(tablePath, options) {
42
+ try {
43
+ const entries = fsSync.readdirSync(path.join(tablePath, "_indices"), { withFileTypes: true });
44
+ const dirs = entries.filter((e) => e.isDirectory());
45
+ if (!options?.withFiles)
46
+ return dirs.length;
47
+ let count = 0;
48
+ for (const dir of dirs) {
49
+ try {
50
+ if (fsSync.readdirSync(path.join(tablePath, "_indices", dir.name)).length > 0)
51
+ count++;
52
+ }
53
+ catch {
54
+ // unreadable dir — count it conservatively
55
+ count++;
56
+ }
57
+ }
58
+ return count;
59
+ }
60
+ catch {
61
+ return 0;
62
+ }
63
+ }
64
+ /**
65
+ * Remove stale empty index-version directories (husks of versions whose files
66
+ * Lance already pruned). Empty directories are never referenced by the index,
67
+ * so removing them is safe; only husks older than `MIN_HUSK_AGE_MS` are swept
68
+ * so a concurrent createIndex from another process can never lose its
69
+ * freshly-created (and momentarily empty) version directory.
70
+ *
71
+ * @param tablePath - Filesystem path of the table directory (e.g. `.../chunks.lance`).
72
+ * @returns The number of removed directories.
73
+ */
74
+ export function sweepEmptyIndexVersionDirs(tablePath) {
75
+ const MIN_HUSK_AGE_MS = 10 * 60 * 1000;
76
+ let removed = 0;
77
+ try {
78
+ const indicesDir = path.join(tablePath, "_indices");
79
+ for (const name of fsSync.readdirSync(indicesDir)) {
80
+ const full = path.join(indicesDir, name);
81
+ try {
82
+ const stat = fsSync.statSync(full);
83
+ if (!stat.isDirectory())
84
+ continue;
85
+ if (Date.now() - stat.mtimeMs < MIN_HUSK_AGE_MS)
86
+ continue;
87
+ if (fsSync.readdirSync(full).length > 0)
88
+ continue;
89
+ fsSync.rmdirSync(full);
90
+ removed++;
91
+ }
92
+ catch {
93
+ // best-effort — skip unreadable/racing entries
94
+ }
95
+ }
96
+ }
97
+ catch {
98
+ // best-effort
99
+ }
100
+ return removed;
101
+ }
11
102
  /**
12
103
  * L2-normalize a vector to unit length. Cosine models require unit vectors
13
104
  * for the dot product to equal cosine similarity.
@@ -108,6 +199,8 @@ export class LanceDbStore {
108
199
  * back to brute-force. This repairs that stale index on first search.
109
200
  */
110
201
  indexRepairPromise = null;
202
+ /** Consecutive failed repair attempts — bounded so a broken store cannot retrain forever. */
203
+ indexRepairFailures = 0;
111
204
  /**
112
205
  * Execute an async function under an exclusive write lock.
113
206
  *
@@ -817,9 +910,9 @@ export class LanceDbStore {
817
910
  * Callers must hold the write lock (searchInternal wraps the call in
818
911
  * withWriteLock; optimize runs under it already).
819
912
  */
820
- ensureCosineIndex() {
913
+ ensureCosineIndex(warn) {
821
914
  if (!this.indexRepairPromise) {
822
- this.indexRepairPromise = this.repairIndexMetricOnce();
915
+ this.indexRepairPromise = this.repairIndexMetricOnce(warn);
823
916
  }
824
917
  return this.indexRepairPromise;
825
918
  }
@@ -831,8 +924,25 @@ export class LanceDbStore {
831
924
  * stale index in place. On failure the memo is cleared so the next
832
925
  * search/optimize retries.
833
926
  */
834
- async repairIndexMetricOnce() {
927
+ async repairIndexMetricOnce(warn) {
928
+ const report = (message) => {
929
+ (warn ?? console.warn)(message);
930
+ };
835
931
  try {
932
+ // Guard against a store that never converges: each failed createIndex
933
+ // leaves a new index-version directory behind. Empty directories are
934
+ // the husks of already-pruned versions and are harmless — only
935
+ // versions that still carry index files count (see countIndexVersionDirs).
936
+ const activeVersions = this.dbPath.startsWith("memory://")
937
+ ? 0
938
+ : countIndexVersionDirs(path.join(this.dbPath, TABLE_NAME + ".lance"), { withFiles: true });
939
+ if (activeVersions > MAX_STALE_INDEX_VERSIONS) {
940
+ report(`[lancedb] ${activeVersions} active index versions detected in ${this.dbPath} — ` +
941
+ `index rebuild skipped to avoid retrain churn. The store cannot converge; ` +
942
+ `delete the rag_db directory and re-index.`);
943
+ // Resolve the memo so no further attempts run this process.
944
+ return;
945
+ }
836
946
  const table = await this.getTable();
837
947
  const count = await table.countRows().catch(() => 0);
838
948
  const indices = await table.listIndices();
@@ -854,6 +964,17 @@ export class LanceDbStore {
854
964
  // race a background index-creation from another process.
855
965
  waitTimeoutSeconds: 120,
856
966
  });
967
+ // Verify the index actually registered. On a store whose index
968
+ // commits never register, createIndex can return successfully
969
+ // while leaving the table without a visible index — the failure
970
+ // mode that caused constant retraining and "partition N is empty,
971
+ // skipping" warning spam. Detect it and stop retraining.
972
+ const verify = await table.indexStats(idxName).catch(() => undefined);
973
+ if (!verify || verify.distanceType !== "cosine") {
974
+ throw new Error(`index build did not register (store cannot converge) — ` +
975
+ `delete the rag_db directory and re-index`);
976
+ }
977
+ this.indexRepairFailures = 0;
857
978
  return;
858
979
  }
859
980
  catch (err) {
@@ -866,9 +987,19 @@ export class LanceDbStore {
866
987
  }
867
988
  }
868
989
  catch (err) {
990
+ this.indexRepairFailures++;
991
+ const message = `[lancedb] index metric repair failed (queries continue in brute-force): ` +
992
+ `${err instanceof Error ? err.message : String(err)}`;
993
+ if (this.indexRepairFailures >= MAX_INDEX_REPAIR_ATTEMPTS) {
994
+ // Stop retrying for this process — a store that fails repeatedly would
995
+ // otherwise be retrained (with KMeans "partition empty" warning spam)
996
+ // on every optimize() and search() call.
997
+ report(`${message} — giving up after ${this.indexRepairFailures} attempts this session. ` +
998
+ `Delete the rag_db directory and re-index.`);
999
+ return; // memo stays resolved: no further attempts this process
1000
+ }
869
1001
  this.indexRepairPromise = null; // allow retry on a later search/optimize
870
- console.warn(`[lancedb] index metric repair failed (queries continue in brute-force): ` +
871
- `${err instanceof Error ? err.message : String(err)}`);
1002
+ report(message);
872
1003
  }
873
1004
  }
874
1005
  /**
@@ -894,15 +1025,31 @@ export class LanceDbStore {
894
1025
  const threshold = new Date(Date.now() - 60 * 60 * 1000);
895
1026
  await table.optimize({ cleanupOlderThan: threshold, deleteUnverified: false });
896
1027
  }
1028
+ // Remove stale empty index-version directories (husks Lance leaves
1029
+ // behind after pruning a version's files). Keeps the directory count
1030
+ // from growing unboundedly and prevents false positives in the
1031
+ // index-repair stale-version guard.
1032
+ if (!this.dbPath.startsWith("memory://")) {
1033
+ const swept = sweepEmptyIndexVersionDirs(path.join(this.dbPath, TABLE_NAME + ".lance"));
1034
+ if (swept > 0) {
1035
+ const message = `[lancedb] swept ${swept} stale index-version directories`;
1036
+ (options?.logger ?? console.warn)(message);
1037
+ }
1038
+ }
897
1039
  // Build the ANN vector index — without it every vectorSearch() is a
898
1040
  // brute-force O(N) flat scan (slow at 50k+ chunks). Skips early when
899
- // an index with the correct cosine metric already exists.
900
- await this.ensureCosineIndex();
1041
+ // an index with the correct cosine metric already exists, and can be
1042
+ // skipped entirely for a private temporary store that is never
1043
+ // searched (the rebuild pipeline builds the index once at the end).
1044
+ if (!options?.skipIndex) {
1045
+ await this.ensureCosineIndex(options?.logger);
1046
+ }
901
1047
  }
902
1048
  catch (err) {
903
1049
  // Optimize is best-effort — must not break indexing, but surface the
904
1050
  // failure instead of swallowing it silently.
905
- console.warn(`[lancedb] optimize failed: ${err instanceof Error ? err.message : String(err)}`);
1051
+ const message = `[lancedb] optimize failed: ${err instanceof Error ? err.message : String(err)}`;
1052
+ (options?.logger ?? console.warn)(message);
906
1053
  }
907
1054
  });
908
1055
  }
@@ -27,5 +27,7 @@ export declare class InMemoryVectorStore implements VectorStore {
27
27
  /** No-op for the in-memory store. */
28
28
  optimize(_options?: {
29
29
  aggressive?: boolean;
30
+ skipIndex?: boolean;
31
+ logger?: (message: string) => void;
30
32
  }): Promise<void>;
31
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-rag-plugin",
3
- "version": "1.21.0",
3
+ "version": "1.21.2",
4
4
  "description": "OpenCode plugin for local-first RAG-based semantic code search",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin-entry.js",