@modusensus/dsh-mneme 0.2.4 → 0.2.6

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.
Files changed (46) hide show
  1. package/README.md +10 -9
  2. package/lib/client.js +2 -2
  3. package/lib/config.js +5 -2
  4. package/lib/dream/decisions.js +174 -62
  5. package/lib/dream.js +30 -5
  6. package/lib/index.js +3 -1
  7. package/lib/mirror.js +7 -1
  8. package/lib/service.js +117 -3
  9. package/lib/store.js +40 -0
  10. package/lib/tools.js +47 -4
  11. package/package.json +3 -1
  12. package/scripts/benchmark-embed.js +201 -0
  13. package/scripts/benchmark-rerank.js +166 -0
  14. package/scripts/e2e-dsh.js +216 -0
  15. package/scripts/stress-dsh.js +255 -0
  16. package/scripts/sync-lib.js +47 -0
  17. package/src/config.js +5 -2
  18. package/src/dream/decisions.js +174 -62
  19. package/src/dream.js +30 -5
  20. package/src/index.js +3 -1
  21. package/src/mirror.js +7 -1
  22. package/src/service.js +117 -3
  23. package/src/store.js +40 -0
  24. package/src/tools.js +47 -4
  25. package/test/api.test.js +385 -0
  26. package/test/audit.test.js +290 -0
  27. package/test/client.test.js +44 -0
  28. package/test/clustering.test.js +100 -0
  29. package/test/commands.test.js +69 -0
  30. package/test/config.test.js +31 -0
  31. package/test/dream.test.js +526 -0
  32. package/test/helpers/dream-mock.js +82 -0
  33. package/test/inject.test.js +82 -0
  34. package/test/local-embedder.test.js +227 -0
  35. package/test/mirror.test.js +249 -0
  36. package/test/reflection.test.js +226 -0
  37. package/test/reranker.test.js +197 -0
  38. package/test/semantic.test.js +123 -0
  39. package/test/service-search.test.js +169 -0
  40. package/test/service.test.js +198 -0
  41. package/test/settings.test.js +101 -0
  42. package/test/store.test.js +293 -0
  43. package/test/stress.test.js +209 -0
  44. package/test/summarize.test.js +156 -0
  45. package/test/tools.test.js +265 -0
  46. package/test/vector-index.test.js +205 -0
@@ -0,0 +1,226 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { validateDecisions, applyDecisions } from "../src/dream/decisions.js";
4
+ import { createStore } from "../src/store.js";
5
+ import { createService } from "../src/service.js";
6
+
7
+ // snapshot helper: memories eligible for consolidation (non-archived, non-summary)
8
+ function makeSnapshot(store) {
9
+ const snap = new Map();
10
+ for (const m of store.all()) {
11
+ if (!m.archived && m.type !== "summary") snap.set(m.id, m);
12
+ }
13
+ return snap;
14
+ }
15
+
16
+ function setup() {
17
+ const store = createStore(":memory:");
18
+ const service = createService({ store, mirror: null, config: {} });
19
+ return { store, service };
20
+ }
21
+
22
+ /** Insert a memory and backdate created_at to `hoursAgo`. */
23
+ function seedMemory(service, store, fields, hoursAgo = 48) {
24
+ const created = service.saveWithDedupe(fields);
25
+ const mem = created.memory;
26
+ if (hoursAgo) {
27
+ const old = new Date(Date.now() - hoursAgo * 3600000).toISOString();
28
+ store.db.prepare("UPDATE memories SET created_at = ? WHERE id = ?").run(old, mem.id);
29
+ }
30
+ return store.getById(mem.id);
31
+ }
32
+
33
+ // --- update validation ---------------------------------------------------
34
+
35
+ test("update validation rejects multiple ids", () => {
36
+ const { store } = setup();
37
+ const snap = makeSnapshot(store);
38
+ const { ok, errors } = validateDecisions(
39
+ [{ action: "update", ids: ["a", "b"], content: "new" }], snap
40
+ );
41
+ assert.equal(ok, false);
42
+ assert.ok(errors.some((e) => e.includes("exactly one id")));
43
+ });
44
+
45
+ test("update validation rejects no actual change", () => {
46
+ const { store, service } = setup();
47
+ const mem = seedMemory(service, store, { type: "preference", title: "语言", content: "喜欢 Python" });
48
+ const snap = makeSnapshot(store);
49
+ const { ok, errors } = validateDecisions(
50
+ [{ action: "update", ids: [mem.id], content: "喜欢 Python" }], snap
51
+ );
52
+ assert.equal(ok, false);
53
+ assert.ok(errors.some((e) => e.includes("change at least one field")));
54
+ });
55
+
56
+ test("update validation rejects summary type", () => {
57
+ const { store, service } = setup();
58
+ const mem = seedMemory(service, store, { type: "summary", title: "总览", content: "overview" });
59
+ const snap = makeSnapshot(store);
60
+ // summary is excluded from snapshot, so also test direct: simulate by forcing it in
61
+ snap.set(mem.id, mem);
62
+ const { ok, errors } = validateDecisions(
63
+ [{ action: "update", ids: [mem.id], content: "new overview" }], snap
64
+ );
65
+ assert.equal(ok, false);
66
+ assert.ok(errors.some((e) => e.includes("summary") || e.includes("not eligible")));
67
+ });
68
+
69
+ test("update validation rejects too-young memory (< minAgeHours)", () => {
70
+ const { store, service } = setup();
71
+ const mem = seedMemory(service, store, { type: "preference", title: "新", content: "新记忆" }, 1); // 1 hour old
72
+ const snap = makeSnapshot(store);
73
+ const { ok, errors } = validateDecisions(
74
+ [{ action: "update", ids: [mem.id], content: "修正" }], snap, { minAgeHours: 24 }
75
+ );
76
+ assert.equal(ok, false);
77
+ assert.ok(errors.some((e) => e.includes("too young")));
78
+ });
79
+
80
+ test("update validation respects configurable minAgeHours", () => {
81
+ const { store, service } = setup();
82
+ const mem = seedMemory(service, store, { type: "preference", title: "新", content: "新记忆" }, 1);
83
+ const snap = makeSnapshot(store);
84
+ // minAgeHours=0 disables the age guard -> update of a 1h-old memory passes
85
+ const { ok, errors } = validateDecisions(
86
+ [{ action: "update", ids: [mem.id], content: "修正" }], snap, { minAgeHours: 0 }
87
+ );
88
+ assert.equal(ok, true, errors.join("; "));
89
+ });
90
+
91
+ test("update validation rejects > maxUpdatePerRun updates", () => {
92
+ const { store, service } = setup();
93
+ const a = seedMemory(service, store, { type: "preference", title: "A", content: "a" });
94
+ const b = seedMemory(service, store, { type: "preference", title: "B", content: "b" });
95
+ const c = seedMemory(service, store, { type: "preference", title: "C", content: "c" });
96
+ const snap = makeSnapshot(store);
97
+ const { ok, errors } = validateDecisions([
98
+ { action: "update", ids: [a.id], content: "a2" },
99
+ { action: "update", ids: [b.id], content: "b2" },
100
+ { action: "update", ids: [c.id], content: "c2" }
101
+ ], snap, { maxUpdatePerRun: 2 });
102
+ assert.equal(ok, false);
103
+ assert.ok(errors.some((e) => e.includes("too many update")));
104
+ });
105
+
106
+ test("update validation passes a single valid update", () => {
107
+ const { store, service } = setup();
108
+ const mem = seedMemory(service, store, { type: "preference", title: "语言", content: "喜欢 Python" });
109
+ const snap = makeSnapshot(store);
110
+ const { ok, errors } = validateDecisions(
111
+ [{ action: "update", ids: [mem.id], content: "喜欢 Rust" }], snap
112
+ );
113
+ assert.equal(ok, true, errors.join("; "));
114
+ });
115
+
116
+ // --- update application --------------------------------------------------
117
+
118
+ test("update application changes the memory content", () => {
119
+ const { store, service } = setup();
120
+ const mem = seedMemory(service, store, { type: "preference", title: "语言", content: "喜欢 Python" });
121
+ const snap = makeSnapshot(store);
122
+ const decisions = [{ action: "update", ids: [mem.id], content: "喜欢 Rust" }];
123
+ assert.equal(validateDecisions(decisions, snap).ok, true);
124
+ const { applied } = applyDecisions(decisions, service);
125
+ assert.equal(applied, 1);
126
+ const updated = store.getById(mem.id);
127
+ assert.equal(updated.content, "喜欢 Rust");
128
+ });
129
+
130
+ test("update application is idempotent on replay", () => {
131
+ const { store, service } = setup();
132
+ const mem = seedMemory(service, store, { type: "preference", title: "语言", content: "喜欢 Python" });
133
+ const snap = makeSnapshot(store);
134
+ const decisions = [{ action: "update", ids: [mem.id], content: "喜欢 Rust" }];
135
+ applyDecisions(decisions, service);
136
+ const appliedAgain = applyDecisions(decisions, service);
137
+ assert.equal(appliedAgain.applied, 0, "replay of an already-applied update should be a no-op");
138
+ });
139
+
140
+ test("update application preserves unspecified fields", () => {
141
+ const { store, service } = setup();
142
+ const mem = seedMemory(service, store, { type: "preference", title: "语言", content: "喜欢 Python", importance: 3 });
143
+ const snap = makeSnapshot(store);
144
+ const decisions = [{ action: "update", ids: [mem.id], content: "喜欢 Rust" }];
145
+ applyDecisions(decisions, service);
146
+ const updated = store.getById(mem.id);
147
+ assert.equal(updated.content, "喜欢 Rust");
148
+ assert.equal(updated.title, "语言", "title preserved");
149
+ assert.equal(updated.importance, 3, "importance preserved");
150
+ });
151
+
152
+ // --- failure tracking ----------------------------------------------------
153
+
154
+ test("user correction records a failure_memories row", () => {
155
+ const store = createStore(":memory:");
156
+ const service = createService({ store, mirror: null, config: { reflectionFailureTracking: true } });
157
+ const mem = seedMemory(service, store, { type: "preference", title: "语言", content: "喜欢 Python" });
158
+ service.update(mem.id, { content: "喜欢 Rust" });
159
+ const failures = store.listFailures();
160
+ assert.equal(failures.length, 1);
161
+ assert.equal(failures[0].failure_type, "user_correction");
162
+ assert.equal(failures[0].memory_id, mem.id);
163
+ assert.equal(failures[0].actual, "喜欢 Python");
164
+ assert.equal(failures[0].expected, "喜欢 Rust");
165
+ });
166
+
167
+ test("user correction records failure when only title changes", () => {
168
+ const store = createStore(":memory:");
169
+ const service = createService({ store, mirror: null, config: { reflectionFailureTracking: true } });
170
+ const mem = seedMemory(service, store, { type: "preference", title: "语言", content: "喜欢 Python" });
171
+ service.update(mem.id, { title: "编程语言" }); // title change, content same
172
+ const failures = store.listFailures();
173
+ assert.equal(failures.length, 1, "title-only change should still record a failure");
174
+ assert.equal(failures[0].failure_type, "user_correction");
175
+ });
176
+
177
+ test("user correction records query context when provided", () => {
178
+ const store = createStore(":memory:");
179
+ const service = createService({ store, mirror: null, config: { reflectionFailureTracking: true } });
180
+ const mem = seedMemory(service, store, { type: "preference", title: "语言", content: "喜欢 Python" });
181
+ service.update(mem.id, { content: "喜欢 Rust" }, { query: "用户说:不对,我喜欢 Rust" });
182
+ const failures = store.listFailures();
183
+ assert.equal(failures.length, 1);
184
+ assert.equal(failures[0].query, "用户说:不对,我喜欢 Rust");
185
+ });
186
+
187
+ test("user correction skips failure when nothing meaningful changes", () => {
188
+ const store = createStore(":memory:");
189
+ const service = createService({ store, mirror: null, config: { reflectionFailureTracking: true } });
190
+ const mem = seedMemory(service, store, { type: "preference", title: "语言", content: "喜欢 Python", importance: 3 });
191
+ service.update(mem.id, { title: "语言", content: "喜欢 Python", importance: 3 }); // identical
192
+ assert.equal(store.listFailures().length, 0, "no meaningful change -> no failure");
193
+ });
194
+
195
+ test("deleteOldFailures removes rows older than the cutoff", () => {
196
+ const store = createStore(":memory:");
197
+ const service = createService({ store, mirror: null, config: { reflectionFailureTracking: true } });
198
+ const mem = seedMemory(service, store, { type: "preference", title: "语言", content: "喜欢 Python" });
199
+ service.update(mem.id, { content: "喜欢 Rust" });
200
+ assert.equal(store.listFailures().length, 1);
201
+ // cutoff in the future: everything is older, so all rows get deleted
202
+ const removed = store.deleteOldFailures(new Date(Date.now() + 86400000).toISOString());
203
+ assert.equal(removed, 1);
204
+ assert.equal(store.listFailures().length, 0);
205
+ });
206
+
207
+ test("failure tracking disabled by config", () => {
208
+ const store = createStore(":memory:");
209
+ const service = createService({ store, mirror: null, config: { reflectionFailureTracking: false } });
210
+ const mem = seedMemory(service, store, { type: "preference", title: "语言", content: "喜欢 Python" });
211
+ service.update(mem.id, { content: "喜欢 Rust" });
212
+ assert.equal(store.listFailures().length, 0);
213
+ });
214
+
215
+ test("listFailures and getFailureStats work", () => {
216
+ const store = createStore(":memory:");
217
+ const service = createService({ store, mirror: null, config: { reflectionFailureTracking: true } });
218
+ const a = seedMemory(service, store, { type: "preference", title: "A", content: "x1" });
219
+ const b = seedMemory(service, store, { type: "preference", title: "B", content: "y1" });
220
+ service.update(a.id, { content: "x2" });
221
+ service.update(b.id, { content: "y2" });
222
+ const failures = store.listFailures();
223
+ assert.equal(failures.length, 2);
224
+ const stats = store.getFailureStats();
225
+ assert.equal(stats.user_correction, 2);
226
+ });
@@ -0,0 +1,197 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { LocalReranker } from "../src/reranker.js";
4
+
5
+ /** Injected scorer: records (query, passage) calls, returns a fixed score. */
6
+ function makeScorer(scoreOf) {
7
+ const calls = [];
8
+ const fn = async (query, passage) => {
9
+ calls.push({ query, passage });
10
+ return typeof scoreOf === "function" ? scoreOf(passage) : scoreOf;
11
+ };
12
+ return { fn, calls };
13
+ }
14
+
15
+ /** Fake feature-extraction engine: each input embeds to [1, k*0.25, 0, 0]. */
16
+ function makeFakeExtractor(dim = 4, calls = []) {
17
+ let k = 0;
18
+ const fn = async (texts) => {
19
+ calls.push({ count: texts.length });
20
+ const rows = texts.map(() => {
21
+ const n = k++;
22
+ return Array.from({ length: dim }, (__, j) => (j === 0 ? 1 : j === 1 ? n * 0.25 : 0));
23
+ });
24
+ const flat = Float32Array.from(rows.flat());
25
+ return { data: flat, dims: [texts.length, dim] };
26
+ };
27
+ fn.dispose = () => {
28
+ fn.disposed = true;
29
+ };
30
+ return fn;
31
+ }
32
+
33
+ /** Fake text-classification engine exposing tokenizer + model for the tc path. */
34
+ function makeFakeTc(logitsPerRow) {
35
+ const tokenizer = (texts, opts) => ({ texts, text_pair: opts.text_pair });
36
+ const model = async (inputs) => {
37
+ const n = inputs.texts.length;
38
+ const flat = new Float32Array(logitsPerRow.slice(0, n * 2));
39
+ return { logits: { data: flat, dims: [n, 2] } };
40
+ };
41
+ return { tokenizer, model, dispose: () => {} };
42
+ }
43
+
44
+ function candidates(ids) {
45
+ return ids.map((id) => ({ id, title: `T-${id}`, content: `C-${id}` }));
46
+ }
47
+
48
+ test("init with injected scorePair skips model loading", async () => {
49
+ let factoryCalled = false;
50
+ const { fn, calls } = makeScorer(0.7);
51
+ const r = new LocalReranker({
52
+ scorePair: fn,
53
+ engineFactory: async () => {
54
+ factoryCalled = true;
55
+ throw new Error("should never load");
56
+ }
57
+ });
58
+ await r.init();
59
+ assert.equal(factoryCalled, false);
60
+ const out = await r.rerank("q", candidates(["a"]));
61
+ assert.deepEqual(out, [{ id: "a", score: 0.7 }]);
62
+ assert.equal(calls.length, 1);
63
+ assert.equal(calls[0].query, "q");
64
+ assert.equal(calls[0].passage, "T-a\nC-a");
65
+ });
66
+
67
+ test("rerank returns results sorted by descending score", async () => {
68
+ const { fn } = makeScorer((p) => (p.includes("A") ? 0.9 : p.includes("B") ? 0.5 : 0.2));
69
+ const r = new LocalReranker({ scorePair: fn });
70
+ await r.init();
71
+ const out = await r.rerank("q", candidates(["A", "B", "C"]));
72
+ assert.deepEqual(out.map((x) => x.id), ["A", "B", "C"]);
73
+ assert.deepEqual(out.map((x) => x.score), [0.9, 0.5, 0.2]);
74
+ });
75
+
76
+ test("rerank returns [] for empty candidates", async () => {
77
+ const { fn } = makeScorer(0.9);
78
+ const r = new LocalReranker({ scorePair: fn });
79
+ await r.init();
80
+ assert.deepEqual(await r.rerank("q", []), []);
81
+ });
82
+
83
+ test("rerank drops candidates below scoreThreshold", async () => {
84
+ const { fn } = makeScorer((p) => (p.includes("A") ? 0.8 : p.includes("B") ? 0.4 : 0.05));
85
+ const r = new LocalReranker({ scorePair: fn, scoreThreshold: 0.3 });
86
+ await r.init();
87
+ const out = await r.rerank("q", candidates(["A", "B", "C"]));
88
+ assert.deepEqual(out.map((x) => x.id), ["A", "B"]);
89
+ });
90
+
91
+ test("rerank clamps scores into 0..1", async () => {
92
+ const { fn } = makeScorer((p) => (p.includes("A") ? 1.5 : p.includes("B") ? -0.2 : NaN));
93
+ const r = new LocalReranker({ scorePair: fn, scoreThreshold: 0 });
94
+ await r.init();
95
+ const out = await r.rerank("q", candidates(["A", "B", "C"]));
96
+ assert.deepEqual(out.map((x) => x.score), [1, 0, 0]);
97
+ });
98
+
99
+ test("rerank truncates to maxCandidates", async () => {
100
+ const { fn, calls } = makeScorer(0.6);
101
+ const r = new LocalReranker({ scorePair: fn, maxCandidates: 2 });
102
+ await r.init();
103
+ const out = await r.rerank("q", candidates(["a", "b", "c", "d"]));
104
+ assert.equal(calls.length, 2);
105
+ assert.equal(out.length, 2);
106
+ });
107
+
108
+ test("passage falls back to title when content is missing", async () => {
109
+ const { fn, calls } = makeScorer(0.8);
110
+ const r = new LocalReranker({ scorePair: fn });
111
+ await r.init();
112
+ await r.rerank("q", [{ id: "x", title: "only-title", content: "" }]);
113
+ assert.equal(calls[0].passage, "only-title");
114
+ });
115
+
116
+ test("rerank propagates injected scorer failures", async () => {
117
+ const bad = async () => {
118
+ throw new Error("scorer boom");
119
+ };
120
+ const r = new LocalReranker({ scorePair: bad });
121
+ await r.init();
122
+ await assert.rejects(() => r.rerank("q", candidates(["a"])), /scorer boom/);
123
+ });
124
+
125
+ test("rerank throws on non-array candidates", async () => {
126
+ const r = new LocalReranker({ scorePair: async () => 0.5 });
127
+ await r.init();
128
+ await assert.rejects(() => r.rerank("q", "not-an-array"), /array/);
129
+ });
130
+
131
+ test("init throws when no strategy can load", async () => {
132
+ const r = new LocalReranker({
133
+ engineFactory: async () => {
134
+ throw new Error("Unsupported pipeline");
135
+ }
136
+ });
137
+ await assert.rejects(() => r.init(), /LocalReranker failed to load/);
138
+ });
139
+
140
+ test("init cascades rerank -> tc -> feature-extraction and batches", async () => {
141
+ const calls = [];
142
+ const loader = async (task) => {
143
+ if (task === "feature-extraction") return makeFakeExtractor(4, calls);
144
+ throw new Error(`Unsupported pipeline: ${task}`);
145
+ };
146
+ const r = new LocalReranker({ engineFactory: loader, batchSize: 2 });
147
+ await r.init();
148
+ const out = await r.rerank("q", candidates(["a", "b", "c"]));
149
+ // Query k=0 [1,0,0,0]; a,b,c embed at k=1..3 -> cosine 0.970, 0.894, 0.8,
150
+ // all above the default threshold.
151
+ assert.equal(out.length, 3);
152
+ assert.deepEqual(out.map((x) => x.id), ["a", "b", "c"]);
153
+ assert.ok(out[0].score >= out[1].score && out[1].score >= out[2].score);
154
+ // Query embed (cached) + two batches of size 2 and 1.
155
+ assert.deepEqual(calls.map((c) => c.count), [1, 2, 1]);
156
+ // Query vector is cached: a second rerank only makes batch calls.
157
+ await r.rerank("q", candidates(["a", "b", "c"]));
158
+ assert.deepEqual(calls.map((c) => c.count), [1, 2, 1, 2, 1]);
159
+ });
160
+
161
+ test("text-classification strategy scores via logit delta", async () => {
162
+ const loader = async (task) => {
163
+ if (task === "text-classification") return makeFakeTc([0, 1, 0, 0, -2, 2]);
164
+ throw new Error(`Unsupported pipeline: ${task}`);
165
+ };
166
+ const r = new LocalReranker({ engineFactory: loader });
167
+ await r.init();
168
+ const out = await r.rerank("q", candidates(["A", "B", "C"]));
169
+ // sigmoid(1-0)=0.731, sigmoid(0)=0.5, sigmoid(2-(-2))=0.982.
170
+ assert.deepEqual(out.map((x) => x.id), ["C", "A", "B"]);
171
+ assert.ok(Math.abs(out[0].score - 0.982) < 1e-3);
172
+ assert.ok(Math.abs(out[1].score - 0.731) < 1e-3);
173
+ assert.ok(Math.abs(out[2].score - 0.5) < 1e-3);
174
+ });
175
+
176
+ test("modelHash follows the embedder convention", () => {
177
+ const a = new LocalReranker({ model: "Xenova/bge-reranker-base" });
178
+ const b = new LocalReranker({ model: "Xenova/bge-reranker-base" });
179
+ assert.equal(a.modelHash, b.modelHash);
180
+ assert.match(a.modelHash, /^Xenova\/bge-reranker-base#[0-9a-f]+$/);
181
+ assert.notEqual(a.modelHash, new LocalReranker({ model: "other/model" }).modelHash);
182
+ });
183
+
184
+ test("dispose releases the loaded pipeline", async () => {
185
+ const extractor = makeFakeExtractor(4);
186
+ const r = new LocalReranker({
187
+ engineFactory: async (task) => {
188
+ if (task === "feature-extraction") return extractor;
189
+ throw new Error("Unsupported pipeline");
190
+ }
191
+ });
192
+ await r.init();
193
+ assert.ok(r.pipeline);
194
+ r.dispose();
195
+ assert.equal(r.pipeline, null);
196
+ assert.equal(extractor.disposed, true);
197
+ });
@@ -0,0 +1,123 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createStore } from "../src/store.js";
4
+ import { createService } from "../src/service.js";
5
+ import { createVectorIndex } from "../src/vector-index.js";
6
+
7
+ const DIM = 4;
8
+
9
+ // Mock embedder: maps a query to a fixed vector so tests exercise the wiring
10
+ // without downloading any model. Prefer embedSingle (local embedders); a
11
+ // legacy embed() fallback is also exercised in one test.
12
+ function mockEmbedder(overrides = {}) {
13
+ return {
14
+ embedSingle: async (text) => [1, 0, 0, 0],
15
+ embed: async (texts) => texts.map(() => [1, 0, 0, 0]),
16
+ modelHash: "mock#abc",
17
+ dimension: DIM,
18
+ ...overrides
19
+ };
20
+ }
21
+
22
+ function setup(embedder = mockEmbedder()) {
23
+ const store = createStore(":memory:");
24
+ const service = createService({ store, mirror: null, config: {} });
25
+ const vectorIndex = createVectorIndex({ store });
26
+ service.setEmbedder(embedder);
27
+ service.setVectorIndex(vectorIndex);
28
+ return { store, service, vectorIndex };
29
+ }
30
+
31
+ test("vector-index stores and searches embeddings with model metadata", async () => {
32
+ const { store, service, vectorIndex } = setup();
33
+ const m1 = service.saveWithDedupe({ type: "preference", title: "猫", content: "喜欢猫" });
34
+ vectorIndex.saveEmbedding(m1.memory.id, [1, 0, 0, 0]);
35
+ const m2 = service.saveWithDedupe({ type: "preference", title: "狗", content: "喜欢狗" });
36
+ vectorIndex.saveEmbedding(m2.memory.id, [0, 1, 0, 0]);
37
+
38
+ const hits = vectorIndex.search([1, 0, 0, 0], { threshold: 0.5 });
39
+ assert.ok(hits.length >= 1);
40
+ assert.equal(hits[0].id, m1.memory.id, "best vector match ranks first");
41
+
42
+ vectorIndex.markModel("mock#abc", DIM);
43
+ const stats = vectorIndex.getStats();
44
+ assert.equal(stats.modelHash, "mock#abc");
45
+ assert.equal(stats.dimension, DIM);
46
+ assert.equal(stats.embeddedCount, 2);
47
+
48
+ vectorIndex.deleteEmbedding(m2.memory.id);
49
+ assert.equal(vectorIndex.getEmbedding(m2.memory.id), undefined);
50
+ });
51
+
52
+ test("searchMemories auto = keyword first + vector fill", async () => {
53
+ const { service } = setup();
54
+ const m1 = service.saveWithDedupe({ type: "preference", title: "猫", content: "喜欢猫" });
55
+ const m2 = service.saveWithDedupe({ type: "preference", title: "狗狗", content: "喜欢狗和猫" });
56
+
57
+ const rows = await service.searchMemories("猫", { mode: "auto", topK: 10 });
58
+ assert.ok(rows.some((m) => m.id === m1.memory.id), "keyword hit present");
59
+ assert.ok(rows.some((m) => m.id === m2.memory.id), "vector fill present");
60
+ });
61
+
62
+ test("searchMemories hybrid = vector leads, keyword fills", async () => {
63
+ const { service, vectorIndex } = setup();
64
+ // "狗" is semantically stored under the vector; literal query misses it.
65
+ const mDog = service.saveWithDedupe({ type: "preference", title: "金毛", content: "金毛是狗" });
66
+ vectorIndex.saveEmbedding(mDog.memory.id, [1, 0, 0, 0]);
67
+ const mCat = service.saveWithDedupe({ type: "preference", title: "猫", content: "喜欢猫" });
68
+ vectorIndex.saveEmbedding(mCat.memory.id, [0, 0, 1, 0]);
69
+
70
+ const rows = await service.searchMemories("狗", { mode: "hybrid", topK: 10 });
71
+ // mock vector always returns the fixed [1,0,0,0] vector, which matches the
72
+ // dog embedding above → dog should surface even though keyword matches "金毛".
73
+ assert.ok(rows.some((m) => m.id === mDog.memory.id), "vector lead surfaces dog");
74
+ });
75
+
76
+ test("searchMemories keyword mode never touches the embedder", async () => {
77
+ let called = 0;
78
+ const { service } = setup(mockEmbedder({
79
+ embedSingle: async () => { called++; return [1, 0, 0, 0]; }
80
+ }));
81
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文交流" });
82
+ const rows = await service.searchMemories("中文", { mode: "keyword", topK: 10 });
83
+ assert.equal(rows.length, 1);
84
+ assert.equal(called, 0, "embedder must not be called in keyword mode");
85
+ });
86
+
87
+ test("searchMemories reranks merged candidates when a reranker is installed", async () => {
88
+ const { service } = setup();
89
+ const a = service.saveWithDedupe({ type: "preference", title: "猫", content: "喜欢猫" });
90
+ const b = service.saveWithDedupe({ type: "preference", title: "猫猫", content: "猫粮" });
91
+ // b is newer, so keyword order is [b, a]. The reranker reverses scores so
92
+ // the last candidate (a) wins — asserting the rerank actually reordered.
93
+ const reranker = {
94
+ rerank: async (query, candidates) =>
95
+ candidates.map((c, i) => ({ id: c.id, score: i })).sort((x, y) => y.score - x.score)
96
+ };
97
+ service.setReranker(reranker);
98
+
99
+ const rows = await service.searchMemories("猫", { mode: "keyword", topK: 10 });
100
+ assert.equal(rows[0].title, "猫", "rerank reorders to the higher score");
101
+ assert.equal(typeof rows[0].score, "number", "reranked rows carry a score");
102
+ });
103
+
104
+ test("searchMemories survives a throwing embedder (degrades to keyword)", async () => {
105
+ const { service } = setup(mockEmbedder({
106
+ embedSingle: async () => { throw new Error("model unavailable"); }
107
+ }));
108
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文交流" });
109
+ const rows = await service.searchMemories("中文", { mode: "auto", topK: 10 });
110
+ assert.equal(rows.length, 1, "keyword fallback still works");
111
+ });
112
+
113
+ test("legacy embedder (embed-only) is adapted by searchMemories", async () => {
114
+ const store = createStore(":memory:");
115
+ const service = createService({ store, mirror: null, config: {} });
116
+ const legacy = { embed: async (q) => [1, 0, 0, 0] };
117
+ service.setEmbedder(legacy); // no embedSingle, no vectorIndex
118
+ service.saveWithDedupe({ type: "preference", title: "猫", content: "喜欢猫" });
119
+ store.setEmbedding(service.all()[0].id, [1, 0, 0, 0]);
120
+
121
+ const rows = await service.searchMemories("猫", { mode: "vector", topK: 10 });
122
+ assert.ok(rows.some((m) => m.title === "猫"), "legacy embed() path works");
123
+ });