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 +79 -14
- package/dist/index.js +23 -20
- package/dist/store.d.ts +5 -0
- package/dist/store.js +125 -8
- package/dist/tools/memory.d.ts +11 -0
- package/dist/tools/memory.js +117 -0
- package/package.json +1 -1
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.
|
|
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.
|
|
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:
|
|
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:
|
|
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.
|
|
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].
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
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.
|
|
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",
|