opencode-memory-pro 1.4.1 → 1.4.2
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 +31 -4
- package/dist/config.js +8 -1
- package/dist/index.js +1 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.js +64 -6
- package/dist/tools/memory.js +12 -0
- package/dist/types.d.ts +3 -0
- package/opencode-memory-pro.example.json +2 -0
- package/package.json +3 -2
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
|
|
43
|
+
The latest release is 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
|
### Getting started
|
|
46
46
|
|
|
@@ -175,9 +175,11 @@ index and capture still works — the plugin is offline-tolerant by design.
|
|
|
175
175
|
|
|
176
176
|
| Key | Default | Description |
|
|
177
177
|
|---|---|---|
|
|
178
|
-
| `retrieval.mode` | `"hybrid"` | `"hybrid"` (vector+BM25 RRF) or `"vector"`. |
|
|
179
|
-
| `retrieval.vectorWeight` | `0.7` | Vector/BM25 ratio before normalization. |
|
|
178
|
+
| `retrieval.mode` | `"hybrid"` | `"hybrid"` (vector+BM25+fuzzy RRF) or `"vector"`. |
|
|
179
|
+
| `retrieval.vectorWeight` | `0.7` | Vector/BM25/fuzzy ratio before normalization. |
|
|
180
180
|
| `retrieval.bm25Weight` | `0.3` | (Weights are normalized to sum 1.) |
|
|
181
|
+
| `retrieval.fuzzyWeight` | `0.15` | fuse.js typo-tolerant fuzzy channel weight; `0` disables it. |
|
|
182
|
+
| `retrieval.fuzzyThreshold` | `0.5` | fuse.js match threshold (lower = stricter). |
|
|
181
183
|
| `retrieval.minScore` | `0.2` | Minimum score for a result to qualify. |
|
|
182
184
|
| `retrieval.rrfK` | `60` | RRF constant. |
|
|
183
185
|
| `retrieval.recencyBoost` | `true` | Boost recently recalled/created memories. |
|
|
@@ -186,9 +188,34 @@ index and capture still works — the plugin is offline-tolerant by design.
|
|
|
186
188
|
| `retrieval.feedbackWeight` | `0.3` | Weight of feedback history in scoring (0–1). |
|
|
187
189
|
|
|
188
190
|
Env: `OPENCODE_MEMORY_PRO_RETRIEVAL_MODE`, `..._VECTOR_WEIGHT`,
|
|
189
|
-
`..._BM25_WEIGHT`, `...
|
|
191
|
+
`..._BM25_WEIGHT`, `..._FUZZY_WEIGHT`, `..._FUZZY_THRESHOLD`, `..._MIN_SCORE`,
|
|
192
|
+
`..._RRF_K`, `..._RECENCY_BOOST`,
|
|
190
193
|
`..._RECENCY_HALF_LIFE_HOURS`, `..._IMPORTANCE_WEIGHT`, `..._FEEDBACK_WEIGHT`.
|
|
191
194
|
|
|
195
|
+
## Changelog
|
|
196
|
+
|
|
197
|
+
### v1.4.2 (2026-09-06)
|
|
198
|
+
|
|
199
|
+
New **fuzzy search channel** — fuse.js joins the RRF merge as a third
|
|
200
|
+
retrieval channel alongside vector and BM25, giving typo-tolerant matching
|
|
201
|
+
out of the box:
|
|
202
|
+
|
|
203
|
+
- **Typo tolerance**: `memory_search "lancedb vectr srch"` now surfaces the
|
|
204
|
+
right memory even when vector and BM25 both miss — useful for queries with
|
|
205
|
+
misspellings, partial words, or accented text (`ignoreDiacritics`).
|
|
206
|
+
- **Zero-config**: `retrieval.fuzzyWeight` defaults to `0.15` (renormalized
|
|
207
|
+
with vector/BM25); set it to `0` to restore pre-1.4.2 scores exactly.
|
|
208
|
+
- **Channel semantics**: records that don't appear in the fuzzy top-N
|
|
209
|
+
contribute no RRF rank, same as the other channels; `fuzzyThreshold`
|
|
210
|
+
(default `0.5`) drops weak matches.
|
|
211
|
+
- **Fallback-aware**: the fuzzy channel stays active in the BM25-only
|
|
212
|
+
fallback (embedder unavailable) — that's exactly when typo tolerance helps
|
|
213
|
+
most — and is disabled only in explicit `retrieval.mode = "vector"`.
|
|
214
|
+
- **Index lifecycle**: fuse.js index is built lazily over the scope cache,
|
|
215
|
+
reused across single-scope searches, and rebuilt automatically on cache
|
|
216
|
+
invalidation or threshold change.
|
|
217
|
+
- `memory_stats` now reports the fuzzy channel (`enabled`/`weight`/`threshold`).
|
|
218
|
+
|
|
192
219
|
### Injection
|
|
193
220
|
|
|
194
221
|
How memories are injected into the model context.
|
package/dist/config.js
CHANGED
|
@@ -18,9 +18,14 @@ export function resolveMemoryConfig(config, worktree) {
|
|
|
18
18
|
const dbPath = expandHomePath(firstString(process.env.OPENCODE_MEMORY_PRO_DB_PATH, raw.dbPath) ?? DEFAULT_DB_PATH);
|
|
19
19
|
const vectorWeight = clamp(toNumber(process.env.OPENCODE_MEMORY_PRO_VECTOR_WEIGHT ?? retrievalRaw.vectorWeight, 0.7), 0, 1);
|
|
20
20
|
const bm25Weight = clamp(toNumber(process.env.OPENCODE_MEMORY_PRO_BM25_WEIGHT ?? retrievalRaw.bm25Weight, 0.3), 0, 1);
|
|
21
|
-
|
|
21
|
+
// FUZZY_CHANNEL (1.4.2): fuse.js fuzzy-match channel participates in the
|
|
22
|
+
// RRF merge alongside vector + BM25. Weight 0 disables it entirely.
|
|
23
|
+
const fuzzyWeight = clamp(toNumber(process.env.OPENCODE_MEMORY_PRO_FUZZY_WEIGHT ?? retrievalRaw.fuzzyWeight, 0.15), 0, 1);
|
|
24
|
+
const fuzzyThreshold = clamp(toNumber(process.env.OPENCODE_MEMORY_PRO_FUZZY_THRESHOLD ?? retrievalRaw.fuzzyThreshold, 0.5), 0, 1);
|
|
25
|
+
const weightSum = vectorWeight + bm25Weight + fuzzyWeight;
|
|
22
26
|
const normalizedVectorWeight = weightSum > 0 ? vectorWeight / weightSum : 0.7;
|
|
23
27
|
const normalizedBm25Weight = weightSum > 0 ? bm25Weight / weightSum : 0.3;
|
|
28
|
+
const normalizedFuzzyWeight = weightSum > 0 ? fuzzyWeight / weightSum : 0;
|
|
24
29
|
const rrfK = Math.max(1, Math.floor(toNumber(process.env.OPENCODE_MEMORY_PRO_RRF_K ?? retrievalRaw.rrfK, 60)));
|
|
25
30
|
const recencyBoost = toBoolean(process.env.OPENCODE_MEMORY_PRO_RECENCY_BOOST ?? retrievalRaw.recencyBoost, true);
|
|
26
31
|
const recencyHalfLifeHours = Math.max(1, toNumber(process.env.OPENCODE_MEMORY_PRO_RECENCY_HALF_LIFE_HOURS ?? retrievalRaw.recencyHalfLifeHours, 72));
|
|
@@ -74,6 +79,8 @@ export function resolveMemoryConfig(config, worktree) {
|
|
|
74
79
|
mode,
|
|
75
80
|
vectorWeight: normalizedVectorWeight,
|
|
76
81
|
bm25Weight: normalizedBm25Weight,
|
|
82
|
+
fuzzyWeight: normalizedFuzzyWeight,
|
|
83
|
+
fuzzyThreshold,
|
|
77
84
|
minScore: clamp(toNumber(process.env.OPENCODE_MEMORY_PRO_MIN_SCORE ?? retrievalRaw.minScore, 0.2), 0, 1),
|
|
78
85
|
rrfK,
|
|
79
86
|
recencyBoost,
|
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.2";
|
|
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
package/dist/store.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { mkdir, open, readFile, readdir, rm } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
+
import Fuse from "fuse.js";
|
|
3
4
|
import { validateEpisodicRecord, validateEpisodicRecordArray } from "./types.js";
|
|
4
5
|
import { tokenize } from "./utils.js";
|
|
5
6
|
import { log, logFileOnly } from "./logger.js";
|
|
@@ -588,13 +589,21 @@ export class MemoryStore {
|
|
|
588
589
|
const queryNorm = vecNorm(params.queryVector);
|
|
589
590
|
const useVectorChannel = params.queryVector.length > 0 && params.vectorWeight > 0;
|
|
590
591
|
const useBm25Channel = queryTokens.length > 0 && params.bm25Weight > 0;
|
|
591
|
-
|
|
592
|
+
// FUZZY_CHANNEL (1.4.2): fuse.js typo-tolerant channel. On by default
|
|
593
|
+
// (weight 0.15) unless the caller passes fuzzyWeight 0.
|
|
594
|
+
const fuzzyWeight = Math.max(0, Number(params.fuzzyWeight) || 0);
|
|
595
|
+
const useFuzzyChannel = params.query.trim().length > 0 && fuzzyWeight > 0;
|
|
596
|
+
const fuzzyThreshold = params.fuzzyThreshold !== undefined ? Math.max(0, Math.min(1, params.fuzzyThreshold)) : 0.5;
|
|
597
|
+
const { vectorWeight, bm25Weight, fuzzyWeight: normalizedFuzzyWeight } = normalizeChannelWeights(useVectorChannel ? params.vectorWeight : 0, useBm25Channel ? params.bm25Weight : 0, useFuzzyChannel ? fuzzyWeight : 0);
|
|
592
598
|
const rrfK = Math.max(1, Math.floor(params.rrfK ?? 60));
|
|
593
599
|
const recencyBoostEnabled = params.recencyBoost ?? true;
|
|
594
600
|
const recencyHalfLifeHours = Math.max(1, params.recencyHalfLifeHours ?? 72);
|
|
595
601
|
const importanceWeight = clampImportanceWeight(params.importanceWeight ?? 0.4);
|
|
596
602
|
const feedbackWeight = Math.max(0, Math.min(1, params.feedbackWeight ?? 0));
|
|
597
603
|
const globalDiscountFactor = params.globalDiscountFactor ?? 1.0;
|
|
604
|
+
const fuzzyResults = useFuzzyChannel ? this.getFuzzyIndex(cached, params.scopes, fuzzyThreshold).search(params.query.trim(), { limit: Math.max(50, params.limit * 4) }) : [];
|
|
605
|
+
const fuzzyRanks = useFuzzyChannel ? buildRankMap(fuzzyResults.map((r) => ({ record: r.item, fuzzyScore: 1 - (r.score ?? 1) })), (item) => item.fuzzyScore) : null;
|
|
606
|
+
const fuzzyScoreMap = new Map(fuzzyResults.map((r) => [r.item.id, 1 - (r.score ?? 1)]));
|
|
598
607
|
const candidates = cached.records
|
|
599
608
|
.filter((record) => params.queryVector.length === 0 || record.vector.length === params.queryVector.length)
|
|
600
609
|
.map((record, index) => {
|
|
@@ -602,7 +611,7 @@ export class MemoryStore {
|
|
|
602
611
|
const vectorScore = useVectorChannel ? fastCosine(params.queryVector, record.vector, queryNorm, recordNorm) : 0;
|
|
603
612
|
const bm25Score = useBm25Channel ? bm25LikeScore(queryTokens, cached.tokenized[index], cached.idf) : 0;
|
|
604
613
|
const isGlobal = record.scope === "global";
|
|
605
|
-
return { record, vectorScore, bm25Score, isGlobal };
|
|
614
|
+
return { record, vectorScore, bm25Score, fuzzyScore: fuzzyScoreMap.get(record.id) ?? 0, isGlobal };
|
|
606
615
|
});
|
|
607
616
|
if (candidates.length === 0)
|
|
608
617
|
return [];
|
|
@@ -624,6 +633,14 @@ export class MemoryStore {
|
|
|
624
633
|
if (rank !== undefined)
|
|
625
634
|
rrfScore += bm25Weight / (rrfK + rank);
|
|
626
635
|
}
|
|
636
|
+
// FUZZY_CHANNEL (1.4.2): only records that appear in the fuzzy
|
|
637
|
+
// top-N contribute a rank; the rest get nothing (same semantics
|
|
638
|
+
// as the other channels).
|
|
639
|
+
if (fuzzyRanks) {
|
|
640
|
+
const rank = fuzzyRanks.get(item.record.id);
|
|
641
|
+
if (rank !== undefined)
|
|
642
|
+
rrfScore += normalizedFuzzyWeight / (rrfK + rank);
|
|
643
|
+
}
|
|
627
644
|
rrfScore *= rrfK + 1;
|
|
628
645
|
const recencyFactor = recencyBoostEnabled
|
|
629
646
|
? computeRecencyMultiplier(item.record.timestamp, recencyHalfLifeHours)
|
|
@@ -640,6 +657,7 @@ export class MemoryStore {
|
|
|
640
657
|
score,
|
|
641
658
|
vectorScore: item.vectorScore,
|
|
642
659
|
bm25Score: item.bm25Score,
|
|
660
|
+
fuzzyScore: item.fuzzyScore,
|
|
643
661
|
};
|
|
644
662
|
})
|
|
645
663
|
.filter((item) => item.score >= params.minScore)
|
|
@@ -1802,7 +1820,34 @@ export class MemoryStore {
|
|
|
1802
1820
|
const idf = scopes.length === 1 && this.scopeCache.has(scopes[0])
|
|
1803
1821
|
? this.scopeCache.get(scopes[0]).idf
|
|
1804
1822
|
: computeIdf(allTokenized);
|
|
1805
|
-
|
|
1823
|
+
const cached = { records: allRecords, tokenized: allTokenized, idf, norms: allNorms, lastAccessTimestamp: Date.now() };
|
|
1824
|
+
// FUZZY_CHANNEL (1.4.2): reuse the per-scope entry index when the
|
|
1825
|
+
// request is single-scope (the common path); multi-scope requests
|
|
1826
|
+
// build a merged index on each call. Threshold changes rebuild.
|
|
1827
|
+
if (scopes.length === 1 && this.scopeCache.has(scopes[0])) {
|
|
1828
|
+
const entry = this.scopeCache.get(scopes[0]);
|
|
1829
|
+
if (entry?.fuse) {
|
|
1830
|
+
cached.fuse = entry.fuse;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return cached;
|
|
1834
|
+
}
|
|
1835
|
+
// FUZZY_CHANNEL (1.4.2): lazily build (and cache) the fuse.js index over
|
|
1836
|
+
// the records a search is about to score. Built once per scope-cache
|
|
1837
|
+
// entry and reused across searches; rebuilt when the threshold changes or
|
|
1838
|
+
// the cache entry is invalidated (the entry itself is replaced on
|
|
1839
|
+
// invalidation, so this never serves stale text).
|
|
1840
|
+
getFuzzyIndex(cached, scopes, threshold) {
|
|
1841
|
+
if (cached.fuse && cached.fuse.threshold === threshold) {
|
|
1842
|
+
return cached.fuse;
|
|
1843
|
+
}
|
|
1844
|
+
const fuse = buildFuseIndex(cached.records, threshold);
|
|
1845
|
+
fuse.threshold = threshold;
|
|
1846
|
+
cached.fuse = fuse;
|
|
1847
|
+
if (scopes.length === 1 && this.scopeCache.has(scopes[0])) {
|
|
1848
|
+
this.scopeCache.get(scopes[0]).fuse = fuse;
|
|
1849
|
+
}
|
|
1850
|
+
return fuse;
|
|
1806
1851
|
}
|
|
1807
1852
|
enforceMaxScopes() {
|
|
1808
1853
|
while (this.scopeCache.size > this.cacheConfig.maxScopes) {
|
|
@@ -2952,14 +2997,27 @@ function buildRankMap(items, scoreOf) {
|
|
|
2952
2997
|
}
|
|
2953
2998
|
return ranks;
|
|
2954
2999
|
}
|
|
2955
|
-
|
|
2956
|
-
|
|
3000
|
+
// FUZZY_CHANNEL (1.4.2): fuse.js index over memory text. ignoreLocation
|
|
3001
|
+
// keeps substring matches relevant, ignoreDiacritics tolerates accents, and
|
|
3002
|
+
// threshold (default 0.5) drops results whose match score is too weak.
|
|
3003
|
+
function buildFuseIndex(records, threshold = 0.5) {
|
|
3004
|
+
return new Fuse(records, {
|
|
3005
|
+
keys: ["text"],
|
|
3006
|
+
includeScore: true,
|
|
3007
|
+
ignoreLocation: true,
|
|
3008
|
+
ignoreDiacritics: true,
|
|
3009
|
+
threshold,
|
|
3010
|
+
});
|
|
3011
|
+
}
|
|
3012
|
+
function normalizeChannelWeights(vectorWeight, bm25Weight, fuzzyWeight = 0) {
|
|
3013
|
+
const sum = vectorWeight + bm25Weight + fuzzyWeight;
|
|
2957
3014
|
if (sum <= 0) {
|
|
2958
|
-
return { vectorWeight: 0.5, bm25Weight: 0.5 };
|
|
3015
|
+
return { vectorWeight: 0.5, bm25Weight: 0.5, fuzzyWeight: 0 };
|
|
2959
3016
|
}
|
|
2960
3017
|
return {
|
|
2961
3018
|
vectorWeight: vectorWeight / sum,
|
|
2962
3019
|
bm25Weight: bm25Weight / sum,
|
|
3020
|
+
fuzzyWeight: fuzzyWeight / sum,
|
|
2963
3021
|
};
|
|
2964
3022
|
}
|
|
2965
3023
|
function computeRecencyMultiplier(timestamp, halfLifeHours) {
|
package/dist/tools/memory.js
CHANGED
|
@@ -89,6 +89,10 @@ export function createMemoryTools(state) {
|
|
|
89
89
|
const isFallback = embedderFailed || queryVector.length === 0;
|
|
90
90
|
const effectiveVectorWeight = isFallback ? 0 : (state.config.retrieval.mode === "vector" ? 1 : state.config.retrieval.vectorWeight);
|
|
91
91
|
const effectiveBm25Weight = isFallback ? 1 : (state.config.retrieval.mode === "vector" ? 0 : state.config.retrieval.bm25Weight);
|
|
92
|
+
// FUZZY_CHANNEL (1.4.2): fuzzy stays on in the bm25-only
|
|
93
|
+
// fallback (that's when typo tolerance helps most); it is
|
|
94
|
+
// disabled only in explicit vector-only mode.
|
|
95
|
+
const effectiveFuzzyWeight = state.config.retrieval.mode === "vector" ? 0 : state.config.retrieval.fuzzyWeight;
|
|
92
96
|
if (isFallback) {
|
|
93
97
|
log("info", "Using BM25-only search (embedder unavailable)");
|
|
94
98
|
}
|
|
@@ -99,6 +103,8 @@ export function createMemoryTools(state) {
|
|
|
99
103
|
limit: args.limit ?? 5,
|
|
100
104
|
vectorWeight: effectiveVectorWeight,
|
|
101
105
|
bm25Weight: effectiveBm25Weight,
|
|
106
|
+
fuzzyWeight: effectiveFuzzyWeight,
|
|
107
|
+
fuzzyThreshold: state.config.retrieval.fuzzyThreshold,
|
|
102
108
|
minScore: state.config.retrieval.minScore,
|
|
103
109
|
rrfK: state.config.retrieval.rrfK,
|
|
104
110
|
recencyBoost: state.config.retrieval.recencyBoost,
|
|
@@ -306,6 +312,11 @@ export function createMemoryTools(state) {
|
|
|
306
312
|
recentCount: entries.length,
|
|
307
313
|
incompatibleVectors,
|
|
308
314
|
index: health,
|
|
315
|
+
fuzzy: {
|
|
316
|
+
enabled: (state.config.retrieval.fuzzyWeight ?? 0) > 0,
|
|
317
|
+
weight: state.config.retrieval.fuzzyWeight ?? 0,
|
|
318
|
+
threshold: state.config.retrieval.fuzzyThreshold ?? 0.5,
|
|
319
|
+
},
|
|
309
320
|
embeddingModel: state.config.embedding.model,
|
|
310
321
|
searchMode,
|
|
311
322
|
embedderHealth,
|
|
@@ -787,6 +798,7 @@ ${explanations.join("\n")}`;
|
|
|
787
798
|
limit: args.limit ?? 20,
|
|
788
799
|
vectorWeight: 0.7,
|
|
789
800
|
bm25Weight: 0.3,
|
|
801
|
+
fuzzyWeight: 0,
|
|
790
802
|
minScore: 0.2,
|
|
791
803
|
globalDiscountFactor: 1.0,
|
|
792
804
|
}).then((results) => results.map((r) => r.record));
|
package/dist/types.d.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface RetrievalConfig {
|
|
|
54
54
|
mode: RetrievalMode;
|
|
55
55
|
vectorWeight: number;
|
|
56
56
|
bm25Weight: number;
|
|
57
|
+
fuzzyWeight: number;
|
|
58
|
+
fuzzyThreshold: number;
|
|
57
59
|
minScore: number;
|
|
58
60
|
rrfK: number;
|
|
59
61
|
recencyBoost: boolean;
|
|
@@ -197,6 +199,7 @@ export interface SearchResult {
|
|
|
197
199
|
score: number;
|
|
198
200
|
vectorScore: number;
|
|
199
201
|
bm25Score: number;
|
|
202
|
+
fuzzyScore: number;
|
|
200
203
|
}
|
|
201
204
|
export interface CaptureCandidate {
|
|
202
205
|
text: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-memory-pro",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
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",
|
|
@@ -44,7 +44,8 @@
|
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@lancedb/lancedb": "^0.38.0",
|
|
46
46
|
"@opencode-ai/plugin": "^1.4.10",
|
|
47
|
-
"@opencode-ai/sdk": "^1.4.10"
|
|
47
|
+
"@opencode-ai/sdk": "^1.4.10",
|
|
48
|
+
"fuse.js": "^7.5.0"
|
|
48
49
|
},
|
|
49
50
|
"devDependencies": {
|
|
50
51
|
"@types/node": "^22.13.9",
|