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 +71 -17
- package/dist/config.js +8 -1
- package/dist/index.js +1 -1
- package/dist/store.d.ts +7 -0
- package/dist/store.js +125 -6
- package/dist/tools/memory.d.ts +11 -0
- package/dist/tools/memory.js +129 -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,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
|
|
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`, `...
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2895
|
-
|
|
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) {
|
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
|
|
@@ -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;
|
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",
|