@modusensus/dsh-mneme 0.6.8 → 0.6.10

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 (117) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +468 -463
  3. package/cordis.patch.yml +15 -15
  4. package/lib/api.js +783 -783
  5. package/lib/client.js +1754 -1757
  6. package/lib/commands.js +64 -64
  7. package/lib/config.js +298 -288
  8. package/lib/dream/clustering.js +118 -118
  9. package/lib/dream/decisions.js +488 -439
  10. package/lib/dream/sleep.js +561 -561
  11. package/lib/dream/tag-extractor.js +156 -156
  12. package/lib/dream.js +958 -935
  13. package/lib/embedding.js +154 -154
  14. package/lib/entities/extractor.js +242 -242
  15. package/lib/hot-memory.js +53 -53
  16. package/lib/index.js +361 -361
  17. package/lib/inject.js +208 -208
  18. package/lib/local-embedder.js +282 -282
  19. package/lib/mirror.js +170 -170
  20. package/lib/parser/tag.js +59 -59
  21. package/lib/parser/wiki-link.js +38 -38
  22. package/lib/quality-filter.js +123 -123
  23. package/lib/reranker.js +218 -218
  24. package/lib/search/adaptive.js +22 -22
  25. package/lib/search/bm25.js +96 -96
  26. package/lib/search/tag-boost.js +61 -61
  27. package/lib/service.js +1726 -1726
  28. package/lib/settings.js +172 -172
  29. package/lib/store.js +2238 -2238
  30. package/lib/summarize.js +236 -236
  31. package/lib/tools.js +290 -290
  32. package/lib/vector-index.js +116 -116
  33. package/package.json +80 -80
  34. package/scripts/benchmark-embed.js +201 -201
  35. package/scripts/benchmark-recall.js +133 -133
  36. package/scripts/benchmark-rerank.js +166 -166
  37. package/scripts/e2e-dsh.js +218 -218
  38. package/scripts/stress-dsh.js +255 -255
  39. package/scripts/sync-lib.js +52 -52
  40. package/src/api.js +783 -783
  41. package/src/commands.js +64 -64
  42. package/src/config.js +298 -288
  43. package/src/dream/clustering.js +118 -118
  44. package/src/dream/decisions.js +488 -439
  45. package/src/dream/sleep.js +561 -561
  46. package/src/dream/tag-extractor.js +156 -156
  47. package/src/dream.js +958 -935
  48. package/src/embedding.js +154 -154
  49. package/src/entities/extractor.js +242 -242
  50. package/src/hot-memory.js +53 -53
  51. package/src/index.js +361 -361
  52. package/src/inject.js +208 -208
  53. package/src/local-embedder.js +282 -282
  54. package/src/mirror.js +170 -170
  55. package/src/parser/tag.js +59 -59
  56. package/src/parser/wiki-link.js +38 -38
  57. package/src/quality-filter.js +123 -123
  58. package/src/reranker.js +218 -218
  59. package/src/search/adaptive.js +22 -22
  60. package/src/search/bm25.js +96 -96
  61. package/src/search/tag-boost.js +61 -61
  62. package/src/service.js +1726 -1726
  63. package/src/settings.js +172 -172
  64. package/src/store.js +2238 -2238
  65. package/src/summarize.js +236 -236
  66. package/src/tools.js +290 -290
  67. package/src/vector-index.js +116 -116
  68. package/test/api.test.js +594 -594
  69. package/test/audit.test.js +448 -448
  70. package/test/benchmark.test.js +35 -35
  71. package/test/boundary-v0625.test.js +82 -82
  72. package/test/client.test.js +368 -368
  73. package/test/clustering.test.js +100 -100
  74. package/test/commands.test.js +69 -69
  75. package/test/config.test.js +50 -50
  76. package/test/conflict-freeze.test.js +290 -290
  77. package/test/directory.test.js +134 -134
  78. package/test/dream.test.js +1060 -903
  79. package/test/entities.test.js +522 -522
  80. package/test/epistemic.test.js +298 -298
  81. package/test/fnew-0112.test.js +311 -311
  82. package/test/fnew-03.test.js +422 -422
  83. package/test/graph-api.test.js +175 -175
  84. package/test/helpers/dream-mock.js +82 -82
  85. package/test/hot-memory.test.js +174 -174
  86. package/test/inject.test.js +103 -103
  87. package/test/llm-audit.test.js +279 -279
  88. package/test/local-embedder.test.js +227 -227
  89. package/test/mirror-dirty.test.js +424 -424
  90. package/test/mirror-edit-digest.test.js +187 -187
  91. package/test/mirror-generation.test.js +499 -499
  92. package/test/mirror.test.js +249 -249
  93. package/test/normalize-decisions.test.js +120 -120
  94. package/test/peer-blockers.test.js +190 -190
  95. package/test/policy-epoch.test.js +259 -259
  96. package/test/provenance.test.js +103 -103
  97. package/test/quality-filter.test.js +118 -118
  98. package/test/reasoning-effort.test.js +199 -199
  99. package/test/recall-evals.test.js +235 -235
  100. package/test/recall-layer.test.js +315 -315
  101. package/test/receipt-chain.test.js +451 -451
  102. package/test/reflection.test.js +226 -226
  103. package/test/reranker.test.js +240 -240
  104. package/test/search-fusion.test.js +90 -90
  105. package/test/semantic.test.js +124 -124
  106. package/test/service-search.test.js +199 -199
  107. package/test/service.test.js +435 -435
  108. package/test/settings.test.js +118 -118
  109. package/test/sleep.test.js +365 -365
  110. package/test/store.test.js +436 -436
  111. package/test/stress.test.js +209 -209
  112. package/test/summarize.test.js +191 -191
  113. package/test/tag-boost.test.js +125 -125
  114. package/test/tag.test.js +312 -312
  115. package/test/tools.test.js +285 -285
  116. package/test/vector-index.test.js +221 -221
  117. package/test/wiki-link.test.js +332 -332
package/src/service.js CHANGED
@@ -1,1726 +1,1726 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { TYPE_FILE } from "./mirror.js";
3
- import { evaluateMemoryQuality } from "./quality-filter.js";
4
- import { createBM25Index } from "./search/bm25.js";
5
- import { adaptiveThreshold } from "./search/adaptive.js";
6
- import { parseWikiLinks } from "./parser/wiki-link.js";
7
- import { extractQueryTags, applyTagBoost } from "./search/tag-boost.js";
8
-
9
- const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
10
-
11
- // Epistemic trust weights (v0.4.5): when config.trustEpistemicWeighting is on,
12
- // each recall candidate's existing score is multiplied by the weight of its
13
- // epistemic_status before ranking — measured facts outrank guesses. Missing /
14
- // unknown statuses are unscaled (×1). Off by default, so nothing changes.
15
- const EPISTEMIC_WEIGHTS = { observation: 1.0, inferred: 0.85, subjective: 0.7 };
16
-
17
- // Bug5: content version history cap (FIFO — the newest 20 versions are kept,
18
- // older ones dropped). Entries are {content, source, updated_at}; source marks
19
- // how the version was superseded (auto_merge | human_override | overwrite).
20
- const CONTENT_HISTORY_MAX = 20;
21
-
22
- /** Prepend the previous content to a memory's content_history (FIFO capped). */
23
- function pushContentHistory(existing, source) {
24
- const history = Array.isArray(existing?.content_history) ? existing.content_history : [];
25
- return [
26
- { content: existing?.content ?? "", source, updated_at: new Date().toISOString() },
27
- ...history
28
- ].slice(0, CONTENT_HISTORY_MAX);
29
- }
30
-
31
- /** Bug5: same-title merge appends the new content under a timestamped `---`
32
- * separator instead of overwriting, so a re-noted memory never loses history.
33
- * The `---` line is compatible with the mirror's readHumanEdits (which strips
34
- * only the LAST structural `---` when parsing the human-editable file). */
35
- function appendContent(oldContent, newContent) {
36
- const ts = new Date().toISOString();
37
- return `${oldContent}\n\n---\n[${ts}] ${newContent}`;
38
- }
39
-
40
- /**
41
- * Standard retrieval-quality metrics over the ordered candidate ids actually
42
- * returned vs the ids the evaluator marked relevant (方案 B). Pure + total, so
43
- * callers (and tests) get deterministic numbers without touching a store:
44
- * precision = |relevant ∩ retrieved| / |retrieved|
45
- * recall = |relevant ∩ retrieved| / |expected|
46
- * mrr = 1 / rank of the first relevant doc (0 when none retrieved)
47
- * hit_count is the raw intersection size. Values are rounded to 4 decimals so
48
- * repeated divisions (e.g. 1/3) never surface binary-float noise.
49
- */
50
- export function computeRetrievalMetrics(actualIds, expectedIds) {
51
- const expected = new Set(Array.isArray(expectedIds) ? expectedIds : []);
52
- const actual = Array.isArray(actualIds) ? actualIds : [];
53
- const relevant = actual.filter((id) => expected.has(id)).length;
54
- const round4 = (x) => Math.round(x * 10000) / 10000;
55
- let mrr = 0;
56
- for (let i = 0; i < actual.length; i++) {
57
- if (expected.has(actual[i])) { mrr = 1 / (i + 1); break; }
58
- }
59
- return {
60
- precision: round4(actual.length ? relevant / actual.length : 0),
61
- recall: round4(expected.size ? relevant / expected.size : 0),
62
- mrr: round4(mrr),
63
- hit_count: relevant
64
- };
65
- }
66
-
67
- export function createService({ store, mirror, config, onWrite, logger }) {
68
- // Optional dream scheduler hook, installed via setDreamHook after creation
69
- // (the scheduler holds a reference back to the service, so it cannot be
70
- // passed in the constructor). Fired on the same write events as onWrite.
71
- let dreamHook = null;
72
-
73
- // Optional sleep scheduler hook (v0.4.0), installed via setSleepHook after
74
- // creation. Fired on the same write events as onWrite: it tells the sleep
75
- // scheduler the store just changed so the idle-detection clock resets.
76
- let sleepHook = null;
77
-
78
- // Optional vector embedder, installed via setEmbedder after creation. After
79
- // any content write it fire-and-forgets a re-embed of the row so vector
80
- // search stays in sync; failures are swallowed inside the embedder.
81
- let embedder = null;
82
-
83
- // Optional entity extractor, installed via setEntityExtractor after creation
84
- // (index.js injects it so the service never depends on the LLM directly).
85
- // After a new memory is saved it fire-and-forgets an extraction pass for the
86
- // entity gene (v0.3.0); failures are swallowed so a broken extraction never
87
- // surfaces as a write failure. Extraction only runs when
88
- // config.entityExtractionEnabled is true.
89
- let entityExtractor = null;
90
- let vectorIndex = null;
91
- let reranker = null;
92
-
93
- // Optional recall recorder, installed via setRecallRecorder after creation.
94
- // When searchMemories is called with recordRecall=true it receives the
95
- // actual merged recall scene (candidates + scores + source + threshold) so
96
- // the retrieval layer can be audited/replayed — the sibling of the dream
97
- // judgment-layer audit trail (dream_runs).
98
- let recallRecorder = null;
99
-
100
- // Bug4: semantic recall cache for the injection path. The system-prompt
101
- // interpolator renders context synchronously, so injectCandidates cannot
102
- // fire a fresh async embed. The most recent searchMemories recall is cached
103
- // here (query + ordered candidates) and reused when the injection query
104
- // matches, giving semantic-first injection without breaking the sync render.
105
- let lastSemanticRecall = null;
106
-
107
- // Transaction nesting depth. Inside service.transaction the per-mutation side
108
- // effects (mirror render, write notify, re-embed) are deferred so a ROLLBACK
109
- // never leaves the mirror file diverged from the database; transaction()
110
- // replays them exactly once against the committed state.
111
- let txDepth = 0;
112
-
113
- // Serial task queue (sleep v0.4.0). Long-running background passes — dream
114
- // consolidation, sleep cycles — must never overlap: two sleep runs racing
115
- // would double-demote or double-mint patterns. enqueue chains the task onto
116
- // a promise tail so N callers can queue work that runs strictly one at a
117
- // time. A task that rejects doesn't poison the queue (the tail swallows the
118
- // rejection) but the rejection still propagates to that caller.
119
- let queueTail = Promise.resolve();
120
- function enqueue(fn) {
121
- const next = queueTail.then(fn, fn);
122
- queueTail = next.catch(() => {});
123
- return next;
124
- }
125
-
126
- // issue #6 (part 2): startup race defense. A local embedder (LocalEmbedder /
127
- // Ollama) exposes an async init(), so between `setEmbedder` and init()
128
- // resolving there is a window where embedSingle would throw "not initialized"
129
- // and the re-embed would be silently dropped. When the embedder carries a
130
- // `ready` flag we queue writes in embedPending until init sets ready=true,
131
- // then flush them through the embedder's real interface. Embedders without a
132
- // `ready` flag (legacy OpenAI, instantly usable) keep their old behavior.
133
- let embedPending = [];
134
- let embedReadyTimer = null;
135
- const EMBED_PENDING_MAX = 100; // bound the queue; drop oldest beyond this
136
- const EMBED_READY_POLL_MS = 100;
137
- const EMBED_READY_POLL_LIMIT = 30; // ~3s ceiling; never poll forever
138
-
139
- /** Flush the queued re-embeds once the embedder is ready. Fail-safe. */
140
- function flushEmbedPending() {
141
- if (!embedder || embedPending.length === 0) return;
142
- const batch = embedPending.splice(0, embedPending.length);
143
- for (const memory of batch) {
144
- try {
145
- if (!memory?.id) continue;
146
- if (typeof embedder.schedule === "function") {
147
- embedder.schedule(memory);
148
- } else if (typeof embedder.embedSingle === "function") {
149
- const text = [memory.title, memory.content].filter(Boolean).join("\n");
150
- if (!text) continue;
151
- embedder
152
- .embedSingle(text)
153
- .then((vec) => {
154
- if (Array.isArray(vec) && vec.length) {
155
- store.setEmbedding(memory.id, vec);
156
- }
157
- })
158
- .catch((err) => {
159
- logger?.warn?.("flushEmbedPending embedSingle failed:", err);
160
- });
161
- }
162
- } catch (err) {
163
- logger?.warn?.("flushEmbedPending failed:", err);
164
- }
165
- }
166
- }
167
-
168
- function stopEmbedReadyPolling() {
169
- if (embedReadyTimer) {
170
- clearInterval(embedReadyTimer);
171
- embedReadyTimer = null;
172
- }
173
- }
174
-
175
- function scheduleEmbed(memory) {
176
- try {
177
- if (txDepth > 0) return; // deferred to the transaction's commit
178
- if (!embedder || !memory?.id) return;
179
-
180
- // Readiness gate: embedder exposes `ready` (async init) and is not ready
181
- // yet — queue instead of firing embedSingle into a half-built extractor.
182
- const hasReady = "ready" in embedder;
183
- if (hasReady && embedder.ready !== true) {
184
- if (embedPending.length >= EMBED_PENDING_MAX) embedPending.shift();
185
- embedPending.push(memory);
186
- return;
187
- }
188
-
189
- if (typeof embedder.schedule === "function") {
190
- embedder.schedule(memory);
191
- return;
192
- }
193
-
194
- if (typeof embedder.embedSingle === "function") {
195
- const text = [memory.title, memory.content].filter(Boolean).join("\n");
196
- if (!text) return;
197
-
198
- embedder
199
- .embedSingle(text)
200
- .then((vec) => {
201
- if (Array.isArray(vec) && vec.length) {
202
- store.setEmbedding(memory.id, vec);
203
- }
204
- })
205
- .catch((err) => {
206
- logger?.warn?.("scheduleEmbed embedSingle failed:", err);
207
- });
208
- }
209
- } catch (err) {
210
- logger?.warn?.("scheduleEmbed failed:", err);
211
- }
212
- }
213
-
214
- /**
215
- * Fire-and-forget entity extraction for a freshly saved memory (entity gene
216
- * v0.3.0). Opt-in via config.entityExtractionEnabled; the extractor is
217
- * injected as a hook so the service never needs a direct LLM reference.
218
- * The hook itself is expected to resolve to { ok:boolean } and never throw;
219
- * a thrown rejection is swallowed here as a final fail-safe.
220
- */
221
- function scheduleEntityExtraction(memory) {
222
- if (txDepth > 0) return; // deferred to the transaction's commit
223
- if (!config.entityExtractionEnabled || !entityExtractor) return;
224
- try {
225
- entityExtractor(memory).catch((err) => {
226
- logger?.warn?.("entity extraction failed:", err);
227
- });
228
- } catch (err) {
229
- logger?.warn?.("entity extraction failed:", err);
230
- }
231
- }
232
-
233
- /**
234
- * Fire-and-forget wiki-link resolution for a freshly saved/updated memory
235
- * (v0.6.1). Opt-in via config.wikiLinkEnabled. Parses [[target]] /
236
- * [[显示|target]] markers out of the memory content and writes links_to
237
- * relations (idempotent via the unique relation index). Runs through
238
- * service.enqueue so it serializes with autoDream/sleep and never overlaps
239
- * another background pass. Fully fail-safe: parse/store errors are swallowed
240
- * and logged, never a write failure.
241
- */
242
- function scheduleWikiLinkResolve(memory) {
243
- if (txDepth > 0) return; // deferred to the transaction's commit
244
- if (!config.wikiLinkEnabled || !memory?.id) return;
245
- try {
246
- const links = parseWikiLinks(memory?.content ?? "");
247
- if (!links.length) return;
248
- const targets = [...new Set(links.map((l) => l.target).filter(Boolean))];
249
- enqueue(() => {
250
- try {
251
- store.saveWikiLinks({ memoryId: memory.id, title: memory.title, targets });
252
- } catch (err) {
253
- logger?.warn?.("wiki link resolve failed:", err);
254
- }
255
- }).catch((err) => {
256
- logger?.warn?.("wiki link resolve failed:", err);
257
- });
258
- } catch (err) {
259
- logger?.warn?.("wiki link resolve failed:", err);
260
- }
261
- }
262
-
263
- /**
264
- * Cross-encoder rerank over a candidate list (best effort). Reranker
265
- * failures degrade to the original candidate order — reranking is an
266
- * accuracy upgrade, never a correctness gate.
267
- */
268
- async function rerankCandidates(query, candidates, topK) {
269
- if (!reranker || !candidates.length) return candidates.slice(0, topK);
270
- try {
271
- const scored = await reranker.rerank(query, candidates.map((c) => ({ id: c.id, title: c.title, content: c.content })));
272
- if (!Array.isArray(scored)) return candidates.slice(0, topK);
273
- const byId = new Map(candidates.map((c) => [c.id, c]));
274
- const out = [];
275
- for (const s of scored) {
276
- const c = byId.get(s.id);
277
- if (c) { out.push({ ...c, score: s.score, source: "rerank" }); if (out.length >= topK) break; }
278
- }
279
- return out.length ? out : candidates.slice(0, topK);
280
- } catch {
281
- return candidates.slice(0, topK);
282
- }
283
- }
284
-
285
- /**
286
- * Search for memories attached to a named entity (v0.3.0 Phase 3).
287
- * 合并优先级:entity_attrs.memory_id 精确关联 = 1.0 > 关键词提及 = 0.7;
288
- * attr 命中不覆盖,keyword 只补充召回,最后按 _score 降序取 topK。
289
- * @param {string} entityName
290
- * @param {object} [options]
291
- * @param {number} [options.topK=20]
292
- * @returns {any[]}
293
- */
294
- function searchByEntity(entityName, { topK = 20 } = {}) {
295
- const entity = store.findEntityByName(entityName);
296
- if (!entity) return [];
297
- const attrs = store.getCurrentAttrs(entity.id);
298
- const memoryIds = [...new Set(attrs.map((a) => a.memory_id).filter(Boolean))];
299
- const attrHits = memoryIds.map((id) => store.getById(id)).filter(Boolean);
300
- const keywordHits = store.search(entityName, { limit: topK });
301
- const merged = new Map();
302
- for (const mem of attrHits) merged.set(mem.id, { ...mem, _source: "entity_attr", _score: 1.0 });
303
- for (const mem of keywordHits) {
304
- if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 });
305
- }
306
- const hits = Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK);
307
- touchRecalled(hits);
308
- return hits;
309
- }
310
-
311
- /**
312
- * Search for memories by attribute key/value (v0.3.0 Phase 3).
313
- * value 为空时由 store.findMemoriesByAttr 返回该 key 的全部有效记忆。
314
- * @param {string} key
315
- * @param {string | undefined} value
316
- * @param {object} [options]
317
- * @param {number} [options.topK=20]
318
- * @returns {any[]}
319
- */
320
- function searchByAttr(key, value, { topK = 20 } = {}) {
321
- if (!key) return [];
322
- // value 可能为 undefined(attr:key 无 = 值):归一为空串后交给
323
- // store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部
324
- // 当前有效记忆(v0.3.0,store.js 已实现)。
325
- const rows = store.findMemoriesByAttr(key, value ?? "");
326
- const hits = rows.slice(0, topK);
327
- touchRecalled(hits);
328
- return hits;
329
- }
330
-
331
- /**
332
- * Search for memories carrying a given tag set (v0.6.2). Multiple tag:
333
- * tokens in one query use AND semantics — a memory must carry every tag.
334
- * Combinable with the leftover query: an entity:/attr: prefix is intersected
335
- * with the tag-matched set, and plain keyword text ranks it (only rows whose
336
- * keyword score > 0 survive). Tags are first-class recall, not entity-gated:
337
- * the tag match itself does not depend on config.entitySearchEnabled.
338
- * @param {string[]} tagTokens
339
- * @param {string} q — leftover query after tag: tokens were stripped
340
- * @param {object} [options]
341
- * @param {number} [options.topK=20]
342
- * @returns {any[]}
343
- */
344
- function searchByTags(tagTokens, q, options) {
345
- const { topK = 20 } = options;
346
- const tags = (Array.isArray(tagTokens) ? tagTokens : [])
347
- .map((t) => String(t).trim()).filter(Boolean);
348
- if (!tags.length) return [];
349
- let hits = store.findMemoriesByTags(tags);
350
- if (!hits.length) return [];
351
- // Intersect with entity:/attr: prefixes present in the leftover query.
352
- if (config?.entitySearchEnabled) {
353
- if (q.startsWith("entity:")) {
354
- const ids = new Set(searchByEntity(q.slice(7).trim(), { topK: 10000 }).map((m) => m.id));
355
- hits = hits.filter((m) => ids.has(m.id));
356
- }
357
- if (q.startsWith("attr:")) {
358
- const [key, value] = q.slice(5).split("=");
359
- const ids = new Set(searchByAttr(key, value, { topK: 10000 }).map((m) => m.id));
360
- hits = hits.filter((m) => ids.has(m.id));
361
- }
362
- }
363
- // Leftover plain text ranks the tag-matched set; only rows whose title or
364
- // content actually mentions the keyword survive (scoreKeyword's 0.3 base is
365
- // a non-match, so the containment check is the real gate); otherwise
366
- // preserve insertion order.
367
- const keywordOnly = q && !q.startsWith("entity:") && !q.startsWith("attr:");
368
- hits = keywordOnly
369
- ? hits
370
- .map((m) => ({ ...m, score: scoreKeyword(m, q), source: "tag" }))
371
- .filter((m) => (m.title ?? "").toLowerCase().includes(q.toLowerCase())
372
- || (m.content ?? "").toLowerCase().includes(q.toLowerCase()))
373
- .sort((a, b) => b.score - a.score)
374
- : hits.map((m) => ({ ...m, source: "tag" }));
375
- const result = hits.slice(0, topK);
376
- // Only count toward recall stats/forgetting when the caller asked for
377
- // recording — the panel's `tag:` search must not pollute the curve.
378
- if (options.recordRecall) touchRecalled(result);
379
- return result;
380
- }
381
-
382
- /**
383
- * Semantic-aware memory search: keyword recall (store.search) plus optional
384
- * vector recall + rerank. mode:
385
- * auto (default) keyword first, vector fills remaining slots (legacy)
386
- * hybrid vector first, keyword fills remaining slots
387
- * vector vector only, falls back to keyword when unavailable
388
- * keyword text only, never touches the embedder
389
- * useRerank runs the cross-encoder over the merged list when a reranker is
390
- * installed; results carry an extra `score` when reranked.
391
- */
392
- // Weighted blend factor for hybrid search; exposed so callers can tune it.
393
- const DEFAULT_HYBRID_WEIGHTS = { vector: 0.6, keyword: 0.4 };
394
-
395
- // Cosine over two plain arrays (shared by the search-time semantic dedup).
396
- function cosineVec(a, b) {
397
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || !a.length) return 0;
398
- let dot = 0, na = 0, nb = 0;
399
- for (let i = 0; i < a.length; i++) {
400
- dot += a[i] * b[i];
401
- na += a[i] * a[i];
402
- nb += b[i] * b[i];
403
- }
404
- if (na === 0 || nb === 0) return 0;
405
- return dot / (Math.sqrt(na) * Math.sqrt(nb));
406
- }
407
-
408
- /**
409
- * BM25 third recall path (v0.5.0 1.1). Scores the query tokens against the
410
- * live non-archived rows and returns the top `limit` hits with scores
411
- * normalized to [0,1]. Failures degrade to [] — BM25 is a recall booster,
412
- * never a correctness gate.
413
- */
414
- function bm25Recall(q, limit) {
415
- if (config?.bm25SearchEnabled === false) return [];
416
- try {
417
- const docs = store.list({ limit: 500, includeForgotten: false }).filter((m) => !m.archived && !m.session_disposed_at);
418
- if (!docs.length) return [];
419
- return createBM25Index(docs).search(q, { limit });
420
- } catch {
421
- return [];
422
- }
423
- }
424
-
425
- /**
426
- * Search-time semantic dedup (v0.5.0 2.3): greedy pass dropping candidates
427
- * whose embedding similarity to an already-kept row exceeds the threshold.
428
- * Rows without a stored embedding are always kept (no signal = no drop).
429
- */
430
- function semanticDeduplicate(candidates) {
431
- // Opt-in aggressive mode (default off): collapsing near-duplicates can
432
- // drop legitimately distinct rows on small embedding models, so it ships
433
- // behind searchSemanticDedup=true.
434
- if (config?.searchSemanticDedup !== true || candidates.length < 2) return candidates;
435
- const threshold = config?.searchSemanticDedupThreshold ?? 0.95;
436
- try {
437
- const vecs = store.getEmbeddings(candidates.map((c) => c.id));
438
- if (vecs.size < 2) return candidates;
439
- const kept = [];
440
- for (const c of candidates) {
441
- const v = vecs.get(c.id);
442
- if (!v) { kept.push(c); continue; }
443
- const dup = kept.some((k) => {
444
- const kv = vecs.get(k.id);
445
- return kv && cosineVec(v, kv) > threshold;
446
- });
447
- if (!dup) kept.push(c);
448
- }
449
- return kept;
450
- } catch {
451
- return candidates;
452
- }
453
- }
454
-
455
- /**
456
- * Give a keyword-hit row a relevance score in [0,1]: title hits score
457
- * higher than content hits, then scaled by importance (1-5). This lets
458
- * keyword results participate in weighted hybrid blends.
459
- */
460
- function scoreKeyword(row, q) {
461
- const ql = q.toLowerCase();
462
- const title = (row.title ?? "").toLowerCase();
463
- const content = (row.content ?? "").toLowerCase();
464
- const titleHit = title.includes(ql);
465
- const base = titleHit ? 1 : content.includes(ql) ? 0.6 : 0.3;
466
- return base * (0.5 + (row.importance ?? 3) / 10);
467
- }
468
-
469
- /**
470
- * Sleep touch (v0.4.0): when sleep is enabled, any memory surfaced by recall
471
- * or auto-injection gets its last_accessed_at bumped, so the "unrecalled N
472
- * days → demote/archive" tiering counts real access. Best-effort and gated on
473
- * config.sleepModeEnabled — when sleep is off this is a complete no-op (no
474
- * writes on the hot recall path). A touch failure must never break search/inject.
475
- */
476
- function touchRecalled(memories) {
477
- if (config?.sleepModeEnabled !== true || !Array.isArray(memories) || memories.length === 0) return;
478
- for (const m of memories) {
479
- if (!m?.id) continue;
480
- try {
481
- store.touchLastAccess(m.id);
482
- } catch { /* touch is best effort */ }
483
- }
484
- }
485
-
486
- async function searchMemories(query, options = {}) {
487
- const { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = options;
488
- const raw = String(query ?? "").trim();
489
- if (!raw) return [];
490
-
491
- // tag: 前缀(v0.6.2)。先把所有 tag: 令牌从 query 里剥出来,剩余的 q 仍可带
492
- // entity:/attr: 前缀或普通关键词 —— searchByTags 负责交集/排序。没有 tag:
493
- // 令牌则走下面的 entity:/attr:/文本原逻辑(完全向后兼容)。
494
- const tagTokens = [];
495
- const q = raw.replace(/\btag:([^\s]+)/g, (_, tok) => {
496
- if (tok) tagTokens.push(tok);
497
- return "";
498
- }).trim();
499
- if (tagTokens.length) return searchByTags(tagTokens, q, options);
500
-
501
- // entity:/attr: 前缀路由(v0.3.0 Phase 3)。entitySearchEnabled 关闭时走原逻辑。
502
- if (config?.entitySearchEnabled) {
503
- if (q.startsWith("entity:")) {
504
- return searchByEntity(q.slice(7).trim(), options);
505
- }
506
- if (q.startsWith("attr:")) {
507
- const [key, value] = q.slice(5).split("=");
508
- return searchByAttr(key, value, options);
509
- }
510
- }
511
-
512
- const lim = topK > 0 ? topK : 20;
513
-
514
- // Keyword results, decorated with a score so they can be weight-blended
515
- // with vector results and reported uniformly. source tracks where each
516
- // candidate came from for the recall layer receipt.
517
- const rawKeyword = store.search(q, { limit: lim });
518
- const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q), source: "keyword" }));
519
- const wantVector = mode === "vector" || mode === "hybrid" || (mode === "auto" && !!embedder);
520
- let vector = [];
521
- if (wantVector && embedder) {
522
- try {
523
- // Legacy embedders expose embed(query); local ones expose embedSingle.
524
- const embedSingle = typeof embedder.embedSingle === "function"
525
- ? embedder.embedSingle.bind(embedder)
526
- : embedder.embed.bind(embedder);
527
- const qv = await embedSingle(q);
528
- if (qv?.length) {
529
- // Adaptive threshold (v0.5.0 1.2): the fetch runs at the loosest
530
- // branch floor so the head-gap rule can still re-admit the tail;
531
- // the final cutoff is computed against the fetched score
532
- // distribution. Explicit `threshold` wins; disabled → legacy 0.
533
- const adaptive = config?.adaptiveThresholdEnabled !== false;
534
- const fetchThreshold = adaptive && threshold === undefined
535
- ? Math.min(0.5, adaptiveThreshold(q))
536
- : (threshold ?? 0);
537
- const search = vectorIndex
538
- ? vectorIndex.search(qv, { limit: lim * 2, threshold: fetchThreshold })
539
- : store.searchVector(qv, { limit: lim * 2, threshold: fetchThreshold });
540
- const finalThreshold = adaptive && threshold === undefined
541
- ? adaptiveThreshold(q, search)
542
- : (threshold ?? 0);
543
- vector = search
544
- .filter((m) => (m.score ?? 1) >= finalThreshold)
545
- .map((m) => ({ ...m, vector: true, source: "vector" }));
546
- }
547
- } catch { /* vector unavailable: keep keyword results */ }
548
- }
549
-
550
- // BM25 third path (v0.5.0 1.1): IDF-weighted token overlap recalls rows
551
- // whose query terms are scattered — the gap LIKE substring matching
552
- // cannot close. Scores are already normalized to [0,1].
553
- const bm25 = bm25Recall(q, lim).map((m) => ({ ...m, source: "bm25" }));
554
- // Loose blend weight: BM25 confirms and backfills, never dominates the
555
- // semantic signal. Same-memory overlap boosts, unseen ids backfill.
556
- const wb = 0.3;
557
- // Path bookkeeping for the boost rule below: which ids each semantic
558
- // recall path surfaced.
559
- const vectorIds = new Set(vector.map((m) => m.id));
560
- const keywordIds = new Set(keyword.map((m) => m.id));
561
-
562
- // Hybrid blending weights from config when provided.
563
- const wv = config?.hybridSearchVectorWeight ?? DEFAULT_HYBRID_WEIGHTS.vector;
564
- const wk = config?.hybridSearchKeywordWeight ?? DEFAULT_HYBRID_WEIGHTS.keyword;
565
-
566
- let merged;
567
- if (mode === "keyword") {
568
- merged = keyword;
569
- } else if (mode === "vector" || mode === "hybrid") {
570
- // semantic-first: vector recalls lead, keyword + BM25 fill remaining
571
- // slots. Weighted blend when sides scored the same memory; otherwise
572
- // vector order leads (it is the semantic signal), lexical paths
573
- // backfill.
574
- const byId = new Map();
575
- for (const m of vector) {
576
- const rec = byId.get(m.id);
577
- byId.set(m.id, rec ? { ...rec, score: Math.max(rec.score ?? 0, m.score ?? 0) } : m);
578
- }
579
- for (const m of keyword) {
580
- const rec = byId.get(m.id);
581
- if (rec) {
582
- // Same memory from both sides: blend the scores.
583
- byId.set(m.id, { ...rec, score: (rec.score ?? 0) * wv + (m.score ?? 0) * wk });
584
- } else {
585
- byId.set(m.id, m);
586
- }
587
- }
588
- for (const m of bm25) {
589
- const rec = byId.get(m.id);
590
- if (rec) {
591
- // Boost rule: a row the LIKE keyword path already hit carries the
592
- // query as a substring, so BM25 tokens are trivially present —
593
- // boosting it double-counts lexical evidence. Only vector-recalled
594
- // rows (lexical hit is genuinely new information) get the boost.
595
- if (keywordIds.has(m.id)) continue;
596
- byId.set(m.id, { ...rec, score: (rec.score ?? 0) + wb * (m.score ?? 0) });
597
- } else {
598
- byId.set(m.id, { ...m, score: wb * (m.score ?? 0) });
599
- }
600
- }
601
- const ranked = [...byId.values()]
602
- .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
603
- .map((r) => ({ ...r, score: Math.max(0, Math.min(1, r.score ?? 0)) }));
604
- // Tag boost (v0.6.4) needs the full ranked pool, not just the top-lim
605
- // slice, so a tagged candidate just below the line can be re-admitted
606
- // after the boost. Without boost this is the legacy lim truncation.
607
- merged = config.tagBoostEnabled === true ? ranked : ranked.slice(0, lim);
608
- if (merged.length < lim && !merged.length) {
609
- // Vector unavailable entirely: fall back to plain keyword.
610
- merged = keyword.slice(0, lim);
611
- }
612
- } else {
613
- // auto: keyword leads, vector + BM25 fill remaining slots (legacy
614
- // behavior, extended with the third path)
615
- merged = keyword.slice(0, lim);
616
- const seen = new Set(merged.map((m) => m.id));
617
- for (const m of vector) {
618
- if (merged.length >= lim) break;
619
- if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
620
- }
621
- for (const m of bm25) {
622
- if (merged.length >= lim) break;
623
- if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
624
- }
625
- }
626
-
627
- // Search-time semantic dedup (v0.5.0 2.3): near-duplicate rows are
628
- // dropped before the reranker sees them, so topK slots carry distinct
629
- // information instead of the same memory twice. Keyword mode is exempt —
630
- // it is the documented text-only path and must not be altered by
631
- // embedding state.
632
- merged = mode === "keyword" ? merged : semanticDeduplicate(merged);
633
-
634
- // Tag-weighted re-rank (v0.6.4): boost candidates whose tags overlap the
635
- // query tags or the current session's hot-memory tags — applied BEFORE the
636
- // final top-K cut so tagged candidates just below the line can be
637
- // re-admitted. Opt-in, and skipped entirely on the keyword-only path. When
638
- // a reranker is configured (opt-in), it remains the final authority on
639
- // order; the boost still shapes which candidates reach it.
640
- if (config.tagBoostEnabled === true && mode !== "keyword" && merged.length) {
641
- const ids = merged.map((m) => m.id);
642
- const tagsMap = store.getMemoryTagsMap(ids);
643
- const enriched = merged.map((m) => ({ ...m, tags: tagsMap.get(m.id) ?? [] }));
644
- const knownTags = [...tagsMap.values()].flat();
645
- const queryTags = extractQueryTags(q, knownTags);
646
- const sessionTags = Array.isArray(options?.sessionTags) ? options.sessionTags : [];
647
- if (queryTags.length || sessionTags.length) {
648
- merged = applyTagBoost(enriched, {
649
- queryTags,
650
- sessionTags,
651
- factor: config.tagBoostFactor,
652
- sessionFactor: config.sessionTagBoostFactor,
653
- }).map(({ tags, ...rest }) => rest);
654
- }
655
- }
656
- merged = merged.slice(0, lim);
657
-
658
- let result = useRerank && reranker && merged.length
659
- ? await rerankCandidates(q, merged, lim)
660
- : merged;
661
- // Epistemic trust (v0.4.5): opt-in re-weighting of the final candidate
662
- // scores by source credibility. When off (default) `result` is returned
663
- // untouched — exactly the legacy behavior.
664
- if (config.trustEpistemicWeighting === true) {
665
- result = result
666
- .map((m) => ({
667
- ...m,
668
- score: (m.score ?? 0) * (EPISTEMIC_WEIGHTS[m.epistemic_status] ?? 1)
669
- }))
670
- .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
671
- .slice(0, lim);
672
- }
673
-
674
- // Recall layer receipt: with recordRecall on, hand the actual merged
675
- // candidate list (id/title/content/score/source) to the injected recorder
676
- // before returning, making the retrieval scene replayable — the sibling of
677
- // the dream judgment-layer audit trail. Recorder failures must never break
678
- // the search itself.
679
- if (recordRecall && recallRecorder) {
680
- try {
681
- recallRecorder({
682
- query: q,
683
- mode,
684
- topK: lim,
685
- threshold: threshold ?? null,
686
- candidates: result.map((m) => ({
687
- id: m.id,
688
- title: m.title,
689
- content: m.content,
690
- score: m.score ?? null,
691
- source: m.source ?? "keyword"
692
- })),
693
- createdAt: new Date().toISOString()
694
- });
695
- } catch { /* recall receipt is best effort */ }
696
- }
697
- // Bug4: cache the latest semantic recall so the sync injection path can
698
- // reuse it when the injection query matches (no async embed available).
699
- lastSemanticRecall = { query: q, items: result };
700
- touchRecalled(result);
701
- return result;
702
- }
703
-
704
- /**
705
- * Retrieval evaluation (方案 B): run one search for `query`, compare the ids
706
- * it actually returned against `expectedIds`, and return the computed
707
- * metrics. When persistence is on (config.evalPersistTestResults, or an
708
- * explicit `persist` override per call) the snapshot is written to the
709
- * recall_evals table — a SEPARATE store from the recall_runs production audit,
710
- * so test/eval data never inflates the production trail.
711
- *
712
- * options:
713
- * mode/topK/threshold/useRerank — passed through to searchMemories
714
- * evalType — label for the snapshot (default 'manual')
715
- * recordRecall — also write a recall_runs audit row for the
716
- * same scene and link it via recall_run_id
717
- * (default false: eval stays unlinked)
718
- * recallRunId — explicit link to an existing recall_runs id
719
- * persist — override the config gate for this call
720
- *
721
- * Returns { metrics, actualIds, expectedIds, recallRunId, persisted }.
722
- * Never throws on persistence failures: a broken eval write must not break
723
- * the retrieval quality measurement.
724
- */
725
- async function evaluateRetrieval(query, expectedIds, options = {}) {
726
- const q = String(query ?? "").trim();
727
- const expected = Array.isArray(expectedIds) ? expectedIds : [];
728
- const {
729
- mode = "auto",
730
- topK = 20,
731
- threshold,
732
- useRerank = true,
733
- evalType = "manual",
734
- recordRecall = false,
735
- recallRunId = null,
736
- persist = config.evalPersistTestResults === true
737
- } = options;
738
- if (!q) {
739
- const empty = computeRetrievalMetrics([], expected);
740
- return { metrics: empty, actualIds: [], expectedIds: expected, recallRunId: null, persisted: false };
741
- }
742
-
743
- const rows = await searchMemories(q, { mode, topK, threshold, useRerank, recordRecall: false });
744
- const actualIds = rows.map((m) => m.id);
745
- const metrics = computeRetrievalMetrics(actualIds, expected);
746
-
747
- // Optional recall_runs audit for the same scene; the eval row then links to
748
- // it. Kept separate from the production recorder (which fires only on
749
- // recordRecall=true inside searchMemories) — eval never double-records.
750
- // An explicit recallRunId wins; recordRecall only mints a NEW audit run when
751
- // the caller did not already link one (never clobber an existing link).
752
- let runId = recallRunId ?? null;
753
- if (recordRecall && runId === null) {
754
- try {
755
- const run = store.saveRecallRun({
756
- query: q,
757
- mode,
758
- topK,
759
- threshold: threshold ?? null,
760
- candidates: rows.map((m) => ({
761
- id: m.id,
762
- title: m.title,
763
- content: m.content,
764
- score: m.score ?? null,
765
- source: m.source ?? "keyword"
766
- })),
767
- created_at: new Date().toISOString()
768
- });
769
- runId = run.id;
770
- } catch { /* non-fatal: the eval itself still succeeds */ }
771
- }
772
-
773
- let persisted = false;
774
- if (persist) {
775
- try {
776
- store.saveRecallEval({
777
- recall_run_id: runId,
778
- query: q,
779
- expected_ids: expected,
780
- actual_ids: actualIds,
781
- metrics,
782
- eval_type: evalType,
783
- created_at: new Date().toISOString()
784
- });
785
- persisted = true;
786
- } catch { /* non-fatal: measurement survives a failed eval write */ }
787
- }
788
- return { metrics, actualIds, expectedIds: expected, recallRunId: runId, persisted };
789
- }
790
-
791
- /**
792
- * Fire-and-forget write notification; errors are swallowed to keep write
793
- * paths clean. The store mutation has already committed, so a throwing
794
- * subscriber must not surface as a write failure. Archive/forget flags are
795
- * state toggles, not content writes, so they never notify.
796
- */
797
- function notifyWrite() {
798
- if (txDepth > 0) return; // deferred to the transaction's commit
799
- if (onWrite) {
800
- try { onWrite(); } catch { /* ignore */ }
801
- }
802
- if (dreamHook) {
803
- try { dreamHook(); } catch { /* ignore */ }
804
- }
805
- if (sleepHook) {
806
- try { sleepHook(); } catch { /* ignore */ }
807
- }
808
- }
809
-
810
- /**
811
- * Run several store mutations atomically (SQLite BEGIN/COMMIT/ROLLBACK) and
812
- * fire the deferred side effects once against the committed state. A throwing
813
- * body rolls the whole batch back — no partial writes, no diverged mirror.
814
- * Errors propagate to the caller. NOTE: the commit path re-renders the mirror
815
- * and notifies subscribers, but re-embedding is left to the caller (the dream
816
- * flow re-embeds through maintainIndexAfterDream).
817
- */
818
- function transaction(fn) {
819
- store.db.exec("BEGIN");
820
- txDepth++;
821
- try {
822
- const result = fn();
823
- store.db.exec("COMMIT");
824
- return result;
825
- } catch (error) {
826
- try { store.db.exec("ROLLBACK"); } catch { /* store may be closed */ }
827
- throw error;
828
- } finally {
829
- txDepth--;
830
- // Sync failures are surfaced, not swallowed (peer blocker 2): the mirror
831
- // debt was already recorded by markMirrorDirty inside syncMirror, so a
832
- // restart recovers — but the operator must see it now, not after restart.
833
- const syncResult = syncMirror();
834
- if (!syncResult?.success && !syncResult?.deferred) {
835
- logger?.warn?.("mirror sync failed after transaction:", syncResult?.error);
836
- }
837
- notifyWrite();
838
- }
839
- }
840
-
841
- /**
842
- * Embed an arbitrary query text and return its vector (null on failure / no
843
- * embedder). Used by the injector to prefetch the semantic-first recall
844
- * vector for the current user message — the system-prompt render is
845
- * synchronous, so the vector must be cached in advance (Bug4).
846
- */
847
- async function embedQuery(query) {
848
- const q = String(query ?? "").trim();
849
- if (!q || !embedder) return null;
850
- try {
851
- const embedSingle = typeof embedder.embedSingle === "function"
852
- ? embedder.embedSingle.bind(embedder)
853
- : embedder.embed.bind(embedder);
854
- const vector = await embedSingle(q);
855
- return Array.isArray(vector) && vector.length ? vector : null;
856
- } catch {
857
- return null;
858
- }
859
- }
860
-
861
- /**
862
- * Save a memory, merging into an existing one when title matches within the same type.
863
- *
864
- * Bug5: a same-title merge no longer overwrites — the new content is appended
865
- * under a timestamped `---` separator (`旧内容\n\n---\n[时间戳] 新内容`) and the
866
- * previous content is archived into content_history (source: auto_merge, FIFO
867
- * capped at 20). importance takes the max of both (capped at 5). Callers that
868
- * truly replace a row (dream summary regeneration) pass `_overwrite: true` to
869
- * overwrite directly while still archiving the old version (source: overwrite).
870
- * mergeHumanEdits entry points pass `_humanEdited: true` — same direct
871
- * overwrite semantics, source: human_override.
872
- *
873
- * Bug7 (memory quality filter): when config.memoryQualityFilter.enabled, the
874
- * memory is scored after dedupe, before write:
875
- * score >= degradeThreshold → stored normally (score persisted)
876
- * archiveThreshold <= score < 60 → persisted + ranked degraded
877
- * score < archiveThreshold → archived + tagged low_quality (still
878
- * explicitly searchable via includeArchived)
879
- * @returns {{action: "created"|"merged", memory: object}}
880
- */
881
- function saveWithDedupe(memory) {
882
- // Bug7: score quality once (after dedupe lookup, before write). Failures
883
- // inside the evaluator are impossible (pure function), but the write that
884
- // records the score must never fail the save — wrap defensively.
885
- const qf = config.memoryQualityFilter;
886
- let quality = null;
887
- if (qf?.enabled === true) {
888
- try {
889
- const recentContents = store.all().slice(0, 20).map((m) => m.content ?? "");
890
- quality = evaluateMemoryQuality(memory, {
891
- minContentLength: qf.minContentLength ?? 10,
892
- recentContents
893
- });
894
- } catch { /* quality scoring is best-effort */ }
895
- }
896
- const existing = store
897
- .list({ type: memory.type, limit: 100 })
898
- .find((m) => m.title.trim() === String(memory.title).trim());
899
- if (existing) {
900
- const newContent = String(memory.content ?? "");
901
- if (!newContent.trim()) {
902
- // Nothing to merge: the row stays untouched.
903
- return { action: "merged", memory: existing };
904
- }
905
- const direct = memory._overwrite === true || memory._humanEdited === true;
906
- const content = direct
907
- ? newContent
908
- : appendContent(existing.content, newContent);
909
- const importance = Math.min(5, Math.max(existing.importance, memory.importance ?? existing.importance));
910
- const merged = store.update(existing.id, {
911
- content,
912
- importance,
913
- tags: memory.tags ?? existing.tags,
914
- title: memory.title ?? existing.title,
915
- content_history: pushContentHistory(existing, direct
916
- ? (memory._humanEdited === true ? "human_override" : "overwrite")
917
- : "auto_merge"),
918
- ...(quality ? { quality_score: quality.score } : {})
919
- });
920
- // Bug7: a degraded/archived result is applied on top of the merged row.
921
- const result = applyQualityDisposition(merged, quality, qf);
922
- afterSync("write");
923
- notifyWrite();
924
- scheduleEmbed(result);
925
- scheduleWikiLinkResolve(result);
926
- return { action: "merged", memory: result };
927
- }
928
- const created = store.save({
929
- type: memory.type,
930
- title: memory.title,
931
- content: memory.content,
932
- tags: memory.tags ?? [],
933
- importance: memory.importance ?? 3,
934
- source: memory.source ?? "manual",
935
- // Provenance (v0.5.x): birth session rides through the create path; the
936
- // merge path above preserves the original row's session_id untouched.
937
- session_id: memory.session_id ?? undefined,
938
- ...(quality ? { quality_score: quality.score } : {})
939
- });
940
- const result = applyQualityDisposition(created, quality, qf);
941
- afterSync("write");
942
- notifyWrite();
943
- scheduleEmbed(result);
944
- scheduleEntityExtraction(result);
945
- scheduleWikiLinkResolve(result);
946
- return { action: "created", memory: result };
947
- }
948
-
949
- /**
950
- * Bug7: apply the quality verdict to a freshly written row. Below the archive
951
- * threshold the memory is archived + tagged low_quality (still searchable
952
- * explicitly via includeArchived); between archive and degrade thresholds the
953
- * score is already persisted and only the injection ranking is affected
954
- * (importance × score/100). Best-effort: a disposition write failure must
955
- * never fail the save. Returns the (possibly refreshed) memory row so callers
956
- * see the archived/tagged state, not the pre-disposition snapshot.
957
- */
958
- function applyQualityDisposition(memory, quality, qf) {
959
- if (!quality || qf?.enabled !== true) return memory;
960
- const archiveThreshold = qf.archiveThreshold ?? 30;
961
- // Signal tags (meta / repetitive / duplicate / short_content / low_quality)
962
- // are merged onto the stored row in every assessed band so the verdict is
963
- // observable, not just the numeric score. Below the archive threshold the
964
- // memory is additionally archived (still explicitly searchable).
965
- const tags = [...new Set([...(memory.tags ?? []), ...(quality.tags ?? [])])];
966
- if (tags.length === (memory.tags?.length ?? 0) && quality.score >= archiveThreshold) {
967
- return memory; // no tag drift and not archived → nothing extra to write
968
- }
969
- try {
970
- store.update(memory.id, { tags, quality_score: quality.score });
971
- if (quality.score < archiveThreshold) store.setArchived(memory.id, true);
972
- return store.getById(memory.id);
973
- } catch {
974
- return memory;
975
- }
976
- }
977
-
978
- /**
979
- * Candidate memories for automatic context injection:
980
- * summaries first, then all preferences, then non-forgotten items with
981
- * importance >= threshold. History is never auto-injected. Archived entries
982
- * are excluded (store.list already filters them by default; the extra
983
- * !m.archived check is kept as double insurance).
984
- *
985
- * Bug4 (hybridInject): when a non-empty `query` is available and a matching
986
- * semantic recall was cached by the last searchMemories, the vector hits
987
- * lead the selection (up to maxItems*2 candidates) and the rule-based pick
988
- * fills + dedupes the remaining slots. Empty query / no cached recall /
989
- * hybridInject off → pure legacy rule-based selection.
990
- */
991
- function injectCandidates({ query = "", maxItems = 5, threshold = 3, queryVector } = {}) {
992
- const q = String(query ?? "").trim();
993
- // Bug7: quality-weighted importance in the rule-based tier. Unassessed rows
994
- // (quality_score null) count as 100 (weight 1), so legacy stores keep their
995
- // exact summary>preference>importance ordering.
996
- const qualityWeight = (m) => (m.quality_score != null ? m.quality_score / 100 : 1);
997
- const items = store.list({ limit: 200, includeForgotten: false })
998
- .filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
999
- (m.type === "summary" || m.type === "preference" || m.importance >= threshold))
1000
- .sort((a, b) => {
1001
- const pa = a.type === "summary" ? 0 : a.type === "preference" ? 1 : 2;
1002
- const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
1003
- return pa - pb || (b.importance * qualityWeight(b)) - (a.importance * qualityWeight(a));
1004
- });
1005
- let candidates = items;
1006
- if (config.hybridInject !== false && q) {
1007
- // Bug4: semantic-first recall. Vector hits (queryVector, cached by the
1008
- // injector's async prefetch) lead when present; otherwise the last
1009
- // searchMemories recall for the exact same query is reused. Rule-based
1010
- // items fill + dedupe the remaining slots. Empty query / no vector /
1011
- // no cached recall → pure legacy rule-based selection.
1012
- const semanticItems = [];
1013
- if (Array.isArray(queryVector) && queryVector.length && vectorIndex) {
1014
- try {
1015
- const hits = vectorIndex.search(queryVector, { limit: maxItems * 2, threshold: 0 });
1016
- for (const m of hits) {
1017
- if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
1018
- (m.type === "summary" || m.type === "preference" || m.importance >= threshold)) {
1019
- semanticItems.push(m);
1020
- }
1021
- }
1022
- } catch { /* vector unavailable: fall through to the recall cache */ }
1023
- }
1024
- if (!semanticItems.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
1025
- for (const m of lastSemanticRecall.items) {
1026
- if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten) semanticItems.push(m);
1027
- }
1028
- }
1029
- if (semanticItems.length) {
1030
- const seen = new Set();
1031
- const merged = [];
1032
- const push = (m) => {
1033
- if (seen.has(m.id)) return;
1034
- seen.add(m.id);
1035
- merged.push(m);
1036
- };
1037
- for (const m of semanticItems) {
1038
- push(m);
1039
- if (merged.length >= maxItems * 2) break;
1040
- }
1041
- for (const m of items) {
1042
- if (merged.length >= maxItems * 2) break;
1043
- push(m);
1044
- }
1045
- candidates = merged;
1046
- }
1047
- }
1048
- // Topic-ranked selection (v0.5.0 2.2): when the current query's vector is
1049
- // available the whole candidate list is re-ordered by similarity to that
1050
- // vector, so the injected slots go to memories on the current topic
1051
- // rather than to the rule-based order. Rows the index did not return
1052
- // keep their relative order after the scored ones.
1053
- if (config?.selectiveInjectEnabled !== false && Array.isArray(queryVector) && queryVector.length && vectorIndex) {
1054
- try {
1055
- const hits = vectorIndex.search(queryVector, { limit: 200, threshold: 0 });
1056
- const sim = new Map(hits.map((m) => [m.id, m.score ?? 0]));
1057
- if (sim.size) {
1058
- candidates = [...candidates].sort((a, b) => (sim.get(b.id) ?? -1) - (sim.get(a.id) ?? -1));
1059
- }
1060
- } catch { /* topic re-rank unavailable: keep rule-based order */ }
1061
- }
1062
- const selected = candidates.slice(0, maxItems);
1063
- touchRecalled(selected);
1064
- return selected;
1065
- }
1066
-
1067
- /**
1068
- * Merge human edits parsed from a mirror file back into the store.
1069
- * Only content/title are taken; structure fields stay machine-owned.
1070
- */
1071
- function mergeHumanEdits(type, edits) {
1072
- let applied = 0;
1073
- for (const edit of edits) {
1074
- if (!edit.id) continue; // corrupt/malformed edit: skip it, keep merging the rest
1075
- const existing = store.getById(edit.id);
1076
- if (!existing || existing.type !== type) continue;
1077
- const patch = {};
1078
- if (typeof edit.title === "string" && edit.title.trim()) patch.title = edit.title.trim();
1079
- if (typeof edit.content === "string" && edit.content.trim()) patch.content = edit.content.trim();
1080
- if (Object.keys(patch).length) {
1081
- // 启动回灌(F-NEW-01):digest 存在且匹配 = 文件自渲染后无人触碰(旧机器
1082
- // 镜像),机器 wins,DB 的 New 必须保留,静默改回 Old 是 bug。
1083
- const digestMatches = typeof edit.digest === "string"
1084
- && typeof edit.title === "string"
1085
- && typeof edit.content === "string"
1086
- && createHash("sha256").update(`${edit.title}\x00${edit.content}`).digest("hex") === edit.digest;
1087
- if (digestMatches) continue;
1088
- // 文件 == store(无实际变化)时不覆盖,也不计入 applied。
1089
- const hasDiff = (patch.title !== undefined && existing.title !== patch.title)
1090
- || (patch.content !== undefined && existing.content !== patch.content);
1091
- if (!hasDiff) continue;
1092
- // 人工编辑回灌后触发 re-embed(issue #3 残留修复):向量必须与
1093
- // 新 title/content 一致。scheduleEmbed 为 fire-and-forget,
1094
- // 内部 try/catch 吞错,失败不影响主流程。
1095
- // Bug5: human edits overwrite directly, but the machine version is
1096
- // archived into content_history (source: human_override) before being
1097
- // replaced, so a manual correction never silently destroys the old value.
1098
- const merged = store.update(edit.id, {
1099
- ...patch,
1100
- content_history: patch.content !== undefined && existing.content !== patch.content
1101
- ? pushContentHistory(existing, "human_override")
1102
- : existing.content_history
1103
- });
1104
- applied++;
1105
- scheduleEmbed(merged);
1106
- }
1107
- }
1108
- if (applied) {
1109
- afterSync("write");
1110
- notifyWrite();
1111
- }
1112
- return applied;
1113
- }
1114
-
1115
- function toApiList(rows) {
1116
- return rows.map((m) => ({
1117
- id: m.id,
1118
- type: m.type,
1119
- title: m.title,
1120
- content: m.content,
1121
- tags: m.tags,
1122
- importance: m.importance,
1123
- source: m.source,
1124
- // session_id is optional on the wire: only carry it when present, so the
1125
- // DTO stays a lossless JSON object (undefined would vanish on serialize).
1126
- ...(m.session_id != null ? { session_id: m.session_id } : {}),
1127
- // Disposed state rides along when set, so a restore flow is not a blind
1128
- // op — the caller can see which entries are hidden before restoreBySession.
1129
- ...(m.session_disposed_at != null ? { disposed: true } : {}),
1130
- created_at: m.created_at,
1131
- updated_at: m.updated_at
1132
- }));
1133
- }
1134
-
1135
- /**
1136
- * Directory view (v0.6.3): group live memories by tag. Delegates to
1137
- * store.getDirectory (tag-sorted groups, importance/updated DESC members,
1138
- * live-only filtering) and maps every memory to the wire DTO so the result
1139
- * is JSON-safe for the API endpoint.
1140
- * @returns {{groups: {tag: string, memories: object[]}[], untagged: object[]}}
1141
- */
1142
- function getDirectory() {
1143
- const { groups, untagged } = store.getDirectory();
1144
- return {
1145
- groups: groups.map((g) => ({ tag: g.tag, memories: toApiList(g.memories) })),
1146
- untagged: toApiList(untagged)
1147
- };
1148
- }
1149
-
1150
- /**
1151
- * Three-way merge of in-flight human mirror edits before a re-render.
1152
- * Runs on every syncMirror, so a human edit made between two store writes is
1153
- * never silently overwritten by the next sync (human priority is not limited
1154
- * to startup). Per edited entry:
1155
- * - file changed only → human wins; the edit is merged back into the store.
1156
- * - file AND store changed → real three-way conflict: keep the human edit
1157
- * and append a marker preserving the store's concurrent version, so no
1158
- * side is dropped.
1159
- * - store changed only → store wins (the file is simply re-rendered).
1160
- * Only title/content are taken (structure fields stay machine-owned, matching
1161
- * mergeHumanEdits). Returns the memory list to render.
1162
- */
1163
- function reconcileHumanEdits(memories) {
1164
- if (!mirror) return memories;
1165
- const byType = new Map();
1166
- for (const m of memories) {
1167
- if (!byType.has(m.type)) byType.set(m.type, []);
1168
- byType.get(m.type).push(m);
1169
- }
1170
- const result = [];
1171
- for (const type of Object.keys(TYPE_FILE)) {
1172
- const list = byType.get(type) ?? [];
1173
- if (list.length === 0) continue;
1174
- const editsById = new Map(mirror.readHumanEdits(type).map((e) => [e.id, e]));
1175
- for (const m of list) {
1176
- const edit = editsById.get(m.id);
1177
- if (!edit) { result.push(m); continue; }
1178
- const humanChanged = (typeof edit.title === "string" && edit.title !== m.title)
1179
- || (typeof edit.content === "string" && edit.content !== m.content);
1180
- if (!humanChanged) { result.push(m); continue; }
1181
- // 判断文件是否被人工动过:digest 存在且匹配则无人触碰,否则视为人工动过。
1182
- // digest 是渲染时对 sha256(title \x00 content) 的记录;机器 store 更新后
1183
- // 镜像还没重渲染时读到旧内容,digest 仍匹配 → 机器 wins,不会误判为
1184
- // 并发人工编辑导致机器写丢失 + 伪冲突标记。
1185
- const digestMatches = typeof edit.digest === "string"
1186
- && typeof edit.title === "string"
1187
- && typeof edit.content === "string"
1188
- && createHash("sha256").update(`${edit.title}\x00${edit.content}`).digest("hex") === edit.digest;
1189
- if (digestMatches) {
1190
- // 无人触碰,机器 wins,走原样
1191
- result.push(m);
1192
- continue;
1193
- }
1194
- // 人工动过(digest 不存在=老文件/手工文件保守视为人工动过),走三方合并
1195
- // (保留现有 storeChanged 逻辑)
1196
- const storeChanged = edit.updated_at !== undefined && m.updated_at !== edit.updated_at;
1197
- if (storeChanged) {
1198
- const marker = `\n\n> ⚠️ 并发冲突:人工编辑 vs 记忆库并发更新(${m.updated_at})\n> 记忆库版本:${m.content}`;
1199
- store.update(m.id, { title: edit.title, content: `${edit.content}${marker}` });
1200
- } else {
1201
- store.update(m.id, { title: edit.title, content: edit.content });
1202
- }
1203
- const merged = store.getById(m.id);
1204
- scheduleEmbed(merged);
1205
- result.push(merged);
1206
- }
1207
- }
1208
- return result;
1209
- }
1210
-
1211
- /**
1212
- * Re-render the human-editable mirror after any store mutation, merging any
1213
- * in-flight human edits first (never silently overwriting them). Only
1214
- * non-forgotten memories are mirrored: forgotten entries must not reach the
1215
- * human-editable file (a human "edit" could otherwise resurrect them).
1216
- */
1217
- // syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
1218
- // v0.3.6(audit peer 4 阻断):
1219
- // - 开始时 incrementGeneration 绑定本次期望轮次 gen;成功用
1220
- // markMirrorCleanForGeneration(gen, now) CAS/fence 清 dirty——旧 worker
1221
- // (gen 已过期)不会误清另一 worker 未恢复的故障债务;
1222
- // - 失败写 markMirrorDirty(递增 desired 绑定新债务),下次 recover 恢复;
1223
- // - 逐 type 用 setTypeStatus 记录部分成功/失败(type_status JSON);
1224
- // - 所有 store 状态写入各自 try/catch,失败只 warn,绝不向外抛(F-NEW-03)。
1225
- function syncMirror() {
1226
- if (txDepth > 0 || !mirror) return { success: true, deferred: true }; // deferred to the transaction's commit
1227
- const now = new Date().toISOString();
1228
- let gen;
1229
- try {
1230
- // desired generation 已在业务写事务中原子递增(peer blocker 1);这里
1231
- // 直接读当前值作为本次同步的目标轮次,不再自行 incrementGeneration。
1232
- const state = store.getMirrorState();
1233
- gen = state?.generation ?? 0;
1234
- } catch (stateError) {
1235
- logger?.warn?.("syncMirror: getMirrorState failed:", stateError);
1236
- return { success: false, error: stateError?.message ?? String(stateError) };
1237
- }
1238
- // coveredTypes 提到 try 外初始化:即使 store.list 先抛错,catch 分支也有
1239
- // 合法的空 Set 可迭代,保证 syncMirror 自身绝不抛(fail-safe)。
1240
- const coveredTypes = new Set();
1241
- try {
1242
- // 预先获取本次要覆盖的 type 集合(只调一次 store.list)
1243
- const list = store.list({ limit: 500, includeForgotten: false });
1244
- for (const memory of list) {
1245
- if (memory?.type && TYPE_FILE[memory.type]) {
1246
- coveredTypes.add(memory.type);
1247
- }
1248
- }
1249
-
1250
- // Per-type physical outcome (audit peer D): mirror.sync writes each type
1251
- // file independently and reports per-type success/failure. A type whose
1252
- // file was physically committed must be marked committed even when a
1253
- // sibling type errors — the old code batch-failed every type on any error,
1254
- // leaving committed files mislabeled as failed and masking partial state.
1255
- // Absent entries (a type with no memories) count as success: sync prunes
1256
- // the stale file, which is itself a completed physical state.
1257
- // v0.6.2: attach entity_attrs-backed tags (entityTags) after the human-edit
1258
- // merge so renderMemory can draw the `#tag` line under each title. The
1259
- // bulk map is a single query, and a missing row simply renders no line.
1260
- const reconciled = reconcileHumanEdits(list);
1261
- const tagsMap = store.getMemoryTagsMap(reconciled.map((m) => m.id));
1262
- const tagged = reconciled.map((m) => ({ ...m, entityTags: tagsMap.get(m.id) ?? [] }));
1263
- let allOk = true;
1264
- const results = mirror.sync(tagged) ?? {};
1265
- for (const type of Object.keys(TYPE_FILE)) {
1266
- const r = results[type];
1267
- const ok = !r || r.ok === true;
1268
- if (!ok) allOk = false;
1269
- try {
1270
- if (ok) {
1271
- store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
1272
- } else {
1273
- store.setTypeStatus(type, { status: "failed", last_error: r.error ?? "mirror sync failed" });
1274
- }
1275
- } catch (stateError) {
1276
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
1277
- }
1278
- }
1279
-
1280
- // 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
1281
- // 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
1282
- if (allOk) {
1283
- try {
1284
- store.markMirrorCleanForGeneration(gen, now);
1285
- } catch (stateError) {
1286
- logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
1287
- return { success: false, error: stateError?.message ?? String(stateError) };
1288
- }
1289
- return { success: true };
1290
- }
1291
-
1292
- // 部分 type 失败:持久 dirty(债务绑定到新轮次),下次 recover 只补未收敛
1293
- // 的 type。committed 的 type 已应用本轮 gen,不因兄弟失败被回滚。
1294
- const failedTypes = Object.entries(results)
1295
- .filter(([, r]) => r && r.ok === false)
1296
- .map(([t]) => t);
1297
- try {
1298
- store.markMirrorDirty(`mirror sync failed for: ${failedTypes.join(", ")}`, now);
1299
- } catch (stateError) {
1300
- logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
1301
- }
1302
- return { success: false, error: `mirror sync failed for: ${failedTypes.join(", ")}` };
1303
- } catch (error) {
1304
- const errMsg = error?.message ?? String(error);
1305
- logger?.warn?.("syncMirror failed:", error);
1306
- try {
1307
- // 债务绑定到新的一轮(desired generation 原子递增;即便 dirty 写失败,
1308
- // generation 已推进,recoverMirror 仍能捕获,不产生 false-clean)。
1309
- store.markMirrorDirty(errMsg, now);
1310
- } catch (stateError) {
1311
- logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
1312
- }
1313
- // 逐 type 标记为 failed(applied_gen 不动)
1314
- for (const type of coveredTypes) {
1315
- try {
1316
- store.setTypeStatus(type, { status: "failed", last_error: errMsg });
1317
- } catch (stateError) {
1318
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
1319
- }
1320
- }
1321
- return { success: false, error: errMsg };
1322
- }
1323
- }
1324
-
1325
- // afterSync: run syncMirror and surface a failure to the operator instead of
1326
- // swallowing it (peer blocker 2 + audit peer B). The mirror debt has already
1327
- // been persisted by markMirrorDirty inside syncMirror, so a restart recovers —
1328
- // but the calling write path must not report clean while the mirror is
1329
- // known-stale. Returns the sync result so the caller can attach an explicit
1330
- // degraded/pending receipt to its return value instead of faking success.
1331
- function afterSync(label) {
1332
- const r = syncMirror();
1333
- if (!r?.success && !r?.deferred) {
1334
- logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
1335
- }
1336
- return r;
1337
- }
1338
-
1339
- // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
1340
- // (F-NEW-03 + v0.3.6)。触发条件不只是 dirty——还检查
1341
- // generation > applied_generation(有未应用的债务),这样 COMMIT→dirty 崩溃
1342
- // 窗口(DB 提交后、markMirrorDirty/clean 前进程退出 → dirty=false 但
1343
- // generation 不一致)也能被捕获。有界重试(最多 3 次)重跑 syncMirror 收敛;
1344
- // 某次成功后 dirty=false 且无更新债务(generation <= applied_generation)
1345
- // 立即停止。返回 { recovered, error } 供 index.js 启动 / api.js health 判断。
1346
- // 一切 fail-safe,绝不向外抛。
1347
- function recoverMirror() {
1348
- const MAX_ATTEMPTS = 3;
1349
- let lastError = null;
1350
- let recovered = false;
1351
-
1352
- try {
1353
- const state = store.getMirrorState();
1354
- // 崩溃窗口检测:dirty 或 generation > applied_generation(COMMIT→dirty 窗口)
1355
- if (!state?.dirty && !(state.generation > state.applied_generation)) {
1356
- // 本来就干净:无需恢复,视为成功
1357
- return { recovered: true, error: null };
1358
- }
1359
-
1360
- // 有 dirty 或有未应用债务:最多尝试 3 次 sync
1361
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
1362
- try {
1363
- syncMirror(); // syncMirror 内部已 catch,不会向外抛
1364
- const currentState = store.getMirrorState();
1365
- // 成功条件:dirty 为 false 且没有更新一轮的债务
1366
- // (generation <= applied_generation,恢复后由 syncMirror 里
1367
- // markMirrorCleanForGeneration 自动把 applied 跟上)
1368
- if (!currentState?.dirty && currentState.generation <= currentState.applied_generation) {
1369
- recovered = true;
1370
- lastError = null;
1371
- break;
1372
- }
1373
- // 仍 dirty 或仍有更新债务:记录最后一次错误供重试耗尽后上报。
1374
- // 注意:若别的 worker 又失败产生新债务(dirty 仍 true),这是"新债务"
1375
- // 不是本次失败,继续重试直到耗尽次数。
1376
- lastError = currentState.last_error || `Sync attempt ${attempt + 1} left mirror dirty or has pending debt`;
1377
- } catch (syncError) {
1378
- // syncMirror 理论不抛,fail-safe 兜底
1379
- const errMsg = syncError?.message ?? String(syncError);
1380
- logger?.warn?.("recoverMirror: sync attempt failed:", errMsg);
1381
- lastError = errMsg;
1382
- }
1383
- }
1384
-
1385
- if (!recovered) {
1386
- logger?.warn?.("dsh-mneme mirror: recover failed after", MAX_ATTEMPTS, "attempts");
1387
- } else {
1388
- logger?.warn?.("dsh-mneme mirror: recovered from dirty/pending state");
1389
- }
1390
- } catch (error) {
1391
- // fail-safe:任何意外异常不向外抛
1392
- lastError = error?.message ?? String(error);
1393
- logger?.warn?.("dsh-mneme mirror: recover failed with unexpected error:", error);
1394
- }
1395
-
1396
- return { recovered, error: recovered ? null : lastError };
1397
- }
1398
-
1399
- // getMirrorHealth: 暴露 mirror 同步健康状态,供 api.js /health 使用
1400
- // (F-NEW-03)。把 DB 的 dirty 0/1 转成 boolean;fail-safe,绝不向外抛。
1401
- function getMirrorHealth() {
1402
- try {
1403
- const state = store.getMirrorState();
1404
- if (!state) {
1405
- // 无状态行:返回安全默认值
1406
- return {
1407
- dirty: false,
1408
- last_error: null,
1409
- last_attempt: null,
1410
- success_at: null
1411
- };
1412
- }
1413
- return {
1414
- dirty: Boolean(state.dirty),
1415
- last_error: state.last_error ?? null,
1416
- last_attempt: state.last_attempt ?? null,
1417
- success_at: state.success_at ?? null
1418
- };
1419
- } catch (error) {
1420
- // fail-safe:状态读取失败也不向外抛,但必须显式表达"未知"而非伪装成
1421
- // 干净(peer blocker 5:真实读取失败要显式 unknown,不得归一为 dirty:false)。
1422
- logger?.warn?.("getMirrorHealth failed:", error);
1423
- return {
1424
- dirty: null,
1425
- last_error: error?.message ?? String(error),
1426
- last_attempt: null,
1427
- success_at: null
1428
- };
1429
- }
1430
- }
1431
-
1432
- /**
1433
- * Forward links (v0.6.1): memories the given memory explicitly links to via
1434
- * [[wiki-links]] in its content. Reads the links_to relations whose
1435
- * from_entity is this memory's title and resolves each to_entity back to a
1436
- * memory row (case-insensitive title match). Returns [{ target, relation }];
1437
- * a target title with no matching memory surfaces as { target: null }.
1438
- */
1439
- function getForwardLinks(memoryId) {
1440
- const memory = store.getById(memoryId);
1441
- if (!memory) return [];
1442
- const out = [];
1443
- for (const rel of store.getRelations(memory.title) ?? []) {
1444
- if (rel.relation_type !== "links_to" || rel.from_entity !== memory.title) continue;
1445
- out.push({ target: store.findByTitle?.(rel.to_entity) ?? null, relation: rel });
1446
- }
1447
- return out;
1448
- }
1449
-
1450
- /**
1451
- * Back links (v0.6.1): memories that explicitly link TO the given memory
1452
- * (their content carries a wiki-link whose target resolves to this memory's
1453
- * title). Reads the links_to relations whose to_entity is this memory's
1454
- * title; the linking memory is rel.memory_id (the source that wrote the
1455
- * relation). Deduped per source memory; missing/self links are dropped.
1456
- * Returns [{ source, relation }].
1457
- */
1458
- function getBacklinks(memoryId) {
1459
- const memory = store.getById(memoryId);
1460
- if (!memory) return [];
1461
- const out = [];
1462
- const seen = new Set();
1463
- for (const rel of store.getRelations(memory.title) ?? []) {
1464
- if (rel.relation_type !== "links_to" || rel.to_entity !== memory.title) continue;
1465
- const source = rel.memory_id ? store.getById(rel.memory_id) : undefined;
1466
- if (!source || source.id === memory.id || seen.has(source.id)) continue;
1467
- seen.add(source.id);
1468
- out.push({ source, relation: rel });
1469
- }
1470
- return out;
1471
- }
1472
-
1473
- return {
1474
- saveWithDedupe,
1475
- getBacklinks,
1476
- getForwardLinks,
1477
- resolveWikiLink: (title) => store.findByTitle?.(title),
1478
- recoverMirror,
1479
- getMirrorHealth,
1480
- getMirrorState: () => store.getMirrorState(),
1481
- injectCandidates,
1482
- mergeHumanEdits,
1483
- toApiList,
1484
- getDirectory,
1485
- transaction,
1486
- enqueue,
1487
- setDreamHook(fn) { dreamHook = fn; },
1488
- setSleepHook(fn) { sleepHook = fn; },
1489
- setEmbedder(emb) {
1490
- embedder = emb;
1491
- if (!emb) {
1492
- // embedder removed (init failed in index.js): stop polling and drop
1493
- // queued re-embeds — search just degrades to keyword.
1494
- stopEmbedReadyPolling();
1495
- embedPending = [];
1496
- return;
1497
- }
1498
- if (emb.ready === true) {
1499
- flushEmbedPending();
1500
- return;
1501
- }
1502
- // Async-initializing embedder: poll `ready` until it flips, then flush.
1503
- if ("ready" in emb && embedReadyTimer === null) {
1504
- let attempts = 0;
1505
- embedReadyTimer = setInterval(() => {
1506
- attempts++;
1507
- if (emb.ready === true || attempts >= EMBED_READY_POLL_LIMIT) {
1508
- stopEmbedReadyPolling();
1509
- if (emb.ready === true) flushEmbedPending();
1510
- else embedPending = []; // init never landed: drop the queue
1511
- }
1512
- }, EMBED_READY_POLL_MS);
1513
- }
1514
- },
1515
- setEntityExtractor(fn) { entityExtractor = fn; },
1516
- setVectorIndex(vi) { vectorIndex = vi; },
1517
- setReranker(rn) { reranker = rn; },
1518
- setRecallRecorder(fn) { recallRecorder = fn; },
1519
- searchMemories,
1520
- embedQuery,
1521
- evaluateRetrieval,
1522
- computeRetrievalMetrics,
1523
- // passthroughs used by tools and api layers; mutations keep the mirror in sync
1524
- search: (q, o) => store.search(q, o),
1525
- searchVector: (v, o) => store.searchVector(v, o),
1526
- embeddedCount: () => store.embeddedCount(),
1527
- list: (o) => store.list(o),
1528
- all: () => store.all(),
1529
- count: (type, opts) => store.count(type, opts),
1530
- getById: (id) => store.getById(id),
1531
- remove: (id) => {
1532
- store.remove(id);
1533
- afterSync("write");
1534
- notifyWrite();
1535
- },
1536
- // Session lifecycle (v0.6.0): mark/clear the session-disposed state on every
1537
- // memory born in a given session. Uses the dedicated `session_disposed_at`
1538
- // column, orthogonal to `archived` — restoring a session never resurrects
1539
- // memories the user archived on purpose. Nothing is destroyed; a session
1540
- // treated as a save point is fully recoverable via restoreBySession.
1541
- disposeBySession: (sessionId) => {
1542
- const disposed = store.setDisposedBySession(sessionId, true);
1543
- if (disposed > 0) {
1544
- afterSync("write");
1545
- notifyWrite();
1546
- }
1547
- return { disposed };
1548
- },
1549
- restoreBySession: (sessionId) => {
1550
- const restored = store.setDisposedBySession(sessionId, false);
1551
- if (restored > 0) {
1552
- afterSync("write");
1553
- notifyWrite();
1554
- }
1555
- return { restored };
1556
- },
1557
- listBySession: (sessionId, opts = {}) => toApiList(store.listBySession(sessionId, opts)),
1558
- update: (id, p, ctx = {}) => {
1559
- const old = store.getById(id);
1560
- const updated = store.update(id, p);
1561
- // Record a user correction when any meaningful field changed and the
1562
- // reflection failure tracker is enabled. expected = what it became,
1563
- // actual = what it was before; query (when provided) captures the
1564
- // user's original intent so later reflection can reason about recall.
1565
- const hasMeaningfulChange = old && updated && (
1566
- old.content !== updated.content ||
1567
- old.title !== updated.title ||
1568
- old.importance !== updated.importance
1569
- );
1570
- if (hasMeaningfulChange && config.reflectionFailureTracking) {
1571
- store.saveFailure({
1572
- id: randomUUID(),
1573
- query: ctx.query ?? null,
1574
- expected: updated.content,
1575
- actual: old.content,
1576
- before: { title: old.title, content: old.content, importance: old.importance },
1577
- failure_type: "user_correction",
1578
- memory_id: id
1579
- });
1580
- }
1581
- const sync = afterSync("write");
1582
- notifyWrite();
1583
- scheduleEmbed(updated);
1584
- scheduleWikiLinkResolve(updated);
1585
- // Audit peer B: when the mirror sync failed, the store write landed but
1586
- // the mirror did not converge — return an explicit degraded receipt rather
1587
- // than a plain success. Non-enumerable so existing deepEqual assertions on
1588
- // the memory shape keep passing.
1589
- if (!sync?.success && !sync?.deferred) {
1590
- Object.defineProperty(updated, "_mirror", {
1591
- value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
1592
- enumerable: false,
1593
- configurable: true
1594
- });
1595
- }
1596
- return updated;
1597
- },
1598
- // Compare-and-set update: applies the patch only when the row still carries
1599
- // `expectedUpdatedAt`. Returns undefined on a miss (no write) so the caller
1600
- // can re-read and retry — the primitive that prevents lost updates across
1601
- // concurrent read-modify-write (see scripts/stress-dsh.js axis 3).
1602
- compareAndUpdate: (id, expectedUpdatedAt, patch, ctx = {}) => {
1603
- const old = store.getById(id);
1604
- const updated = store.compareAndUpdate(id, expectedUpdatedAt, patch);
1605
- if (updated === undefined) return undefined; // CAS miss: no write, no side effects
1606
- const hasMeaningfulChange = old && updated && (
1607
- old.content !== updated.content ||
1608
- old.title !== updated.title ||
1609
- old.importance !== updated.importance
1610
- );
1611
- if (hasMeaningfulChange && config.reflectionFailureTracking) {
1612
- store.saveFailure({
1613
- id: randomUUID(),
1614
- query: ctx.query ?? null,
1615
- expected: updated.content,
1616
- actual: old.content,
1617
- before: { title: old.title, content: old.content, importance: old.importance },
1618
- failure_type: "user_correction",
1619
- memory_id: id
1620
- });
1621
- }
1622
- const sync = afterSync("write");
1623
- notifyWrite();
1624
- scheduleEmbed(updated);
1625
- // Audit peer B: mirror sync failure on a CAS write must surface too.
1626
- if (!sync?.success && !sync?.deferred) {
1627
- Object.defineProperty(updated, "_mirror", {
1628
- value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
1629
- enumerable: false,
1630
- configurable: true
1631
- });
1632
- }
1633
- return updated;
1634
- },
1635
- setForget: (id, f) => {
1636
- const updated = store.setForget(id, f);
1637
- afterSync("write");
1638
- return updated;
1639
- },
1640
- setArchived: (id, f) => {
1641
- const updated = store.setArchived(id, f);
1642
- afterSync("write");
1643
- return updated;
1644
- },
1645
- // sleep-mode storage (v0.4.0). demoteToSummary / restoreContent mutate
1646
- // content so they ride the normal write-hook path (mirror re-renders).
1647
- // touchLastAccess is a read-stamp — deliberately NO write hook (a recall
1648
- // must not dirty the mirror). getUnrecalledSince is a pure read.
1649
- demoteToSummary: (id, summary, opts) => {
1650
- const updated = store.demoteToSummary(id, summary, opts);
1651
- afterSync("write");
1652
- return updated;
1653
- },
1654
- restoreContent: (id) => {
1655
- const updated = store.restoreContent(id);
1656
- afterSync("write");
1657
- return updated;
1658
- },
1659
- touchLastAccess: (id, at) => store.touchLastAccess(id, at),
1660
- getUnrecalledSince: (cutMs, opts) => store.getUnrecalledSince(cutMs, opts),
1661
- // autoDream audit trail: passthroughs deliberately bypass write hooks —
1662
- // an audit write is bookkeeping, and notifyWrite would loop back into the
1663
- // dream scheduler that just recorded the run.
1664
- saveDreamRun: (run) => store.saveDreamRun(run),
1665
- getDreamRun: (id) => store.getDreamRun(id),
1666
- listDreamRuns: (opts) => store.listDreamRuns(opts),
1667
- // Per-record receipt chain (same bookkeeping semantics as saveDreamRun: an
1668
- // audit write, never a write-hook-triggering memory mutation).
1669
- saveReceipt: (r) => store.saveReceipt(r),
1670
- getReceipt: (id) => store.getReceipt(id),
1671
- listReceipts: (opts) => store.listReceipts(opts),
1672
- // Conflict freeze bookkeeping (same semantics as the audit passthroughs
1673
- // above: an audit write, never a write-hook-triggering memory mutation).
1674
- saveConflictPending: (r) => store.saveConflictPending(r),
1675
- listConflictPending: (opts) => store.listConflictPending(opts),
1676
- resolveConflictPending: (id, o) => store.resolveConflictPending(id, o),
1677
- countConflictPending: () => store.countConflictPending(),
1678
- // Recall evaluation trail (方案 B): audit-bookkeeping semantics like the
1679
- // dream/recall passthroughs above — a recall_evals write is a snapshot, not
1680
- // a memory mutation, so it never triggers write hooks.
1681
- saveRecallEval: (r) => store.saveRecallEval(r),
1682
- getRecallEval: (id) => store.getRecallEval(id),
1683
- listRecallEvals: (opts) => store.listRecallEvals(opts),
1684
- // LLM audit trail (Bug8): bookkeeping semantics like the recall/dream
1685
- // passthroughs — a saveLlmAudit write never triggers write hooks.
1686
- saveLlmAudit: (entry) => store.saveLlmAudit(entry),
1687
- listLlmAudits: (opts) => store.listLlmAudits(opts),
1688
- countLlmAudits: (opts) => store.countLlmAudits(opts),
1689
- getLlmAuditStats: (opts) => store.getLlmAuditStats(opts),
1690
- deleteOldLlmAudits: (before) => store.deleteOldLlmAudits(before),
1691
- // Entity gene (v0.3.0) passthroughs for the autoDream apply path
1692
- // (applyDecisions): records supersedes relations after an update and
1693
- // migrates entity_attrs on merge. Bookkeeping writes like the audit
1694
- // passthroughs above — never write-hook-triggering memory mutations.
1695
- saveRelation: (r) => store.saveRelation(r),
1696
- saveWikiLinks: (r) => store.saveWikiLinks(r),
1697
- listEntities: (o) => store.listEntities(o),
1698
- getRelations: (id) => store.getRelations(id),
1699
- // Tag system (v0.6.2). setMemoryTags is the manual/user path: gated by
1700
- // config.manualTagEnabled (default true), re-renders the mirror and fires
1701
- // the write hook. applyMemoryTags is the raw write used by the autoDream
1702
- // tag pass (gated by autoTagEnabled, wrapped in a transaction by the
1703
- // extractor so the mirror re-renders exactly once).
1704
- setMemoryTags: (memoryId, tags) => {
1705
- if (config.manualTagEnabled === false) {
1706
- return { ok: false, error: "manualTagEnabled is off" };
1707
- }
1708
- const stored = store.setMemoryTags(memoryId, tags);
1709
- afterSync("write");
1710
- notifyWrite();
1711
- return { ok: true, tags: stored };
1712
- },
1713
- getMemoryTags: (memoryId) => store.getMemoryTags(memoryId),
1714
- // Read-only gate flag so the Web panel can hide tag editing when the
1715
- // manual path is disabled (default: manual tagging is on).
1716
- manualTagEnabled: () => config.manualTagEnabled !== false,
1717
- applyMemoryTags: (memoryId, tags) => store.setMemoryTags(memoryId, tags),
1718
- saveAttr: (r) => store.saveAttr(r),
1719
- createEntity: (r) => store.createEntity(r),
1720
- findEntityByName: (n) => store.findEntityByName(n),
1721
- findEntityById: (id) => store.findEntityById(id),
1722
- getAttrsByMemory: (id) => store.getAttrsByMemory(id),
1723
- getCurrentAttrs: (id) => store.getCurrentAttrs(id),
1724
- migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
1725
- };
1726
- }
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { TYPE_FILE } from "./mirror.js";
3
+ import { evaluateMemoryQuality } from "./quality-filter.js";
4
+ import { createBM25Index } from "./search/bm25.js";
5
+ import { adaptiveThreshold } from "./search/adaptive.js";
6
+ import { parseWikiLinks } from "./parser/wiki-link.js";
7
+ import { extractQueryTags, applyTagBoost } from "./search/tag-boost.js";
8
+
9
+ const INJECT_TYPES = new Set(["preference", "project", "decision", "summary"]);
10
+
11
+ // Epistemic trust weights (v0.4.5): when config.trustEpistemicWeighting is on,
12
+ // each recall candidate's existing score is multiplied by the weight of its
13
+ // epistemic_status before ranking — measured facts outrank guesses. Missing /
14
+ // unknown statuses are unscaled (×1). Off by default, so nothing changes.
15
+ const EPISTEMIC_WEIGHTS = { observation: 1.0, inferred: 0.85, subjective: 0.7 };
16
+
17
+ // Bug5: content version history cap (FIFO — the newest 20 versions are kept,
18
+ // older ones dropped). Entries are {content, source, updated_at}; source marks
19
+ // how the version was superseded (auto_merge | human_override | overwrite).
20
+ const CONTENT_HISTORY_MAX = 20;
21
+
22
+ /** Prepend the previous content to a memory's content_history (FIFO capped). */
23
+ function pushContentHistory(existing, source) {
24
+ const history = Array.isArray(existing?.content_history) ? existing.content_history : [];
25
+ return [
26
+ { content: existing?.content ?? "", source, updated_at: new Date().toISOString() },
27
+ ...history
28
+ ].slice(0, CONTENT_HISTORY_MAX);
29
+ }
30
+
31
+ /** Bug5: same-title merge appends the new content under a timestamped `---`
32
+ * separator instead of overwriting, so a re-noted memory never loses history.
33
+ * The `---` line is compatible with the mirror's readHumanEdits (which strips
34
+ * only the LAST structural `---` when parsing the human-editable file). */
35
+ function appendContent(oldContent, newContent) {
36
+ const ts = new Date().toISOString();
37
+ return `${oldContent}\n\n---\n[${ts}] ${newContent}`;
38
+ }
39
+
40
+ /**
41
+ * Standard retrieval-quality metrics over the ordered candidate ids actually
42
+ * returned vs the ids the evaluator marked relevant (方案 B). Pure + total, so
43
+ * callers (and tests) get deterministic numbers without touching a store:
44
+ * precision = |relevant ∩ retrieved| / |retrieved|
45
+ * recall = |relevant ∩ retrieved| / |expected|
46
+ * mrr = 1 / rank of the first relevant doc (0 when none retrieved)
47
+ * hit_count is the raw intersection size. Values are rounded to 4 decimals so
48
+ * repeated divisions (e.g. 1/3) never surface binary-float noise.
49
+ */
50
+ export function computeRetrievalMetrics(actualIds, expectedIds) {
51
+ const expected = new Set(Array.isArray(expectedIds) ? expectedIds : []);
52
+ const actual = Array.isArray(actualIds) ? actualIds : [];
53
+ const relevant = actual.filter((id) => expected.has(id)).length;
54
+ const round4 = (x) => Math.round(x * 10000) / 10000;
55
+ let mrr = 0;
56
+ for (let i = 0; i < actual.length; i++) {
57
+ if (expected.has(actual[i])) { mrr = 1 / (i + 1); break; }
58
+ }
59
+ return {
60
+ precision: round4(actual.length ? relevant / actual.length : 0),
61
+ recall: round4(expected.size ? relevant / expected.size : 0),
62
+ mrr: round4(mrr),
63
+ hit_count: relevant
64
+ };
65
+ }
66
+
67
+ export function createService({ store, mirror, config, onWrite, logger }) {
68
+ // Optional dream scheduler hook, installed via setDreamHook after creation
69
+ // (the scheduler holds a reference back to the service, so it cannot be
70
+ // passed in the constructor). Fired on the same write events as onWrite.
71
+ let dreamHook = null;
72
+
73
+ // Optional sleep scheduler hook (v0.4.0), installed via setSleepHook after
74
+ // creation. Fired on the same write events as onWrite: it tells the sleep
75
+ // scheduler the store just changed so the idle-detection clock resets.
76
+ let sleepHook = null;
77
+
78
+ // Optional vector embedder, installed via setEmbedder after creation. After
79
+ // any content write it fire-and-forgets a re-embed of the row so vector
80
+ // search stays in sync; failures are swallowed inside the embedder.
81
+ let embedder = null;
82
+
83
+ // Optional entity extractor, installed via setEntityExtractor after creation
84
+ // (index.js injects it so the service never depends on the LLM directly).
85
+ // After a new memory is saved it fire-and-forgets an extraction pass for the
86
+ // entity gene (v0.3.0); failures are swallowed so a broken extraction never
87
+ // surfaces as a write failure. Extraction only runs when
88
+ // config.entityExtractionEnabled is true.
89
+ let entityExtractor = null;
90
+ let vectorIndex = null;
91
+ let reranker = null;
92
+
93
+ // Optional recall recorder, installed via setRecallRecorder after creation.
94
+ // When searchMemories is called with recordRecall=true it receives the
95
+ // actual merged recall scene (candidates + scores + source + threshold) so
96
+ // the retrieval layer can be audited/replayed — the sibling of the dream
97
+ // judgment-layer audit trail (dream_runs).
98
+ let recallRecorder = null;
99
+
100
+ // Bug4: semantic recall cache for the injection path. The system-prompt
101
+ // interpolator renders context synchronously, so injectCandidates cannot
102
+ // fire a fresh async embed. The most recent searchMemories recall is cached
103
+ // here (query + ordered candidates) and reused when the injection query
104
+ // matches, giving semantic-first injection without breaking the sync render.
105
+ let lastSemanticRecall = null;
106
+
107
+ // Transaction nesting depth. Inside service.transaction the per-mutation side
108
+ // effects (mirror render, write notify, re-embed) are deferred so a ROLLBACK
109
+ // never leaves the mirror file diverged from the database; transaction()
110
+ // replays them exactly once against the committed state.
111
+ let txDepth = 0;
112
+
113
+ // Serial task queue (sleep v0.4.0). Long-running background passes — dream
114
+ // consolidation, sleep cycles — must never overlap: two sleep runs racing
115
+ // would double-demote or double-mint patterns. enqueue chains the task onto
116
+ // a promise tail so N callers can queue work that runs strictly one at a
117
+ // time. A task that rejects doesn't poison the queue (the tail swallows the
118
+ // rejection) but the rejection still propagates to that caller.
119
+ let queueTail = Promise.resolve();
120
+ function enqueue(fn) {
121
+ const next = queueTail.then(fn, fn);
122
+ queueTail = next.catch(() => {});
123
+ return next;
124
+ }
125
+
126
+ // issue #6 (part 2): startup race defense. A local embedder (LocalEmbedder /
127
+ // Ollama) exposes an async init(), so between `setEmbedder` and init()
128
+ // resolving there is a window where embedSingle would throw "not initialized"
129
+ // and the re-embed would be silently dropped. When the embedder carries a
130
+ // `ready` flag we queue writes in embedPending until init sets ready=true,
131
+ // then flush them through the embedder's real interface. Embedders without a
132
+ // `ready` flag (legacy OpenAI, instantly usable) keep their old behavior.
133
+ let embedPending = [];
134
+ let embedReadyTimer = null;
135
+ const EMBED_PENDING_MAX = 100; // bound the queue; drop oldest beyond this
136
+ const EMBED_READY_POLL_MS = 100;
137
+ const EMBED_READY_POLL_LIMIT = 30; // ~3s ceiling; never poll forever
138
+
139
+ /** Flush the queued re-embeds once the embedder is ready. Fail-safe. */
140
+ function flushEmbedPending() {
141
+ if (!embedder || embedPending.length === 0) return;
142
+ const batch = embedPending.splice(0, embedPending.length);
143
+ for (const memory of batch) {
144
+ try {
145
+ if (!memory?.id) continue;
146
+ if (typeof embedder.schedule === "function") {
147
+ embedder.schedule(memory);
148
+ } else if (typeof embedder.embedSingle === "function") {
149
+ const text = [memory.title, memory.content].filter(Boolean).join("\n");
150
+ if (!text) continue;
151
+ embedder
152
+ .embedSingle(text)
153
+ .then((vec) => {
154
+ if (Array.isArray(vec) && vec.length) {
155
+ store.setEmbedding(memory.id, vec);
156
+ }
157
+ })
158
+ .catch((err) => {
159
+ logger?.warn?.("flushEmbedPending embedSingle failed:", err);
160
+ });
161
+ }
162
+ } catch (err) {
163
+ logger?.warn?.("flushEmbedPending failed:", err);
164
+ }
165
+ }
166
+ }
167
+
168
+ function stopEmbedReadyPolling() {
169
+ if (embedReadyTimer) {
170
+ clearInterval(embedReadyTimer);
171
+ embedReadyTimer = null;
172
+ }
173
+ }
174
+
175
+ function scheduleEmbed(memory) {
176
+ try {
177
+ if (txDepth > 0) return; // deferred to the transaction's commit
178
+ if (!embedder || !memory?.id) return;
179
+
180
+ // Readiness gate: embedder exposes `ready` (async init) and is not ready
181
+ // yet — queue instead of firing embedSingle into a half-built extractor.
182
+ const hasReady = "ready" in embedder;
183
+ if (hasReady && embedder.ready !== true) {
184
+ if (embedPending.length >= EMBED_PENDING_MAX) embedPending.shift();
185
+ embedPending.push(memory);
186
+ return;
187
+ }
188
+
189
+ if (typeof embedder.schedule === "function") {
190
+ embedder.schedule(memory);
191
+ return;
192
+ }
193
+
194
+ if (typeof embedder.embedSingle === "function") {
195
+ const text = [memory.title, memory.content].filter(Boolean).join("\n");
196
+ if (!text) return;
197
+
198
+ embedder
199
+ .embedSingle(text)
200
+ .then((vec) => {
201
+ if (Array.isArray(vec) && vec.length) {
202
+ store.setEmbedding(memory.id, vec);
203
+ }
204
+ })
205
+ .catch((err) => {
206
+ logger?.warn?.("scheduleEmbed embedSingle failed:", err);
207
+ });
208
+ }
209
+ } catch (err) {
210
+ logger?.warn?.("scheduleEmbed failed:", err);
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Fire-and-forget entity extraction for a freshly saved memory (entity gene
216
+ * v0.3.0). Opt-in via config.entityExtractionEnabled; the extractor is
217
+ * injected as a hook so the service never needs a direct LLM reference.
218
+ * The hook itself is expected to resolve to { ok:boolean } and never throw;
219
+ * a thrown rejection is swallowed here as a final fail-safe.
220
+ */
221
+ function scheduleEntityExtraction(memory) {
222
+ if (txDepth > 0) return; // deferred to the transaction's commit
223
+ if (!config.entityExtractionEnabled || !entityExtractor) return;
224
+ try {
225
+ entityExtractor(memory).catch((err) => {
226
+ logger?.warn?.("entity extraction failed:", err);
227
+ });
228
+ } catch (err) {
229
+ logger?.warn?.("entity extraction failed:", err);
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Fire-and-forget wiki-link resolution for a freshly saved/updated memory
235
+ * (v0.6.1). Opt-in via config.wikiLinkEnabled. Parses [[target]] /
236
+ * [[显示|target]] markers out of the memory content and writes links_to
237
+ * relations (idempotent via the unique relation index). Runs through
238
+ * service.enqueue so it serializes with autoDream/sleep and never overlaps
239
+ * another background pass. Fully fail-safe: parse/store errors are swallowed
240
+ * and logged, never a write failure.
241
+ */
242
+ function scheduleWikiLinkResolve(memory) {
243
+ if (txDepth > 0) return; // deferred to the transaction's commit
244
+ if (!config.wikiLinkEnabled || !memory?.id) return;
245
+ try {
246
+ const links = parseWikiLinks(memory?.content ?? "");
247
+ if (!links.length) return;
248
+ const targets = [...new Set(links.map((l) => l.target).filter(Boolean))];
249
+ enqueue(() => {
250
+ try {
251
+ store.saveWikiLinks({ memoryId: memory.id, title: memory.title, targets });
252
+ } catch (err) {
253
+ logger?.warn?.("wiki link resolve failed:", err);
254
+ }
255
+ }).catch((err) => {
256
+ logger?.warn?.("wiki link resolve failed:", err);
257
+ });
258
+ } catch (err) {
259
+ logger?.warn?.("wiki link resolve failed:", err);
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Cross-encoder rerank over a candidate list (best effort). Reranker
265
+ * failures degrade to the original candidate order — reranking is an
266
+ * accuracy upgrade, never a correctness gate.
267
+ */
268
+ async function rerankCandidates(query, candidates, topK) {
269
+ if (!reranker || !candidates.length) return candidates.slice(0, topK);
270
+ try {
271
+ const scored = await reranker.rerank(query, candidates.map((c) => ({ id: c.id, title: c.title, content: c.content })));
272
+ if (!Array.isArray(scored)) return candidates.slice(0, topK);
273
+ const byId = new Map(candidates.map((c) => [c.id, c]));
274
+ const out = [];
275
+ for (const s of scored) {
276
+ const c = byId.get(s.id);
277
+ if (c) { out.push({ ...c, score: s.score, source: "rerank" }); if (out.length >= topK) break; }
278
+ }
279
+ return out.length ? out : candidates.slice(0, topK);
280
+ } catch {
281
+ return candidates.slice(0, topK);
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Search for memories attached to a named entity (v0.3.0 Phase 3).
287
+ * 合并优先级:entity_attrs.memory_id 精确关联 = 1.0 > 关键词提及 = 0.7;
288
+ * attr 命中不覆盖,keyword 只补充召回,最后按 _score 降序取 topK。
289
+ * @param {string} entityName
290
+ * @param {object} [options]
291
+ * @param {number} [options.topK=20]
292
+ * @returns {any[]}
293
+ */
294
+ function searchByEntity(entityName, { topK = 20 } = {}) {
295
+ const entity = store.findEntityByName(entityName);
296
+ if (!entity) return [];
297
+ const attrs = store.getCurrentAttrs(entity.id);
298
+ const memoryIds = [...new Set(attrs.map((a) => a.memory_id).filter(Boolean))];
299
+ const attrHits = memoryIds.map((id) => store.getById(id)).filter(Boolean);
300
+ const keywordHits = store.search(entityName, { limit: topK });
301
+ const merged = new Map();
302
+ for (const mem of attrHits) merged.set(mem.id, { ...mem, _source: "entity_attr", _score: 1.0 });
303
+ for (const mem of keywordHits) {
304
+ if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 });
305
+ }
306
+ const hits = Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK);
307
+ touchRecalled(hits);
308
+ return hits;
309
+ }
310
+
311
+ /**
312
+ * Search for memories by attribute key/value (v0.3.0 Phase 3).
313
+ * value 为空时由 store.findMemoriesByAttr 返回该 key 的全部有效记忆。
314
+ * @param {string} key
315
+ * @param {string | undefined} value
316
+ * @param {object} [options]
317
+ * @param {number} [options.topK=20]
318
+ * @returns {any[]}
319
+ */
320
+ function searchByAttr(key, value, { topK = 20 } = {}) {
321
+ if (!key) return [];
322
+ // value 可能为 undefined(attr:key 无 = 值):归一为空串后交给
323
+ // store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部
324
+ // 当前有效记忆(v0.3.0,store.js 已实现)。
325
+ const rows = store.findMemoriesByAttr(key, value ?? "");
326
+ const hits = rows.slice(0, topK);
327
+ touchRecalled(hits);
328
+ return hits;
329
+ }
330
+
331
+ /**
332
+ * Search for memories carrying a given tag set (v0.6.2). Multiple tag:
333
+ * tokens in one query use AND semantics — a memory must carry every tag.
334
+ * Combinable with the leftover query: an entity:/attr: prefix is intersected
335
+ * with the tag-matched set, and plain keyword text ranks it (only rows whose
336
+ * keyword score > 0 survive). Tags are first-class recall, not entity-gated:
337
+ * the tag match itself does not depend on config.entitySearchEnabled.
338
+ * @param {string[]} tagTokens
339
+ * @param {string} q — leftover query after tag: tokens were stripped
340
+ * @param {object} [options]
341
+ * @param {number} [options.topK=20]
342
+ * @returns {any[]}
343
+ */
344
+ function searchByTags(tagTokens, q, options) {
345
+ const { topK = 20 } = options;
346
+ const tags = (Array.isArray(tagTokens) ? tagTokens : [])
347
+ .map((t) => String(t).trim()).filter(Boolean);
348
+ if (!tags.length) return [];
349
+ let hits = store.findMemoriesByTags(tags);
350
+ if (!hits.length) return [];
351
+ // Intersect with entity:/attr: prefixes present in the leftover query.
352
+ if (config?.entitySearchEnabled) {
353
+ if (q.startsWith("entity:")) {
354
+ const ids = new Set(searchByEntity(q.slice(7).trim(), { topK: 10000 }).map((m) => m.id));
355
+ hits = hits.filter((m) => ids.has(m.id));
356
+ }
357
+ if (q.startsWith("attr:")) {
358
+ const [key, value] = q.slice(5).split("=");
359
+ const ids = new Set(searchByAttr(key, value, { topK: 10000 }).map((m) => m.id));
360
+ hits = hits.filter((m) => ids.has(m.id));
361
+ }
362
+ }
363
+ // Leftover plain text ranks the tag-matched set; only rows whose title or
364
+ // content actually mentions the keyword survive (scoreKeyword's 0.3 base is
365
+ // a non-match, so the containment check is the real gate); otherwise
366
+ // preserve insertion order.
367
+ const keywordOnly = q && !q.startsWith("entity:") && !q.startsWith("attr:");
368
+ hits = keywordOnly
369
+ ? hits
370
+ .map((m) => ({ ...m, score: scoreKeyword(m, q), source: "tag" }))
371
+ .filter((m) => (m.title ?? "").toLowerCase().includes(q.toLowerCase())
372
+ || (m.content ?? "").toLowerCase().includes(q.toLowerCase()))
373
+ .sort((a, b) => b.score - a.score)
374
+ : hits.map((m) => ({ ...m, source: "tag" }));
375
+ const result = hits.slice(0, topK);
376
+ // Only count toward recall stats/forgetting when the caller asked for
377
+ // recording — the panel's `tag:` search must not pollute the curve.
378
+ if (options.recordRecall) touchRecalled(result);
379
+ return result;
380
+ }
381
+
382
+ /**
383
+ * Semantic-aware memory search: keyword recall (store.search) plus optional
384
+ * vector recall + rerank. mode:
385
+ * auto (default) keyword first, vector fills remaining slots (legacy)
386
+ * hybrid vector first, keyword fills remaining slots
387
+ * vector vector only, falls back to keyword when unavailable
388
+ * keyword text only, never touches the embedder
389
+ * useRerank runs the cross-encoder over the merged list when a reranker is
390
+ * installed; results carry an extra `score` when reranked.
391
+ */
392
+ // Weighted blend factor for hybrid search; exposed so callers can tune it.
393
+ const DEFAULT_HYBRID_WEIGHTS = { vector: 0.6, keyword: 0.4 };
394
+
395
+ // Cosine over two plain arrays (shared by the search-time semantic dedup).
396
+ function cosineVec(a, b) {
397
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || !a.length) return 0;
398
+ let dot = 0, na = 0, nb = 0;
399
+ for (let i = 0; i < a.length; i++) {
400
+ dot += a[i] * b[i];
401
+ na += a[i] * a[i];
402
+ nb += b[i] * b[i];
403
+ }
404
+ if (na === 0 || nb === 0) return 0;
405
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
406
+ }
407
+
408
+ /**
409
+ * BM25 third recall path (v0.5.0 1.1). Scores the query tokens against the
410
+ * live non-archived rows and returns the top `limit` hits with scores
411
+ * normalized to [0,1]. Failures degrade to [] — BM25 is a recall booster,
412
+ * never a correctness gate.
413
+ */
414
+ function bm25Recall(q, limit) {
415
+ if (config?.bm25SearchEnabled === false) return [];
416
+ try {
417
+ const docs = store.list({ limit: 500, includeForgotten: false }).filter((m) => !m.archived && !m.session_disposed_at);
418
+ if (!docs.length) return [];
419
+ return createBM25Index(docs).search(q, { limit });
420
+ } catch {
421
+ return [];
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Search-time semantic dedup (v0.5.0 2.3): greedy pass dropping candidates
427
+ * whose embedding similarity to an already-kept row exceeds the threshold.
428
+ * Rows without a stored embedding are always kept (no signal = no drop).
429
+ */
430
+ function semanticDeduplicate(candidates) {
431
+ // Opt-in aggressive mode (default off): collapsing near-duplicates can
432
+ // drop legitimately distinct rows on small embedding models, so it ships
433
+ // behind searchSemanticDedup=true.
434
+ if (config?.searchSemanticDedup !== true || candidates.length < 2) return candidates;
435
+ const threshold = config?.searchSemanticDedupThreshold ?? 0.95;
436
+ try {
437
+ const vecs = store.getEmbeddings(candidates.map((c) => c.id));
438
+ if (vecs.size < 2) return candidates;
439
+ const kept = [];
440
+ for (const c of candidates) {
441
+ const v = vecs.get(c.id);
442
+ if (!v) { kept.push(c); continue; }
443
+ const dup = kept.some((k) => {
444
+ const kv = vecs.get(k.id);
445
+ return kv && cosineVec(v, kv) > threshold;
446
+ });
447
+ if (!dup) kept.push(c);
448
+ }
449
+ return kept;
450
+ } catch {
451
+ return candidates;
452
+ }
453
+ }
454
+
455
+ /**
456
+ * Give a keyword-hit row a relevance score in [0,1]: title hits score
457
+ * higher than content hits, then scaled by importance (1-5). This lets
458
+ * keyword results participate in weighted hybrid blends.
459
+ */
460
+ function scoreKeyword(row, q) {
461
+ const ql = q.toLowerCase();
462
+ const title = (row.title ?? "").toLowerCase();
463
+ const content = (row.content ?? "").toLowerCase();
464
+ const titleHit = title.includes(ql);
465
+ const base = titleHit ? 1 : content.includes(ql) ? 0.6 : 0.3;
466
+ return base * (0.5 + (row.importance ?? 3) / 10);
467
+ }
468
+
469
+ /**
470
+ * Sleep touch (v0.4.0): when sleep is enabled, any memory surfaced by recall
471
+ * or auto-injection gets its last_accessed_at bumped, so the "unrecalled N
472
+ * days → demote/archive" tiering counts real access. Best-effort and gated on
473
+ * config.sleepModeEnabled — when sleep is off this is a complete no-op (no
474
+ * writes on the hot recall path). A touch failure must never break search/inject.
475
+ */
476
+ function touchRecalled(memories) {
477
+ if (config?.sleepModeEnabled !== true || !Array.isArray(memories) || memories.length === 0) return;
478
+ for (const m of memories) {
479
+ if (!m?.id) continue;
480
+ try {
481
+ store.touchLastAccess(m.id);
482
+ } catch { /* touch is best effort */ }
483
+ }
484
+ }
485
+
486
+ async function searchMemories(query, options = {}) {
487
+ const { mode = "auto", topK = 20, threshold, useRerank = true, recordRecall = false } = options;
488
+ const raw = String(query ?? "").trim();
489
+ if (!raw) return [];
490
+
491
+ // tag: 前缀(v0.6.2)。先把所有 tag: 令牌从 query 里剥出来,剩余的 q 仍可带
492
+ // entity:/attr: 前缀或普通关键词 —— searchByTags 负责交集/排序。没有 tag:
493
+ // 令牌则走下面的 entity:/attr:/文本原逻辑(完全向后兼容)。
494
+ const tagTokens = [];
495
+ const q = raw.replace(/\btag:([^\s]+)/g, (_, tok) => {
496
+ if (tok) tagTokens.push(tok);
497
+ return "";
498
+ }).trim();
499
+ if (tagTokens.length) return searchByTags(tagTokens, q, options);
500
+
501
+ // entity:/attr: 前缀路由(v0.3.0 Phase 3)。entitySearchEnabled 关闭时走原逻辑。
502
+ if (config?.entitySearchEnabled) {
503
+ if (q.startsWith("entity:")) {
504
+ return searchByEntity(q.slice(7).trim(), options);
505
+ }
506
+ if (q.startsWith("attr:")) {
507
+ const [key, value] = q.slice(5).split("=");
508
+ return searchByAttr(key, value, options);
509
+ }
510
+ }
511
+
512
+ const lim = topK > 0 ? topK : 20;
513
+
514
+ // Keyword results, decorated with a score so they can be weight-blended
515
+ // with vector results and reported uniformly. source tracks where each
516
+ // candidate came from for the recall layer receipt.
517
+ const rawKeyword = store.search(q, { limit: lim });
518
+ const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q), source: "keyword" }));
519
+ const wantVector = mode === "vector" || mode === "hybrid" || (mode === "auto" && !!embedder);
520
+ let vector = [];
521
+ if (wantVector && embedder) {
522
+ try {
523
+ // Legacy embedders expose embed(query); local ones expose embedSingle.
524
+ const embedSingle = typeof embedder.embedSingle === "function"
525
+ ? embedder.embedSingle.bind(embedder)
526
+ : embedder.embed.bind(embedder);
527
+ const qv = await embedSingle(q);
528
+ if (qv?.length) {
529
+ // Adaptive threshold (v0.5.0 1.2): the fetch runs at the loosest
530
+ // branch floor so the head-gap rule can still re-admit the tail;
531
+ // the final cutoff is computed against the fetched score
532
+ // distribution. Explicit `threshold` wins; disabled → legacy 0.
533
+ const adaptive = config?.adaptiveThresholdEnabled !== false;
534
+ const fetchThreshold = adaptive && threshold === undefined
535
+ ? Math.min(0.5, adaptiveThreshold(q))
536
+ : (threshold ?? 0);
537
+ const search = vectorIndex
538
+ ? vectorIndex.search(qv, { limit: lim * 2, threshold: fetchThreshold })
539
+ : store.searchVector(qv, { limit: lim * 2, threshold: fetchThreshold });
540
+ const finalThreshold = adaptive && threshold === undefined
541
+ ? adaptiveThreshold(q, search)
542
+ : (threshold ?? 0);
543
+ vector = search
544
+ .filter((m) => (m.score ?? 1) >= finalThreshold)
545
+ .map((m) => ({ ...m, vector: true, source: "vector" }));
546
+ }
547
+ } catch { /* vector unavailable: keep keyword results */ }
548
+ }
549
+
550
+ // BM25 third path (v0.5.0 1.1): IDF-weighted token overlap recalls rows
551
+ // whose query terms are scattered — the gap LIKE substring matching
552
+ // cannot close. Scores are already normalized to [0,1].
553
+ const bm25 = bm25Recall(q, lim).map((m) => ({ ...m, source: "bm25" }));
554
+ // Loose blend weight: BM25 confirms and backfills, never dominates the
555
+ // semantic signal. Same-memory overlap boosts, unseen ids backfill.
556
+ const wb = 0.3;
557
+ // Path bookkeeping for the boost rule below: which ids each semantic
558
+ // recall path surfaced.
559
+ const vectorIds = new Set(vector.map((m) => m.id));
560
+ const keywordIds = new Set(keyword.map((m) => m.id));
561
+
562
+ // Hybrid blending weights from config when provided.
563
+ const wv = config?.hybridSearchVectorWeight ?? DEFAULT_HYBRID_WEIGHTS.vector;
564
+ const wk = config?.hybridSearchKeywordWeight ?? DEFAULT_HYBRID_WEIGHTS.keyword;
565
+
566
+ let merged;
567
+ if (mode === "keyword") {
568
+ merged = keyword;
569
+ } else if (mode === "vector" || mode === "hybrid") {
570
+ // semantic-first: vector recalls lead, keyword + BM25 fill remaining
571
+ // slots. Weighted blend when sides scored the same memory; otherwise
572
+ // vector order leads (it is the semantic signal), lexical paths
573
+ // backfill.
574
+ const byId = new Map();
575
+ for (const m of vector) {
576
+ const rec = byId.get(m.id);
577
+ byId.set(m.id, rec ? { ...rec, score: Math.max(rec.score ?? 0, m.score ?? 0) } : m);
578
+ }
579
+ for (const m of keyword) {
580
+ const rec = byId.get(m.id);
581
+ if (rec) {
582
+ // Same memory from both sides: blend the scores.
583
+ byId.set(m.id, { ...rec, score: (rec.score ?? 0) * wv + (m.score ?? 0) * wk });
584
+ } else {
585
+ byId.set(m.id, m);
586
+ }
587
+ }
588
+ for (const m of bm25) {
589
+ const rec = byId.get(m.id);
590
+ if (rec) {
591
+ // Boost rule: a row the LIKE keyword path already hit carries the
592
+ // query as a substring, so BM25 tokens are trivially present —
593
+ // boosting it double-counts lexical evidence. Only vector-recalled
594
+ // rows (lexical hit is genuinely new information) get the boost.
595
+ if (keywordIds.has(m.id)) continue;
596
+ byId.set(m.id, { ...rec, score: (rec.score ?? 0) + wb * (m.score ?? 0) });
597
+ } else {
598
+ byId.set(m.id, { ...m, score: wb * (m.score ?? 0) });
599
+ }
600
+ }
601
+ const ranked = [...byId.values()]
602
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
603
+ .map((r) => ({ ...r, score: Math.max(0, Math.min(1, r.score ?? 0)) }));
604
+ // Tag boost (v0.6.4) needs the full ranked pool, not just the top-lim
605
+ // slice, so a tagged candidate just below the line can be re-admitted
606
+ // after the boost. Without boost this is the legacy lim truncation.
607
+ merged = config.tagBoostEnabled === true ? ranked : ranked.slice(0, lim);
608
+ if (merged.length < lim && !merged.length) {
609
+ // Vector unavailable entirely: fall back to plain keyword.
610
+ merged = keyword.slice(0, lim);
611
+ }
612
+ } else {
613
+ // auto: keyword leads, vector + BM25 fill remaining slots (legacy
614
+ // behavior, extended with the third path)
615
+ merged = keyword.slice(0, lim);
616
+ const seen = new Set(merged.map((m) => m.id));
617
+ for (const m of vector) {
618
+ if (merged.length >= lim) break;
619
+ if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
620
+ }
621
+ for (const m of bm25) {
622
+ if (merged.length >= lim) break;
623
+ if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
624
+ }
625
+ }
626
+
627
+ // Search-time semantic dedup (v0.5.0 2.3): near-duplicate rows are
628
+ // dropped before the reranker sees them, so topK slots carry distinct
629
+ // information instead of the same memory twice. Keyword mode is exempt —
630
+ // it is the documented text-only path and must not be altered by
631
+ // embedding state.
632
+ merged = mode === "keyword" ? merged : semanticDeduplicate(merged);
633
+
634
+ // Tag-weighted re-rank (v0.6.4): boost candidates whose tags overlap the
635
+ // query tags or the current session's hot-memory tags — applied BEFORE the
636
+ // final top-K cut so tagged candidates just below the line can be
637
+ // re-admitted. Opt-in, and skipped entirely on the keyword-only path. When
638
+ // a reranker is configured (opt-in), it remains the final authority on
639
+ // order; the boost still shapes which candidates reach it.
640
+ if (config.tagBoostEnabled === true && mode !== "keyword" && merged.length) {
641
+ const ids = merged.map((m) => m.id);
642
+ const tagsMap = store.getMemoryTagsMap(ids);
643
+ const enriched = merged.map((m) => ({ ...m, tags: tagsMap.get(m.id) ?? [] }));
644
+ const knownTags = [...tagsMap.values()].flat();
645
+ const queryTags = extractQueryTags(q, knownTags);
646
+ const sessionTags = Array.isArray(options?.sessionTags) ? options.sessionTags : [];
647
+ if (queryTags.length || sessionTags.length) {
648
+ merged = applyTagBoost(enriched, {
649
+ queryTags,
650
+ sessionTags,
651
+ factor: config.tagBoostFactor,
652
+ sessionFactor: config.sessionTagBoostFactor,
653
+ }).map(({ tags, ...rest }) => rest);
654
+ }
655
+ }
656
+ merged = merged.slice(0, lim);
657
+
658
+ let result = useRerank && reranker && merged.length
659
+ ? await rerankCandidates(q, merged, lim)
660
+ : merged;
661
+ // Epistemic trust (v0.4.5): opt-in re-weighting of the final candidate
662
+ // scores by source credibility. When off (default) `result` is returned
663
+ // untouched — exactly the legacy behavior.
664
+ if (config.trustEpistemicWeighting === true) {
665
+ result = result
666
+ .map((m) => ({
667
+ ...m,
668
+ score: (m.score ?? 0) * (EPISTEMIC_WEIGHTS[m.epistemic_status] ?? 1)
669
+ }))
670
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
671
+ .slice(0, lim);
672
+ }
673
+
674
+ // Recall layer receipt: with recordRecall on, hand the actual merged
675
+ // candidate list (id/title/content/score/source) to the injected recorder
676
+ // before returning, making the retrieval scene replayable — the sibling of
677
+ // the dream judgment-layer audit trail. Recorder failures must never break
678
+ // the search itself.
679
+ if (recordRecall && recallRecorder) {
680
+ try {
681
+ recallRecorder({
682
+ query: q,
683
+ mode,
684
+ topK: lim,
685
+ threshold: threshold ?? null,
686
+ candidates: result.map((m) => ({
687
+ id: m.id,
688
+ title: m.title,
689
+ content: m.content,
690
+ score: m.score ?? null,
691
+ source: m.source ?? "keyword"
692
+ })),
693
+ createdAt: new Date().toISOString()
694
+ });
695
+ } catch { /* recall receipt is best effort */ }
696
+ }
697
+ // Bug4: cache the latest semantic recall so the sync injection path can
698
+ // reuse it when the injection query matches (no async embed available).
699
+ lastSemanticRecall = { query: q, items: result };
700
+ touchRecalled(result);
701
+ return result;
702
+ }
703
+
704
+ /**
705
+ * Retrieval evaluation (方案 B): run one search for `query`, compare the ids
706
+ * it actually returned against `expectedIds`, and return the computed
707
+ * metrics. When persistence is on (config.evalPersistTestResults, or an
708
+ * explicit `persist` override per call) the snapshot is written to the
709
+ * recall_evals table — a SEPARATE store from the recall_runs production audit,
710
+ * so test/eval data never inflates the production trail.
711
+ *
712
+ * options:
713
+ * mode/topK/threshold/useRerank — passed through to searchMemories
714
+ * evalType — label for the snapshot (default 'manual')
715
+ * recordRecall — also write a recall_runs audit row for the
716
+ * same scene and link it via recall_run_id
717
+ * (default false: eval stays unlinked)
718
+ * recallRunId — explicit link to an existing recall_runs id
719
+ * persist — override the config gate for this call
720
+ *
721
+ * Returns { metrics, actualIds, expectedIds, recallRunId, persisted }.
722
+ * Never throws on persistence failures: a broken eval write must not break
723
+ * the retrieval quality measurement.
724
+ */
725
+ async function evaluateRetrieval(query, expectedIds, options = {}) {
726
+ const q = String(query ?? "").trim();
727
+ const expected = Array.isArray(expectedIds) ? expectedIds : [];
728
+ const {
729
+ mode = "auto",
730
+ topK = 20,
731
+ threshold,
732
+ useRerank = true,
733
+ evalType = "manual",
734
+ recordRecall = false,
735
+ recallRunId = null,
736
+ persist = config.evalPersistTestResults === true
737
+ } = options;
738
+ if (!q) {
739
+ const empty = computeRetrievalMetrics([], expected);
740
+ return { metrics: empty, actualIds: [], expectedIds: expected, recallRunId: null, persisted: false };
741
+ }
742
+
743
+ const rows = await searchMemories(q, { mode, topK, threshold, useRerank, recordRecall: false });
744
+ const actualIds = rows.map((m) => m.id);
745
+ const metrics = computeRetrievalMetrics(actualIds, expected);
746
+
747
+ // Optional recall_runs audit for the same scene; the eval row then links to
748
+ // it. Kept separate from the production recorder (which fires only on
749
+ // recordRecall=true inside searchMemories) — eval never double-records.
750
+ // An explicit recallRunId wins; recordRecall only mints a NEW audit run when
751
+ // the caller did not already link one (never clobber an existing link).
752
+ let runId = recallRunId ?? null;
753
+ if (recordRecall && runId === null) {
754
+ try {
755
+ const run = store.saveRecallRun({
756
+ query: q,
757
+ mode,
758
+ topK,
759
+ threshold: threshold ?? null,
760
+ candidates: rows.map((m) => ({
761
+ id: m.id,
762
+ title: m.title,
763
+ content: m.content,
764
+ score: m.score ?? null,
765
+ source: m.source ?? "keyword"
766
+ })),
767
+ created_at: new Date().toISOString()
768
+ });
769
+ runId = run.id;
770
+ } catch { /* non-fatal: the eval itself still succeeds */ }
771
+ }
772
+
773
+ let persisted = false;
774
+ if (persist) {
775
+ try {
776
+ store.saveRecallEval({
777
+ recall_run_id: runId,
778
+ query: q,
779
+ expected_ids: expected,
780
+ actual_ids: actualIds,
781
+ metrics,
782
+ eval_type: evalType,
783
+ created_at: new Date().toISOString()
784
+ });
785
+ persisted = true;
786
+ } catch { /* non-fatal: measurement survives a failed eval write */ }
787
+ }
788
+ return { metrics, actualIds, expectedIds: expected, recallRunId: runId, persisted };
789
+ }
790
+
791
+ /**
792
+ * Fire-and-forget write notification; errors are swallowed to keep write
793
+ * paths clean. The store mutation has already committed, so a throwing
794
+ * subscriber must not surface as a write failure. Archive/forget flags are
795
+ * state toggles, not content writes, so they never notify.
796
+ */
797
+ function notifyWrite() {
798
+ if (txDepth > 0) return; // deferred to the transaction's commit
799
+ if (onWrite) {
800
+ try { onWrite(); } catch { /* ignore */ }
801
+ }
802
+ if (dreamHook) {
803
+ try { dreamHook(); } catch { /* ignore */ }
804
+ }
805
+ if (sleepHook) {
806
+ try { sleepHook(); } catch { /* ignore */ }
807
+ }
808
+ }
809
+
810
+ /**
811
+ * Run several store mutations atomically (SQLite BEGIN/COMMIT/ROLLBACK) and
812
+ * fire the deferred side effects once against the committed state. A throwing
813
+ * body rolls the whole batch back — no partial writes, no diverged mirror.
814
+ * Errors propagate to the caller. NOTE: the commit path re-renders the mirror
815
+ * and notifies subscribers, but re-embedding is left to the caller (the dream
816
+ * flow re-embeds through maintainIndexAfterDream).
817
+ */
818
+ function transaction(fn) {
819
+ store.db.exec("BEGIN");
820
+ txDepth++;
821
+ try {
822
+ const result = fn();
823
+ store.db.exec("COMMIT");
824
+ return result;
825
+ } catch (error) {
826
+ try { store.db.exec("ROLLBACK"); } catch { /* store may be closed */ }
827
+ throw error;
828
+ } finally {
829
+ txDepth--;
830
+ // Sync failures are surfaced, not swallowed (peer blocker 2): the mirror
831
+ // debt was already recorded by markMirrorDirty inside syncMirror, so a
832
+ // restart recovers — but the operator must see it now, not after restart.
833
+ const syncResult = syncMirror();
834
+ if (!syncResult?.success && !syncResult?.deferred) {
835
+ logger?.warn?.("mirror sync failed after transaction:", syncResult?.error);
836
+ }
837
+ notifyWrite();
838
+ }
839
+ }
840
+
841
+ /**
842
+ * Embed an arbitrary query text and return its vector (null on failure / no
843
+ * embedder). Used by the injector to prefetch the semantic-first recall
844
+ * vector for the current user message — the system-prompt render is
845
+ * synchronous, so the vector must be cached in advance (Bug4).
846
+ */
847
+ async function embedQuery(query) {
848
+ const q = String(query ?? "").trim();
849
+ if (!q || !embedder) return null;
850
+ try {
851
+ const embedSingle = typeof embedder.embedSingle === "function"
852
+ ? embedder.embedSingle.bind(embedder)
853
+ : embedder.embed.bind(embedder);
854
+ const vector = await embedSingle(q);
855
+ return Array.isArray(vector) && vector.length ? vector : null;
856
+ } catch {
857
+ return null;
858
+ }
859
+ }
860
+
861
+ /**
862
+ * Save a memory, merging into an existing one when title matches within the same type.
863
+ *
864
+ * Bug5: a same-title merge no longer overwrites — the new content is appended
865
+ * under a timestamped `---` separator (`旧内容\n\n---\n[时间戳] 新内容`) and the
866
+ * previous content is archived into content_history (source: auto_merge, FIFO
867
+ * capped at 20). importance takes the max of both (capped at 5). Callers that
868
+ * truly replace a row (dream summary regeneration) pass `_overwrite: true` to
869
+ * overwrite directly while still archiving the old version (source: overwrite).
870
+ * mergeHumanEdits entry points pass `_humanEdited: true` — same direct
871
+ * overwrite semantics, source: human_override.
872
+ *
873
+ * Bug7 (memory quality filter): when config.memoryQualityFilter.enabled, the
874
+ * memory is scored after dedupe, before write:
875
+ * score >= degradeThreshold → stored normally (score persisted)
876
+ * archiveThreshold <= score < 60 → persisted + ranked degraded
877
+ * score < archiveThreshold → archived + tagged low_quality (still
878
+ * explicitly searchable via includeArchived)
879
+ * @returns {{action: "created"|"merged", memory: object}}
880
+ */
881
+ function saveWithDedupe(memory) {
882
+ // Bug7: score quality once (after dedupe lookup, before write). Failures
883
+ // inside the evaluator are impossible (pure function), but the write that
884
+ // records the score must never fail the save — wrap defensively.
885
+ const qf = config.memoryQualityFilter;
886
+ let quality = null;
887
+ if (qf?.enabled === true) {
888
+ try {
889
+ const recentContents = store.all().slice(0, 20).map((m) => m.content ?? "");
890
+ quality = evaluateMemoryQuality(memory, {
891
+ minContentLength: qf.minContentLength ?? 10,
892
+ recentContents
893
+ });
894
+ } catch { /* quality scoring is best-effort */ }
895
+ }
896
+ const existing = store
897
+ .list({ type: memory.type, limit: 100 })
898
+ .find((m) => m.title.trim() === String(memory.title).trim());
899
+ if (existing) {
900
+ const newContent = String(memory.content ?? "");
901
+ if (!newContent.trim()) {
902
+ // Nothing to merge: the row stays untouched.
903
+ return { action: "merged", memory: existing };
904
+ }
905
+ const direct = memory._overwrite === true || memory._humanEdited === true;
906
+ const content = direct
907
+ ? newContent
908
+ : appendContent(existing.content, newContent);
909
+ const importance = Math.min(5, Math.max(existing.importance, memory.importance ?? existing.importance));
910
+ const merged = store.update(existing.id, {
911
+ content,
912
+ importance,
913
+ tags: memory.tags ?? existing.tags,
914
+ title: memory.title ?? existing.title,
915
+ content_history: pushContentHistory(existing, direct
916
+ ? (memory._humanEdited === true ? "human_override" : "overwrite")
917
+ : "auto_merge"),
918
+ ...(quality ? { quality_score: quality.score } : {})
919
+ });
920
+ // Bug7: a degraded/archived result is applied on top of the merged row.
921
+ const result = applyQualityDisposition(merged, quality, qf);
922
+ afterSync("write");
923
+ notifyWrite();
924
+ scheduleEmbed(result);
925
+ scheduleWikiLinkResolve(result);
926
+ return { action: "merged", memory: result };
927
+ }
928
+ const created = store.save({
929
+ type: memory.type,
930
+ title: memory.title,
931
+ content: memory.content,
932
+ tags: memory.tags ?? [],
933
+ importance: memory.importance ?? 3,
934
+ source: memory.source ?? "manual",
935
+ // Provenance (v0.5.x): birth session rides through the create path; the
936
+ // merge path above preserves the original row's session_id untouched.
937
+ session_id: memory.session_id ?? undefined,
938
+ ...(quality ? { quality_score: quality.score } : {})
939
+ });
940
+ const result = applyQualityDisposition(created, quality, qf);
941
+ afterSync("write");
942
+ notifyWrite();
943
+ scheduleEmbed(result);
944
+ scheduleEntityExtraction(result);
945
+ scheduleWikiLinkResolve(result);
946
+ return { action: "created", memory: result };
947
+ }
948
+
949
+ /**
950
+ * Bug7: apply the quality verdict to a freshly written row. Below the archive
951
+ * threshold the memory is archived + tagged low_quality (still searchable
952
+ * explicitly via includeArchived); between archive and degrade thresholds the
953
+ * score is already persisted and only the injection ranking is affected
954
+ * (importance × score/100). Best-effort: a disposition write failure must
955
+ * never fail the save. Returns the (possibly refreshed) memory row so callers
956
+ * see the archived/tagged state, not the pre-disposition snapshot.
957
+ */
958
+ function applyQualityDisposition(memory, quality, qf) {
959
+ if (!quality || qf?.enabled !== true) return memory;
960
+ const archiveThreshold = qf.archiveThreshold ?? 30;
961
+ // Signal tags (meta / repetitive / duplicate / short_content / low_quality)
962
+ // are merged onto the stored row in every assessed band so the verdict is
963
+ // observable, not just the numeric score. Below the archive threshold the
964
+ // memory is additionally archived (still explicitly searchable).
965
+ const tags = [...new Set([...(memory.tags ?? []), ...(quality.tags ?? [])])];
966
+ if (tags.length === (memory.tags?.length ?? 0) && quality.score >= archiveThreshold) {
967
+ return memory; // no tag drift and not archived → nothing extra to write
968
+ }
969
+ try {
970
+ store.update(memory.id, { tags, quality_score: quality.score });
971
+ if (quality.score < archiveThreshold) store.setArchived(memory.id, true);
972
+ return store.getById(memory.id);
973
+ } catch {
974
+ return memory;
975
+ }
976
+ }
977
+
978
+ /**
979
+ * Candidate memories for automatic context injection:
980
+ * summaries first, then all preferences, then non-forgotten items with
981
+ * importance >= threshold. History is never auto-injected. Archived entries
982
+ * are excluded (store.list already filters them by default; the extra
983
+ * !m.archived check is kept as double insurance).
984
+ *
985
+ * Bug4 (hybridInject): when a non-empty `query` is available and a matching
986
+ * semantic recall was cached by the last searchMemories, the vector hits
987
+ * lead the selection (up to maxItems*2 candidates) and the rule-based pick
988
+ * fills + dedupes the remaining slots. Empty query / no cached recall /
989
+ * hybridInject off → pure legacy rule-based selection.
990
+ */
991
+ function injectCandidates({ query = "", maxItems = 5, threshold = 3, queryVector } = {}) {
992
+ const q = String(query ?? "").trim();
993
+ // Bug7: quality-weighted importance in the rule-based tier. Unassessed rows
994
+ // (quality_score null) count as 100 (weight 1), so legacy stores keep their
995
+ // exact summary>preference>importance ordering.
996
+ const qualityWeight = (m) => (m.quality_score != null ? m.quality_score / 100 : 1);
997
+ const items = store.list({ limit: 200, includeForgotten: false })
998
+ .filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
999
+ (m.type === "summary" || m.type === "preference" || m.importance >= threshold))
1000
+ .sort((a, b) => {
1001
+ const pa = a.type === "summary" ? 0 : a.type === "preference" ? 1 : 2;
1002
+ const pb = b.type === "summary" ? 0 : b.type === "preference" ? 1 : 2;
1003
+ return pa - pb || (b.importance * qualityWeight(b)) - (a.importance * qualityWeight(a));
1004
+ });
1005
+ let candidates = items;
1006
+ if (config.hybridInject !== false && q) {
1007
+ // Bug4: semantic-first recall. Vector hits (queryVector, cached by the
1008
+ // injector's async prefetch) lead when present; otherwise the last
1009
+ // searchMemories recall for the exact same query is reused. Rule-based
1010
+ // items fill + dedupe the remaining slots. Empty query / no vector /
1011
+ // no cached recall → pure legacy rule-based selection.
1012
+ const semanticItems = [];
1013
+ if (Array.isArray(queryVector) && queryVector.length && vectorIndex) {
1014
+ try {
1015
+ const hits = vectorIndex.search(queryVector, { limit: maxItems * 2, threshold: 0 });
1016
+ for (const m of hits) {
1017
+ if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
1018
+ (m.type === "summary" || m.type === "preference" || m.importance >= threshold)) {
1019
+ semanticItems.push(m);
1020
+ }
1021
+ }
1022
+ } catch { /* vector unavailable: fall through to the recall cache */ }
1023
+ }
1024
+ if (!semanticItems.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
1025
+ for (const m of lastSemanticRecall.items) {
1026
+ if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten) semanticItems.push(m);
1027
+ }
1028
+ }
1029
+ if (semanticItems.length) {
1030
+ const seen = new Set();
1031
+ const merged = [];
1032
+ const push = (m) => {
1033
+ if (seen.has(m.id)) return;
1034
+ seen.add(m.id);
1035
+ merged.push(m);
1036
+ };
1037
+ for (const m of semanticItems) {
1038
+ push(m);
1039
+ if (merged.length >= maxItems * 2) break;
1040
+ }
1041
+ for (const m of items) {
1042
+ if (merged.length >= maxItems * 2) break;
1043
+ push(m);
1044
+ }
1045
+ candidates = merged;
1046
+ }
1047
+ }
1048
+ // Topic-ranked selection (v0.5.0 2.2): when the current query's vector is
1049
+ // available the whole candidate list is re-ordered by similarity to that
1050
+ // vector, so the injected slots go to memories on the current topic
1051
+ // rather than to the rule-based order. Rows the index did not return
1052
+ // keep their relative order after the scored ones.
1053
+ if (config?.selectiveInjectEnabled !== false && Array.isArray(queryVector) && queryVector.length && vectorIndex) {
1054
+ try {
1055
+ const hits = vectorIndex.search(queryVector, { limit: 200, threshold: 0 });
1056
+ const sim = new Map(hits.map((m) => [m.id, m.score ?? 0]));
1057
+ if (sim.size) {
1058
+ candidates = [...candidates].sort((a, b) => (sim.get(b.id) ?? -1) - (sim.get(a.id) ?? -1));
1059
+ }
1060
+ } catch { /* topic re-rank unavailable: keep rule-based order */ }
1061
+ }
1062
+ const selected = candidates.slice(0, maxItems);
1063
+ touchRecalled(selected);
1064
+ return selected;
1065
+ }
1066
+
1067
+ /**
1068
+ * Merge human edits parsed from a mirror file back into the store.
1069
+ * Only content/title are taken; structure fields stay machine-owned.
1070
+ */
1071
+ function mergeHumanEdits(type, edits) {
1072
+ let applied = 0;
1073
+ for (const edit of edits) {
1074
+ if (!edit.id) continue; // corrupt/malformed edit: skip it, keep merging the rest
1075
+ const existing = store.getById(edit.id);
1076
+ if (!existing || existing.type !== type) continue;
1077
+ const patch = {};
1078
+ if (typeof edit.title === "string" && edit.title.trim()) patch.title = edit.title.trim();
1079
+ if (typeof edit.content === "string" && edit.content.trim()) patch.content = edit.content.trim();
1080
+ if (Object.keys(patch).length) {
1081
+ // 启动回灌(F-NEW-01):digest 存在且匹配 = 文件自渲染后无人触碰(旧机器
1082
+ // 镜像),机器 wins,DB 的 New 必须保留,静默改回 Old 是 bug。
1083
+ const digestMatches = typeof edit.digest === "string"
1084
+ && typeof edit.title === "string"
1085
+ && typeof edit.content === "string"
1086
+ && createHash("sha256").update(`${edit.title}\x00${edit.content}`).digest("hex") === edit.digest;
1087
+ if (digestMatches) continue;
1088
+ // 文件 == store(无实际变化)时不覆盖,也不计入 applied。
1089
+ const hasDiff = (patch.title !== undefined && existing.title !== patch.title)
1090
+ || (patch.content !== undefined && existing.content !== patch.content);
1091
+ if (!hasDiff) continue;
1092
+ // 人工编辑回灌后触发 re-embed(issue #3 残留修复):向量必须与
1093
+ // 新 title/content 一致。scheduleEmbed 为 fire-and-forget,
1094
+ // 内部 try/catch 吞错,失败不影响主流程。
1095
+ // Bug5: human edits overwrite directly, but the machine version is
1096
+ // archived into content_history (source: human_override) before being
1097
+ // replaced, so a manual correction never silently destroys the old value.
1098
+ const merged = store.update(edit.id, {
1099
+ ...patch,
1100
+ content_history: patch.content !== undefined && existing.content !== patch.content
1101
+ ? pushContentHistory(existing, "human_override")
1102
+ : existing.content_history
1103
+ });
1104
+ applied++;
1105
+ scheduleEmbed(merged);
1106
+ }
1107
+ }
1108
+ if (applied) {
1109
+ afterSync("write");
1110
+ notifyWrite();
1111
+ }
1112
+ return applied;
1113
+ }
1114
+
1115
+ function toApiList(rows) {
1116
+ return rows.map((m) => ({
1117
+ id: m.id,
1118
+ type: m.type,
1119
+ title: m.title,
1120
+ content: m.content,
1121
+ tags: m.tags,
1122
+ importance: m.importance,
1123
+ source: m.source,
1124
+ // session_id is optional on the wire: only carry it when present, so the
1125
+ // DTO stays a lossless JSON object (undefined would vanish on serialize).
1126
+ ...(m.session_id != null ? { session_id: m.session_id } : {}),
1127
+ // Disposed state rides along when set, so a restore flow is not a blind
1128
+ // op — the caller can see which entries are hidden before restoreBySession.
1129
+ ...(m.session_disposed_at != null ? { disposed: true } : {}),
1130
+ created_at: m.created_at,
1131
+ updated_at: m.updated_at
1132
+ }));
1133
+ }
1134
+
1135
+ /**
1136
+ * Directory view (v0.6.3): group live memories by tag. Delegates to
1137
+ * store.getDirectory (tag-sorted groups, importance/updated DESC members,
1138
+ * live-only filtering) and maps every memory to the wire DTO so the result
1139
+ * is JSON-safe for the API endpoint.
1140
+ * @returns {{groups: {tag: string, memories: object[]}[], untagged: object[]}}
1141
+ */
1142
+ function getDirectory() {
1143
+ const { groups, untagged } = store.getDirectory();
1144
+ return {
1145
+ groups: groups.map((g) => ({ tag: g.tag, memories: toApiList(g.memories) })),
1146
+ untagged: toApiList(untagged)
1147
+ };
1148
+ }
1149
+
1150
+ /**
1151
+ * Three-way merge of in-flight human mirror edits before a re-render.
1152
+ * Runs on every syncMirror, so a human edit made between two store writes is
1153
+ * never silently overwritten by the next sync (human priority is not limited
1154
+ * to startup). Per edited entry:
1155
+ * - file changed only → human wins; the edit is merged back into the store.
1156
+ * - file AND store changed → real three-way conflict: keep the human edit
1157
+ * and append a marker preserving the store's concurrent version, so no
1158
+ * side is dropped.
1159
+ * - store changed only → store wins (the file is simply re-rendered).
1160
+ * Only title/content are taken (structure fields stay machine-owned, matching
1161
+ * mergeHumanEdits). Returns the memory list to render.
1162
+ */
1163
+ function reconcileHumanEdits(memories) {
1164
+ if (!mirror) return memories;
1165
+ const byType = new Map();
1166
+ for (const m of memories) {
1167
+ if (!byType.has(m.type)) byType.set(m.type, []);
1168
+ byType.get(m.type).push(m);
1169
+ }
1170
+ const result = [];
1171
+ for (const type of Object.keys(TYPE_FILE)) {
1172
+ const list = byType.get(type) ?? [];
1173
+ if (list.length === 0) continue;
1174
+ const editsById = new Map(mirror.readHumanEdits(type).map((e) => [e.id, e]));
1175
+ for (const m of list) {
1176
+ const edit = editsById.get(m.id);
1177
+ if (!edit) { result.push(m); continue; }
1178
+ const humanChanged = (typeof edit.title === "string" && edit.title !== m.title)
1179
+ || (typeof edit.content === "string" && edit.content !== m.content);
1180
+ if (!humanChanged) { result.push(m); continue; }
1181
+ // 判断文件是否被人工动过:digest 存在且匹配则无人触碰,否则视为人工动过。
1182
+ // digest 是渲染时对 sha256(title \x00 content) 的记录;机器 store 更新后
1183
+ // 镜像还没重渲染时读到旧内容,digest 仍匹配 → 机器 wins,不会误判为
1184
+ // 并发人工编辑导致机器写丢失 + 伪冲突标记。
1185
+ const digestMatches = typeof edit.digest === "string"
1186
+ && typeof edit.title === "string"
1187
+ && typeof edit.content === "string"
1188
+ && createHash("sha256").update(`${edit.title}\x00${edit.content}`).digest("hex") === edit.digest;
1189
+ if (digestMatches) {
1190
+ // 无人触碰,机器 wins,走原样
1191
+ result.push(m);
1192
+ continue;
1193
+ }
1194
+ // 人工动过(digest 不存在=老文件/手工文件保守视为人工动过),走三方合并
1195
+ // (保留现有 storeChanged 逻辑)
1196
+ const storeChanged = edit.updated_at !== undefined && m.updated_at !== edit.updated_at;
1197
+ if (storeChanged) {
1198
+ const marker = `\n\n> ⚠️ 并发冲突:人工编辑 vs 记忆库并发更新(${m.updated_at})\n> 记忆库版本:${m.content}`;
1199
+ store.update(m.id, { title: edit.title, content: `${edit.content}${marker}` });
1200
+ } else {
1201
+ store.update(m.id, { title: edit.title, content: edit.content });
1202
+ }
1203
+ const merged = store.getById(m.id);
1204
+ scheduleEmbed(merged);
1205
+ result.push(merged);
1206
+ }
1207
+ }
1208
+ return result;
1209
+ }
1210
+
1211
+ /**
1212
+ * Re-render the human-editable mirror after any store mutation, merging any
1213
+ * in-flight human edits first (never silently overwriting them). Only
1214
+ * non-forgotten memories are mirrored: forgotten entries must not reach the
1215
+ * human-editable file (a human "edit" could otherwise resurrect them).
1216
+ */
1217
+ // syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
1218
+ // v0.3.6(audit peer 4 阻断):
1219
+ // - 开始时 incrementGeneration 绑定本次期望轮次 gen;成功用
1220
+ // markMirrorCleanForGeneration(gen, now) CAS/fence 清 dirty——旧 worker
1221
+ // (gen 已过期)不会误清另一 worker 未恢复的故障债务;
1222
+ // - 失败写 markMirrorDirty(递增 desired 绑定新债务),下次 recover 恢复;
1223
+ // - 逐 type 用 setTypeStatus 记录部分成功/失败(type_status JSON);
1224
+ // - 所有 store 状态写入各自 try/catch,失败只 warn,绝不向外抛(F-NEW-03)。
1225
+ function syncMirror() {
1226
+ if (txDepth > 0 || !mirror) return { success: true, deferred: true }; // deferred to the transaction's commit
1227
+ const now = new Date().toISOString();
1228
+ let gen;
1229
+ try {
1230
+ // desired generation 已在业务写事务中原子递增(peer blocker 1);这里
1231
+ // 直接读当前值作为本次同步的目标轮次,不再自行 incrementGeneration。
1232
+ const state = store.getMirrorState();
1233
+ gen = state?.generation ?? 0;
1234
+ } catch (stateError) {
1235
+ logger?.warn?.("syncMirror: getMirrorState failed:", stateError);
1236
+ return { success: false, error: stateError?.message ?? String(stateError) };
1237
+ }
1238
+ // coveredTypes 提到 try 外初始化:即使 store.list 先抛错,catch 分支也有
1239
+ // 合法的空 Set 可迭代,保证 syncMirror 自身绝不抛(fail-safe)。
1240
+ const coveredTypes = new Set();
1241
+ try {
1242
+ // 预先获取本次要覆盖的 type 集合(只调一次 store.list)
1243
+ const list = store.list({ limit: 500, includeForgotten: false });
1244
+ for (const memory of list) {
1245
+ if (memory?.type && TYPE_FILE[memory.type]) {
1246
+ coveredTypes.add(memory.type);
1247
+ }
1248
+ }
1249
+
1250
+ // Per-type physical outcome (audit peer D): mirror.sync writes each type
1251
+ // file independently and reports per-type success/failure. A type whose
1252
+ // file was physically committed must be marked committed even when a
1253
+ // sibling type errors — the old code batch-failed every type on any error,
1254
+ // leaving committed files mislabeled as failed and masking partial state.
1255
+ // Absent entries (a type with no memories) count as success: sync prunes
1256
+ // the stale file, which is itself a completed physical state.
1257
+ // v0.6.2: attach entity_attrs-backed tags (entityTags) after the human-edit
1258
+ // merge so renderMemory can draw the `#tag` line under each title. The
1259
+ // bulk map is a single query, and a missing row simply renders no line.
1260
+ const reconciled = reconcileHumanEdits(list);
1261
+ const tagsMap = store.getMemoryTagsMap(reconciled.map((m) => m.id));
1262
+ const tagged = reconciled.map((m) => ({ ...m, entityTags: tagsMap.get(m.id) ?? [] }));
1263
+ let allOk = true;
1264
+ const results = mirror.sync(tagged) ?? {};
1265
+ for (const type of Object.keys(TYPE_FILE)) {
1266
+ const r = results[type];
1267
+ const ok = !r || r.ok === true;
1268
+ if (!ok) allOk = false;
1269
+ try {
1270
+ if (ok) {
1271
+ store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
1272
+ } else {
1273
+ store.setTypeStatus(type, { status: "failed", last_error: r.error ?? "mirror sync failed" });
1274
+ }
1275
+ } catch (stateError) {
1276
+ logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
1277
+ }
1278
+ }
1279
+
1280
+ // 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
1281
+ // 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
1282
+ if (allOk) {
1283
+ try {
1284
+ store.markMirrorCleanForGeneration(gen, now);
1285
+ } catch (stateError) {
1286
+ logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
1287
+ return { success: false, error: stateError?.message ?? String(stateError) };
1288
+ }
1289
+ return { success: true };
1290
+ }
1291
+
1292
+ // 部分 type 失败:持久 dirty(债务绑定到新轮次),下次 recover 只补未收敛
1293
+ // 的 type。committed 的 type 已应用本轮 gen,不因兄弟失败被回滚。
1294
+ const failedTypes = Object.entries(results)
1295
+ .filter(([, r]) => r && r.ok === false)
1296
+ .map(([t]) => t);
1297
+ try {
1298
+ store.markMirrorDirty(`mirror sync failed for: ${failedTypes.join(", ")}`, now);
1299
+ } catch (stateError) {
1300
+ logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
1301
+ }
1302
+ return { success: false, error: `mirror sync failed for: ${failedTypes.join(", ")}` };
1303
+ } catch (error) {
1304
+ const errMsg = error?.message ?? String(error);
1305
+ logger?.warn?.("syncMirror failed:", error);
1306
+ try {
1307
+ // 债务绑定到新的一轮(desired generation 原子递增;即便 dirty 写失败,
1308
+ // generation 已推进,recoverMirror 仍能捕获,不产生 false-clean)。
1309
+ store.markMirrorDirty(errMsg, now);
1310
+ } catch (stateError) {
1311
+ logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
1312
+ }
1313
+ // 逐 type 标记为 failed(applied_gen 不动)
1314
+ for (const type of coveredTypes) {
1315
+ try {
1316
+ store.setTypeStatus(type, { status: "failed", last_error: errMsg });
1317
+ } catch (stateError) {
1318
+ logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
1319
+ }
1320
+ }
1321
+ return { success: false, error: errMsg };
1322
+ }
1323
+ }
1324
+
1325
+ // afterSync: run syncMirror and surface a failure to the operator instead of
1326
+ // swallowing it (peer blocker 2 + audit peer B). The mirror debt has already
1327
+ // been persisted by markMirrorDirty inside syncMirror, so a restart recovers —
1328
+ // but the calling write path must not report clean while the mirror is
1329
+ // known-stale. Returns the sync result so the caller can attach an explicit
1330
+ // degraded/pending receipt to its return value instead of faking success.
1331
+ function afterSync(label) {
1332
+ const r = syncMirror();
1333
+ if (!r?.success && !r?.deferred) {
1334
+ logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
1335
+ }
1336
+ return r;
1337
+ }
1338
+
1339
+ // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
1340
+ // (F-NEW-03 + v0.3.6)。触发条件不只是 dirty——还检查
1341
+ // generation > applied_generation(有未应用的债务),这样 COMMIT→dirty 崩溃
1342
+ // 窗口(DB 提交后、markMirrorDirty/clean 前进程退出 → dirty=false 但
1343
+ // generation 不一致)也能被捕获。有界重试(最多 3 次)重跑 syncMirror 收敛;
1344
+ // 某次成功后 dirty=false 且无更新债务(generation <= applied_generation)
1345
+ // 立即停止。返回 { recovered, error } 供 index.js 启动 / api.js health 判断。
1346
+ // 一切 fail-safe,绝不向外抛。
1347
+ function recoverMirror() {
1348
+ const MAX_ATTEMPTS = 3;
1349
+ let lastError = null;
1350
+ let recovered = false;
1351
+
1352
+ try {
1353
+ const state = store.getMirrorState();
1354
+ // 崩溃窗口检测:dirty 或 generation > applied_generation(COMMIT→dirty 窗口)
1355
+ if (!state?.dirty && !(state.generation > state.applied_generation)) {
1356
+ // 本来就干净:无需恢复,视为成功
1357
+ return { recovered: true, error: null };
1358
+ }
1359
+
1360
+ // 有 dirty 或有未应用债务:最多尝试 3 次 sync
1361
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
1362
+ try {
1363
+ syncMirror(); // syncMirror 内部已 catch,不会向外抛
1364
+ const currentState = store.getMirrorState();
1365
+ // 成功条件:dirty 为 false 且没有更新一轮的债务
1366
+ // (generation <= applied_generation,恢复后由 syncMirror 里
1367
+ // markMirrorCleanForGeneration 自动把 applied 跟上)
1368
+ if (!currentState?.dirty && currentState.generation <= currentState.applied_generation) {
1369
+ recovered = true;
1370
+ lastError = null;
1371
+ break;
1372
+ }
1373
+ // 仍 dirty 或仍有更新债务:记录最后一次错误供重试耗尽后上报。
1374
+ // 注意:若别的 worker 又失败产生新债务(dirty 仍 true),这是"新债务"
1375
+ // 不是本次失败,继续重试直到耗尽次数。
1376
+ lastError = currentState.last_error || `Sync attempt ${attempt + 1} left mirror dirty or has pending debt`;
1377
+ } catch (syncError) {
1378
+ // syncMirror 理论不抛,fail-safe 兜底
1379
+ const errMsg = syncError?.message ?? String(syncError);
1380
+ logger?.warn?.("recoverMirror: sync attempt failed:", errMsg);
1381
+ lastError = errMsg;
1382
+ }
1383
+ }
1384
+
1385
+ if (!recovered) {
1386
+ logger?.warn?.("dsh-mneme mirror: recover failed after", MAX_ATTEMPTS, "attempts");
1387
+ } else {
1388
+ logger?.warn?.("dsh-mneme mirror: recovered from dirty/pending state");
1389
+ }
1390
+ } catch (error) {
1391
+ // fail-safe:任何意外异常不向外抛
1392
+ lastError = error?.message ?? String(error);
1393
+ logger?.warn?.("dsh-mneme mirror: recover failed with unexpected error:", error);
1394
+ }
1395
+
1396
+ return { recovered, error: recovered ? null : lastError };
1397
+ }
1398
+
1399
+ // getMirrorHealth: 暴露 mirror 同步健康状态,供 api.js /health 使用
1400
+ // (F-NEW-03)。把 DB 的 dirty 0/1 转成 boolean;fail-safe,绝不向外抛。
1401
+ function getMirrorHealth() {
1402
+ try {
1403
+ const state = store.getMirrorState();
1404
+ if (!state) {
1405
+ // 无状态行:返回安全默认值
1406
+ return {
1407
+ dirty: false,
1408
+ last_error: null,
1409
+ last_attempt: null,
1410
+ success_at: null
1411
+ };
1412
+ }
1413
+ return {
1414
+ dirty: Boolean(state.dirty),
1415
+ last_error: state.last_error ?? null,
1416
+ last_attempt: state.last_attempt ?? null,
1417
+ success_at: state.success_at ?? null
1418
+ };
1419
+ } catch (error) {
1420
+ // fail-safe:状态读取失败也不向外抛,但必须显式表达"未知"而非伪装成
1421
+ // 干净(peer blocker 5:真实读取失败要显式 unknown,不得归一为 dirty:false)。
1422
+ logger?.warn?.("getMirrorHealth failed:", error);
1423
+ return {
1424
+ dirty: null,
1425
+ last_error: error?.message ?? String(error),
1426
+ last_attempt: null,
1427
+ success_at: null
1428
+ };
1429
+ }
1430
+ }
1431
+
1432
+ /**
1433
+ * Forward links (v0.6.1): memories the given memory explicitly links to via
1434
+ * [[wiki-links]] in its content. Reads the links_to relations whose
1435
+ * from_entity is this memory's title and resolves each to_entity back to a
1436
+ * memory row (case-insensitive title match). Returns [{ target, relation }];
1437
+ * a target title with no matching memory surfaces as { target: null }.
1438
+ */
1439
+ function getForwardLinks(memoryId) {
1440
+ const memory = store.getById(memoryId);
1441
+ if (!memory) return [];
1442
+ const out = [];
1443
+ for (const rel of store.getRelations(memory.title) ?? []) {
1444
+ if (rel.relation_type !== "links_to" || rel.from_entity !== memory.title) continue;
1445
+ out.push({ target: store.findByTitle?.(rel.to_entity) ?? null, relation: rel });
1446
+ }
1447
+ return out;
1448
+ }
1449
+
1450
+ /**
1451
+ * Back links (v0.6.1): memories that explicitly link TO the given memory
1452
+ * (their content carries a wiki-link whose target resolves to this memory's
1453
+ * title). Reads the links_to relations whose to_entity is this memory's
1454
+ * title; the linking memory is rel.memory_id (the source that wrote the
1455
+ * relation). Deduped per source memory; missing/self links are dropped.
1456
+ * Returns [{ source, relation }].
1457
+ */
1458
+ function getBacklinks(memoryId) {
1459
+ const memory = store.getById(memoryId);
1460
+ if (!memory) return [];
1461
+ const out = [];
1462
+ const seen = new Set();
1463
+ for (const rel of store.getRelations(memory.title) ?? []) {
1464
+ if (rel.relation_type !== "links_to" || rel.to_entity !== memory.title) continue;
1465
+ const source = rel.memory_id ? store.getById(rel.memory_id) : undefined;
1466
+ if (!source || source.id === memory.id || seen.has(source.id)) continue;
1467
+ seen.add(source.id);
1468
+ out.push({ source, relation: rel });
1469
+ }
1470
+ return out;
1471
+ }
1472
+
1473
+ return {
1474
+ saveWithDedupe,
1475
+ getBacklinks,
1476
+ getForwardLinks,
1477
+ resolveWikiLink: (title) => store.findByTitle?.(title),
1478
+ recoverMirror,
1479
+ getMirrorHealth,
1480
+ getMirrorState: () => store.getMirrorState(),
1481
+ injectCandidates,
1482
+ mergeHumanEdits,
1483
+ toApiList,
1484
+ getDirectory,
1485
+ transaction,
1486
+ enqueue,
1487
+ setDreamHook(fn) { dreamHook = fn; },
1488
+ setSleepHook(fn) { sleepHook = fn; },
1489
+ setEmbedder(emb) {
1490
+ embedder = emb;
1491
+ if (!emb) {
1492
+ // embedder removed (init failed in index.js): stop polling and drop
1493
+ // queued re-embeds — search just degrades to keyword.
1494
+ stopEmbedReadyPolling();
1495
+ embedPending = [];
1496
+ return;
1497
+ }
1498
+ if (emb.ready === true) {
1499
+ flushEmbedPending();
1500
+ return;
1501
+ }
1502
+ // Async-initializing embedder: poll `ready` until it flips, then flush.
1503
+ if ("ready" in emb && embedReadyTimer === null) {
1504
+ let attempts = 0;
1505
+ embedReadyTimer = setInterval(() => {
1506
+ attempts++;
1507
+ if (emb.ready === true || attempts >= EMBED_READY_POLL_LIMIT) {
1508
+ stopEmbedReadyPolling();
1509
+ if (emb.ready === true) flushEmbedPending();
1510
+ else embedPending = []; // init never landed: drop the queue
1511
+ }
1512
+ }, EMBED_READY_POLL_MS);
1513
+ }
1514
+ },
1515
+ setEntityExtractor(fn) { entityExtractor = fn; },
1516
+ setVectorIndex(vi) { vectorIndex = vi; },
1517
+ setReranker(rn) { reranker = rn; },
1518
+ setRecallRecorder(fn) { recallRecorder = fn; },
1519
+ searchMemories,
1520
+ embedQuery,
1521
+ evaluateRetrieval,
1522
+ computeRetrievalMetrics,
1523
+ // passthroughs used by tools and api layers; mutations keep the mirror in sync
1524
+ search: (q, o) => store.search(q, o),
1525
+ searchVector: (v, o) => store.searchVector(v, o),
1526
+ embeddedCount: () => store.embeddedCount(),
1527
+ list: (o) => store.list(o),
1528
+ all: () => store.all(),
1529
+ count: (type, opts) => store.count(type, opts),
1530
+ getById: (id) => store.getById(id),
1531
+ remove: (id) => {
1532
+ store.remove(id);
1533
+ afterSync("write");
1534
+ notifyWrite();
1535
+ },
1536
+ // Session lifecycle (v0.6.0): mark/clear the session-disposed state on every
1537
+ // memory born in a given session. Uses the dedicated `session_disposed_at`
1538
+ // column, orthogonal to `archived` — restoring a session never resurrects
1539
+ // memories the user archived on purpose. Nothing is destroyed; a session
1540
+ // treated as a save point is fully recoverable via restoreBySession.
1541
+ disposeBySession: (sessionId) => {
1542
+ const disposed = store.setDisposedBySession(sessionId, true);
1543
+ if (disposed > 0) {
1544
+ afterSync("write");
1545
+ notifyWrite();
1546
+ }
1547
+ return { disposed };
1548
+ },
1549
+ restoreBySession: (sessionId) => {
1550
+ const restored = store.setDisposedBySession(sessionId, false);
1551
+ if (restored > 0) {
1552
+ afterSync("write");
1553
+ notifyWrite();
1554
+ }
1555
+ return { restored };
1556
+ },
1557
+ listBySession: (sessionId, opts = {}) => toApiList(store.listBySession(sessionId, opts)),
1558
+ update: (id, p, ctx = {}) => {
1559
+ const old = store.getById(id);
1560
+ const updated = store.update(id, p);
1561
+ // Record a user correction when any meaningful field changed and the
1562
+ // reflection failure tracker is enabled. expected = what it became,
1563
+ // actual = what it was before; query (when provided) captures the
1564
+ // user's original intent so later reflection can reason about recall.
1565
+ const hasMeaningfulChange = old && updated && (
1566
+ old.content !== updated.content ||
1567
+ old.title !== updated.title ||
1568
+ old.importance !== updated.importance
1569
+ );
1570
+ if (hasMeaningfulChange && config.reflectionFailureTracking) {
1571
+ store.saveFailure({
1572
+ id: randomUUID(),
1573
+ query: ctx.query ?? null,
1574
+ expected: updated.content,
1575
+ actual: old.content,
1576
+ before: { title: old.title, content: old.content, importance: old.importance },
1577
+ failure_type: "user_correction",
1578
+ memory_id: id
1579
+ });
1580
+ }
1581
+ const sync = afterSync("write");
1582
+ notifyWrite();
1583
+ scheduleEmbed(updated);
1584
+ scheduleWikiLinkResolve(updated);
1585
+ // Audit peer B: when the mirror sync failed, the store write landed but
1586
+ // the mirror did not converge — return an explicit degraded receipt rather
1587
+ // than a plain success. Non-enumerable so existing deepEqual assertions on
1588
+ // the memory shape keep passing.
1589
+ if (!sync?.success && !sync?.deferred) {
1590
+ Object.defineProperty(updated, "_mirror", {
1591
+ value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
1592
+ enumerable: false,
1593
+ configurable: true
1594
+ });
1595
+ }
1596
+ return updated;
1597
+ },
1598
+ // Compare-and-set update: applies the patch only when the row still carries
1599
+ // `expectedUpdatedAt`. Returns undefined on a miss (no write) so the caller
1600
+ // can re-read and retry — the primitive that prevents lost updates across
1601
+ // concurrent read-modify-write (see scripts/stress-dsh.js axis 3).
1602
+ compareAndUpdate: (id, expectedUpdatedAt, patch, ctx = {}) => {
1603
+ const old = store.getById(id);
1604
+ const updated = store.compareAndUpdate(id, expectedUpdatedAt, patch);
1605
+ if (updated === undefined) return undefined; // CAS miss: no write, no side effects
1606
+ const hasMeaningfulChange = old && updated && (
1607
+ old.content !== updated.content ||
1608
+ old.title !== updated.title ||
1609
+ old.importance !== updated.importance
1610
+ );
1611
+ if (hasMeaningfulChange && config.reflectionFailureTracking) {
1612
+ store.saveFailure({
1613
+ id: randomUUID(),
1614
+ query: ctx.query ?? null,
1615
+ expected: updated.content,
1616
+ actual: old.content,
1617
+ before: { title: old.title, content: old.content, importance: old.importance },
1618
+ failure_type: "user_correction",
1619
+ memory_id: id
1620
+ });
1621
+ }
1622
+ const sync = afterSync("write");
1623
+ notifyWrite();
1624
+ scheduleEmbed(updated);
1625
+ // Audit peer B: mirror sync failure on a CAS write must surface too.
1626
+ if (!sync?.success && !sync?.deferred) {
1627
+ Object.defineProperty(updated, "_mirror", {
1628
+ value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
1629
+ enumerable: false,
1630
+ configurable: true
1631
+ });
1632
+ }
1633
+ return updated;
1634
+ },
1635
+ setForget: (id, f) => {
1636
+ const updated = store.setForget(id, f);
1637
+ afterSync("write");
1638
+ return updated;
1639
+ },
1640
+ setArchived: (id, f) => {
1641
+ const updated = store.setArchived(id, f);
1642
+ afterSync("write");
1643
+ return updated;
1644
+ },
1645
+ // sleep-mode storage (v0.4.0). demoteToSummary / restoreContent mutate
1646
+ // content so they ride the normal write-hook path (mirror re-renders).
1647
+ // touchLastAccess is a read-stamp — deliberately NO write hook (a recall
1648
+ // must not dirty the mirror). getUnrecalledSince is a pure read.
1649
+ demoteToSummary: (id, summary, opts) => {
1650
+ const updated = store.demoteToSummary(id, summary, opts);
1651
+ afterSync("write");
1652
+ return updated;
1653
+ },
1654
+ restoreContent: (id) => {
1655
+ const updated = store.restoreContent(id);
1656
+ afterSync("write");
1657
+ return updated;
1658
+ },
1659
+ touchLastAccess: (id, at) => store.touchLastAccess(id, at),
1660
+ getUnrecalledSince: (cutMs, opts) => store.getUnrecalledSince(cutMs, opts),
1661
+ // autoDream audit trail: passthroughs deliberately bypass write hooks —
1662
+ // an audit write is bookkeeping, and notifyWrite would loop back into the
1663
+ // dream scheduler that just recorded the run.
1664
+ saveDreamRun: (run) => store.saveDreamRun(run),
1665
+ getDreamRun: (id) => store.getDreamRun(id),
1666
+ listDreamRuns: (opts) => store.listDreamRuns(opts),
1667
+ // Per-record receipt chain (same bookkeeping semantics as saveDreamRun: an
1668
+ // audit write, never a write-hook-triggering memory mutation).
1669
+ saveReceipt: (r) => store.saveReceipt(r),
1670
+ getReceipt: (id) => store.getReceipt(id),
1671
+ listReceipts: (opts) => store.listReceipts(opts),
1672
+ // Conflict freeze bookkeeping (same semantics as the audit passthroughs
1673
+ // above: an audit write, never a write-hook-triggering memory mutation).
1674
+ saveConflictPending: (r) => store.saveConflictPending(r),
1675
+ listConflictPending: (opts) => store.listConflictPending(opts),
1676
+ resolveConflictPending: (id, o) => store.resolveConflictPending(id, o),
1677
+ countConflictPending: () => store.countConflictPending(),
1678
+ // Recall evaluation trail (方案 B): audit-bookkeeping semantics like the
1679
+ // dream/recall passthroughs above — a recall_evals write is a snapshot, not
1680
+ // a memory mutation, so it never triggers write hooks.
1681
+ saveRecallEval: (r) => store.saveRecallEval(r),
1682
+ getRecallEval: (id) => store.getRecallEval(id),
1683
+ listRecallEvals: (opts) => store.listRecallEvals(opts),
1684
+ // LLM audit trail (Bug8): bookkeeping semantics like the recall/dream
1685
+ // passthroughs — a saveLlmAudit write never triggers write hooks.
1686
+ saveLlmAudit: (entry) => store.saveLlmAudit(entry),
1687
+ listLlmAudits: (opts) => store.listLlmAudits(opts),
1688
+ countLlmAudits: (opts) => store.countLlmAudits(opts),
1689
+ getLlmAuditStats: (opts) => store.getLlmAuditStats(opts),
1690
+ deleteOldLlmAudits: (before) => store.deleteOldLlmAudits(before),
1691
+ // Entity gene (v0.3.0) passthroughs for the autoDream apply path
1692
+ // (applyDecisions): records supersedes relations after an update and
1693
+ // migrates entity_attrs on merge. Bookkeeping writes like the audit
1694
+ // passthroughs above — never write-hook-triggering memory mutations.
1695
+ saveRelation: (r) => store.saveRelation(r),
1696
+ saveWikiLinks: (r) => store.saveWikiLinks(r),
1697
+ listEntities: (o) => store.listEntities(o),
1698
+ getRelations: (id) => store.getRelations(id),
1699
+ // Tag system (v0.6.2). setMemoryTags is the manual/user path: gated by
1700
+ // config.manualTagEnabled (default true), re-renders the mirror and fires
1701
+ // the write hook. applyMemoryTags is the raw write used by the autoDream
1702
+ // tag pass (gated by autoTagEnabled, wrapped in a transaction by the
1703
+ // extractor so the mirror re-renders exactly once).
1704
+ setMemoryTags: (memoryId, tags) => {
1705
+ if (config.manualTagEnabled === false) {
1706
+ return { ok: false, error: "manualTagEnabled is off" };
1707
+ }
1708
+ const stored = store.setMemoryTags(memoryId, tags);
1709
+ afterSync("write");
1710
+ notifyWrite();
1711
+ return { ok: true, tags: stored };
1712
+ },
1713
+ getMemoryTags: (memoryId) => store.getMemoryTags(memoryId),
1714
+ // Read-only gate flag so the Web panel can hide tag editing when the
1715
+ // manual path is disabled (default: manual tagging is on).
1716
+ manualTagEnabled: () => config.manualTagEnabled !== false,
1717
+ applyMemoryTags: (memoryId, tags) => store.setMemoryTags(memoryId, tags),
1718
+ saveAttr: (r) => store.saveAttr(r),
1719
+ createEntity: (r) => store.createEntity(r),
1720
+ findEntityByName: (n) => store.findEntityByName(n),
1721
+ findEntityById: (id) => store.findEntityById(id),
1722
+ getAttrsByMemory: (id) => store.getAttrsByMemory(id),
1723
+ getCurrentAttrs: (id) => store.getCurrentAttrs(id),
1724
+ migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
1725
+ };
1726
+ }