opencode-memory-pro 1.3.9 → 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
@@ -40,13 +40,7 @@ Published on npm — install directly (requires OpenCode ≥ 1.x and Node.js ≥
40
40
  opencode plugin opencode-memory-pro
41
41
  ```
42
42
 
43
- The latest release is **v1.3.9** 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
-
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
- ```
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).
50
44
 
51
45
  ### Getting started
52
46
 
@@ -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,85 @@ 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
+
519
+ ### v1.4.0 (2026-09-06)
520
+
521
+ Dedup correctness overhaul — the write-time duplicate check compared against
522
+ the wrong score type, and the resulting flags were a one-way ratchet:
523
+
524
+ - **Write-time dedup now compares a raw cosine similarity**: the capture path
525
+ went through the hybrid `search()` API, whose RRF score is algebraically
526
+ `>= 1.0` for `limit: 1` (and up to `1.4` with importance) — so every capture
527
+ in a non-empty scope compared `>= 1.0` against `dedup.writeThreshold`
528
+ (clamped to `[0,1]`) and got falsely flagged as a potential duplicate.
529
+ `storeCapturedMemory` now calls `findSimilarVectors` (the same raw cosine
530
+ primitive consolidation measures) and compares that to the threshold.
531
+ Consequence: recall scores can no longer exceed 100%, and
532
+ `dedup.enabled`'s write-time detection actually detects.
533
+ - **False duplicate flags now self-correct**: `isPotentialDuplicate` was a
534
+ one-way ratchet — consolidation never cleared it, so `memory_stats`
535
+ `flaggedCount` only grew (153 flagged / 0 merged observed on a live store).
536
+ `consolidateDuplicates` now revalidates flags against the real cosine
537
+ threshold and clears (`isPotentialDuplicate`/`duplicateOf` removed) any
538
+ flagged row whose closest found neighbor never reaches the merge bar.
539
+ Returns `clearedFlags` so tools can report the correction.
540
+ - **Auto-consolidation cooldown is per-scope**: the shared
541
+ `lastConsolidateAt` timestamp meant the first scope to consolidate blocked
542
+ all other scopes for 30 minutes. Cooldowns are now tracked per scope
543
+ (same for the retention sweep, which had the identical flaw).
544
+ - **Scope cache staleness bound**: the per-process version counter can't see
545
+ writes from another opencode process sharing the same `dbPath`, so process A
546
+ could serve stale records indefinitely. Cache entries now reload after a
547
+ 60s age bound even when the local version is unchanged (configurable via
548
+ `cache.staleAfterMs`; 0 restores pure version gating).
549
+ - **Consistent truncation warnings**: `deleteByIdForce`'s 100k-row fallback
550
+ scan and `pruneScope`'s 100k-row read now log a warning when the cap is hit,
551
+ matching `getCachedScopes`.
552
+ - **Tests**: three new integration tests — the dedup write-check primitive
553
+ returns cosine in `[0,1]` (plus a guard that the old RRF path still scores
554
+ `>= 1.0`), consolidation clears false flags, and the scope cache reloads
555
+ after the age bound when a second process writes behind its back.
556
+
492
557
  ### v1.3.8 (2026-09-06)
493
558
 
494
559
  Fixes `memory_forget(force=true)` being unable to permanently delete a memory
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.3.9";
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)
@@ -597,12 +597,16 @@ async function createRuntimeState(input) {
597
597
  activeEpisodes: new Map(),
598
598
  sessionErrors: new Map(),
599
599
  lastRecall: null,
600
+ // PER_SCOPE_COOLDOWN (1.4.0): cooldowns are per-scope (Map keyed by scope)
601
+ // instead of a single shared timestamp — one shared value meant the
602
+ // first scope to consolidate/sweep blocked every other scope for the
603
+ // whole 30-minute cooldown, even scopes that had never run.
600
604
  consolidationInProgress: new Map(),
601
- lastConsolidateAt: 0,
605
+ lastConsolidateAt: new Map(),
602
606
  // MEMORY_RETENTION (1.0): digest-then-hide expiry sweep state — same
603
607
  // throttle pattern as consolidation (cooldown-gated, one per scope).
604
608
  sweepInProgress: new Map(),
605
- lastSweepAt: 0,
609
+ lastSweepAt: new Map(),
606
610
  ensureInitialized: async () => {
607
611
  if (state.initialized)
608
612
  return;
@@ -800,22 +804,19 @@ async function storeCapturedMemory(state, opts) {
800
804
  }
801
805
  let isPotentialDuplicate = false;
802
806
  let duplicateOf = null;
807
+ // DEDUP_COSINE_CHECK (1.4.0): the write-time dedup check used to go
808
+ // through the hybrid search() API, whose RRF score is algebraically >= 1.0
809
+ // for limit:1 (rrfScore = 1/(rrfK+1) * (rrfK+1) == 1.0, then multiplied by
810
+ // an importance factor in [1, 1.4]) — so every capture with any same-dim
811
+ // record in the scope compared >= 1.0 against writeThreshold (clamped
812
+ // [0,1]) and got falsely flagged as a duplicate. Now it uses
813
+ // findSimilarVectors, which returns a raw cosine similarity in [0,1], the
814
+ // same primitive consolidateDuplicates measures against.
803
815
  if (state.config.dedup.enabled) {
804
- const similar = await state.store.search({
805
- query: opts.text,
806
- queryVector: vector,
807
- scopes: [opts.scope],
808
- limit: 1,
809
- vectorWeight: 1.0,
810
- bm25Weight: 0.0,
811
- minScore: 0.0,
812
- rrfK: 60,
813
- recencyBoost: false,
814
- globalDiscountFactor: 1.0,
815
- });
816
+ const similar = await state.store.findSimilarVectors(vector, opts.scope, 1);
816
817
  if (similar.length > 0 && similar[0].score >= state.config.dedup.writeThreshold) {
817
818
  isPotentialDuplicate = true;
818
- duplicateOf = similar[0].record.id;
819
+ duplicateOf = similar[0].id;
819
820
  }
820
821
  }
821
822
  const memoryId = generateId();
@@ -866,11 +867,12 @@ async function maybeConsolidateDuplicates(state, scope, force = false) {
866
867
  if (state.consolidationInProgress.get(scope))
867
868
  return;
868
869
  if (!force) {
869
- const elapsed = Date.now() - state.lastConsolidateAt;
870
+ const last = state.lastConsolidateAt.get(scope) ?? 0;
871
+ const elapsed = Date.now() - last;
870
872
  if (elapsed < CONSOLIDATE_COOLDOWN_MS)
871
873
  return;
872
874
  }
873
- state.lastConsolidateAt = Date.now();
875
+ state.lastConsolidateAt.set(scope, Date.now());
874
876
  state.consolidationInProgress.set(scope, true);
875
877
  state.store
876
878
  .consolidateDuplicates(scope, state.config.dedup.consolidateThreshold, state.config.dedup.candidateLimit)
@@ -888,11 +890,12 @@ async function maybeSweepExpiredMemories(state, scope, force = false) {
888
890
  if (state.sweepInProgress.get(scope))
889
891
  return;
890
892
  if (!force) {
891
- const elapsed = Date.now() - state.lastSweepAt;
893
+ const last = state.lastSweepAt.get(scope) ?? 0;
894
+ const elapsed = Date.now() - last;
892
895
  if (elapsed < CONSOLIDATE_COOLDOWN_MS)
893
896
  return;
894
897
  }
895
- state.lastSweepAt = Date.now();
898
+ state.lastSweepAt.set(scope, Date.now());
896
899
  state.sweepInProgress.set(scope, true);
897
900
  sweepExpiredMemories(state, { scope })
898
901
  .then((result) => {
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
@@ -10,6 +10,13 @@ const DEFAULT_CACHE_CONFIG = {
10
10
  maxScopes: 10,
11
11
  maxRecordsPerScope: 1000,
12
12
  enabled: true,
13
+ // SCOPE_CACHE_STALENESS (1.4.0): the version counter only sees THIS
14
+ // process's writes, so when two opencode processes share one dbPath the
15
+ // scope cache could serve stale records forever. A modest age-based
16
+ // staleness bound forces a reload after staleAfterMs even when the local
17
+ // version is unchanged, bounding cross-process staleness without a schema
18
+ // change. 0 disables the age check (pure version gating, pre-1.4.0).
19
+ staleAfterMs: 60 * 1000,
13
20
  };
14
21
  // ANN_TUNABLES (1.3.0): nprobes controls IVF recall-vs-latency on filtered
15
22
  // vector searches; the consolidation query batch controls how many ANN
@@ -49,6 +56,13 @@ export class MemoryStore {
49
56
  ftsError: "",
50
57
  vectorRetries: 0,
51
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,
52
66
  };
53
67
  scopeCache = new Map();
54
68
  // SCOPE_CACHE_LAZY (1.1.7): per-scope write counter. invalidateScope()
@@ -355,6 +369,35 @@ export class MemoryStore {
355
369
  await this.ensureMemoriesTableCompatibility();
356
370
  await this.ensureEventTableCompatibility();
357
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
+ }
358
401
  const retentionDays = this.retentionConfig?.effectivenessEventsDays;
359
402
  if (retentionDays !== undefined && retentionDays > 0) {
360
403
  await this.cleanupExpiredEvents(undefined, retentionDays);
@@ -642,6 +685,9 @@ export class MemoryStore {
642
685
  }
643
686
  const table = this.requireTable();
644
687
  const rows = await table.query().limit(100000).toArray();
688
+ if (rows.length === 100000) {
689
+ log("warn", "[store] deleteByIdForce fallback scan hit the 100000-row cap; the target may not be found if it lives beyond the cap");
690
+ }
645
691
  const match = rows.find((row) => this.matchesId(row.id, id));
646
692
  if (!match)
647
693
  return false;
@@ -711,6 +757,9 @@ export class MemoryStore {
711
757
  }
712
758
  async pruneScope(scope, maxEntries) {
713
759
  const rows = await this.list(scope, 100000);
760
+ if (rows.length === 100000) {
761
+ log("warn", `[store] pruneScope scanned up to the 100000-row cap for scope=${scope}; entries older than the newest 100k are not candidates for pruning`);
762
+ }
714
763
  if (rows.length <= maxEntries)
715
764
  return 0;
716
765
  const flagged = rows.filter((r) => {
@@ -748,7 +797,7 @@ export class MemoryStore {
748
797
  let rows = await this.readByScopesIncludingMerged([scope]);
749
798
  rows = rows.filter((r) => r.status === undefined || r.status === null || r.status === "" || r.status === "active");
750
799
  if (rows.length === 0) {
751
- return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0 };
800
+ return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0, clearedFlags: 0 };
752
801
  }
753
802
  const BATCH_SIZE = 100;
754
803
  const FALLBACK_THRESHOLD = 500;
@@ -763,12 +812,23 @@ export class MemoryStore {
763
812
  row,
764
813
  norm: this.scopeCache.get(scope)?.norms.get(row.id) ?? vecNorm(row.vector),
765
814
  }));
815
+ // DEDUP_FLAG_REVALIDATION (1.4.0): the write-time dedup check used to
816
+ // flag nearly every capture (RRF score >= 1.0 vs writeThreshold in
817
+ // [0,1]), and the flag was a one-way ratchet — nothing ever cleared it,
818
+ // so flaggedCount only grew. Consolidation is where a real cosine
819
+ // comparison happens, so flagged rows whose closest found neighbor
820
+ // stays below the consolidate threshold get the flag cleared; rows
821
+ // that DO have a near-duplicate keep it.
822
+ const metaById = new Map(rowsWithNorms.map(({ row }) => [row.id, parseMetadata(row.metadataJson)]));
823
+ const flaggedIds = new Set([...metaById].filter(([, meta]) => meta.isPotentialDuplicate === true).map(([id]) => id));
824
+ const bestSimByFlagged = new Map();
825
+ const mergedIds = new Set();
826
+ let clearedFlags = 0;
766
827
  log("debug", `[consolidate] scope=${scope} rows=${rows.length} threshold=${threshold} candidateLimit=${candidateLimit} batchSize=${BATCH_SIZE} fallbackThreshold=${FALLBACK_THRESHOLD}`);
767
828
  const processWithANN = async () => {
768
829
  let localMerged = 0;
769
830
  let localUpdated = 0;
770
831
  let localSkipped = 0;
771
- const mergedIds = new Set();
772
832
  const totalChunks = Math.ceil(rowsWithNorms.length / BATCH_SIZE);
773
833
  for (let chunkIdx = 0; chunkIdx < totalChunks; chunkIdx++) {
774
834
  const chunkStart = chunkIdx * BATCH_SIZE;
@@ -805,6 +865,12 @@ export class MemoryStore {
805
865
  if (mergedIds.has(b.row.id))
806
866
  continue;
807
867
  const sim = storeFastCosine(a.row.vector, b.row.vector, a.norm, b.norm);
868
+ if (flaggedIds.has(a.row.id)) {
869
+ bestSimByFlagged.set(a.row.id, Math.max(bestSimByFlagged.get(a.row.id) ?? -1, sim));
870
+ }
871
+ if (flaggedIds.has(b.row.id)) {
872
+ bestSimByFlagged.set(b.row.id, Math.max(bestSimByFlagged.get(b.row.id) ?? -1, sim));
873
+ }
808
874
  if (sim < threshold)
809
875
  continue;
810
876
  const aMeta = parseMetadata(a.row.metadataJson);
@@ -879,7 +945,6 @@ export class MemoryStore {
879
945
  let localMerged = 0;
880
946
  let localUpdated = 0;
881
947
  let localSkipped = 0;
882
- const mergedIds = new Set();
883
948
  for (let i = 0; i < rowsWithNorms.length; i += 1) {
884
949
  const a = rowsWithNorms[i];
885
950
  if (mergedIds.has(a.row.id))
@@ -889,6 +954,12 @@ export class MemoryStore {
889
954
  if (mergedIds.has(b.row.id))
890
955
  continue;
891
956
  const sim = storeFastCosine(a.row.vector, b.row.vector, a.norm, b.norm);
957
+ if (flaggedIds.has(a.row.id)) {
958
+ bestSimByFlagged.set(a.row.id, Math.max(bestSimByFlagged.get(a.row.id) ?? -1, sim));
959
+ }
960
+ if (flaggedIds.has(b.row.id)) {
961
+ bestSimByFlagged.set(b.row.id, Math.max(bestSimByFlagged.get(b.row.id) ?? -1, sim));
962
+ }
892
963
  if (sim < threshold)
893
964
  continue;
894
965
  const aMeta = parseMetadata(a.row.metadataJson);
@@ -955,14 +1026,33 @@ export class MemoryStore {
955
1026
  }
956
1027
  else {
957
1028
  log("warn", `[consolidate] Skipping fallback for large scope (${rows.length} >= ${FALLBACK_THRESHOLD})`);
958
- return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0 };
1029
+ return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0, clearedFlags: 0 };
959
1030
  }
960
1031
  }
961
- if (mergedPairs > 0) {
1032
+ // DEDUP_FLAG_REVALIDATION (1.4.0): clear false duplicate flags. Rows
1033
+ // whose best found neighbor never reached the merge threshold were
1034
+ // flagged by the pre-1.4.0 RRF write-check (or carry a flag made stale
1035
+ // by later edits); unsetting isPotentialDuplicate lets flaggedCount
1036
+ // self-correct instead of ratcheting up forever.
1037
+ for (const [id, bestSim] of bestSimByFlagged) {
1038
+ if (mergedIds.has(id) || bestSim >= threshold)
1039
+ continue;
1040
+ const meta = metaById.get(id);
1041
+ if (!meta || meta.isPotentialDuplicate !== true)
1042
+ continue;
1043
+ delete meta.isPotentialDuplicate;
1044
+ delete meta.duplicateOf;
1045
+ await this.requireTable().update({
1046
+ where: `id = '${escapeSql(id)}'`,
1047
+ values: { metadataJson: JSON.stringify(meta) },
1048
+ });
1049
+ clearedFlags += 1;
1050
+ }
1051
+ if (mergedPairs > 0 || clearedFlags > 0) {
962
1052
  this.invalidateScope(scope);
963
1053
  }
964
1054
  await this.maybeOptimizeAll(false);
965
- return { mergedPairs, updatedRecords, skippedRecords };
1055
+ return { mergedPairs, updatedRecords, skippedRecords, clearedFlags };
966
1056
  }
967
1057
  // ANN_CONSOLIDATION (1.1.7): previously this did
968
1058
  // query().where(scope).limit(limit).toArray() — which returns the FIRST N
@@ -1619,8 +1709,33 @@ export class MemoryStore {
1619
1709
  ftsError: this.indexState.ftsError || undefined,
1620
1710
  vectorRetries: this.indexState.vectorRetries,
1621
1711
  ftsRetries: this.indexState.ftsRetries,
1712
+ dimensionMismatch: this.indexState.dimensionMismatch,
1713
+ expectedDim: this.indexState.expectedDim,
1714
+ actualDim: this.indexState.actualDim,
1622
1715
  };
1623
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
+ }
1624
1739
  invalidateScope(scope) {
1625
1740
  this.scopeVersions.set(scope, (this.scopeVersions.get(scope) ?? 0) + 1);
1626
1741
  }
@@ -1647,7 +1762,9 @@ export class MemoryStore {
1647
1762
  for (const scope of scopes) {
1648
1763
  const currentVersion = this.scopeVersions.get(scope) ?? 0;
1649
1764
  let entry = this.scopeCache.get(scope);
1650
- if (!entry || entry.version !== currentVersion) {
1765
+ const maxAgeMs = Number.isFinite(this.cacheConfig.staleAfterMs) ? this.cacheConfig.staleAfterMs : 0;
1766
+ const staleByAge = maxAgeMs > 0 && (entry ? Date.now() - (entry.loadedAt ?? entry.lastAccessTimestamp) > maxAgeMs : false);
1767
+ if (!entry || entry.version !== currentVersion || staleByAge) {
1651
1768
  if (entry) {
1652
1769
  this.cacheStats.evictions++;
1653
1770
  }
@@ -1667,7 +1784,7 @@ export class MemoryStore {
1667
1784
  for (const record of sortedRecords) {
1668
1785
  norms.set(record.id, vecNorm(record.vector));
1669
1786
  }
1670
- entry = { records: sortedRecords, tokenized, idf, norms, lastAccessTimestamp: Date.now(), version: currentVersion };
1787
+ entry = { records: sortedRecords, tokenized, idf, norms, loadedAt: Date.now(), lastAccessTimestamp: Date.now(), version: currentVersion };
1671
1788
  this.scopeCache.set(scope, entry);
1672
1789
  this.cacheStats.misses++;
1673
1790
  this.enforceMaxScopes();
@@ -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.3.9",
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",