opencode-memory-pro 1.4.0 → 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,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.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
-
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 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
 
@@ -181,9 +175,11 @@ index and capture still works — the plugin is offline-tolerant by design.
181
175
 
182
176
  | Key | Default | Description |
183
177
  |---|---|---|
184
- | `retrieval.mode` | `"hybrid"` | `"hybrid"` (vector+BM25 RRF) or `"vector"`. |
185
- | `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. |
186
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). |
187
183
  | `retrieval.minScore` | `0.2` | Minimum score for a result to qualify. |
188
184
  | `retrieval.rrfK` | `60` | RRF constant. |
189
185
  | `retrieval.recencyBoost` | `true` | Boost recently recalled/created memories. |
@@ -192,9 +188,34 @@ index and capture still works — the plugin is offline-tolerant by design.
192
188
  | `retrieval.feedbackWeight` | `0.3` | Weight of feedback history in scoring (0–1). |
193
189
 
194
190
  Env: `OPENCODE_MEMORY_PRO_RETRIEVAL_MODE`, `..._VECTOR_WEIGHT`,
195
- `..._BM25_WEIGHT`, `..._MIN_SCORE`, `..._RRF_K`, `..._RECENCY_BOOST`,
191
+ `..._BM25_WEIGHT`, `..._FUZZY_WEIGHT`, `..._FUZZY_THRESHOLD`, `..._MIN_SCORE`,
192
+ `..._RRF_K`, `..._RECENCY_BOOST`,
196
193
  `..._RECENCY_HALF_LIFE_HOURS`, `..._IMPORTANCE_WEIGHT`, `..._FEEDBACK_WEIGHT`.
197
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
+
198
219
  ### Injection
199
220
 
200
221
  How memories are injected into the model context.
@@ -432,6 +453,7 @@ All tools are auto-registered when the plugin loads. Hybrid recall surfaces
432
453
  | `memory_event_cleanup` | Clean up expired effectiveness events (optional archive). |
433
454
  | `memory_consolidate` | Merge near-duplicate memories in a scope. |
434
455
  | `memory_consolidate_all` | Global duplicate cleanup (daily cron friendly). |
456
+ | `memory_reembed` | Detect/repair an embedding-dimension mismatch (backs up, rebuilds the table, re-embeds every memory). |
435
457
 
436
458
  **Scoping**
437
459
 
@@ -480,15 +502,47 @@ npm run verify # tests + pack dry-run
480
502
 
481
503
  CI runs on GitHub Actions (Node 22 + 24) on every push/PR to `main`.
482
504
 
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
505
  ## Changelog
491
506
 
507
+ ### v1.4.1 (2026-09-06)
508
+
509
+ New `memory_reembed` tool — detects and repairs embedding-dimension
510
+ mismatches, which previously corrupted the store silently:
511
+
512
+ - **Root cause**: the `memories` table's `vector` column is an Arrow
513
+ `FixedSizeList` whose width is fixed forever by the first row ever
514
+ written. `init()` re-probes the embedder's dimension on every startup but
515
+ silently discarded that value once a table already existed — nothing ever
516
+ compared "what the embedder produces now" against "what the table is
517
+ physically built for." Switching `embedding.provider`/`embedding.model` to
518
+ a different-dimension model did not error: LanceDB silently coerced
519
+ mismatched writes into the old fixed-width column (corrupting the vector,
520
+ not rejecting the write), and every `vectorSearch()` call at the new
521
+ dimension threw inside `findSimilarVectors`'s catch block, which silently
522
+ swallowed it — so write-time dedup and `memory_consolidate` silently
523
+ stopped finding neighbors for anything written after the switch, with zero
524
+ visible symptom beyond a passive `memory_stats.incompatibleVectors` count.
525
+ - **Detection**: `init()` now reads back the table's actual physical vector
526
+ width (`getPhysicalVectorDim()`) and compares it to the freshly-probed
527
+ embedder dimension on every startup, logging a `warn` on mismatch.
528
+ `getIndexHealth()` (and therefore `memory_stats.index`) now reports
529
+ `dimensionMismatch`/`expectedDim`/`actualDim`, and `computeDegradedFlags`
530
+ surfaces an `embedding-dimension-mismatch` flag pointing at the fix.
531
+ - **Repair**: `memory_reembed` (`dryRun` default `true`, `confirm` gate for
532
+ the actual repair — same pattern as `memory_clear`/`memory_forget`)
533
+ discovers every scope in the store (a dimension mismatch is table-wide,
534
+ not scope-scoped), backs up every memory to
535
+ `<dbPath's parent>/backups/reembed-repair-<ts>.json` (same shape as
536
+ `memory_export`, written *before* any mutation, always), then drops and
537
+ recreates the `memories` table at the current embedder's dimension and
538
+ re-embeds every memory from its stored text under its original id (so
539
+ entity-graph edges and citation chains keyed by id stay valid).
540
+ - **Tests**: new integration test covers detection on a freshly-created
541
+ table (no false positive), detection after reopening with a different
542
+ dimension, and a full repair pass — asserting the physical column width
543
+ actually changes, every original id/text survives, and post-repair health
544
+ reports no mismatch.
545
+
492
546
  ### v1.4.0 (2026-09-06)
493
547
 
494
548
  Dedup correctness overhaul — the write-time duplicate check compared against
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.0";
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;
@@ -98,7 +100,12 @@ export declare class MemoryStore {
98
100
  ftsError?: string;
99
101
  vectorRetries?: number;
100
102
  ftsRetries?: number;
103
+ dimensionMismatch: boolean;
104
+ expectedDim: number | null;
105
+ actualDim: number | null;
101
106
  };
107
+ getPhysicalVectorDim(): Promise<number | null>;
108
+ listDistinctScopes(): Promise<string[]>;
102
109
  private invalidateScope;
103
110
  private getCachedScopes;
104
111
  private enforceMaxScopes;
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";
@@ -56,6 +57,13 @@ export class MemoryStore {
56
57
  ftsError: "",
57
58
  vectorRetries: 0,
58
59
  ftsRetries: 0,
60
+ // DIMENSION_MISMATCH_DETECT: set by init() by comparing the live
61
+ // embedder's probed dimension against the "vector" column's actual
62
+ // physical FixedSizeList width (fixed forever once the table's first
63
+ // row is written). See getPhysicalVectorDim() / repairEmbeddingDimension().
64
+ dimensionMismatch: false,
65
+ expectedDim: null,
66
+ actualDim: null,
59
67
  };
60
68
  scopeCache = new Map();
61
69
  // SCOPE_CACHE_LAZY (1.1.7): per-scope write counter. invalidateScope()
@@ -362,6 +370,35 @@ export class MemoryStore {
362
370
  await this.ensureMemoriesTableCompatibility();
363
371
  await this.ensureEventTableCompatibility();
364
372
  await this.ensureIndexes();
373
+ // DIMENSION_MISMATCH_DETECT: compare the embedder dimension this
374
+ // process just probed (vectorDim, the init() argument) against the
375
+ // table's actual physical column width. They only diverge when
376
+ // embedding.provider/embedding.model was changed to a different-
377
+ // output-size model without resetting the store — and when that
378
+ // happens, LanceDB does NOT reject the mismatched write; it silently
379
+ // coerces it into the old fixed-width column (corrupting the vector),
380
+ // and every vectorSearch() call at the new dimension throws (silently
381
+ // swallowed by findSimilarVectors's catch), so dedup/consolidation
382
+ // silently stop finding neighbors for anything written after the
383
+ // switch. See repairEmbeddingDimension() for the fix.
384
+ try {
385
+ const physicalDim = await this.getPhysicalVectorDim();
386
+ this.indexState.expectedDim = physicalDim;
387
+ this.indexState.actualDim = vectorDim;
388
+ this.indexState.dimensionMismatch = physicalDim !== null && physicalDim !== vectorDim;
389
+ if (this.indexState.dimensionMismatch) {
390
+ log("warn", `[store] Embedding dimension mismatch: the embedder currently produces ` +
391
+ `${vectorDim}-dim vectors, but this store's "vector" column is physically fixed ` +
392
+ `at ${physicalDim}-dim (set when the table was first created). New memories will ` +
393
+ `be written with corrupted vectors and dedup/consolidation will silently stop ` +
394
+ `finding neighbors for anything written from now on. Fix: call the memory_reembed ` +
395
+ `tool (dryRun:false, confirm:true) to back up and re-embed every memory under the ` +
396
+ `current model.`);
397
+ }
398
+ }
399
+ catch (error) {
400
+ log("debug", `[store] dimension-mismatch check failed: ${error instanceof Error ? error.message : String(error)}`);
401
+ }
365
402
  const retentionDays = this.retentionConfig?.effectivenessEventsDays;
366
403
  if (retentionDays !== undefined && retentionDays > 0) {
367
404
  await this.cleanupExpiredEvents(undefined, retentionDays);
@@ -552,13 +589,21 @@ export class MemoryStore {
552
589
  const queryNorm = vecNorm(params.queryVector);
553
590
  const useVectorChannel = params.queryVector.length > 0 && params.vectorWeight > 0;
554
591
  const useBm25Channel = queryTokens.length > 0 && params.bm25Weight > 0;
555
- 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);
556
598
  const rrfK = Math.max(1, Math.floor(params.rrfK ?? 60));
557
599
  const recencyBoostEnabled = params.recencyBoost ?? true;
558
600
  const recencyHalfLifeHours = Math.max(1, params.recencyHalfLifeHours ?? 72);
559
601
  const importanceWeight = clampImportanceWeight(params.importanceWeight ?? 0.4);
560
602
  const feedbackWeight = Math.max(0, Math.min(1, params.feedbackWeight ?? 0));
561
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)]));
562
607
  const candidates = cached.records
563
608
  .filter((record) => params.queryVector.length === 0 || record.vector.length === params.queryVector.length)
564
609
  .map((record, index) => {
@@ -566,7 +611,7 @@ export class MemoryStore {
566
611
  const vectorScore = useVectorChannel ? fastCosine(params.queryVector, record.vector, queryNorm, recordNorm) : 0;
567
612
  const bm25Score = useBm25Channel ? bm25LikeScore(queryTokens, cached.tokenized[index], cached.idf) : 0;
568
613
  const isGlobal = record.scope === "global";
569
- return { record, vectorScore, bm25Score, isGlobal };
614
+ return { record, vectorScore, bm25Score, fuzzyScore: fuzzyScoreMap.get(record.id) ?? 0, isGlobal };
570
615
  });
571
616
  if (candidates.length === 0)
572
617
  return [];
@@ -588,6 +633,14 @@ export class MemoryStore {
588
633
  if (rank !== undefined)
589
634
  rrfScore += bm25Weight / (rrfK + rank);
590
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
+ }
591
644
  rrfScore *= rrfK + 1;
592
645
  const recencyFactor = recencyBoostEnabled
593
646
  ? computeRecencyMultiplier(item.record.timestamp, recencyHalfLifeHours)
@@ -604,6 +657,7 @@ export class MemoryStore {
604
657
  score,
605
658
  vectorScore: item.vectorScore,
606
659
  bm25Score: item.bm25Score,
660
+ fuzzyScore: item.fuzzyScore,
607
661
  };
608
662
  })
609
663
  .filter((item) => item.score >= params.minScore)
@@ -1673,8 +1727,33 @@ export class MemoryStore {
1673
1727
  ftsError: this.indexState.ftsError || undefined,
1674
1728
  vectorRetries: this.indexState.vectorRetries,
1675
1729
  ftsRetries: this.indexState.ftsRetries,
1730
+ dimensionMismatch: this.indexState.dimensionMismatch,
1731
+ expectedDim: this.indexState.expectedDim,
1732
+ actualDim: this.indexState.actualDim,
1676
1733
  };
1677
1734
  }
1735
+ // DIMENSION_MISMATCH_DETECT: the "vector" column is an Arrow
1736
+ // FixedSizeList whose width is fixed forever by the first row ever
1737
+ // written to the table (LanceDB/Arrow enforce a uniform width per
1738
+ // column) — NOT by whatever `vectorDim` a later write claims in its
1739
+ // bookkeeping column. Reading it back via table.schema() is the only
1740
+ // reliable way to know the table's true, physical embedding dimension.
1741
+ async getPhysicalVectorDim() {
1742
+ const table = this.requireTable();
1743
+ const schema = await table.schema();
1744
+ const vectorField = schema.fields.find((field) => field.name === "vector");
1745
+ const listSize = vectorField?.type?.listSize;
1746
+ return typeof listSize === "number" ? listSize : null;
1747
+ }
1748
+ // DIMENSION_MISMATCH_REPAIR: a dimension mismatch is a whole-table
1749
+ // structural problem (the physical column width is table-wide, not
1750
+ // scope-scoped), so the repair must span every scope present, not just
1751
+ // the caller's current scope.
1752
+ async listDistinctScopes() {
1753
+ const table = this.requireTable();
1754
+ const rows = await table.query().select(["scope"]).limit(200000).toArray();
1755
+ return [...new Set(rows.map((row) => String(row.scope ?? "")).filter((scope) => scope.length > 0))];
1756
+ }
1678
1757
  invalidateScope(scope) {
1679
1758
  this.scopeVersions.set(scope, (this.scopeVersions.get(scope) ?? 0) + 1);
1680
1759
  }
@@ -1741,7 +1820,34 @@ export class MemoryStore {
1741
1820
  const idf = scopes.length === 1 && this.scopeCache.has(scopes[0])
1742
1821
  ? this.scopeCache.get(scopes[0]).idf
1743
1822
  : computeIdf(allTokenized);
1744
- 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;
1745
1851
  }
1746
1852
  enforceMaxScopes() {
1747
1853
  while (this.scopeCache.size > this.cacheConfig.maxScopes) {
@@ -2891,14 +2997,27 @@ function buildRankMap(items, scoreOf) {
2891
2997
  }
2892
2998
  return ranks;
2893
2999
  }
2894
- function normalizeChannelWeights(vectorWeight, bm25Weight) {
2895
- 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;
2896
3014
  if (sum <= 0) {
2897
- return { vectorWeight: 0.5, bm25Weight: 0.5 };
3015
+ return { vectorWeight: 0.5, bm25Weight: 0.5, fuzzyWeight: 0 };
2898
3016
  }
2899
3017
  return {
2900
3018
  vectorWeight: vectorWeight / sum,
2901
3019
  bm25Weight: bm25Weight / sum,
3020
+ fuzzyWeight: fuzzyWeight / sum,
2902
3021
  };
2903
3022
  }
2904
3023
  function computeRecencyMultiplier(timestamp, halfLifeHours) {
@@ -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
@@ -83,6 +89,10 @@ export function createMemoryTools(state) {
83
89
  const isFallback = embedderFailed || queryVector.length === 0;
84
90
  const effectiveVectorWeight = isFallback ? 0 : (state.config.retrieval.mode === "vector" ? 1 : state.config.retrieval.vectorWeight);
85
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;
86
96
  if (isFallback) {
87
97
  log("info", "Using BM25-only search (embedder unavailable)");
88
98
  }
@@ -93,6 +103,8 @@ export function createMemoryTools(state) {
93
103
  limit: args.limit ?? 5,
94
104
  vectorWeight: effectiveVectorWeight,
95
105
  bm25Weight: effectiveBm25Weight,
106
+ fuzzyWeight: effectiveFuzzyWeight,
107
+ fuzzyThreshold: state.config.retrieval.fuzzyThreshold,
96
108
  minScore: state.config.retrieval.minScore,
97
109
  rrfK: state.config.retrieval.rrfK,
98
110
  recencyBoost: state.config.retrieval.recencyBoost,
@@ -300,6 +312,11 @@ export function createMemoryTools(state) {
300
312
  recentCount: entries.length,
301
313
  incompatibleVectors,
302
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
+ },
303
320
  embeddingModel: state.config.embedding.model,
304
321
  searchMode,
305
322
  embedderHealth,
@@ -781,6 +798,7 @@ ${explanations.join("\n")}`;
781
798
  limit: args.limit ?? 20,
782
799
  vectorWeight: 0.7,
783
800
  bm25Weight: 0.3,
801
+ fuzzyWeight: 0,
784
802
  minScore: 0.2,
785
803
  globalDiscountFactor: 1.0,
786
804
  }).then((results) => results.map((r) => r.record));
@@ -1226,6 +1244,117 @@ ${explanations.join("\n")}`;
1226
1244
  }, null, 2);
1227
1245
  },
1228
1246
  }),
1247
+ // DIMENSION_MISMATCH_REPAIR: the "vector" column's physical width is
1248
+ // fixed for the whole table (set by the first row ever written), not
1249
+ // per-scope, so this operates on every scope in the store — unlike
1250
+ // every other tool here, it does not take a `scope` argument.
1251
+ // Backs up first (always, even dryRun) so the operation is never
1252
+ // riskier than memory_export followed by memory_import(replace).
1253
+ memory_reembed: tool({
1254
+ description: "Detect (and, with confirm:true, repair) an embedding-dimension mismatch between the " +
1255
+ "configured embedder and the on-disk vector store. A mismatch happens when embedding.provider " +
1256
+ "or embedding.model changed to a different output dimension without resetting the store — " +
1257
+ "LanceDB silently corrupts new writes in that state instead of rejecting them, and dedup/" +
1258
+ "consolidation silently stop finding neighbors. Repair backs up every memory (all scopes) to " +
1259
+ "a JSON file, drops and recreates the memories table at the current embedder's dimension, and " +
1260
+ "re-embeds every memory from its stored text under its original id (graph edges and citation " +
1261
+ "chains keyed by id stay valid).",
1262
+ args: {
1263
+ dryRun: tool.schema.boolean().optional().default(true),
1264
+ confirm: tool.schema.boolean().optional().default(false),
1265
+ },
1266
+ execute: async (args) => {
1267
+ await state.ensureInitialized();
1268
+ if (!state.initialized)
1269
+ return unavailableMessage(state.config.embedding.provider);
1270
+ const actualDim = await state.embedder.dim();
1271
+ const expectedDim = await state.store.getPhysicalVectorDim();
1272
+ if (expectedDim === null || expectedDim === actualDim) {
1273
+ return JSON.stringify({
1274
+ mismatch: false,
1275
+ actualDim,
1276
+ message: "No dimension mismatch detected. Nothing to repair.",
1277
+ }, null, 2);
1278
+ }
1279
+ const scopes = await state.store.listDistinctScopes();
1280
+ const records = await state.store.exportAllRecords(scopes);
1281
+ if (args.dryRun && !args.confirm) {
1282
+ return JSON.stringify({
1283
+ mismatch: true,
1284
+ expectedDim,
1285
+ actualDim,
1286
+ scopes,
1287
+ recordCount: records.length,
1288
+ message: "Dry run — no changes made. Call again with dryRun:false, confirm:true to " +
1289
+ "repair (this drops and rebuilds the memories table; a backup is written first).",
1290
+ }, null, 2);
1291
+ }
1292
+ if (!args.confirm) {
1293
+ return JSON.stringify({
1294
+ error: "Set confirm:true to actually repair — this drops and rebuilds the memories " +
1295
+ "table (like memory_clear/memory_forget, destructive operations require confirm:true).",
1296
+ }, null, 2);
1297
+ }
1298
+ // BACKUP_ALWAYS_FIRST: same JSON shape as memory_export, so
1299
+ // memory_import can restore from it independently if anything
1300
+ // below fails partway through.
1301
+ const fs = await import("node:fs");
1302
+ const dbDirEnd = state.config.dbPath.lastIndexOf("/");
1303
+ const backupDir = (dbDirEnd > 0 ? state.config.dbPath.slice(0, dbDirEnd) : ".") + "/backups";
1304
+ await fs.promises.mkdir(backupDir, { recursive: true }).catch(() => { });
1305
+ const backupPath = `${backupDir}/reembed-repair-${Date.now()}.json`;
1306
+ await fs.promises.writeFile(backupPath, JSON.stringify({
1307
+ format: "opencode-memory-pro/backup",
1308
+ version: 1,
1309
+ exportedAt: new Date().toISOString(),
1310
+ provider: state.config.provider,
1311
+ dbPath: state.config.dbPath,
1312
+ reason: "pre-reembed-repair-backup",
1313
+ fromDim: expectedDim,
1314
+ toDim: actualDim,
1315
+ scopes,
1316
+ count: records.length,
1317
+ memories: records,
1318
+ }, null, 2));
1319
+ await state.store.connection.dropTable("memories");
1320
+ state.store.table = null;
1321
+ await state.store.init(actualDim);
1322
+ let repaired = 0;
1323
+ let failed = 0;
1324
+ const failures = [];
1325
+ for (const record of records) {
1326
+ try {
1327
+ const vector = await state.embedder.embed(record.text || "");
1328
+ await state.store.put({
1329
+ ...record,
1330
+ vector,
1331
+ vectorDim: vector.length,
1332
+ embeddingModel: state.embedder.model,
1333
+ });
1334
+ repaired += 1;
1335
+ }
1336
+ catch (error) {
1337
+ failed += 1;
1338
+ failures.push({ id: record.id, reason: error instanceof Error ? error.message : String(error) });
1339
+ }
1340
+ }
1341
+ await state.store.ensureIndexes();
1342
+ return JSON.stringify({
1343
+ mismatch: true,
1344
+ repaired,
1345
+ failed,
1346
+ failures: failures.slice(0, 10),
1347
+ fromDim: expectedDim,
1348
+ toDim: actualDim,
1349
+ backupPath,
1350
+ scopes,
1351
+ message: failed > 0
1352
+ ? `Repaired ${repaired}/${records.length}. ${failed} failed but remain intact in the ` +
1353
+ `backup at ${backupPath} — re-run memory_reembed once the embedder issue is fixed.`
1354
+ : `Repaired all ${repaired} memories at ${actualDim}-dim. Backup retained at ${backupPath}.`,
1355
+ }, null, 2);
1356
+ },
1357
+ }),
1229
1358
  memory_summarize: tool({
1230
1359
  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
1360
  args: {
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.0",
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",