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 +40 -13
- package/dist/index.js +1 -1
- package/dist/store.d.ts +5 -0
- package/dist/store.js +61 -0
- package/dist/tools/memory.d.ts +11 -0
- package/dist/tools/memory.js +117 -0
- package/package.json +1 -1
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.
|
|
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
|
}
|
package/dist/tools/memory.d.ts
CHANGED
|
@@ -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: {
|
package/dist/tools/memory.js
CHANGED
|
@@ -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.
|
|
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",
|