opencode-memory-pro 1.3.9 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -1
- package/dist/index.js +23 -20
- package/dist/store.js +64 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,7 +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.
|
|
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
45
|
Remove the old plugin pin at the same time:
|
|
46
46
|
|
|
@@ -489,6 +489,44 @@ so your memories and graph carry over untouched.
|
|
|
489
489
|
|
|
490
490
|
## Changelog
|
|
491
491
|
|
|
492
|
+
### v1.4.0 (2026-09-06)
|
|
493
|
+
|
|
494
|
+
Dedup correctness overhaul — the write-time duplicate check compared against
|
|
495
|
+
the wrong score type, and the resulting flags were a one-way ratchet:
|
|
496
|
+
|
|
497
|
+
- **Write-time dedup now compares a raw cosine similarity**: the capture path
|
|
498
|
+
went through the hybrid `search()` API, whose RRF score is algebraically
|
|
499
|
+
`>= 1.0` for `limit: 1` (and up to `1.4` with importance) — so every capture
|
|
500
|
+
in a non-empty scope compared `>= 1.0` against `dedup.writeThreshold`
|
|
501
|
+
(clamped to `[0,1]`) and got falsely flagged as a potential duplicate.
|
|
502
|
+
`storeCapturedMemory` now calls `findSimilarVectors` (the same raw cosine
|
|
503
|
+
primitive consolidation measures) and compares that to the threshold.
|
|
504
|
+
Consequence: recall scores can no longer exceed 100%, and
|
|
505
|
+
`dedup.enabled`'s write-time detection actually detects.
|
|
506
|
+
- **False duplicate flags now self-correct**: `isPotentialDuplicate` was a
|
|
507
|
+
one-way ratchet — consolidation never cleared it, so `memory_stats`
|
|
508
|
+
`flaggedCount` only grew (153 flagged / 0 merged observed on a live store).
|
|
509
|
+
`consolidateDuplicates` now revalidates flags against the real cosine
|
|
510
|
+
threshold and clears (`isPotentialDuplicate`/`duplicateOf` removed) any
|
|
511
|
+
flagged row whose closest found neighbor never reaches the merge bar.
|
|
512
|
+
Returns `clearedFlags` so tools can report the correction.
|
|
513
|
+
- **Auto-consolidation cooldown is per-scope**: the shared
|
|
514
|
+
`lastConsolidateAt` timestamp meant the first scope to consolidate blocked
|
|
515
|
+
all other scopes for 30 minutes. Cooldowns are now tracked per scope
|
|
516
|
+
(same for the retention sweep, which had the identical flaw).
|
|
517
|
+
- **Scope cache staleness bound**: the per-process version counter can't see
|
|
518
|
+
writes from another opencode process sharing the same `dbPath`, so process A
|
|
519
|
+
could serve stale records indefinitely. Cache entries now reload after a
|
|
520
|
+
60s age bound even when the local version is unchanged (configurable via
|
|
521
|
+
`cache.staleAfterMs`; 0 restores pure version gating).
|
|
522
|
+
- **Consistent truncation warnings**: `deleteByIdForce`'s 100k-row fallback
|
|
523
|
+
scan and `pruneScope`'s 100k-row read now log a warning when the cap is hit,
|
|
524
|
+
matching `getCachedScopes`.
|
|
525
|
+
- **Tests**: three new integration tests — the dedup write-check primitive
|
|
526
|
+
returns cosine in `[0,1]` (plus a guard that the old RRF path still scores
|
|
527
|
+
`>= 1.0`), consolidation clears false flags, and the scope cache reloads
|
|
528
|
+
after the age bound when a second process writes behind its back.
|
|
529
|
+
|
|
492
530
|
### v1.3.8 (2026-09-06)
|
|
493
531
|
|
|
494
532
|
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.0";
|
|
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.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
|
|
@@ -642,6 +649,9 @@ export class MemoryStore {
|
|
|
642
649
|
}
|
|
643
650
|
const table = this.requireTable();
|
|
644
651
|
const rows = await table.query().limit(100000).toArray();
|
|
652
|
+
if (rows.length === 100000) {
|
|
653
|
+
log("warn", "[store] deleteByIdForce fallback scan hit the 100000-row cap; the target may not be found if it lives beyond the cap");
|
|
654
|
+
}
|
|
645
655
|
const match = rows.find((row) => this.matchesId(row.id, id));
|
|
646
656
|
if (!match)
|
|
647
657
|
return false;
|
|
@@ -711,6 +721,9 @@ export class MemoryStore {
|
|
|
711
721
|
}
|
|
712
722
|
async pruneScope(scope, maxEntries) {
|
|
713
723
|
const rows = await this.list(scope, 100000);
|
|
724
|
+
if (rows.length === 100000) {
|
|
725
|
+
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`);
|
|
726
|
+
}
|
|
714
727
|
if (rows.length <= maxEntries)
|
|
715
728
|
return 0;
|
|
716
729
|
const flagged = rows.filter((r) => {
|
|
@@ -748,7 +761,7 @@ export class MemoryStore {
|
|
|
748
761
|
let rows = await this.readByScopesIncludingMerged([scope]);
|
|
749
762
|
rows = rows.filter((r) => r.status === undefined || r.status === null || r.status === "" || r.status === "active");
|
|
750
763
|
if (rows.length === 0) {
|
|
751
|
-
return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0 };
|
|
764
|
+
return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0, clearedFlags: 0 };
|
|
752
765
|
}
|
|
753
766
|
const BATCH_SIZE = 100;
|
|
754
767
|
const FALLBACK_THRESHOLD = 500;
|
|
@@ -763,12 +776,23 @@ export class MemoryStore {
|
|
|
763
776
|
row,
|
|
764
777
|
norm: this.scopeCache.get(scope)?.norms.get(row.id) ?? vecNorm(row.vector),
|
|
765
778
|
}));
|
|
779
|
+
// DEDUP_FLAG_REVALIDATION (1.4.0): the write-time dedup check used to
|
|
780
|
+
// flag nearly every capture (RRF score >= 1.0 vs writeThreshold in
|
|
781
|
+
// [0,1]), and the flag was a one-way ratchet — nothing ever cleared it,
|
|
782
|
+
// so flaggedCount only grew. Consolidation is where a real cosine
|
|
783
|
+
// comparison happens, so flagged rows whose closest found neighbor
|
|
784
|
+
// stays below the consolidate threshold get the flag cleared; rows
|
|
785
|
+
// that DO have a near-duplicate keep it.
|
|
786
|
+
const metaById = new Map(rowsWithNorms.map(({ row }) => [row.id, parseMetadata(row.metadataJson)]));
|
|
787
|
+
const flaggedIds = new Set([...metaById].filter(([, meta]) => meta.isPotentialDuplicate === true).map(([id]) => id));
|
|
788
|
+
const bestSimByFlagged = new Map();
|
|
789
|
+
const mergedIds = new Set();
|
|
790
|
+
let clearedFlags = 0;
|
|
766
791
|
log("debug", `[consolidate] scope=${scope} rows=${rows.length} threshold=${threshold} candidateLimit=${candidateLimit} batchSize=${BATCH_SIZE} fallbackThreshold=${FALLBACK_THRESHOLD}`);
|
|
767
792
|
const processWithANN = async () => {
|
|
768
793
|
let localMerged = 0;
|
|
769
794
|
let localUpdated = 0;
|
|
770
795
|
let localSkipped = 0;
|
|
771
|
-
const mergedIds = new Set();
|
|
772
796
|
const totalChunks = Math.ceil(rowsWithNorms.length / BATCH_SIZE);
|
|
773
797
|
for (let chunkIdx = 0; chunkIdx < totalChunks; chunkIdx++) {
|
|
774
798
|
const chunkStart = chunkIdx * BATCH_SIZE;
|
|
@@ -805,6 +829,12 @@ export class MemoryStore {
|
|
|
805
829
|
if (mergedIds.has(b.row.id))
|
|
806
830
|
continue;
|
|
807
831
|
const sim = storeFastCosine(a.row.vector, b.row.vector, a.norm, b.norm);
|
|
832
|
+
if (flaggedIds.has(a.row.id)) {
|
|
833
|
+
bestSimByFlagged.set(a.row.id, Math.max(bestSimByFlagged.get(a.row.id) ?? -1, sim));
|
|
834
|
+
}
|
|
835
|
+
if (flaggedIds.has(b.row.id)) {
|
|
836
|
+
bestSimByFlagged.set(b.row.id, Math.max(bestSimByFlagged.get(b.row.id) ?? -1, sim));
|
|
837
|
+
}
|
|
808
838
|
if (sim < threshold)
|
|
809
839
|
continue;
|
|
810
840
|
const aMeta = parseMetadata(a.row.metadataJson);
|
|
@@ -879,7 +909,6 @@ export class MemoryStore {
|
|
|
879
909
|
let localMerged = 0;
|
|
880
910
|
let localUpdated = 0;
|
|
881
911
|
let localSkipped = 0;
|
|
882
|
-
const mergedIds = new Set();
|
|
883
912
|
for (let i = 0; i < rowsWithNorms.length; i += 1) {
|
|
884
913
|
const a = rowsWithNorms[i];
|
|
885
914
|
if (mergedIds.has(a.row.id))
|
|
@@ -889,6 +918,12 @@ export class MemoryStore {
|
|
|
889
918
|
if (mergedIds.has(b.row.id))
|
|
890
919
|
continue;
|
|
891
920
|
const sim = storeFastCosine(a.row.vector, b.row.vector, a.norm, b.norm);
|
|
921
|
+
if (flaggedIds.has(a.row.id)) {
|
|
922
|
+
bestSimByFlagged.set(a.row.id, Math.max(bestSimByFlagged.get(a.row.id) ?? -1, sim));
|
|
923
|
+
}
|
|
924
|
+
if (flaggedIds.has(b.row.id)) {
|
|
925
|
+
bestSimByFlagged.set(b.row.id, Math.max(bestSimByFlagged.get(b.row.id) ?? -1, sim));
|
|
926
|
+
}
|
|
892
927
|
if (sim < threshold)
|
|
893
928
|
continue;
|
|
894
929
|
const aMeta = parseMetadata(a.row.metadataJson);
|
|
@@ -955,14 +990,33 @@ export class MemoryStore {
|
|
|
955
990
|
}
|
|
956
991
|
else {
|
|
957
992
|
log("warn", `[consolidate] Skipping fallback for large scope (${rows.length} >= ${FALLBACK_THRESHOLD})`);
|
|
958
|
-
return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0 };
|
|
993
|
+
return { mergedPairs: 0, updatedRecords: 0, skippedRecords: 0, clearedFlags: 0 };
|
|
959
994
|
}
|
|
960
995
|
}
|
|
961
|
-
|
|
996
|
+
// DEDUP_FLAG_REVALIDATION (1.4.0): clear false duplicate flags. Rows
|
|
997
|
+
// whose best found neighbor never reached the merge threshold were
|
|
998
|
+
// flagged by the pre-1.4.0 RRF write-check (or carry a flag made stale
|
|
999
|
+
// by later edits); unsetting isPotentialDuplicate lets flaggedCount
|
|
1000
|
+
// self-correct instead of ratcheting up forever.
|
|
1001
|
+
for (const [id, bestSim] of bestSimByFlagged) {
|
|
1002
|
+
if (mergedIds.has(id) || bestSim >= threshold)
|
|
1003
|
+
continue;
|
|
1004
|
+
const meta = metaById.get(id);
|
|
1005
|
+
if (!meta || meta.isPotentialDuplicate !== true)
|
|
1006
|
+
continue;
|
|
1007
|
+
delete meta.isPotentialDuplicate;
|
|
1008
|
+
delete meta.duplicateOf;
|
|
1009
|
+
await this.requireTable().update({
|
|
1010
|
+
where: `id = '${escapeSql(id)}'`,
|
|
1011
|
+
values: { metadataJson: JSON.stringify(meta) },
|
|
1012
|
+
});
|
|
1013
|
+
clearedFlags += 1;
|
|
1014
|
+
}
|
|
1015
|
+
if (mergedPairs > 0 || clearedFlags > 0) {
|
|
962
1016
|
this.invalidateScope(scope);
|
|
963
1017
|
}
|
|
964
1018
|
await this.maybeOptimizeAll(false);
|
|
965
|
-
return { mergedPairs, updatedRecords, skippedRecords };
|
|
1019
|
+
return { mergedPairs, updatedRecords, skippedRecords, clearedFlags };
|
|
966
1020
|
}
|
|
967
1021
|
// ANN_CONSOLIDATION (1.1.7): previously this did
|
|
968
1022
|
// query().where(scope).limit(limit).toArray() — which returns the FIRST N
|
|
@@ -1647,7 +1701,9 @@ export class MemoryStore {
|
|
|
1647
1701
|
for (const scope of scopes) {
|
|
1648
1702
|
const currentVersion = this.scopeVersions.get(scope) ?? 0;
|
|
1649
1703
|
let entry = this.scopeCache.get(scope);
|
|
1650
|
-
|
|
1704
|
+
const maxAgeMs = Number.isFinite(this.cacheConfig.staleAfterMs) ? this.cacheConfig.staleAfterMs : 0;
|
|
1705
|
+
const staleByAge = maxAgeMs > 0 && (entry ? Date.now() - (entry.loadedAt ?? entry.lastAccessTimestamp) > maxAgeMs : false);
|
|
1706
|
+
if (!entry || entry.version !== currentVersion || staleByAge) {
|
|
1651
1707
|
if (entry) {
|
|
1652
1708
|
this.cacheStats.evictions++;
|
|
1653
1709
|
}
|
|
@@ -1667,7 +1723,7 @@ export class MemoryStore {
|
|
|
1667
1723
|
for (const record of sortedRecords) {
|
|
1668
1724
|
norms.set(record.id, vecNorm(record.vector));
|
|
1669
1725
|
}
|
|
1670
|
-
entry = { records: sortedRecords, tokenized, idf, norms, lastAccessTimestamp: Date.now(), version: currentVersion };
|
|
1726
|
+
entry = { records: sortedRecords, tokenized, idf, norms, loadedAt: Date.now(), lastAccessTimestamp: Date.now(), version: currentVersion };
|
|
1671
1727
|
this.scopeCache.set(scope, entry);
|
|
1672
1728
|
this.cacheStats.misses++;
|
|
1673
1729
|
this.enforceMaxScopes();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-memory-pro",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
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",
|