opencode-rag-plugin 1.22.1 → 1.23.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.
@@ -53,10 +53,45 @@ export declare function isCorruptionError(err: unknown): boolean;
53
53
  * @returns True if the error matches a transient transaction conflict.
54
54
  */
55
55
  export declare function isTransientConflictError(err: unknown): boolean;
56
+ /**
57
+ * Thrown when a write supplies embeddings whose length differs from the
58
+ * store's vector column dimension. LanceDB itself does NOT validate this —
59
+ * it silently zero-pads or truncates vectors into the fixed-size column,
60
+ * producing rows that can never be found by a query. Failing loudly here
61
+ * keeps that silent corruption out of the store.
62
+ */
63
+ export declare class DimensionMismatchError extends Error {
64
+ /** Dimension of the store's vector column. */
65
+ readonly storeDimension: number;
66
+ /** Dimension of the embedding that was supplied. */
67
+ readonly embeddingDimension: number;
68
+ constructor(storeDimension: number, embeddingDimension: number, action?: string);
69
+ }
70
+ /** Type guard for {@link DimensionMismatchError}. */
71
+ export declare function isDimensionMismatchError(err: unknown): err is DimensionMismatchError;
72
+ /**
73
+ * Extract the fixed-size vector dimension of the `embedding` column from a
74
+ * LanceDB/Arrow schema. Returns `undefined` when the column is missing or is
75
+ * not a fixed-size list (e.g. before the table is created).
76
+ */
77
+ export declare function extractEmbeddingDimension(fields: ReadonlyArray<{
78
+ name: string;
79
+ type: unknown;
80
+ }>): number | undefined;
81
+ /**
82
+ * Read the embedding dimension of an existing store without creating a table
83
+ * or holding a long-lived connection. Returns `undefined` when the store or
84
+ * table does not exist (or cannot be read).
85
+ *
86
+ * Used by the CLI bootstrap to avoid creating a brand-new store with a
87
+ * speculative dimension when the embedding provider cannot be probed.
88
+ */
89
+ export declare function readStoreDimension(storePath: string): Promise<number | undefined>;
56
90
  /**
57
91
  * Atomically replace one LanceDB store directory with another.
58
92
  * Swaps the real directory with a temporary one that was built during a rebuild.
59
- * The old directory is moved to `${realPath}_old` and deleted asynchronously.
93
+ * The old directory is moved to `${realPath}_old` and deleted asynchronously
94
+ * after non-Lance artifacts (quirk memory, caches) have been carried over.
60
95
  *
61
96
  * @param tempPath - Path to the newly built store (source).
62
97
  * @param realPath - Path to the current store (destination, will be replaced).
@@ -94,6 +129,13 @@ export declare class LanceDbStore implements VectorStore {
94
129
  private indexRepairPromise;
95
130
  /** Consecutive failed repair attempts — bounded so a broken store cannot retrain forever. */
96
131
  private indexRepairFailures;
132
+ /**
133
+ * Actual dimension of the `embedding` column in the opened table. Read from
134
+ * the schema on first table access and used to validate writes/searches —
135
+ * the constructor's `vectorDimension` describes what the caller *expects*,
136
+ * which can drift from the store when the embedding model changes.
137
+ */
138
+ private knownDimension;
97
139
  /**
98
140
  * Execute an async function under an exclusive write lock.
99
141
  *
@@ -120,6 +162,23 @@ export declare class LanceDbStore implements VectorStore {
120
162
  private getTable;
121
163
  private initTable;
122
164
  private tableHasDescriptionColumn;
165
+ /**
166
+ * Read and cache the actual fixed-size dimension of the `embedding` column.
167
+ * Called whenever the table is (re)opened so write/search validation uses
168
+ * the store's real schema instead of the constructor's expectation.
169
+ */
170
+ private cacheTableDimension;
171
+ /**
172
+ * Return the actual embedding dimension of the store's vector column, or
173
+ * `undefined` when no table exists yet. Never creates the table.
174
+ */
175
+ getVectorDimension(): Promise<number | undefined>;
176
+ /**
177
+ * Fail loudly when rows carry embeddings whose length differs from the
178
+ * store's vector column. LanceDB would silently pad/truncate them instead,
179
+ * leaving rows that no query can match.
180
+ */
181
+ private assertEmbeddingDimensions;
123
182
  private hasColumn;
124
183
  /** Add kind/quirkType/tags columns if missing from an existing table. */
125
184
  private migrateNewColumns;
@@ -148,10 +148,129 @@ export function isTransientConflictError(err) {
148
148
  }
149
149
  return false;
150
150
  }
151
+ /**
152
+ * Thrown when a write supplies embeddings whose length differs from the
153
+ * store's vector column dimension. LanceDB itself does NOT validate this —
154
+ * it silently zero-pads or truncates vectors into the fixed-size column,
155
+ * producing rows that can never be found by a query. Failing loudly here
156
+ * keeps that silent corruption out of the store.
157
+ */
158
+ export class DimensionMismatchError extends Error {
159
+ /** Dimension of the store's vector column. */
160
+ storeDimension;
161
+ /** Dimension of the embedding that was supplied. */
162
+ embeddingDimension;
163
+ constructor(storeDimension, embeddingDimension, action = "rebuild it with 'opencode-rag index --force'") {
164
+ super(`Embedding dimension mismatch: the store holds ${storeDimension}-dimensional vectors ` +
165
+ `but the embedding model produced ${embeddingDimension}. The index was built with a ` +
166
+ `different embedding model — ${action}.`);
167
+ this.name = "DimensionMismatchError";
168
+ this.storeDimension = storeDimension;
169
+ this.embeddingDimension = embeddingDimension;
170
+ }
171
+ }
172
+ /** Type guard for {@link DimensionMismatchError}. */
173
+ export function isDimensionMismatchError(err) {
174
+ return err instanceof DimensionMismatchError || (err instanceof Error && err.name === "DimensionMismatchError");
175
+ }
176
+ /**
177
+ * Extract the fixed-size vector dimension of the `embedding` column from a
178
+ * LanceDB/Arrow schema. Returns `undefined` when the column is missing or is
179
+ * not a fixed-size list (e.g. before the table is created).
180
+ */
181
+ export function extractEmbeddingDimension(fields) {
182
+ const field = fields.find((f) => f.name === "embedding");
183
+ const type = field?.type;
184
+ if (type && typeof type.listSize === "number" && type.listSize > 0) {
185
+ return type.listSize;
186
+ }
187
+ return undefined;
188
+ }
189
+ /**
190
+ * Read the embedding dimension of an existing store without creating a table
191
+ * or holding a long-lived connection. Returns `undefined` when the store or
192
+ * table does not exist (or cannot be read).
193
+ *
194
+ * Used by the CLI bootstrap to avoid creating a brand-new store with a
195
+ * speculative dimension when the embedding provider cannot be probed.
196
+ */
197
+ export async function readStoreDimension(storePath) {
198
+ if (storePath.startsWith("memory:"))
199
+ return undefined;
200
+ try {
201
+ // Do not create a store directory just to read a schema.
202
+ await fs.access(storePath);
203
+ }
204
+ catch {
205
+ return undefined;
206
+ }
207
+ try {
208
+ const db = await lancedb.connect(storePath);
209
+ const tableNames = await db.tableNames();
210
+ if (!tableNames.includes(TABLE_NAME))
211
+ return undefined;
212
+ const table = await db.openTable(TABLE_NAME);
213
+ const schema = await table.schema();
214
+ return extractEmbeddingDimension(schema.fields);
215
+ }
216
+ catch {
217
+ return undefined;
218
+ }
219
+ }
220
+ /**
221
+ * Non-Lance artifacts that live in the store directory next to the LanceDB
222
+ * data and must survive a rebuild swap. `chunks.lance`/`manifest.json` are
223
+ * rebuilt by the pipeline; the files below are not derivable from the new
224
+ * index and would otherwise be destroyed with the old directory.
225
+ */
226
+ const PRESERVED_STORE_ENTRIES = [
227
+ "quirks.jsonl",
228
+ "runtime-overrides.json",
229
+ "watcher-status.json",
230
+ ".desc-cache.json",
231
+ "keyword-index.json",
232
+ "eval-sessions",
233
+ ];
234
+ /**
235
+ * Carry non-Lance store artifacts (quirk memory, caches, overrides) from the
236
+ * pre-swap directory into the freshly promoted one. Best-effort: a failure to
237
+ * preserve one entry must not abort the swap.
238
+ */
239
+ async function preserveStoreArtifacts(oldPath, realPath) {
240
+ for (const entry of PRESERVED_STORE_ENTRIES) {
241
+ const src = path.join(oldPath, entry);
242
+ const dest = path.join(realPath, entry);
243
+ try {
244
+ await fs.access(src);
245
+ }
246
+ catch {
247
+ continue; // artifact not present in the old store
248
+ }
249
+ try {
250
+ try {
251
+ await fs.access(dest);
252
+ continue; // destination already has this artifact (e.g. re-saved desc cache)
253
+ }
254
+ catch {
255
+ // destination missing — move or copy it over
256
+ }
257
+ await fs.rename(src, dest);
258
+ }
259
+ catch {
260
+ try {
261
+ await fs.cp(src, dest, { recursive: true, force: true });
262
+ }
263
+ catch {
264
+ // best-effort — keep the swap result even if an artifact cannot be carried over
265
+ }
266
+ }
267
+ }
268
+ }
151
269
  /**
152
270
  * Atomically replace one LanceDB store directory with another.
153
271
  * Swaps the real directory with a temporary one that was built during a rebuild.
154
- * The old directory is moved to `${realPath}_old` and deleted asynchronously.
272
+ * The old directory is moved to `${realPath}_old` and deleted asynchronously
273
+ * after non-Lance artifacts (quirk memory, caches) have been carried over.
155
274
  *
156
275
  * @param tempPath - Path to the newly built store (source).
157
276
  * @param realPath - Path to the current store (destination, will be replaced).
@@ -176,7 +295,9 @@ export async function swapStoreDirectories(tempPath, realPath) {
176
295
  catch { }
177
296
  throw err;
178
297
  }
179
- // Best-effort async cleanup of old directory
298
+ // Preserve quirks.jsonl & friends (the temp store contains only Lance data),
299
+ // then clean up the old directory best-effort.
300
+ await preserveStoreArtifacts(oldPath, realPath);
180
301
  fs.rm(oldPath, { recursive: true, force: true }).catch(() => { });
181
302
  }
182
303
  /**
@@ -201,6 +322,13 @@ export class LanceDbStore {
201
322
  indexRepairPromise = null;
202
323
  /** Consecutive failed repair attempts — bounded so a broken store cannot retrain forever. */
203
324
  indexRepairFailures = 0;
325
+ /**
326
+ * Actual dimension of the `embedding` column in the opened table. Read from
327
+ * the schema on first table access and used to validate writes/searches —
328
+ * the constructor's `vectorDimension` describes what the caller *expects*,
329
+ * which can drift from the store when the embedding model changes.
330
+ */
331
+ knownDimension = null;
204
332
  /**
205
333
  * Execute an async function under an exclusive write lock.
206
334
  *
@@ -270,6 +398,7 @@ export class LanceDbStore {
270
398
  const tableNames = await db.tableNames();
271
399
  if (tableNames.includes(TABLE_NAME)) {
272
400
  this.table = await db.openTable(TABLE_NAME);
401
+ await this.cacheTableDimension(this.table);
273
402
  if (await this.tableHasDescriptionColumn()) {
274
403
  await this.migrateNewColumns();
275
404
  return this.table;
@@ -323,6 +452,7 @@ export class LanceDbStore {
323
452
  data: [seedRow],
324
453
  mode: "overwrite",
325
454
  });
455
+ this.knownDimension = this.vectorDimension;
326
456
  const deleted = await this.table.delete('id = "__seed__"');
327
457
  if (deleted === undefined) {
328
458
  // LanceDB may not return a count; try a direct query to verify
@@ -342,6 +472,59 @@ export class LanceDbStore {
342
472
  return false;
343
473
  }
344
474
  }
475
+ /**
476
+ * Read and cache the actual fixed-size dimension of the `embedding` column.
477
+ * Called whenever the table is (re)opened so write/search validation uses
478
+ * the store's real schema instead of the constructor's expectation.
479
+ */
480
+ async cacheTableDimension(table) {
481
+ if (this.knownDimension !== null)
482
+ return this.knownDimension;
483
+ try {
484
+ const schema = await table.schema();
485
+ const dim = extractEmbeddingDimension(schema.fields);
486
+ if (dim !== undefined)
487
+ this.knownDimension = dim;
488
+ return dim;
489
+ }
490
+ catch {
491
+ return undefined;
492
+ }
493
+ }
494
+ /**
495
+ * Return the actual embedding dimension of the store's vector column, or
496
+ * `undefined` when no table exists yet. Never creates the table.
497
+ */
498
+ async getVectorDimension() {
499
+ if (this.knownDimension !== null)
500
+ return this.knownDimension;
501
+ try {
502
+ const db = await this.getDb();
503
+ const tableNames = await db.tableNames();
504
+ if (!tableNames.includes(TABLE_NAME))
505
+ return undefined;
506
+ const table = await this.getTable();
507
+ return await this.cacheTableDimension(table);
508
+ }
509
+ catch {
510
+ return undefined;
511
+ }
512
+ }
513
+ /**
514
+ * Fail loudly when rows carry embeddings whose length differs from the
515
+ * store's vector column. LanceDB would silently pad/truncate them instead,
516
+ * leaving rows that no query can match.
517
+ */
518
+ assertEmbeddingDimensions(rows) {
519
+ const storeDim = this.knownDimension;
520
+ if (storeDim === null)
521
+ return;
522
+ for (const row of rows) {
523
+ if (row.embedding.length !== storeDim) {
524
+ throw new DimensionMismatchError(storeDim, row.embedding.length);
525
+ }
526
+ }
527
+ }
345
528
  async hasColumn(name) {
346
529
  try {
347
530
  const schema = await this.table.schema();
@@ -489,6 +672,7 @@ export class LanceDbStore {
489
672
  .filter((r) => r !== null);
490
673
  if (rows.length === 0)
491
674
  return;
675
+ this.assertEmbeddingDimensions(rows);
492
676
  // INSERT FIRST: data is safely stored before any delete
493
677
  await table.add(rows);
494
678
  if (!dedup)
@@ -530,6 +714,7 @@ export class LanceDbStore {
530
714
  }
531
715
  if (allRows.length === 0)
532
716
  return;
717
+ this.assertEmbeddingDimensions(allRows);
533
718
  // INSERT FIRST (single add for the whole batch), then per-file dedup
534
719
  await table.add(allRows);
535
720
  for (const [filePath, ids] of dedupByFile) {
@@ -551,9 +736,21 @@ export class LanceDbStore {
551
736
  async searchWithFilter(embedding, topK, filter) {
552
737
  try {
553
738
  // Guard against dimension mismatch BEFORE the native call — LanceDB
554
- // throws a cryptic error that used to be swallowed into "no results".
555
- if (embedding.length !== this.vectorDimension) {
556
- console.warn(`[lancedb] searchWithFilter: query embedding dimension ${embedding.length} != store dimension ${this.vectorDimension} — returning empty`);
739
+ // throws a cryptic "No vector column found to match with the query
740
+ // vector dimension" error. Compare against the table's *actual* column
741
+ // dimension, not the constructor's expectation: a store built by a
742
+ // different embedding model has a mismatching schema even when the
743
+ // handle was constructed with the current model's dimension.
744
+ const storeDimension = (await this.getVectorDimension()) ?? this.vectorDimension;
745
+ if (embedding.length !== storeDimension) {
746
+ if (storeDimension !== this.vectorDimension) {
747
+ console.warn(`[lancedb] Store vector column is ${storeDimension}-dimensional but this handle expects ` +
748
+ `${this.vectorDimension} — the index was built with a different embedding model. ` +
749
+ "Rebuild it with 'opencode-rag index --force' (a plain 'opencode-rag index' also rebuilds automatically).");
750
+ }
751
+ else {
752
+ console.warn(`[lancedb] searchWithFilter: query embedding dimension ${embedding.length} != store dimension ${storeDimension} — returning empty`);
753
+ }
557
754
  return [];
558
755
  }
559
756
  return await this.searchInternal(embedding, topK, filter);
@@ -1090,6 +1287,7 @@ export class LanceDbStore {
1090
1287
  await this.close();
1091
1288
  if (newPath)
1092
1289
  this.dbPath = newPath;
1290
+ this.knownDimension = null;
1093
1291
  }
1094
1292
  /**
1095
1293
  * Close the database connection and release resources.
@@ -1153,6 +1351,7 @@ export class LanceDbStore {
1153
1351
  console.warn(`[lancedb] Backed up chunks.lance to ${backup}`);
1154
1352
  await this.table?.close();
1155
1353
  this.table = null;
1354
+ this.knownDimension = null;
1156
1355
  try {
1157
1356
  const db = await this.getDb();
1158
1357
  const tableNames = await db.tableNames();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-rag-plugin",
3
- "version": "1.22.1",
3
+ "version": "1.23.0",
4
4
  "description": "OpenCode plugin for local-first RAG-based semantic code search",
5
5
  "type": "module",
6
6
  "main": "./dist/plugin-entry.js",