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 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.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).
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`, `..._MIN_SCORE`, `..._RRF_K`, `..._RECENCY_BOOST`,
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
- const weightSum = vectorWeight + bm25Weight;
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.1";
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
@@ -40,6 +40,8 @@ export declare class MemoryStore {
40
40
  limit: number;
41
41
  vectorWeight: number;
42
42
  bm25Weight: number;
43
+ fuzzyWeight?: number;
44
+ fuzzyThreshold?: number;
43
45
  minScore: number;
44
46
  rrfK?: number;
45
47
  recencyBoost?: boolean;
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
- const { vectorWeight, bm25Weight } = normalizeChannelWeights(useVectorChannel ? params.vectorWeight : 0, useBm25Channel ? params.bm25Weight : 0);
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
- return { records: allRecords, tokenized: allTokenized, idf, norms: allNorms, lastAccessTimestamp: Date.now() };
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
- function normalizeChannelWeights(vectorWeight, bm25Weight) {
2956
- const sum = vectorWeight + bm25Weight;
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) {
@@ -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;
@@ -19,6 +19,8 @@
19
19
  "mode": "hybrid",
20
20
  "vectorWeight": 0.7,
21
21
  "bm25Weight": 0.3,
22
+ "fuzzyWeight": 0.15,
23
+ "fuzzyThreshold": 0.5,
22
24
  "minScore": 0.2,
23
25
  "rrfK": 60,
24
26
  "recencyBoost": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-memory-pro",
3
- "version": "1.4.1",
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",