opencode-memory-pro 1.4.0 → 1.4.1

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.
package/README.md CHANGED
@@ -42,12 +42,6 @@ opencode plugin opencode-memory-pro
42
42
 
43
43
  The latest release is **v1.4.0** on [npm](https://www.npmjs.com/package/opencode-memory-pro); source and releases are on [GitHub](https://github.com/tman204-50/opencode-memory-pro).
44
44
 
45
- Remove the old plugin pin at the same time:
46
-
47
- ```bash
48
- opencode plugin lancedb-opencode-pro -g # removes pin (if installed)
49
- ```
50
-
51
45
  ### Getting started
52
46
 
53
47
  **1. Install and restart OpenCode** — done above. That's it for a baseline
@@ -432,6 +426,7 @@ All tools are auto-registered when the plugin loads. Hybrid recall surfaces
432
426
  | `memory_event_cleanup` | Clean up expired effectiveness events (optional archive). |
433
427
  | `memory_consolidate` | Merge near-duplicate memories in a scope. |
434
428
  | `memory_consolidate_all` | Global duplicate cleanup (daily cron friendly). |
429
+ | `memory_reembed` | Detect/repair an embedding-dimension mismatch (backs up, rebuilds the table, re-embeds every memory). |
435
430
 
436
431
  **Scoping**
437
432
 
@@ -480,15 +475,47 @@ npm run verify # tests + pack dry-run
480
475
 
481
476
  CI runs on GitHub Actions (Node 22 + 24) on every push/PR to `main`.
482
477
 
483
- ## Migrating from `lancedb-opencode-pro`
484
-
485
- Clean-break rename: sidecar is `opencode-memory-pro.json`, env prefix is
486
- `OPENCODE_MEMORY_PRO_*`. Data is **not** affected — the default storage paths
487
- are unchanged (`~/.opencode/memory/lancedb` + `~/.opencode/memory/graph.db`),
488
- so your memories and graph carry over untouched.
489
-
490
478
  ## Changelog
491
479
 
480
+ ### v1.4.1 (2026-09-06)
481
+
482
+ New `memory_reembed` tool — detects and repairs embedding-dimension
483
+ mismatches, which previously corrupted the store silently:
484
+
485
+ - **Root cause**: the `memories` table's `vector` column is an Arrow
486
+ `FixedSizeList` whose width is fixed forever by the first row ever
487
+ written. `init()` re-probes the embedder's dimension on every startup but
488
+ silently discarded that value once a table already existed — nothing ever
489
+ compared "what the embedder produces now" against "what the table is
490
+ physically built for." Switching `embedding.provider`/`embedding.model` to
491
+ a different-dimension model did not error: LanceDB silently coerced
492
+ mismatched writes into the old fixed-width column (corrupting the vector,
493
+ not rejecting the write), and every `vectorSearch()` call at the new
494
+ dimension threw inside `findSimilarVectors`'s catch block, which silently
495
+ swallowed it — so write-time dedup and `memory_consolidate` silently
496
+ stopped finding neighbors for anything written after the switch, with zero
497
+ visible symptom beyond a passive `memory_stats.incompatibleVectors` count.
498
+ - **Detection**: `init()` now reads back the table's actual physical vector
499
+ width (`getPhysicalVectorDim()`) and compares it to the freshly-probed
500
+ embedder dimension on every startup, logging a `warn` on mismatch.
501
+ `getIndexHealth()` (and therefore `memory_stats.index`) now reports
502
+ `dimensionMismatch`/`expectedDim`/`actualDim`, and `computeDegradedFlags`
503
+ surfaces an `embedding-dimension-mismatch` flag pointing at the fix.
504
+ - **Repair**: `memory_reembed` (`dryRun` default `true`, `confirm` gate for
505
+ the actual repair — same pattern as `memory_clear`/`memory_forget`)
506
+ discovers every scope in the store (a dimension mismatch is table-wide,
507
+ not scope-scoped), backs up every memory to
508
+ `<dbPath's parent>/backups/reembed-repair-<ts>.json` (same shape as
509
+ `memory_export`, written *before* any mutation, always), then drops and
510
+ recreates the `memories` table at the current embedder's dimension and
511
+ re-embeds every memory from its stored text under its original id (so
512
+ entity-graph edges and citation chains keyed by id stay valid).
513
+ - **Tests**: new integration test covers detection on a freshly-created
514
+ table (no false positive), detection after reopening with a different
515
+ dimension, and a full repair pass — asserting the physical column width
516
+ actually changes, every original id/text survives, and post-repair health
517
+ reports no mismatch.
518
+
492
519
  ### v1.4.0 (2026-09-06)
493
520
 
494
521
  Dedup correctness overhaul — the write-time duplicate check compared against
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ import { requestLLMCapture, isOwnSession } from "./llm.js";
11
11
  import { createMemoryTools, createFeedbackTools, createEpisodicTools } from "./tools/index.js";
12
12
  import { sweepExpiredMemories } from "./tools/memory.js";
13
13
  import { createGraphStore } from "./graph.js";
14
- const PLUGIN_VERSION = "1.4.0";
14
+ const PLUGIN_VERSION = "1.4.1";
15
15
  const SCHEMA_VERSION = 1;
16
16
  // Event-driven dedup: run consolidateDuplicates on session.idle (throttled to
17
17
  // this interval so chatty sessions aren't re-scanning the store every turn)
package/dist/store.d.ts CHANGED
@@ -98,7 +98,12 @@ export declare class MemoryStore {
98
98
  ftsError?: string;
99
99
  vectorRetries?: number;
100
100
  ftsRetries?: number;
101
+ dimensionMismatch: boolean;
102
+ expectedDim: number | null;
103
+ actualDim: number | null;
101
104
  };
105
+ getPhysicalVectorDim(): Promise<number | null>;
106
+ listDistinctScopes(): Promise<string[]>;
102
107
  private invalidateScope;
103
108
  private getCachedScopes;
104
109
  private enforceMaxScopes;
package/dist/store.js CHANGED
@@ -56,6 +56,13 @@ export class MemoryStore {
56
56
  ftsError: "",
57
57
  vectorRetries: 0,
58
58
  ftsRetries: 0,
59
+ // DIMENSION_MISMATCH_DETECT: set by init() by comparing the live
60
+ // embedder's probed dimension against the "vector" column's actual
61
+ // physical FixedSizeList width (fixed forever once the table's first
62
+ // row is written). See getPhysicalVectorDim() / repairEmbeddingDimension().
63
+ dimensionMismatch: false,
64
+ expectedDim: null,
65
+ actualDim: null,
59
66
  };
60
67
  scopeCache = new Map();
61
68
  // SCOPE_CACHE_LAZY (1.1.7): per-scope write counter. invalidateScope()
@@ -362,6 +369,35 @@ export class MemoryStore {
362
369
  await this.ensureMemoriesTableCompatibility();
363
370
  await this.ensureEventTableCompatibility();
364
371
  await this.ensureIndexes();
372
+ // DIMENSION_MISMATCH_DETECT: compare the embedder dimension this
373
+ // process just probed (vectorDim, the init() argument) against the
374
+ // table's actual physical column width. They only diverge when
375
+ // embedding.provider/embedding.model was changed to a different-
376
+ // output-size model without resetting the store — and when that
377
+ // happens, LanceDB does NOT reject the mismatched write; it silently
378
+ // coerces it into the old fixed-width column (corrupting the vector),
379
+ // and every vectorSearch() call at the new dimension throws (silently
380
+ // swallowed by findSimilarVectors's catch), so dedup/consolidation
381
+ // silently stop finding neighbors for anything written after the
382
+ // switch. See repairEmbeddingDimension() for the fix.
383
+ try {
384
+ const physicalDim = await this.getPhysicalVectorDim();
385
+ this.indexState.expectedDim = physicalDim;
386
+ this.indexState.actualDim = vectorDim;
387
+ this.indexState.dimensionMismatch = physicalDim !== null && physicalDim !== vectorDim;
388
+ if (this.indexState.dimensionMismatch) {
389
+ log("warn", `[store] Embedding dimension mismatch: the embedder currently produces ` +
390
+ `${vectorDim}-dim vectors, but this store's "vector" column is physically fixed ` +
391
+ `at ${physicalDim}-dim (set when the table was first created). New memories will ` +
392
+ `be written with corrupted vectors and dedup/consolidation will silently stop ` +
393
+ `finding neighbors for anything written from now on. Fix: call the memory_reembed ` +
394
+ `tool (dryRun:false, confirm:true) to back up and re-embed every memory under the ` +
395
+ `current model.`);
396
+ }
397
+ }
398
+ catch (error) {
399
+ log("debug", `[store] dimension-mismatch check failed: ${error instanceof Error ? error.message : String(error)}`);
400
+ }
365
401
  const retentionDays = this.retentionConfig?.effectivenessEventsDays;
366
402
  if (retentionDays !== undefined && retentionDays > 0) {
367
403
  await this.cleanupExpiredEvents(undefined, retentionDays);
@@ -1673,8 +1709,33 @@ export class MemoryStore {
1673
1709
  ftsError: this.indexState.ftsError || undefined,
1674
1710
  vectorRetries: this.indexState.vectorRetries,
1675
1711
  ftsRetries: this.indexState.ftsRetries,
1712
+ dimensionMismatch: this.indexState.dimensionMismatch,
1713
+ expectedDim: this.indexState.expectedDim,
1714
+ actualDim: this.indexState.actualDim,
1676
1715
  };
1677
1716
  }
1717
+ // DIMENSION_MISMATCH_DETECT: the "vector" column is an Arrow
1718
+ // FixedSizeList whose width is fixed forever by the first row ever
1719
+ // written to the table (LanceDB/Arrow enforce a uniform width per
1720
+ // column) — NOT by whatever `vectorDim` a later write claims in its
1721
+ // bookkeeping column. Reading it back via table.schema() is the only
1722
+ // reliable way to know the table's true, physical embedding dimension.
1723
+ async getPhysicalVectorDim() {
1724
+ const table = this.requireTable();
1725
+ const schema = await table.schema();
1726
+ const vectorField = schema.fields.find((field) => field.name === "vector");
1727
+ const listSize = vectorField?.type?.listSize;
1728
+ return typeof listSize === "number" ? listSize : null;
1729
+ }
1730
+ // DIMENSION_MISMATCH_REPAIR: a dimension mismatch is a whole-table
1731
+ // structural problem (the physical column width is table-wide, not
1732
+ // scope-scoped), so the repair must span every scope present, not just
1733
+ // the caller's current scope.
1734
+ async listDistinctScopes() {
1735
+ const table = this.requireTable();
1736
+ const rows = await table.query().select(["scope"]).limit(200000).toArray();
1737
+ return [...new Set(rows.map((row) => String(row.scope ?? "")).filter((scope) => scope.length > 0))];
1738
+ }
1678
1739
  invalidateScope(scope) {
1679
1740
  this.scopeVersions.set(scope, (this.scopeVersions.get(scope) ?? 0) + 1);
1680
1741
  }
@@ -92,6 +92,17 @@ export declare function createMemoryTools(state: ToolRuntimeState): {
92
92
  scope?: string | undefined;
93
93
  }, context: import("@opencode-ai/plugin").ToolContext): Promise<string>;
94
94
  };
95
+ memory_reembed: {
96
+ description: string;
97
+ args: {
98
+ dryRun: import("zod").ZodDefault<import("zod").ZodOptional<import("zod").ZodBoolean>>;
99
+ confirm: import("zod").ZodDefault<import("zod").ZodOptional<import("zod").ZodBoolean>>;
100
+ };
101
+ execute(args: {
102
+ dryRun: boolean;
103
+ confirm: boolean;
104
+ }): Promise<string>;
105
+ };
95
106
  memory_event_cleanup: {
96
107
  description: string;
97
108
  args: {
@@ -32,6 +32,12 @@ function computeDegradedFlags(state, embedderHealth, graphStats) {
32
32
  if (state.config?.capture?.llm?.provider && state.config?.capture?.llm?.model && getLlmHealth().status === "error") {
33
33
  flags.push("llm-unhealthy: last LLM capture/digest call failed — falling back to heuristics/extractive digests");
34
34
  }
35
+ const idx = state.store?.getIndexHealth?.();
36
+ if (idx?.dimensionMismatch) {
37
+ flags.push(`embedding-dimension-mismatch: embedder produces ${idx.actualDim}-dim vectors but the ` +
38
+ `store is fixed at ${idx.expectedDim}-dim — new writes are being silently corrupted and ` +
39
+ `dedup/consolidation are silently disabled. Run memory_reembed (dryRun:false, confirm:true) to repair.`);
40
+ }
35
41
  return flags;
36
42
  }
37
43
  // LLM_CAPTURE (1.1): mode-aware digest builder shared by memory_summarize
@@ -1226,6 +1232,117 @@ ${explanations.join("\n")}`;
1226
1232
  }, null, 2);
1227
1233
  },
1228
1234
  }),
1235
+ // DIMENSION_MISMATCH_REPAIR: the "vector" column's physical width is
1236
+ // fixed for the whole table (set by the first row ever written), not
1237
+ // per-scope, so this operates on every scope in the store — unlike
1238
+ // every other tool here, it does not take a `scope` argument.
1239
+ // Backs up first (always, even dryRun) so the operation is never
1240
+ // riskier than memory_export followed by memory_import(replace).
1241
+ memory_reembed: tool({
1242
+ description: "Detect (and, with confirm:true, repair) an embedding-dimension mismatch between the " +
1243
+ "configured embedder and the on-disk vector store. A mismatch happens when embedding.provider " +
1244
+ "or embedding.model changed to a different output dimension without resetting the store — " +
1245
+ "LanceDB silently corrupts new writes in that state instead of rejecting them, and dedup/" +
1246
+ "consolidation silently stop finding neighbors. Repair backs up every memory (all scopes) to " +
1247
+ "a JSON file, drops and recreates the memories table at the current embedder's dimension, and " +
1248
+ "re-embeds every memory from its stored text under its original id (graph edges and citation " +
1249
+ "chains keyed by id stay valid).",
1250
+ args: {
1251
+ dryRun: tool.schema.boolean().optional().default(true),
1252
+ confirm: tool.schema.boolean().optional().default(false),
1253
+ },
1254
+ execute: async (args) => {
1255
+ await state.ensureInitialized();
1256
+ if (!state.initialized)
1257
+ return unavailableMessage(state.config.embedding.provider);
1258
+ const actualDim = await state.embedder.dim();
1259
+ const expectedDim = await state.store.getPhysicalVectorDim();
1260
+ if (expectedDim === null || expectedDim === actualDim) {
1261
+ return JSON.stringify({
1262
+ mismatch: false,
1263
+ actualDim,
1264
+ message: "No dimension mismatch detected. Nothing to repair.",
1265
+ }, null, 2);
1266
+ }
1267
+ const scopes = await state.store.listDistinctScopes();
1268
+ const records = await state.store.exportAllRecords(scopes);
1269
+ if (args.dryRun && !args.confirm) {
1270
+ return JSON.stringify({
1271
+ mismatch: true,
1272
+ expectedDim,
1273
+ actualDim,
1274
+ scopes,
1275
+ recordCount: records.length,
1276
+ message: "Dry run — no changes made. Call again with dryRun:false, confirm:true to " +
1277
+ "repair (this drops and rebuilds the memories table; a backup is written first).",
1278
+ }, null, 2);
1279
+ }
1280
+ if (!args.confirm) {
1281
+ return JSON.stringify({
1282
+ error: "Set confirm:true to actually repair — this drops and rebuilds the memories " +
1283
+ "table (like memory_clear/memory_forget, destructive operations require confirm:true).",
1284
+ }, null, 2);
1285
+ }
1286
+ // BACKUP_ALWAYS_FIRST: same JSON shape as memory_export, so
1287
+ // memory_import can restore from it independently if anything
1288
+ // below fails partway through.
1289
+ const fs = await import("node:fs");
1290
+ const dbDirEnd = state.config.dbPath.lastIndexOf("/");
1291
+ const backupDir = (dbDirEnd > 0 ? state.config.dbPath.slice(0, dbDirEnd) : ".") + "/backups";
1292
+ await fs.promises.mkdir(backupDir, { recursive: true }).catch(() => { });
1293
+ const backupPath = `${backupDir}/reembed-repair-${Date.now()}.json`;
1294
+ await fs.promises.writeFile(backupPath, JSON.stringify({
1295
+ format: "opencode-memory-pro/backup",
1296
+ version: 1,
1297
+ exportedAt: new Date().toISOString(),
1298
+ provider: state.config.provider,
1299
+ dbPath: state.config.dbPath,
1300
+ reason: "pre-reembed-repair-backup",
1301
+ fromDim: expectedDim,
1302
+ toDim: actualDim,
1303
+ scopes,
1304
+ count: records.length,
1305
+ memories: records,
1306
+ }, null, 2));
1307
+ await state.store.connection.dropTable("memories");
1308
+ state.store.table = null;
1309
+ await state.store.init(actualDim);
1310
+ let repaired = 0;
1311
+ let failed = 0;
1312
+ const failures = [];
1313
+ for (const record of records) {
1314
+ try {
1315
+ const vector = await state.embedder.embed(record.text || "");
1316
+ await state.store.put({
1317
+ ...record,
1318
+ vector,
1319
+ vectorDim: vector.length,
1320
+ embeddingModel: state.embedder.model,
1321
+ });
1322
+ repaired += 1;
1323
+ }
1324
+ catch (error) {
1325
+ failed += 1;
1326
+ failures.push({ id: record.id, reason: error instanceof Error ? error.message : String(error) });
1327
+ }
1328
+ }
1329
+ await state.store.ensureIndexes();
1330
+ return JSON.stringify({
1331
+ mismatch: true,
1332
+ repaired,
1333
+ failed,
1334
+ failures: failures.slice(0, 10),
1335
+ fromDim: expectedDim,
1336
+ toDim: actualDim,
1337
+ backupPath,
1338
+ scopes,
1339
+ message: failed > 0
1340
+ ? `Repaired ${repaired}/${records.length}. ${failed} failed but remain intact in the ` +
1341
+ `backup at ${backupPath} — re-run memory_reembed once the embedder issue is fixed.`
1342
+ : `Repaired all ${repaired} memories at ${actualDim}-dim. Backup retained at ${backupPath}.`,
1343
+ }, null, 2);
1344
+ },
1345
+ }),
1229
1346
  memory_summarize: tool({
1230
1347
  description: "Create digests of old memories (store-level summarization). LLM abstractive digests when capture.mode=llm, offline extractive otherwise. Optionally mark originals 'digested' (replace=true) so only the digest remains in recall.",
1231
1348
  args: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-memory-pro",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "LanceDB-backed long-term memory provider for OpenCode — standalone fork of lancedb-opencode-pro with entity graph, lifecycle, and retention",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",