@modusensus/dsh-mneme 0.1.6 → 0.2.0

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.
@@ -0,0 +1,203 @@
1
+ // Cross-encoder re-ranker for dsh-mneme recall candidates. Uses
2
+ // bge-reranker-base through transformers.js: tries the native `rerank` task
3
+ // first, then the sequence-classification head (sigmoid on the logit delta),
4
+ // and finally feature-extraction over concatenated query+passage (cosine).
5
+ // Every strategy funnels into scorePair(query, passage) so tests can inject a
6
+ // fake scorer and never download a model. Failures throw — the caller degrades
7
+ // back to the original candidate order.
8
+ function hashString(s) {
9
+ let h = 5381;
10
+ for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
11
+ return h.toString(16);
12
+ }
13
+
14
+ /** Same provider#hash fingerprint convention as the embedders. */
15
+ function modelHash(model) {
16
+ return `${model}#${hashString(model)}`;
17
+ }
18
+
19
+ /** Lazy default pipeline factory: dynamic import keeps module load cheap. */
20
+ async function defaultPipelineLoader(task, model, options) {
21
+ const { pipeline } = await import("@huggingface/transformers");
22
+ return pipeline(task, model, options);
23
+ }
24
+
25
+ /** Flatten a transformers.js Tensor [batch, seq, dim] into number[][] rows. */
26
+ function tensorToRows(tensor) {
27
+ const { data, dims } = tensor;
28
+ const dim = dims[dims.length - 1] || 0;
29
+ const rows = [];
30
+ for (let i = 0; i < data.length; i += dim) rows.push(Array.from(data.subarray(i, i + dim)));
31
+ if (rows.length === 0 && dim > 0) rows.push(Array.from(data));
32
+ return rows;
33
+ }
34
+
35
+ function cosine(a, b) {
36
+ let dot = 0;
37
+ let na = 0;
38
+ let nb = 0;
39
+ for (let i = 0; i < a.length; i++) {
40
+ dot += a[i] * b[i];
41
+ na += a[i] * a[i];
42
+ nb += b[i] * b[i];
43
+ }
44
+ if (na === 0 || nb === 0) return 0;
45
+ return dot / Math.sqrt(na * nb);
46
+ }
47
+
48
+ function clamp01(x) {
49
+ return Math.max(0, Math.min(1, x));
50
+ }
51
+
52
+ // Pipeline strategies, best to worst. `rerank` is unsupported by the bundled
53
+ // transformers.js (v4.2.0) and rejects cheaply before any model download, so
54
+ // the cascade normally lands on the classification head.
55
+ const STRATEGIES = [
56
+ ["rerank", "rerank"],
57
+ ["text-classification", "tc"],
58
+ ["feature-extraction", "fe"]
59
+ ];
60
+
61
+ export class LocalReranker {
62
+ constructor(opts = {}) {
63
+ this.model = opts.model || "Xenova/bge-reranker-base";
64
+ this.batchSize = opts.batchSize || 8;
65
+ this.maxCandidates = opts.maxCandidates || 30;
66
+ this.scoreThreshold = opts.scoreThreshold ?? 0.1;
67
+ this.device = opts.device || "cpu";
68
+ this.cacheDir = String(opts.cacheDir ?? "").trim();
69
+ this.logger = opts.logger ?? null;
70
+ this.engineFactory = opts.engineFactory || defaultPipelineLoader;
71
+ // Injectable seam: async (query, passage) => number. When set, init()
72
+ // skips model loading entirely so tests never hit the network.
73
+ this.scorePair = opts.scorePair ?? null;
74
+ this.pipeline = null;
75
+ this._batchScorer = null;
76
+ this._queryVec = null;
77
+ }
78
+
79
+ /** Load the model; throws when no strategy can be bound. */
80
+ async init() {
81
+ if (this.scorePair) return this;
82
+ let lastErr = null;
83
+ for (const [task, strategy] of STRATEGIES) {
84
+ try {
85
+ this.pipeline = await this.engineFactory(task, this.model, this._engineOptions());
86
+ this._bindBatchScorer(strategy);
87
+ this.logger?.info?.(
88
+ `[dsh-mneme] local reranker ready: ${this.model} (strategy=${strategy}, device=${this.device})`
89
+ );
90
+ return this;
91
+ } catch (err) {
92
+ lastErr = err;
93
+ this.logger?.warn?.(
94
+ `[dsh-mneme] reranker task "${task}" unavailable for ${this.model}: ${String(err?.message ?? err)}`
95
+ );
96
+ }
97
+ }
98
+ throw new Error(`LocalReranker failed to load ${this.model}: ${String(lastErr?.message ?? lastErr)}`);
99
+ }
100
+
101
+ _engineOptions() {
102
+ const options = { device: this.device };
103
+ if (this.cacheDir) options.cache_dir = this.cacheDir;
104
+ return options;
105
+ }
106
+
107
+ /** Bind a batch scorer for the chosen strategy. */
108
+ _bindBatchScorer(strategy) {
109
+ if (strategy === "rerank") {
110
+ // Native cross-encoder task (transformers.js >= 4.6): { query, documents }
111
+ // returns one score per document.
112
+ this._batchScorer = async (query, passages) => {
113
+ const out = await this.pipeline({ query, documents: passages });
114
+ if (!Array.isArray(out)) throw new Error("rerank pipeline returned unexpected output");
115
+ return out.map((r) => clamp01(typeof r?.score === "number" ? r.score : 0));
116
+ };
117
+ } else if (strategy === "tc") {
118
+ // Classification head: tokenize (query, passage) pairs, score with
119
+ // sigmoid(l1 - l0) so relevance lands in [0, 1].
120
+ this._batchScorer = async (query, passages) => {
121
+ const { tokenizer, model } = this.pipeline;
122
+ const inputs = tokenizer(passages.map(() => query), {
123
+ text_pair: passages,
124
+ padding: true,
125
+ truncation: true
126
+ });
127
+ const logits = await model(inputs).then((o) => o.logits);
128
+ const dims = logits.dims;
129
+ const cols = dims[dims.length - 1] || 2;
130
+ const rows = [];
131
+ for (let i = 0; i < dims[0]; i++) {
132
+ const base = i * cols;
133
+ const l0 = logits.data[base];
134
+ const l1 = cols > 1 ? logits.data[base + 1] : l0;
135
+ // sigmoid(l1 - l0) == softmax probability of the positive class.
136
+ rows.push(clamp01(1 / (1 + Math.exp(l0 - l1))));
137
+ }
138
+ return rows;
139
+ };
140
+ } else {
141
+ // Feature extraction: mean-pool the concatenated pair and compare with
142
+ // the query embedding via cosine. Degraded but model-agnostic.
143
+ this._batchScorer = async (query, passages) => {
144
+ if (!this._queryVec) {
145
+ const t = await this.pipeline([query], { pooling: "mean", normalize: true });
146
+ this._queryVec = tensorToRows(t)[0];
147
+ }
148
+ const t = await this.pipeline(passages.map((p) => `${query}\n${p}`), {
149
+ pooling: "mean",
150
+ normalize: true
151
+ });
152
+ return tensorToRows(t).map((v) => clamp01(cosine(this._queryVec, v)));
153
+ };
154
+ }
155
+ }
156
+
157
+ /** Score one batch of passages; injected scorePair scores pair by pair. */
158
+ async _scores(query, passages) {
159
+ if (this._batchScorer) return this._batchScorer(query, passages);
160
+ return Promise.all(passages.map((p) => (p ? this.scorePair(query, p) : 0)));
161
+ }
162
+
163
+ /**
164
+ * Re-rank recall candidates. Returns [{ id, score }] filtered by
165
+ * scoreThreshold and sorted by descending score. Throws on engine failure —
166
+ * the caller degrades to the original candidate order.
167
+ */
168
+ async rerank(query, candidates) {
169
+ if (!Array.isArray(candidates)) throw new TypeError("rerank expects an array of candidates");
170
+ const list = candidates.slice(0, this.maxCandidates);
171
+ if (!list.length) return [];
172
+ const q = String(query ?? "");
173
+ const results = [];
174
+ for (let i = 0; i < list.length; i += this.batchSize) {
175
+ const chunk = list.slice(i, i + this.batchSize);
176
+ const passages = chunk.map((c) => [c.title, c.content].filter(Boolean).join("\n"));
177
+ const scores = await this._scores(q, passages);
178
+ if (!Array.isArray(scores) || scores.length !== chunk.length) {
179
+ throw new Error("reranker scorer returned mismatched scores");
180
+ }
181
+ for (let j = 0; j < chunk.length; j++) {
182
+ results.push({ id: chunk[j].id, score: clamp01(Number(scores[j]) || 0) });
183
+ }
184
+ }
185
+ return results
186
+ .filter((r) => r.score >= this.scoreThreshold)
187
+ .sort((a, b) => b.score - a.score);
188
+ }
189
+
190
+ get modelHash() {
191
+ return modelHash(this.model);
192
+ }
193
+
194
+ dispose() {
195
+ try {
196
+ this.pipeline?.dispose?.();
197
+ } catch {
198
+ // best-effort: some engines free resources on GC
199
+ }
200
+ this.pipeline = null;
201
+ this._queryVec = null;
202
+ }
203
+ }
package/lib/service.js CHANGED
@@ -1,174 +1,268 @@
1
- const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
2
-
3
- export function createService({ store, mirror, config, onWrite }) {
4
- // Optional dream scheduler hook, installed via setDreamHook after creation
5
- // (the scheduler holds a reference back to the service, so it cannot be
6
- // passed in the constructor). Fired on the same write events as onWrite.
7
- let dreamHook = null;
8
-
9
- // Optional vector embedder, installed via setEmbedder after creation. After
10
- // any content write it fire-and-forgets a re-embed of the row so vector
11
- // search stays in sync; failures are swallowed inside the embedder.
12
- let embedder = null;
13
-
14
- function scheduleEmbed(memory) {
15
- if (embedder && memory?.id) {
16
- try { embedder.schedule(memory); } catch { /* ignore */ }
17
- }
18
- }
19
-
20
- /**
21
- * Fire-and-forget write notification; errors are swallowed to keep write
22
- * paths clean. The store mutation has already committed, so a throwing
23
- * subscriber must not surface as a write failure. Archive/forget flags are
24
- * state toggles, not content writes, so they never notify.
25
- */
26
- function notifyWrite() {
27
- if (onWrite) {
28
- try { onWrite(); } catch { /* ignore */ }
29
- }
30
- if (dreamHook) {
31
- try { dreamHook(); } catch { /* ignore */ }
32
- }
33
- }
34
-
35
- /**
36
- * Save a memory, merging into an existing one when title matches within the same type.
37
- * @returns {{action: "created"|"merged", memory: object}}
38
- */
39
- function saveWithDedupe(memory) {
40
- const existing = store
41
- .list({ type: memory.type, limit: 100 })
42
- .find((m) => m.title.trim() === String(memory.title).trim());
43
- if (existing) {
44
- const merged = store.update(existing.id, {
45
- content: memory.content ?? existing.content,
46
- importance: memory.importance ?? existing.importance,
47
- tags: memory.tags ?? existing.tags,
48
- title: memory.title ?? existing.title
49
- });
50
- syncMirror();
51
- notifyWrite();
52
- scheduleEmbed(merged);
53
- return { action: "merged", memory: merged };
54
- }
55
- const created = store.save({
56
- type: memory.type,
57
- title: memory.title,
58
- content: memory.content,
59
- tags: memory.tags ?? [],
60
- importance: memory.importance ?? 3,
61
- source: memory.source ?? "manual"
62
- });
63
- syncMirror();
64
- notifyWrite();
65
- scheduleEmbed(created);
66
- return { action: "created", memory: created };
67
- }
68
-
69
- /**
70
- * Candidate memories for automatic context injection:
71
- * summaries first, then all preferences, then non-forgotten items with
72
- * importance >= threshold. History is never auto-injected. Archived entries
73
- * are excluded (store.list already filters them by default; the extra
74
- * !m.archived check is kept as double insurance).
75
- */
76
- function injectCandidates({ maxItems = 5, threshold = 3 } = {}) {
77
- const items = store.list({ limit: 200, includeForgotten: false })
78
- .filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
79
- (m.type === "summary" || m.type === "preference" || m.importance >= threshold))
80
- .sort((a, b) => {
81
- const pa = a.type === "summary" ? 0 : a.type === "preference" ? 1 : 2;
82
- const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
83
- return pa - pb || b.importance - a.importance;
84
- });
85
- return items.slice(0, maxItems);
86
- }
87
-
88
- /**
89
- * Merge human edits parsed from a mirror file back into the store.
90
- * Only content/title are taken; structure fields stay machine-owned.
91
- */
92
- function mergeHumanEdits(type, edits) {
93
- let applied = 0;
94
- for (const edit of edits) {
95
- if (!edit.id) continue; // corrupt/malformed edit: skip it, keep merging the rest
96
- const existing = store.getById(edit.id);
97
- if (!existing || existing.type !== type) continue;
98
- const patch = {};
99
- if (typeof edit.title === "string" && edit.title.trim()) patch.title = edit.title.trim();
100
- if (typeof edit.content === "string" && edit.content.trim()) patch.content = edit.content.trim();
101
- if (Object.keys(patch).length) {
102
- store.update(edit.id, patch);
103
- applied++;
104
- }
105
- }
106
- if (applied) {
107
- syncMirror();
108
- notifyWrite();
109
- }
110
- return applied;
111
- }
112
-
113
- function toApiList(rows) {
114
- return rows.map((m) => ({
115
- id: m.id,
116
- type: m.type,
117
- title: m.title,
118
- content: m.content,
119
- tags: m.tags,
120
- importance: m.importance,
121
- source: m.source,
122
- created_at: m.created_at,
123
- updated_at: m.updated_at
124
- }));
125
- }
126
-
127
- /**
128
- * Re-render the human-editable mirror after any store mutation. Only
129
- * non-forgotten memories are mirrored: forgotten entries must not reach the
130
- * human-editable file (a human "edit" could otherwise resurrect them).
131
- */
132
- function syncMirror() {
133
- if (mirror) mirror.sync(store.list({ limit: 500, includeForgotten: false }));
134
- }
135
-
136
- return {
137
- saveWithDedupe,
138
- injectCandidates,
139
- mergeHumanEdits,
140
- toApiList,
141
- setDreamHook(fn) { dreamHook = fn; },
142
- setEmbedder(emb) { embedder = emb; },
143
- // passthroughs used by tools and api layers; mutations keep the mirror in sync
144
- search: (q, o) => store.search(q, o),
145
- searchVector: (v, o) => store.searchVector(v, o),
146
- embeddedCount: () => store.embeddedCount(),
147
- list: (o) => store.list(o),
148
- all: () => store.all(),
149
- count: (type) => store.count(type),
150
- getById: (id) => store.getById(id),
151
- remove: (id) => {
152
- store.remove(id);
153
- syncMirror();
154
- notifyWrite();
155
- },
156
- update: (id, p) => {
157
- const updated = store.update(id, p);
158
- syncMirror();
159
- notifyWrite();
160
- scheduleEmbed(updated);
161
- return updated;
162
- },
163
- setForget: (id, f) => {
164
- const updated = store.setForget(id, f);
165
- syncMirror();
166
- return updated;
167
- },
168
- setArchived: (id, f) => {
169
- const updated = store.setArchived(id, f);
170
- syncMirror();
171
- return updated;
172
- }
173
- };
174
- }
1
+ const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
2
+
3
+ export function createService({ store, mirror, config, onWrite }) {
4
+ // Optional dream scheduler hook, installed via setDreamHook after creation
5
+ // (the scheduler holds a reference back to the service, so it cannot be
6
+ // passed in the constructor). Fired on the same write events as onWrite.
7
+ let dreamHook = null;
8
+
9
+ // Optional vector embedder, installed via setEmbedder after creation. After
10
+ // any content write it fire-and-forgets a re-embed of the row so vector
11
+ // search stays in sync; failures are swallowed inside the embedder.
12
+ let embedder = null;
13
+ let vectorIndex = null;
14
+ let reranker = null;
15
+
16
+ function scheduleEmbed(memory) {
17
+ if (embedder && memory?.id) {
18
+ try { embedder.schedule(memory); } catch { /* ignore */ }
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Cross-encoder rerank over a candidate list (best effort). Reranker
24
+ * failures degrade to the original candidate order reranking is an
25
+ * accuracy upgrade, never a correctness gate.
26
+ */
27
+ async function rerankCandidates(query, candidates, topK) {
28
+ if (!reranker || !candidates.length) return candidates.slice(0, topK);
29
+ try {
30
+ const scored = await reranker.rerank(query, candidates.map((c) => ({ id: c.id, title: c.title, content: c.content })));
31
+ if (!Array.isArray(scored)) return candidates.slice(0, topK);
32
+ const byId = new Map(candidates.map((c) => [c.id, c]));
33
+ const out = [];
34
+ for (const s of scored) {
35
+ const c = byId.get(s.id);
36
+ if (c) { out.push({ ...c, score: s.score }); if (out.length >= topK) break; }
37
+ }
38
+ return out.length ? out : candidates.slice(0, topK);
39
+ } catch {
40
+ return candidates.slice(0, topK);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Semantic-aware memory search: keyword recall (store.search) plus optional
46
+ * vector recall + rerank. mode:
47
+ * auto (default) keyword first, vector fills remaining slots (legacy)
48
+ * hybrid vector first, keyword fills remaining slots
49
+ * vector vector only, falls back to keyword when unavailable
50
+ * keyword text only, never touches the embedder
51
+ * useRerank runs the cross-encoder over the merged list when a reranker is
52
+ * installed; results carry an extra `score` when reranked.
53
+ */
54
+ async function searchMemories(query, { mode = "auto", topK = 20, threshold, useRerank = true } = {}) {
55
+ const q = String(query ?? "").trim();
56
+ if (!q) return [];
57
+ const lim = topK > 0 ? topK : 20;
58
+
59
+ const keyword = store.search(q, { limit: lim });
60
+ const wantVector = mode === "vector" || mode === "hybrid" || (mode === "auto" && !!embedder);
61
+ let vector = [];
62
+ if (wantVector && embedder) {
63
+ try {
64
+ // Legacy embedders expose embed(query); local ones expose embedSingle.
65
+ const embedSingle = typeof embedder.embedSingle === "function"
66
+ ? embedder.embedSingle.bind(embedder)
67
+ : embedder.embed.bind(embedder);
68
+ const qv = await embedSingle(q);
69
+ if (qv?.length) {
70
+ vector = vectorIndex
71
+ ? vectorIndex.search(qv, { limit: lim * 2, threshold: threshold ?? 0 })
72
+ : store.searchVector(qv, { limit: lim * 2, threshold: threshold ?? 0 });
73
+ }
74
+ } catch { /* vector unavailable: keep keyword results */ }
75
+ }
76
+
77
+ let merged;
78
+ if (mode === "keyword") {
79
+ merged = keyword;
80
+ } else if (mode === "vector" || mode === "hybrid") {
81
+ // semantic-first: vector recalls lead, keyword fills remaining slots
82
+ merged = vector.length ? vector.slice(0, lim) : keyword;
83
+ const seen = new Set(merged.map((m) => m.id));
84
+ for (const m of keyword) {
85
+ if (merged.length >= lim) break;
86
+ if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
87
+ }
88
+ } else {
89
+ // auto: keyword leads, vector fills remaining slots (legacy behavior)
90
+ merged = keyword.slice(0, lim);
91
+ const seen = new Set(merged.map((m) => m.id));
92
+ for (const m of vector) {
93
+ if (merged.length >= lim) break;
94
+ if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
95
+ }
96
+ }
97
+
98
+ merged = merged.slice(0, lim);
99
+ if (useRerank && reranker && merged.length) {
100
+ return rerankCandidates(q, merged, lim);
101
+ }
102
+ return merged;
103
+ }
104
+
105
+ /**
106
+ * Fire-and-forget write notification; errors are swallowed to keep write
107
+ * paths clean. The store mutation has already committed, so a throwing
108
+ * subscriber must not surface as a write failure. Archive/forget flags are
109
+ * state toggles, not content writes, so they never notify.
110
+ */
111
+ function notifyWrite() {
112
+ if (onWrite) {
113
+ try { onWrite(); } catch { /* ignore */ }
114
+ }
115
+ if (dreamHook) {
116
+ try { dreamHook(); } catch { /* ignore */ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Save a memory, merging into an existing one when title matches within the same type.
122
+ * @returns {{action: "created"|"merged", memory: object}}
123
+ */
124
+ function saveWithDedupe(memory) {
125
+ const existing = store
126
+ .list({ type: memory.type, limit: 100 })
127
+ .find((m) => m.title.trim() === String(memory.title).trim());
128
+ if (existing) {
129
+ const merged = store.update(existing.id, {
130
+ content: memory.content ?? existing.content,
131
+ importance: memory.importance ?? existing.importance,
132
+ tags: memory.tags ?? existing.tags,
133
+ title: memory.title ?? existing.title
134
+ });
135
+ syncMirror();
136
+ notifyWrite();
137
+ scheduleEmbed(merged);
138
+ return { action: "merged", memory: merged };
139
+ }
140
+ const created = store.save({
141
+ type: memory.type,
142
+ title: memory.title,
143
+ content: memory.content,
144
+ tags: memory.tags ?? [],
145
+ importance: memory.importance ?? 3,
146
+ source: memory.source ?? "manual"
147
+ });
148
+ syncMirror();
149
+ notifyWrite();
150
+ scheduleEmbed(created);
151
+ return { action: "created", memory: created };
152
+ }
153
+
154
+ /**
155
+ * Candidate memories for automatic context injection:
156
+ * summaries first, then all preferences, then non-forgotten items with
157
+ * importance >= threshold. History is never auto-injected. Archived entries
158
+ * are excluded (store.list already filters them by default; the extra
159
+ * !m.archived check is kept as double insurance).
160
+ */
161
+ function injectCandidates({ maxItems = 5, threshold = 3 } = {}) {
162
+ const items = store.list({ limit: 200, includeForgotten: false })
163
+ .filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
164
+ (m.type === "summary" || m.type === "preference" || m.importance >= threshold))
165
+ .sort((a, b) => {
166
+ const pa = a.type === "summary" ? 0 : a.type === "preference" ? 1 : 2;
167
+ const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
168
+ return pa - pb || b.importance - a.importance;
169
+ });
170
+ return items.slice(0, maxItems);
171
+ }
172
+
173
+ /**
174
+ * Merge human edits parsed from a mirror file back into the store.
175
+ * Only content/title are taken; structure fields stay machine-owned.
176
+ */
177
+ function mergeHumanEdits(type, edits) {
178
+ let applied = 0;
179
+ for (const edit of edits) {
180
+ if (!edit.id) continue; // corrupt/malformed edit: skip it, keep merging the rest
181
+ const existing = store.getById(edit.id);
182
+ if (!existing || existing.type !== type) continue;
183
+ const patch = {};
184
+ if (typeof edit.title === "string" && edit.title.trim()) patch.title = edit.title.trim();
185
+ if (typeof edit.content === "string" && edit.content.trim()) patch.content = edit.content.trim();
186
+ if (Object.keys(patch).length) {
187
+ store.update(edit.id, patch);
188
+ applied++;
189
+ }
190
+ }
191
+ if (applied) {
192
+ syncMirror();
193
+ notifyWrite();
194
+ }
195
+ return applied;
196
+ }
197
+
198
+ function toApiList(rows) {
199
+ return rows.map((m) => ({
200
+ id: m.id,
201
+ type: m.type,
202
+ title: m.title,
203
+ content: m.content,
204
+ tags: m.tags,
205
+ importance: m.importance,
206
+ source: m.source,
207
+ created_at: m.created_at,
208
+ updated_at: m.updated_at
209
+ }));
210
+ }
211
+
212
+ /**
213
+ * Re-render the human-editable mirror after any store mutation. Only
214
+ * non-forgotten memories are mirrored: forgotten entries must not reach the
215
+ * human-editable file (a human "edit" could otherwise resurrect them).
216
+ */
217
+ function syncMirror() {
218
+ if (mirror) mirror.sync(store.list({ limit: 500, includeForgotten: false }));
219
+ }
220
+
221
+ return {
222
+ saveWithDedupe,
223
+ injectCandidates,
224
+ mergeHumanEdits,
225
+ toApiList,
226
+ setDreamHook(fn) { dreamHook = fn; },
227
+ setEmbedder(emb) { embedder = emb; },
228
+ setVectorIndex(vi) { vectorIndex = vi; },
229
+ setReranker(rn) { reranker = rn; },
230
+ searchMemories,
231
+ // passthroughs used by tools and api layers; mutations keep the mirror in sync
232
+ search: (q, o) => store.search(q, o),
233
+ searchVector: (v, o) => store.searchVector(v, o),
234
+ embeddedCount: () => store.embeddedCount(),
235
+ list: (o) => store.list(o),
236
+ all: () => store.all(),
237
+ count: (type) => store.count(type),
238
+ getById: (id) => store.getById(id),
239
+ remove: (id) => {
240
+ store.remove(id);
241
+ syncMirror();
242
+ notifyWrite();
243
+ },
244
+ update: (id, p) => {
245
+ const updated = store.update(id, p);
246
+ syncMirror();
247
+ notifyWrite();
248
+ scheduleEmbed(updated);
249
+ return updated;
250
+ },
251
+ setForget: (id, f) => {
252
+ const updated = store.setForget(id, f);
253
+ syncMirror();
254
+ return updated;
255
+ },
256
+ setArchived: (id, f) => {
257
+ const updated = store.setArchived(id, f);
258
+ syncMirror();
259
+ return updated;
260
+ },
261
+ // autoDream audit trail: passthroughs deliberately bypass write hooks —
262
+ // an audit write is bookkeeping, and notifyWrite would loop back into the
263
+ // dream scheduler that just recorded the run.
264
+ saveDreamRun: (run) => store.saveDreamRun(run),
265
+ getDreamRun: (id) => store.getDreamRun(id),
266
+ listDreamRuns: (opts) => store.listDreamRuns(opts)
267
+ };
268
+ }