@modusensus/dsh-mneme 0.8.3 → 0.8.5

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 (205) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +727 -636
  3. package/bin/cli.mjs +0 -0
  4. package/bin/dsh-mneme-mcp.mjs +510 -0
  5. package/cordis.patch.yml +21 -21
  6. package/lib/api-standalone.js +506 -316
  7. package/lib/api.js +1403 -1316
  8. package/lib/bootstrap.js +211 -0
  9. package/lib/client.js +4657 -4366
  10. package/lib/commands.js +95 -95
  11. package/lib/config.js +615 -510
  12. package/lib/document.js +221 -0
  13. package/lib/dream/clustering.js +118 -118
  14. package/lib/dream/decisions.js +658 -658
  15. package/lib/dream/narratives.js +44 -0
  16. package/lib/dream/sleep.js +702 -700
  17. package/lib/dream.js +147 -16
  18. package/lib/embedding.js +177 -177
  19. package/lib/entities/extractor.js +249 -249
  20. package/lib/guide.js +44 -0
  21. package/lib/heat.js +144 -136
  22. package/lib/hot-memory.js +46 -46
  23. package/lib/index.js +515 -515
  24. package/lib/inject.js +375 -295
  25. package/lib/lang.js +208 -179
  26. package/lib/local-embedder.js +320 -320
  27. package/lib/mirror.js +198 -198
  28. package/lib/organize.js +450 -0
  29. package/lib/quality-filter.js +126 -126
  30. package/lib/recall-stats.js +125 -0
  31. package/lib/reranker.js +269 -269
  32. package/lib/runtime/adopt.js +176 -176
  33. package/lib/runtime/closure.js +172 -172
  34. package/lib/runtime/download.js +247 -247
  35. package/lib/runtime/layout.js +265 -235
  36. package/lib/runtime/loader.js +252 -249
  37. package/lib/runtime/model-fetch.js +361 -361
  38. package/lib/runtime/provision.js +237 -237
  39. package/lib/runtime/tarball.js +130 -130
  40. package/lib/runtime/verify.js +204 -191
  41. package/lib/scope.js +118 -118
  42. package/lib/search/adaptive.js +60 -22
  43. package/lib/search/bm25.js +96 -96
  44. package/lib/service.js +2324 -2087
  45. package/lib/settings.js +459 -420
  46. package/lib/store.js +2549 -2367
  47. package/lib/summarize.js +820 -449
  48. package/lib/tools.js +702 -574
  49. package/lib/vector-index.js +110 -110
  50. package/lib/version-check.js +122 -122
  51. package/package.json +109 -108
  52. package/runtime-manifest.json +526 -596
  53. package/scripts/benchmark-embed.js +201 -201
  54. package/scripts/benchmark-recall.js +199 -199
  55. package/scripts/benchmark-rerank.js +166 -166
  56. package/scripts/build-runtime-manifest.mjs +224 -217
  57. package/scripts/check-sync.js +42 -42
  58. package/scripts/e2e-dsh.js +220 -220
  59. package/scripts/mneme-runtime.mjs +277 -277
  60. package/scripts/stress-dsh.js +255 -255
  61. package/scripts/sync-lib.js +47 -47
  62. package/scripts/sync-test-badge.mjs +67 -61
  63. package/src/api-standalone.js +506 -316
  64. package/src/api.js +1403 -1316
  65. package/src/bootstrap.js +211 -0
  66. package/src/commands.js +95 -95
  67. package/src/config.js +615 -510
  68. package/src/document.js +221 -0
  69. package/src/dream/clustering.js +118 -118
  70. package/src/dream/decisions.js +658 -658
  71. package/src/dream/narratives.js +44 -0
  72. package/src/dream/sleep.js +702 -700
  73. package/src/dream.js +147 -16
  74. package/src/embedding.js +177 -177
  75. package/src/entities/extractor.js +249 -249
  76. package/src/guide.js +44 -0
  77. package/src/heat.js +144 -136
  78. package/src/hot-memory.js +46 -46
  79. package/src/index.js +515 -515
  80. package/src/inject.js +375 -295
  81. package/src/lang.js +208 -179
  82. package/src/local-embedder.js +320 -320
  83. package/src/mirror.js +198 -198
  84. package/src/organize.js +450 -0
  85. package/src/quality-filter.js +126 -126
  86. package/src/recall-stats.js +125 -0
  87. package/src/reranker.js +269 -269
  88. package/src/runtime/adopt.js +176 -176
  89. package/src/runtime/closure.js +172 -172
  90. package/src/runtime/download.js +247 -247
  91. package/src/runtime/layout.js +265 -235
  92. package/src/runtime/loader.js +252 -249
  93. package/src/runtime/model-fetch.js +361 -361
  94. package/src/runtime/provision.js +237 -237
  95. package/src/runtime/tarball.js +130 -130
  96. package/src/runtime/verify.js +204 -191
  97. package/src/scope.js +118 -118
  98. package/src/search/adaptive.js +60 -22
  99. package/src/search/bm25.js +96 -96
  100. package/src/service.js +2324 -2087
  101. package/src/settings.js +459 -420
  102. package/src/store.js +2549 -2367
  103. package/src/summarize.js +820 -449
  104. package/src/tools.js +702 -574
  105. package/src/vector-index.js +110 -110
  106. package/src/version-check.js +122 -122
  107. package/test/adaptive-inject-budget.test.js +85 -0
  108. package/test/api-routes-gaps.test.js +218 -218
  109. package/test/api.test.js +1640 -1482
  110. package/test/audit.test.js +448 -448
  111. package/test/benchmark.test.js +94 -94
  112. package/test/bootstrap.test.js +220 -0
  113. package/test/client.test.js +860 -736
  114. package/test/clustering.test.js +100 -100
  115. package/test/commands.test.js +106 -106
  116. package/test/config.test.js +66 -66
  117. package/test/conflict-freeze.test.js +300 -300
  118. package/test/document.test.js +523 -0
  119. package/test/dream-archive-guardrail.test.js +116 -116
  120. package/test/dream-candidate-hybrid.test.js +281 -281
  121. package/test/dream-coverage-degrade.test.js +86 -86
  122. package/test/dream.test.js +1308 -1254
  123. package/test/embedding-ready.test.js +196 -196
  124. package/test/entities.test.js +547 -547
  125. package/test/entity-recall.test.js +179 -0
  126. package/test/epistemic.test.js +298 -298
  127. package/test/fnew-0112.test.js +311 -311
  128. package/test/fnew-03.test.js +422 -422
  129. package/test/graph-api.test.js +175 -175
  130. package/test/heat.test.js +175 -148
  131. package/test/helpers/dream-mock.js +82 -82
  132. package/test/helpers/peer-worker.mjs +25 -25
  133. package/test/helpers/runtime-source.js +60 -60
  134. package/test/helpers/tar-builder.js +80 -80
  135. package/test/hot-memory.test.js +145 -145
  136. package/test/inject-content-cap.test.js +91 -0
  137. package/test/inject-first-round-bm25.test.js +89 -89
  138. package/test/inject-heat.test.js +61 -0
  139. package/test/inject-pools.test.js +170 -0
  140. package/test/inject-recall.test.js +105 -0
  141. package/test/inject-rotation.test.js +161 -161
  142. package/test/inject.test.js +283 -249
  143. package/test/lang.test.js +87 -87
  144. package/test/lib-smoke.test.js +96 -96
  145. package/test/llm-audit.test.js +462 -309
  146. package/test/llm-message-source.test.js +177 -177
  147. package/test/local-embedder.test.js +249 -249
  148. package/test/mcp-stdio.test.js +385 -0
  149. package/test/mirror-dirty.test.js +424 -424
  150. package/test/mirror-edit-digest.test.js +187 -187
  151. package/test/mirror-generation.test.js +499 -499
  152. package/test/mirror.test.js +249 -249
  153. package/test/narrative-bars.test.js +162 -0
  154. package/test/organize.test.js +418 -0
  155. package/test/peer-blockers.test.js +193 -193
  156. package/test/policy-epoch.test.js +259 -259
  157. package/test/quality-filter.test.js +186 -186
  158. package/test/reasoning-effort.test.js +746 -746
  159. package/test/recall-evals.test.js +235 -235
  160. package/test/recall-layer.test.js +326 -326
  161. package/test/recall-stats.test.js +123 -0
  162. package/test/receipt-chain.test.js +451 -451
  163. package/test/reflection.test.js +226 -226
  164. package/test/reindex-backfill.test.js +62 -62
  165. package/test/reranker-single-logit.test.js +73 -73
  166. package/test/reranker.test.js +197 -197
  167. package/test/resident-status.test.js +83 -0
  168. package/test/runtime-adopt.test.js +206 -206
  169. package/test/runtime-cli.test.js +89 -89
  170. package/test/runtime-closure.test.js +193 -193
  171. package/test/runtime-download.test.js +302 -302
  172. package/test/runtime-layout.test.js +163 -163
  173. package/test/runtime-loader.test.js +255 -237
  174. package/test/runtime-manifest.test.js +159 -158
  175. package/test/runtime-model-fetch.test.js +348 -348
  176. package/test/runtime-provision.test.js +101 -101
  177. package/test/runtime-tarball.test.js +81 -81
  178. package/test/runtime-verify.test.js +279 -214
  179. package/test/scope-candidates.test.js +239 -239
  180. package/test/scope-explicit.test.js +452 -452
  181. package/test/scope-retrieval.test.js +197 -197
  182. package/test/scope-storage.test.js +255 -255
  183. package/test/scope-strict.test.js +231 -231
  184. package/test/search-fusion.test.js +90 -90
  185. package/test/semantic.test.js +124 -124
  186. package/test/service-search.test.js +174 -174
  187. package/test/service.test.js +405 -405
  188. package/test/settings.test.js +261 -261
  189. package/test/skip-invalid-restore.test.js +262 -262
  190. package/test/sleep-action-set.test.js +422 -422
  191. package/test/sleep-heat.test.js +126 -125
  192. package/test/sleep-scheduler-rearm.test.js +89 -89
  193. package/test/sleep.test.js +410 -373
  194. package/test/standalone-api.test.js +544 -359
  195. package/test/store-vector-cache.test.js +98 -98
  196. package/test/store.test.js +396 -383
  197. package/test/stress.test.js +209 -209
  198. package/test/summarize.test.js +1288 -575
  199. package/test/tools-dto-schema-matrix.test.js +325 -0
  200. package/test/tools-memory-get-schema.test.js +65 -65
  201. package/test/tools.test.js +374 -341
  202. package/test/updated-at-semantics.test.js +113 -113
  203. package/test/vector-index.test.js +221 -221
  204. package/test/version-check.test.js +152 -152
  205. package/test/webServer-optional.test.js +82 -82
package/lib/service.js CHANGED
@@ -1,2087 +1,2324 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { TYPE_FILE } from "./mirror.js";
3
- import { updatedAtBounds } from "./store.js";
4
- import { normalizeExplicitScope, scopeKeyOf } from "./scope.js";
5
- import { STR, langOf } from "./lang.js";
6
- import { computeHeat } from "./heat.js";
7
- import { evaluateMemoryQuality } from "./quality-filter.js";
8
- import { applyDecisions } from "./dream/decisions.js";
9
- import { createBM25Index } from "./search/bm25.js";
10
- import { adaptiveThreshold } from "./search/adaptive.js";
11
-
12
- const INJECT_TYPES = new Set(["preference", "project", "decision", "summary", "rejected_solution", "pitfall", "constraint"]);
13
-
14
- // 编码记忆类型(codingRetrospect):rejected_solution / pitfall / constraint
15
- // 只在编码任务时注入(防噪声污染其他业务),且编码场景下按 codingBoostFactor
16
- // 加权排序提前。
17
- const CODING_MEMORY_TYPES = new Set(["rejected_solution", "pitfall", "constraint"]);
18
-
19
- /**
20
- * 判断一段文本是否编码类任务(关键词匹配,codingRetrospect 读取侧门控)。
21
- * 纯函数,无副作用,便于单测。
22
- */
23
- export function isCodingTask(text, keywords = []) {
24
- const t = String(text ?? "").toLowerCase();
25
- return keywords.some((kw) => t.includes(String(kw).toLowerCase()));
26
- }
27
-
28
- // Epistemic trust weights (v0.4.5): when config.trustEpistemicWeighting is on,
29
- // each recall candidate's existing score is multiplied by the weight of its
30
- // epistemic_status before ranking — measured facts outrank guesses. Missing /
31
- // unknown statuses are unscaled (×1). Off by default, so nothing changes.
32
- const EPISTEMIC_WEIGHTS = { observation: 1.0, inferred: 0.85, subjective: 0.7 };
33
-
34
- // Bug5: content version history cap (FIFO — the newest 20 versions are kept,
35
- // older ones dropped). Entries are {content, source, updated_at}; source marks
36
- // how the version was superseded (auto_merge | human_override | overwrite).
37
- const CONTENT_HISTORY_MAX = 20;
38
-
39
- // Issue #135 附属发现 2:质量过滤器写下的系统信号标签(「为什么被降权/归档」的
40
- // 唯一审计线索)。更新路径整组替换 tags 会把它们抹掉——更新时按此清单并集保留。
41
- // 清单必须与 quality-filter.js 的写入端对齐(6 个,含 type 自指的 self_referential)。
42
- const SIGNAL_TAGS = ["low_quality", "duplicate", "meta", "repetitive", "short_content", "self_referential"];
43
-
44
- // scopeKeyOf 已上提到 ./scope.js(v0.8.1,issue #170 第 2 步):与 sleep 的跨
45
- // scope 配对共用同一把比较钥匙,避免两处定义漂移。
46
-
47
- // v0.8.0 A2(issue #17)scope 检索加权系数:两维都无法确立 foreign 的候选
48
- // (命中行、未标注行、当前侧维度解析不到的行)→ 加成;只有确立了 foreign
49
- // (记忆带标注 + 当前维度可解析 + 值不等)→ 降权但保留可见(硬过滤是 A3
50
- // strictScope)。即未标注行与命中行同列、不吃惩罚,但注意它们吃的是 BOOST
51
- // 而非 ×1 中性。系数是模块常量而非配置项:A2 只定性行为,数值要等线上检索
52
- // 质量反馈再调(加配置面=提前优化)。
53
- const SCOPE_MATCH_BOOST = 1.25;
54
- const SCOPE_FOREIGN_PENALTY = 0.5;
55
-
56
- /**
57
- * 单条候选的 scope 乘数。匹配语义与去重键一致(scopeKeyOf):记忆侧未标注视同
58
- * 该维度命中(全局记忆对谁都可见);当前会话侧某维度解析不到则该维度不参与
59
- * 比较(无法比较 ≠ 不匹配)。
60
- */
61
- function scopeMultiplier(m, current) {
62
- const agentForeign = scopeKeyOf(m.agent_scope) !== null
63
- && current.agent_scope !== null
64
- && scopeKeyOf(m.agent_scope) !== current.agent_scope;
65
- const workspaceForeign = scopeKeyOf(m.workspace_scope) !== null
66
- && current.workspace_scope !== null
67
- && scopeKeyOf(m.workspace_scope) !== current.workspace_scope;
68
- return (agentForeign || workspaceForeign) ? SCOPE_FOREIGN_PENALTY : SCOPE_MATCH_BOOST;
69
- }
70
-
71
- /**
72
- * v0.8.0 A2:occurred_at 后置过滤谓词(搜索融合池用)。行侧取
73
- * COALESCE(occurred_at, created_at)——与 store.list 的 SQL 过滤同口径,未标注
74
- * 行回退创建时间;时间戳损坏的行保留(宁可放宽也不误杀,同 summarize inWindow)。
75
- */
76
- function inOccurredBounds(m, bounds) {
77
- const t = Date.parse(String(m.occurred_at ?? m.created_at ?? ""));
78
- if (!Number.isFinite(t)) return true;
79
- if (bounds.from && t < Date.parse(bounds.from)) return false;
80
- if (bounds.to && t > Date.parse(bounds.to)) return false;
81
- return true;
82
- }
83
-
84
- /**
85
- * v0.8.0 A3(issue #17)strictScope 可见性谓词:issue 的四象限可见性公式
86
- * (agent 不对称可见性)——记忆对当前会话可见 ⇔ (agent 维:未标注 或 命中当前
87
- * agent) AND (workspace 维:未标注 或 命中当前 workspace)。NULL=未标注=全局;
88
- * 当前会话某维度解析不到时,该维度带标注的记忆一律不可见(fail-closed:身份
89
- * 不明的会话只见全局,不冒认)。sensitivity 是标签不参与可见性判定(其语义
90
- * 留给后续批次)。store.list 的 SQL 过滤与此谓词同口径(见 store.js visibility)。
91
- */
92
- function isVisibleInScope(m, current) {
93
- // v0.8.1 第 3 步(issue #170 4.3,作者已确认):硬过滤只认显式声明。某维
94
- // 仅当「该维有标注且来源为 explicit」才构成硬墙;auto(含 v0.8.0 存量的
95
- // NULL 来源行)只吃 A2 软加权(foreign ×0.5 保留可见),不进硬过滤。
96
- // 显式全局(值 NULL + source explicit)与未标注同形,天然恒可见。
97
- if (m.agent_scope_source === "explicit") {
98
- const agent = scopeKeyOf(m.agent_scope);
99
- if (agent !== null && (current.agent_scope === null || agent !== current.agent_scope)) return false;
100
- }
101
- if (m.workspace_scope_source === "explicit") {
102
- const ws = scopeKeyOf(m.workspace_scope);
103
- if (ws !== null && (current.workspace_scope === null || ws !== current.workspace_scope)) return false;
104
- }
105
- return true;
106
- }
107
-
108
- /** Prepend the previous content to a memory's content_history (FIFO capped). */
109
- function pushContentHistory(existing, source) {
110
- const history = Array.isArray(existing?.content_history) ? existing.content_history : [];
111
- return [
112
- { content: existing?.content ?? "", source, updated_at: new Date().toISOString() },
113
- ...history
114
- ].slice(0, CONTENT_HISTORY_MAX);
115
- }
116
-
117
- /** Bug5: same-title merge appends the new content under a timestamped `---`
118
- * separator instead of overwriting, so a re-noted memory never loses history.
119
- * The `---` line is compatible with the mirror's readHumanEdits (which strips
120
- * only the LAST structural `---` when parsing the human-editable file). */
121
- function appendContent(oldContent, newContent) {
122
- const ts = new Date().toISOString();
123
- return `${oldContent}\n\n---\n[${ts}] ${newContent}`;
124
- }
125
-
126
- /**
127
- * Standard retrieval-quality metrics over the ordered candidate ids actually
128
- * returned vs the ids the evaluator marked relevant (方案 B). Pure + total, so
129
- * callers (and tests) get deterministic numbers without touching a store:
130
- * precision = |relevant ∩ retrieved| / |retrieved|
131
- * recall = |relevant ∩ retrieved| / |expected|
132
- * mrr = 1 / rank of the first relevant doc (0 when none retrieved)
133
- * hit_count is the raw intersection size. Values are rounded to 4 decimals so
134
- * repeated divisions (e.g. 1/3) never surface binary-float noise.
135
- */
136
- export function computeRetrievalMetrics(actualIds, expectedIds) {
137
- const expected = new Set(Array.isArray(expectedIds) ? expectedIds : []);
138
- const actual = Array.isArray(actualIds) ? actualIds : [];
139
- const relevant = actual.filter((id) => expected.has(id)).length;
140
- const round4 = (x) => Math.round(x * 10000) / 10000;
141
- let mrr = 0;
142
- for (let i = 0; i < actual.length; i++) {
143
- if (expected.has(actual[i])) { mrr = 1 / (i + 1); break; }
144
- }
145
- return {
146
- precision: round4(actual.length ? relevant / actual.length : 0),
147
- recall: round4(expected.size ? relevant / expected.size : 0),
148
- mrr: round4(mrr),
149
- hit_count: relevant
150
- };
151
- }
152
-
153
- export function createService({ store, mirror, config, onWrite, logger }) {
154
- const language = langOf(config);
155
- // Optional dream scheduler hook, installed via setDreamHook after creation
156
- // (the scheduler holds a reference back to the service, so it cannot be
157
- // passed in the constructor). Fired on the same write events as onWrite.
158
- let dreamHook = null;
159
-
160
- // Optional sleep scheduler hook (v0.4.0), installed via setSleepHook after
161
- // creation. Fired on the same write events as onWrite: it tells the sleep
162
- // scheduler the store just changed so the idle-detection clock resets.
163
- let sleepHook = null;
164
-
165
- // Optional vector embedder, installed via setEmbedder after creation. After
166
- // any content write it fire-and-forgets a re-embed of the row so vector
167
- // search stays in sync; failures are swallowed inside the embedder.
168
- let embedder = null;
169
-
170
- // Optional entity extractor, installed via setEntityExtractor after creation
171
- // (index.js injects it so the service never depends on the LLM directly).
172
- // After a new memory is saved it fire-and-forgets an extraction pass for the
173
- // entity gene (v0.3.0); failures are swallowed so a broken extraction never
174
- // surfaces as a write failure. Extraction only runs when
175
- // config.entityExtractionEnabled is true.
176
- let entityExtractor = null;
177
- let vectorIndex = null;
178
- let reranker = null;
179
-
180
- // Optional recall recorder, installed via setRecallRecorder after creation.
181
- // When searchMemories is called with recordRecall=true it receives the
182
- // actual merged recall scene (candidates + scores + source + threshold) so
183
- // the retrieval layer can be audited/replayed — the sibling of the dream
184
- // judgment-layer audit trail (dream_runs).
185
- let recallRecorder = null;
186
-
187
- // Bug4: semantic recall cache for the injection path. The system-prompt
188
- // interpolator renders context synchronously, so injectCandidates cannot
189
- // fire a fresh async embed. The most recent searchMemories recall is cached
190
- // here (query + ordered candidates) and reused when the injection query
191
- // matches, giving semantic-first injection without breaking the sync render.
192
- let lastSemanticRecall = null;
193
-
194
- // Transaction nesting depth. Inside service.transaction the per-mutation side
195
- // effects (mirror render, write notify, re-embed) are deferred so a ROLLBACK
196
- // never leaves the mirror file diverged from the database; transaction()
197
- // replays them exactly once against the committed state.
198
- let txDepth = 0;
199
-
200
- // Serial task queue (sleep v0.4.0). Long-running background passes — dream
201
- // consolidation, sleep cycles — must never overlap: two sleep runs racing
202
- // would double-demote or double-mint patterns. enqueue chains the task onto
203
- // a promise tail so N callers can queue work that runs strictly one at a
204
- // time. A task that rejects doesn't poison the queue (the tail swallows the
205
- // rejection) but the rejection still propagates to that caller.
206
- let queueTail = Promise.resolve();
207
- function enqueue(fn) {
208
- const next = queueTail.then(fn, fn);
209
- queueTail = next.catch(() => {});
210
- return next;
211
- }
212
-
213
- // issue #6 (part 2): startup race defense. A local embedder (LocalEmbedder /
214
- // Ollama) exposes an async init(), so between `setEmbedder` and init()
215
- // resolving there is a window where embedSingle would throw "not initialized"
216
- // and the re-embed would be silently dropped. When the embedder carries a
217
- // `ready` flag we queue writes in embedPending until init sets ready=true,
218
- // then flush them through the embedder's real interface. Embedders without a
219
- // `ready` flag (legacy OpenAI, instantly usable) keep their old behavior.
220
- let embedPending = [];
221
- let embedReadyTimer = null;
222
- const EMBED_PENDING_MAX = 100; // bound the queue; drop oldest beyond this
223
- const EMBED_READY_POLL_MS = 100;
224
- const EMBED_READY_POLL_LIMIT = 30; // ~3s ceiling; never poll forever
225
-
226
- /** Flush the queued re-embeds once the embedder is ready. Fail-safe. */
227
- function flushEmbedPending() {
228
- if (!embedder || embedPending.length === 0) return;
229
- const batch = embedPending.splice(0, embedPending.length);
230
- for (const memory of batch) {
231
- try {
232
- if (!memory?.id) continue;
233
- if (typeof embedder.schedule === "function") {
234
- embedder.schedule(memory);
235
- } else if (typeof embedder.embedSingle === "function") {
236
- const text = [memory.title, memory.content].filter(Boolean).join("\n");
237
- if (!text) continue;
238
- embedder
239
- .embedSingle(text)
240
- .then((vec) => {
241
- if (Array.isArray(vec) && vec.length) {
242
- store.setEmbedding(memory.id, vec);
243
- }
244
- })
245
- .catch((err) => {
246
- logger?.warn?.("flushEmbedPending embedSingle failed:", err);
247
- });
248
- }
249
- } catch (err) {
250
- logger?.warn?.("flushEmbedPending failed:", err);
251
- }
252
- }
253
- }
254
-
255
- function stopEmbedReadyPolling() {
256
- if (embedReadyTimer) {
257
- clearInterval(embedReadyTimer);
258
- embedReadyTimer = null;
259
- }
260
- }
261
-
262
- function scheduleEmbed(memory) {
263
- try {
264
- if (txDepth > 0) return; // deferred to the transaction's commit
265
- if (!embedder || !memory?.id) return;
266
-
267
- // Readiness gate: embedder exposes `ready` (async init) and is not ready
268
- // yet — queue instead of firing embedSingle into a half-built extractor.
269
- const hasReady = "ready" in embedder;
270
- if (hasReady && embedder.ready !== true) {
271
- if (embedPending.length >= EMBED_PENDING_MAX) embedPending.shift();
272
- embedPending.push(memory);
273
- return;
274
- }
275
-
276
- if (typeof embedder.schedule === "function") {
277
- embedder.schedule(memory);
278
- return;
279
- }
280
-
281
- if (typeof embedder.embedSingle === "function") {
282
- const text = [memory.title, memory.content].filter(Boolean).join("\n");
283
- if (!text) return;
284
-
285
- embedder
286
- .embedSingle(text)
287
- .then((vec) => {
288
- if (Array.isArray(vec) && vec.length) {
289
- store.setEmbedding(memory.id, vec);
290
- }
291
- })
292
- .catch((err) => {
293
- logger?.warn?.("scheduleEmbed embedSingle failed:", err);
294
- });
295
- }
296
- } catch (err) {
297
- logger?.warn?.("scheduleEmbed failed:", err);
298
- }
299
- }
300
-
301
- /**
302
- * Fire-and-forget entity extraction for a freshly saved memory (entity gene
303
- * v0.3.0). Opt-in via config.entityExtractionEnabled; the extractor is
304
- * injected as a hook so the service never needs a direct LLM reference.
305
- * The hook itself is expected to resolve to { ok:boolean } and never throw;
306
- * a thrown rejection is swallowed here as a final fail-safe.
307
- */
308
- function scheduleEntityExtraction(memory) {
309
- if (txDepth > 0) return; // deferred to the transaction's commit
310
- if (!config.entityExtractionEnabled || !entityExtractor) return;
311
- try {
312
- entityExtractor(memory).catch((err) => {
313
- logger?.warn?.("entity extraction failed:", err);
314
- });
315
- } catch (err) {
316
- logger?.warn?.("entity extraction failed:", err);
317
- }
318
- }
319
-
320
- /**
321
- * Cross-encoder rerank over a candidate list (best effort). Reranker
322
- * failures degrade to the original candidate order — reranking is an
323
- * accuracy upgrade, never a correctness gate.
324
- */
325
- async function rerankCandidates(query, candidates, topK) {
326
- if (!reranker || !candidates.length) return candidates.slice(0, topK);
327
- try {
328
- const scored = await reranker.rerank(query, candidates.map((c) => ({ id: c.id, title: c.title, content: c.content })));
329
- if (!Array.isArray(scored)) return candidates.slice(0, topK);
330
- const byId = new Map(candidates.map((c) => [c.id, c]));
331
- const out = [];
332
- for (const s of scored) {
333
- const c = byId.get(s.id);
334
- if (c) { out.push({ ...c, score: s.score, source: "rerank" }); if (out.length >= topK) break; }
335
- }
336
- return out.length ? out : candidates.slice(0, topK);
337
- } catch {
338
- return candidates.slice(0, topK);
339
- }
340
- }
341
-
342
- /**
343
- * Search for memories attached to a named entity (v0.3.0 Phase 3).
344
- * 合并优先级:entity_attrs.memory_id 精确关联 = 1.0 > 关键词提及 = 0.7;
345
- * attr 命中不覆盖,keyword 只补充召回,最后按 _score 降序取 topK。
346
- * @param {string} entityName
347
- * @param {object} [options]
348
- * @param {number} [options.topK=20]
349
- * @returns {any[]}
350
- */
351
- function searchByEntity(entityName, { topK = 20 } = {}) {
352
- const entity = store.findEntityByName(entityName);
353
- if (!entity) return [];
354
- const attrs = store.getCurrentAttrs(entity.id);
355
- const memoryIds = [...new Set(attrs.map((a) => a.memory_id).filter(Boolean))];
356
- const attrHits = memoryIds.map((id) => store.getById(id)).filter(Boolean);
357
- const keywordHits = store.search(entityName, { limit: topK });
358
- const merged = new Map();
359
- for (const mem of attrHits) merged.set(mem.id, { ...mem, _source: "entity_attr", _score: 1.0 });
360
- for (const mem of keywordHits) {
361
- if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 });
362
- }
363
- const hits = Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK);
364
- touchRecalled(hits);
365
- return hits;
366
- }
367
-
368
- /**
369
- * Search for memories by attribute key/value (v0.3.0 Phase 3).
370
- * value 为空时由 store.findMemoriesByAttr 返回该 key 的全部有效记忆。
371
- * @param {string} key
372
- * @param {string | undefined} value
373
- * @param {object} [options]
374
- * @param {number} [options.topK=20]
375
- * @returns {any[]}
376
- */
377
- function searchByAttr(key, value, { topK = 20 } = {}) {
378
- if (!key) return [];
379
- // value 可能为 undefined(attr:key 无 = 值):归一为空串后交给
380
- // store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部
381
- // 当前有效记忆(v0.3.0,store.js 已实现)。
382
- const rows = store.findMemoriesByAttr(key, value ?? "");
383
- const hits = rows.slice(0, topK);
384
- touchRecalled(hits);
385
- return hits;
386
- }
387
-
388
- /**
389
- * Semantic-aware memory search: keyword recall (store.search) plus optional
390
- * vector recall + rerank. mode:
391
- * auto (default) keyword first, vector fills remaining slots (legacy)
392
- * hybrid vector first, keyword fills remaining slots
393
- * vector vector only, falls back to keyword when unavailable
394
- * keyword text only, never touches the embedder
395
- * useRerank runs the cross-encoder over the merged list when a reranker is
396
- * installed; results carry an extra `score` when reranked.
397
- */
398
- // Weighted blend factor for hybrid search; exposed so callers can tune it.
399
- const DEFAULT_HYBRID_WEIGHTS = { vector: 0.6, keyword: 0.4 };
400
-
401
- // Cosine over two plain arrays (shared by the search-time semantic dedup).
402
- function cosineVec(a, b) {
403
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || !a.length) return 0;
404
- let dot = 0, na = 0, nb = 0;
405
- for (let i = 0; i < a.length; i++) {
406
- dot += a[i] * b[i];
407
- na += a[i] * a[i];
408
- nb += b[i] * b[i];
409
- }
410
- if (na === 0 || nb === 0) return 0;
411
- return dot / (Math.sqrt(na) * Math.sqrt(nb));
412
- }
413
-
414
- /**
415
- * BM25 third recall path (v0.5.0 1.1). Scores the query tokens against the
416
- * live non-archived rows and returns the top `limit` hits with scores
417
- * normalized to [0,1]. Failures degrade to [] — BM25 is a recall booster,
418
- * never a correctness gate.
419
- */
420
- function bm25Recall(q, limit) {
421
- if (config?.bm25SearchEnabled === false) return [];
422
- try {
423
- const docs = store.list({ limit: 500, includeForgotten: false }).filter((m) => !m.archived);
424
- if (!docs.length) return [];
425
- return createBM25Index(docs).search(q, { limit });
426
- } catch {
427
- return [];
428
- }
429
- }
430
-
431
- /**
432
- * Search-time semantic dedup (v0.5.0 2.3): greedy pass dropping candidates
433
- * whose embedding similarity to an already-kept row exceeds the threshold.
434
- * Rows without a stored embedding are always kept (no signal = no drop).
435
- */
436
- function semanticDeduplicate(candidates) {
437
- // Opt-in aggressive mode (default off): collapsing near-duplicates can
438
- // drop legitimately distinct rows on small embedding models, so it ships
439
- // behind searchSemanticDedup=true.
440
- if (config?.searchSemanticDedup !== true || candidates.length < 2) return candidates;
441
- const threshold = config?.searchSemanticDedupThreshold ?? 0.95;
442
- try {
443
- const vecs = store.getEmbeddings(candidates.map((c) => c.id));
444
- if (vecs.size < 2) return candidates;
445
- const kept = [];
446
- for (const c of candidates) {
447
- const v = vecs.get(c.id);
448
- if (!v) { kept.push(c); continue; }
449
- const dup = kept.some((k) => {
450
- const kv = vecs.get(k.id);
451
- return kv && cosineVec(v, kv) > threshold;
452
- });
453
- if (!dup) kept.push(c);
454
- }
455
- return kept;
456
- } catch {
457
- return candidates;
458
- }
459
- }
460
-
461
- /**
462
- * Give a keyword-hit row a relevance score in [0,1]: title hits score
463
- * higher than content hits, then scaled by importance (1-5). This lets
464
- * keyword results participate in weighted hybrid blends.
465
- */
466
- function scoreKeyword(row, q) {
467
- const ql = q.toLowerCase();
468
- const title = (row.title ?? "").toLowerCase();
469
- const content = (row.content ?? "").toLowerCase();
470
- const titleHit = title.includes(ql);
471
- const base = titleHit ? 1 : content.includes(ql) ? 0.6 : 0.3;
472
- return base * (0.5 + (row.importance ?? 3) / 10);
473
- }
474
-
475
- /**
476
- * Recall touch (v0.4.0 sleep; v0.7.0 heat gating): any memory surfaced by
477
- * recall or auto-injection gets its last_accessed_at bumped, so the "unrecalled
478
- * N days → demote/archive" tiering counts real access — and the heat clock
479
- * resets (heat ref = last_accessed_at). Best-effort and gated on
480
- * config.heatEnabled — when heat is off this is a complete no-op (no writes
481
- * on the hot recall path). A touch failure must never break search/inject.
482
- */
483
- function touchRecalled(memories) {
484
- if (config?.heatEnabled === false || !Array.isArray(memories) || memories.length === 0) return;
485
- for (const m of memories) {
486
- if (!m?.id) continue;
487
- try {
488
- store.touchLastAccess(m.id);
489
- } catch { /* touch is best effort */ }
490
- }
491
- }
492
-
493
- /**
494
- * Recall fusion (plan #1). Turns the three ranked signal lists (keyword,
495
- * vector, BM25) into a single merged list. Three recipes, selected by
496
- * config.recallFusion:
497
- * - blend (default): legacy behavior — weighted sum for vector/hybrid, union
498
- * backfill for auto. Byte-identical to pre-fusion code, so enabling the
499
- * config never regresses anybody.
500
- * - rrf: Reciprocal Rank Fusion — Σ 1/(k + rank + 1) over each list a row
501
- * appears in. Rank-based, so the unit mismatch (raw cosine vs keyword
502
- * score vs normalized IDF) is irrelevant.
503
- * - minmax: min-max normalize each source list's scores to [0,1] then take
504
- * the weighted sum — a scale-aware version of `blend`.
505
- * Returns { merged, signals }, where signals is Map<id, {keyword, vector,
506
- * bm25}> so searchMemories can decorate rows when signalTransparency is on.
507
- */
508
- function fuseRecall({ keyword, vector, bm25, lim, mode, wv, wk, wb }) {
509
- const recipe = config?.recallFusion ?? "blend";
510
-
511
- // Per-source scores are recorded for every recipe so signalTransparency
512
- // works regardless of how the ranking was produced.
513
- const signals = new Map();
514
- const addSig = (id, field, sc) => {
515
- const cur = signals.get(id) ?? {};
516
- cur[field] = sc;
517
- signals.set(id, cur);
518
- };
519
- for (const m of keyword) addSig(m.id, "keyword", m.score ?? 0);
520
- for (const m of vector) addSig(m.id, "vector", m.score ?? 0);
521
- for (const m of bm25) addSig(m.id, "bm25", m.score ?? 0);
522
-
523
- const vectorIds = new Set(vector.map((m) => m.id));
524
- const keywordIds = new Set(keyword.map((m) => m.id));
525
-
526
- // Mode contract (aligns rrf/minmax with blend): keyword-only searches must
527
- // stay keyword-only regardless of recipe, so enabling an opt-in recipe can
528
- // never pull vector/BM25 rows into a mode="keyword" request. This mirrors
529
- // the blend branch's `mode === "keyword"` short-circuit (byte-for-byte).
530
- if (mode === "keyword") {
531
- return { merged: keyword.slice(0, lim), signals };
532
- }
533
-
534
- let merged;
535
- if (recipe === "rrf") {
536
- // Rank-based: only the position of a row inside each surviving source
537
- // list matters, so no cross-signal scale calibration is needed.
538
- const k = 60; // standard RRF constant (plan #1 documents k=60)
539
- const rows = new Map();
540
- const addList = (list) => list.forEach((m, idx) => {
541
- const s = 1 / (k + idx + 1);
542
- const cur = rows.get(m.id);
543
- if (cur) cur.score += s;
544
- else rows.set(m.id, { ...m, score: s });
545
- });
546
- addList(keyword);
547
- addList(vector);
548
- addList(bm25);
549
- merged = [...rows.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, lim);
550
- } else if (recipe === "minmax") {
551
- // Scale-aware weighted sum: each source list is min-max normalized to
552
- // [0,1] before blending, so raw cosine and keyword score live on the
553
- // same footing.
554
- const norm = (list) => {
555
- if (!list.length) return new Map();
556
- let min = Infinity, max = -Infinity;
557
- for (const m of list) { const s = m.score ?? 0; if (s < min) min = s; if (s > max) max = s; }
558
- const range = max - min;
559
- const out = new Map();
560
- for (const m of list) out.set(m.id, range > 0 ? ((m.score ?? 0) - min) / range : 0.5);
561
- return out;
562
- };
563
- const kw = norm(keyword), ve = norm(vector), bm = norm(bm25);
564
- const rows = new Map();
565
- const seed = (m) => { if (!rows.has(m.id)) rows.set(m.id, { ...m, score: 0 }); };
566
- for (const m of keyword) seed(m);
567
- for (const m of vector) seed(m);
568
- for (const m of bm25) seed(m);
569
- for (const [id, row] of rows) {
570
- const k = kw.get(id) ?? 0;
571
- const v = ve.get(id) ?? 0;
572
- const b = bm.get(id) ?? 0;
573
- row.score = v * wv + k * wk + b * wb;
574
- }
575
- merged = [...rows.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, lim);
576
- } else {
577
- // blend — the pre-existing per-mode behavior, extracted verbatim.
578
- if (mode === "keyword") {
579
- merged = keyword;
580
- } else if (mode === "vector" || mode === "hybrid") {
581
- const byId = new Map();
582
- for (const m of vector) {
583
- const rec = byId.get(m.id);
584
- byId.set(m.id, rec ? { ...rec, score: Math.max(rec.score ?? 0, m.score ?? 0) } : m);
585
- }
586
- for (const m of keyword) {
587
- const rec = byId.get(m.id);
588
- if (rec) {
589
- byId.set(m.id, { ...rec, score: (rec.score ?? 0) * wv + (m.score ?? 0) * wk });
590
- } else {
591
- byId.set(m.id, m);
592
- }
593
- }
594
- for (const m of bm25) {
595
- const rec = byId.get(m.id);
596
- if (rec) {
597
- if (keywordIds.has(m.id)) continue;
598
- byId.set(m.id, { ...rec, score: (rec.score ?? 0) + wb * (m.score ?? 0) });
599
- } else {
600
- byId.set(m.id, { ...m, score: wb * (m.score ?? 0) });
601
- }
602
- }
603
- const ranked = [...byId.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
604
- merged = ranked.slice(0, lim);
605
- if (merged.length < lim && !merged.length) {
606
- merged = keyword.slice(0, lim);
607
- }
608
- } else {
609
- // auto: keyword leads, vector + BM25 fill remaining slots.
610
- merged = keyword.slice(0, lim);
611
- const seen = new Set(merged.map((m) => m.id));
612
- for (const m of vector) {
613
- if (merged.length >= lim) break;
614
- if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
615
- }
616
- for (const m of bm25) {
617
- if (merged.length >= lim) break;
618
- if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
619
- }
620
- }
621
- }
622
-
623
- // Mode contract, auto (aligns rrf/minmax with blend): keyword leads, the
624
- // recipe fills the remaining slots. blend.auto already front-loads keyword;
625
- // rrf/minmax rank across sources, so re-apply the same "keyword first"
626
- // ordering here to preserve the pre-fusion auto contract — the keyword
627
- // hit list keeps its power, and only slots it couldn't fill go to the
628
- // recipe's ranking.
629
- if (recipe !== "blend" && mode === "auto" && keyword.length) {
630
- const head = keyword.slice(0, lim);
631
- const seen = new Set(head.map((m) => m.id));
632
- const tail = merged.filter((m) => !seen.has(m.id)).slice(0, Math.max(0, lim - head.length));
633
- merged = head.concat(tail);
634
- }
635
-
636
- return { merged, signals };
637
- }
638
-
639
- /**
640
- * Search memories for a query. Merges up to three recall sources (keyword,
641
- * vector, BM25) according to config.recallFusion (blend/rrf/minmax — see
642
- * fuseRecall), then optionally decorates rows with per-source signals
643
- * (config.signalTransparency), applies semantic dedup (non-keyword modes),
644
- * reranking, and epistemic trust re-weighting, and finally hands the merged
645
- * list to the recall-layer recorder.
646
- *
647
- * options:
648
- * mode — 'auto' (default) | 'keyword' | 'vector' | 'hybrid'
649
- * topK — max rows (default 20)
650
- * threshold — explicit vector score floor (overrides adaptive)
651
- * useRerank — apply the reranker if available (default true)
652
- * recordRecall — write a recall_runs audit row (default from config)
653
- *
654
- * Returns an array of memory rows { id, title, content, score, source, ... },
655
- * with `signals` added when config.signalTransparency is on. Never throws:
656
- * a vector/rerank failure degrades to keyword results.
657
- */
658
- async function searchMemories(query, options = {}) {
659
- const {
660
- mode = "auto",
661
- topK = 20,
662
- threshold,
663
- useRerank = true,
664
- // v0.8.0 A2(issue #17):当前会话 scope(工具层从 exec 解析后传入)与
665
- // occurred_at 时间窗。两者都只在显式传入时生效,既有调用方(注入/梦/评测)
666
- // 不传 → 行为与 A2 前一致。
667
- scope = null,
668
- occurredFrom = null,
669
- occurredTo = null,
670
- recordRecall = options.recordRecall ?? (config?.recallRecordDefault ?? true)
671
- } = options;
672
- const q = String(query ?? "").trim();
673
- if (!q) return [];
674
-
675
- // entity:/attr: 前缀路由(v0.3.0 Phase 3)。entitySearchEnabled 关闭时走原逻辑。
676
- if (config?.entitySearchEnabled) {
677
- if (q.startsWith("entity:")) {
678
- return searchByEntity(q.slice(7).trim(), options);
679
- }
680
- if (q.startsWith("attr:")) {
681
- const [key, value] = q.slice(5).split("=");
682
- return searchByAttr(key, value, options);
683
- }
684
- }
685
-
686
- const lim = topK > 0 ? topK : 20;
687
-
688
- // Keyword results, decorated with a score so they can be weight-blended
689
- // with vector results and reported uniformly. source tracks where each
690
- // candidate came from for the recall layer receipt.
691
- const rawKeyword = store.search(q, { limit: lim });
692
- const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q), source: "keyword" }));
693
- const wantVector = mode === "vector" || mode === "hybrid" || (mode === "auto" && !!embedder);
694
- let vector = [];
695
- if (wantVector && embedder) {
696
- try {
697
- // Legacy embedders expose embed(query); local ones expose embedSingle.
698
- const embedSingle = typeof embedder.embedSingle === "function"
699
- ? embedder.embedSingle.bind(embedder)
700
- : embedder.embed.bind(embedder);
701
- const qv = await embedSingle(q);
702
- if (qv?.length) {
703
- // Adaptive threshold (v0.5.0 1.2): the fetch runs at the loosest
704
- // branch floor so the head-gap rule can still re-admit the tail;
705
- // the final cutoff is computed against the fetched score
706
- // distribution. Explicit `threshold` wins; disabled → legacy 0.
707
- const adaptive = config?.adaptiveThresholdEnabled !== false;
708
- const fetchThreshold = adaptive && threshold === undefined
709
- ? Math.min(0.5, adaptiveThreshold(q))
710
- : (threshold ?? 0);
711
- const search = vectorIndex
712
- ? vectorIndex.search(qv, { limit: lim * 2, threshold: fetchThreshold })
713
- : store.searchVector(qv, { limit: lim * 2, threshold: fetchThreshold });
714
- const finalThreshold = adaptive && threshold === undefined
715
- ? adaptiveThreshold(q, search)
716
- : (threshold ?? 0);
717
- vector = search
718
- .filter((m) => (m.score ?? 1) >= finalThreshold)
719
- .map((m) => ({ ...m, vector: true, source: "vector" }));
720
- }
721
- } catch { /* vector unavailable: keep keyword results */ }
722
- }
723
-
724
- // BM25 third path (v0.5.0 1.1): IDF-weighted token overlap recalls rows
725
- // whose query terms are scattered — the gap LIKE substring matching
726
- // cannot close. Scores are already normalized to [0,1].
727
- const bm25 = bm25Recall(q, lim).map((m) => ({ ...m, source: "bm25" }));
728
- // Loose blend weight: BM25 confirms and backfills, never dominates the
729
- // semantic signal. Same-memory overlap boosts, unseen ids backfill.
730
- const wb = 0.3;
731
-
732
- // Hybrid blending weights from config when provided.
733
- const wv = config?.hybridSearchVectorWeight ?? DEFAULT_HYBRID_WEIGHTS.vector;
734
- const wk = config?.hybridSearchKeywordWeight ?? DEFAULT_HYBRID_WEIGHTS.keyword;
735
-
736
- const { merged: fusedMerged, signals } = fuseRecall({ keyword, vector, bm25, lim, mode, wv, wk, wb });
737
- let merged = fusedMerged;
738
- // v0.8.0 A3(issue #17):strictScope 硬过滤——他 scope 的候选直接出局
739
- // (区别于 A2 的降权保留可见);未标注行与命中行保留。strict 与 A2 加权
740
- // 叠加:过滤后剩下的命中行仍吃加成。scope 未传(flag 关)或完全解析不到
741
- // 时跳过——identity 为空的对象({null,null})按 fail-closed 过滤。
742
- if (config.strictScope === true && scope) {
743
- merged = merged.filter((m) => isVisibleInScope(m, scope));
744
- }
745
- // v0.8.0 A2:occurred_at 时间过滤——在融合池上先滤再 dedup/slice,rerank
746
- // 只看窗内候选,topK 槽位不被窗外行占用。
747
- const occurredBounds = updatedAtBounds(occurredFrom, occurredTo);
748
- if (occurredBounds.from || occurredBounds.to) {
749
- merged = merged.filter((m) => inOccurredBounds(m, occurredBounds));
750
- }
751
- // Signal transparency (#2): decorate each returned row with its per-source
752
- // scores and the final fused score. Purely additive — never changes rank.
753
- if (config?.signalTransparency === true) {
754
- merged = merged.map((m) => ({ ...m, signals: { ...(signals.get(m.id) ?? {}), final: m.score ?? 0 } }));
755
- }
756
-
757
- // Search-time semantic dedup (v0.5.0 2.3): near-duplicate rows are
758
- // dropped before the reranker sees them, so topK slots carry distinct
759
- // information instead of the same memory twice. Keyword mode is exempt —
760
- // it is the documented text-only path and must not be altered by
761
- // embedding state.
762
- merged = mode === "keyword" ? merged : semanticDeduplicate(merged);
763
- merged = merged.slice(0, lim);
764
- let result = useRerank && reranker && merged.length
765
- ? await rerankCandidates(q, merged, lim)
766
- : merged;
767
- // Epistemic trust (v0.4.5): opt-in re-weighting of the final candidate
768
- // scores by source credibility. When off (default) `result` is returned
769
- // untouched — exactly the legacy behavior.
770
- if (config.trustEpistemicWeighting === true) {
771
- result = result
772
- .map((m) => ({
773
- ...m,
774
- score: (m.score ?? 0) * (EPISTEMIC_WEIGHTS[m.epistemic_status] ?? 1)
775
- }))
776
- .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
777
- .slice(0, lim);
778
- }
779
-
780
- // v0.8.0 A2(issue #17):scope 检索加权——两维都无法确立 foreign 的候选
781
- // (命中/未标注/当前侧解析不到)加成,确立 foreign 的候选降权但保留可见
782
- // (硬过滤是 A3 strictScope);未标注行与命中行同列吃 BOOST、不被压制。
783
- // flag 关或调用方未传 scope 时不动分,排序与 A2 前逐字节一致。与
784
- // epistemic 加权同款收尾:乘分 → 降序 → 截 topK。
785
- if (config.scopeEnabled === true && scope && (scope.agent_scope || scope.workspace_scope)) {
786
- result = result
787
- .map((m) => ({ ...m, score: (m.score ?? 0) * scopeMultiplier(m, scope) }))
788
- .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
789
- .slice(0, lim);
790
- }
791
-
792
- // Recall layer receipt: with recordRecall on, hand the actual merged
793
- // candidate list (id/title/content/score/source) to the injected recorder
794
- // before returning, making the retrieval scene replayable — the sibling of
795
- // the dream judgment-layer audit trail. Recorder failures must never break
796
- // the search itself.
797
- if (recordRecall && recallRecorder) {
798
- try {
799
- recallRecorder({
800
- query: q,
801
- mode,
802
- topK: lim,
803
- threshold: threshold ?? null,
804
- candidates: result.map((m) => ({
805
- id: m.id,
806
- title: m.title,
807
- content: m.content,
808
- score: m.score ?? null,
809
- source: m.source ?? "keyword"
810
- })),
811
- createdAt: new Date().toISOString()
812
- });
813
- } catch { /* recall receipt is best effort */ }
814
- }
815
- // Bug4: cache the latest semantic recall so the sync injection path can
816
- // reuse it when the injection query matches (no async embed available).
817
- lastSemanticRecall = { query: q, items: result };
818
- touchRecalled(result);
819
- return result;
820
- }
821
-
822
- /**
823
- * Retrieval evaluation (方案 B): run one search for `query`, compare the ids
824
- * it actually returned against `expectedIds`, and return the computed
825
- * metrics. When persistence is on (config.evalPersistTestResults, or an
826
- * explicit `persist` override per call) the snapshot is written to the
827
- * recall_evals table — a SEPARATE store from the recall_runs production audit,
828
- * so test/eval data never inflates the production trail.
829
- *
830
- * options:
831
- * mode/topK/threshold/useRerank — passed through to searchMemories
832
- * evalType — label for the snapshot (default 'manual')
833
- * recordRecall — also write a recall_runs audit row for the
834
- * same scene and link it via recall_run_id
835
- * (default false: eval stays unlinked)
836
- * recallRunId — explicit link to an existing recall_runs id
837
- * persist — override the config gate for this call
838
- *
839
- * Returns { metrics, actualIds, expectedIds, recallRunId, persisted }.
840
- * Never throws on persistence failures: a broken eval write must not break
841
- * the retrieval quality measurement.
842
- */
843
- async function evaluateRetrieval(query, expectedIds, options = {}) {
844
- const q = String(query ?? "").trim();
845
- const expected = Array.isArray(expectedIds) ? expectedIds : [];
846
- const {
847
- mode = "auto",
848
- topK = 20,
849
- threshold,
850
- useRerank = true,
851
- evalType = "manual",
852
- recordRecall = false,
853
- recallRunId = null,
854
- persist = config.evalPersistTestResults === true
855
- } = options;
856
- if (!q) {
857
- const empty = computeRetrievalMetrics([], expected);
858
- return { metrics: empty, actualIds: [], expectedIds: expected, recallRunId: null, persisted: false };
859
- }
860
-
861
- const rows = await searchMemories(q, { mode, topK, threshold, useRerank, recordRecall: false });
862
- const actualIds = rows.map((m) => m.id);
863
- const metrics = computeRetrievalMetrics(actualIds, expected);
864
-
865
- // Optional recall_runs audit for the same scene; the eval row then links to
866
- // it. Kept separate from the production recorder (which fires only on
867
- // recordRecall=true inside searchMemories) — eval never double-records.
868
- // An explicit recallRunId wins; recordRecall only mints a NEW audit run when
869
- // the caller did not already link one (never clobber an existing link).
870
- let runId = recallRunId ?? null;
871
- if (recordRecall && runId === null) {
872
- try {
873
- const run = store.saveRecallRun({
874
- query: q,
875
- mode,
876
- topK,
877
- threshold: threshold ?? null,
878
- candidates: rows.map((m) => ({
879
- id: m.id,
880
- title: m.title,
881
- content: m.content,
882
- score: m.score ?? null,
883
- source: m.source ?? "keyword"
884
- })),
885
- created_at: new Date().toISOString()
886
- });
887
- runId = run.id;
888
- } catch { /* non-fatal: the eval itself still succeeds */ }
889
- }
890
-
891
- let persisted = false;
892
- if (persist) {
893
- try {
894
- store.saveRecallEval({
895
- recall_run_id: runId,
896
- query: q,
897
- expected_ids: expected,
898
- actual_ids: actualIds,
899
- metrics,
900
- eval_type: evalType,
901
- created_at: new Date().toISOString()
902
- });
903
- persisted = true;
904
- } catch { /* non-fatal: measurement survives a failed eval write */ }
905
- }
906
- return { metrics, actualIds, expectedIds: expected, recallRunId: runId, persisted };
907
- }
908
-
909
- /**
910
- * Fire-and-forget write notification; errors are swallowed to keep write
911
- * paths clean. The store mutation has already committed, so a throwing
912
- * subscriber must not surface as a write failure. Archive/forget flags are
913
- * state toggles, not content writes, so they never notify.
914
- */
915
- function notifyWrite() {
916
- if (txDepth > 0) return; // deferred to the transaction's commit
917
- if (onWrite) {
918
- try { onWrite(); } catch { /* ignore */ }
919
- }
920
- if (dreamHook) {
921
- try { dreamHook(); } catch { /* ignore */ }
922
- }
923
- if (sleepHook) {
924
- try { sleepHook(); } catch { /* ignore */ }
925
- }
926
- }
927
-
928
- /**
929
- * Run several store mutations atomically (SQLite BEGIN/COMMIT/ROLLBACK) and
930
- * fire the deferred side effects once against the committed state. A throwing
931
- * body rolls the whole batch back — no partial writes, no diverged mirror.
932
- * Errors propagate to the caller. NOTE: the commit path re-renders the mirror
933
- * and notifies subscribers, but re-embedding is left to the caller (the dream
934
- * flow re-embeds through maintainIndexAfterDream).
935
- */
936
- function transaction(fn) {
937
- store.db.exec("BEGIN");
938
- txDepth++;
939
- try {
940
- const result = fn();
941
- store.db.exec("COMMIT");
942
- return result;
943
- } catch (error) {
944
- try { store.db.exec("ROLLBACK"); } catch { /* store may be closed */ }
945
- throw error;
946
- } finally {
947
- txDepth--;
948
- // Sync failures are surfaced, not swallowed (peer blocker 2): the mirror
949
- // debt was already recorded by markMirrorDirty inside syncMirror, so a
950
- // restart recovers — but the operator must see it now, not after restart.
951
- const syncResult = syncMirror();
952
- if (!syncResult?.success && !syncResult?.deferred) {
953
- logger?.warn?.("mirror sync failed after transaction:", syncResult?.error);
954
- }
955
- notifyWrite();
956
- }
957
- }
958
-
959
- /**
960
- * Embed an arbitrary query text and return its vector (null on failure / no
961
- * embedder). Used by the injector to prefetch the semantic-first recall
962
- * vector for the current user message — the system-prompt render is
963
- * synchronous, so the vector must be cached in advance (Bug4).
964
- */
965
- async function embedQuery(query) {
966
- const q = String(query ?? "").trim();
967
- if (!q || !embedder) return null;
968
- try {
969
- const embedSingle = typeof embedder.embedSingle === "function"
970
- ? embedder.embedSingle.bind(embedder)
971
- : embedder.embed.bind(embedder);
972
- const vector = await embedSingle(q);
973
- return Array.isArray(vector) && vector.length ? vector : null;
974
- } catch {
975
- return null;
976
- }
977
- }
978
-
979
- /**
980
- * 写入端同会话语义去重(Issue #127,opt-in,默认 off):落库前把新条目与
981
- * 「同会话 + 时间窗」内已落库的记忆比对,命中即并入既有条目,不再新造一行。
982
- *
983
- * 分层:title 档走零成本的标题归一化;vector 档复用已有 embedding 列做近邻,
984
- * 新条目的向量由 embedder 现算(embedQuery),全程无 LLM 调用。embedder 不可用
985
- * 或向量缺失一律返回 undefined 回落正常写入——去重是增强,绝不能成为写入依赖。
986
- *
987
- * 作用域刻意限定「同会话」:跨会话的同类事实仍交给 autoDream / sleep 的批量裁决。
988
- * 比对逻辑与并入动作分别复用 cosineVec 与 saveWithDedupe 的 _mergeInto 分支。
989
- *
990
- * @returns {{action: "merged", memory: object, sim: number}|undefined} 命中则返回
991
- * 并入目标与相似度;未命中(含任何异常)返回 undefined。
992
- */
993
- async function findSessionDuplicate(memory, { mode = "off", minSim = 0.92, windowHours = 24, source } = {}) {
994
- if (mode === "off" || !source || !memory?.title) return undefined;
995
- try {
996
- const sinceMs = windowHours > 0 ? Date.now() - windowHours * 3600000 : 0;
997
- const inWindow = (m) => {
998
- if (sinceMs <= 0) return true;
999
- const t = Date.parse(m.created_at ?? "");
1000
- return !Number.isFinite(t) || t >= sinceMs; // 时间戳损坏时不误杀
1001
- };
1002
- const candidates = store
1003
- .list({ type: memory.type, source, limit: 200 })
1004
- .filter((m) => !m.archived && inWindow(m));
1005
- if (!candidates.length) return undefined;
1006
-
1007
- if (mode === "title") {
1008
- const norm = (s) => String(s ?? "").toLowerCase().replace(/[\s\p{P}]+/gu, "");
1009
- const key = norm(memory.title);
1010
- if (!key) return undefined;
1011
- const hit = candidates.find((m) => norm(m.title) === key);
1012
- return hit ? { action: "merged", memory: hit, sim: 1 } : undefined;
1013
- }
1014
- if (mode !== "vector") return undefined;
1015
-
1016
- const vecs = store.getEmbeddings(candidates.map((m) => m.id));
1017
- if (!vecs.size) return undefined;
1018
- const probe = await embedQuery([memory.title, memory.content].filter(Boolean).join("\n"));
1019
- if (!probe) return undefined;
1020
- let best;
1021
- for (const m of candidates) {
1022
- const v = vecs.get(m.id);
1023
- if (!v) continue; // 无向量的既有行不参与比对(无信号 = 不判定)
1024
- const sim = cosineVec(probe, v);
1025
- if (sim >= minSim && (!best || sim > best.sim)) best = { action: "merged", memory: m, sim };
1026
- }
1027
- return best;
1028
- } catch {
1029
- return undefined; // 去重失败绝不阻塞写入
1030
- }
1031
- }
1032
-
1033
- /**
1034
- * Save a memory, merging into an existing one when title matches within the same type.
1035
- *
1036
- * Bug5: a same-title merge no longer overwrites — the new content is appended
1037
- * under a timestamped `---` separator (`旧内容\n\n---\n[时间戳] 新内容`) and the
1038
- * previous content is archived into content_history (source: auto_merge, FIFO
1039
- * capped at 20). importance takes the max of both (capped at 5). Callers that
1040
- * truly replace a row (dream summary regeneration) pass `_overwrite: true` to
1041
- * overwrite directly while still archiving the old version (source: overwrite).
1042
- * mergeHumanEdits entry points pass `_humanEdited: true` — same direct
1043
- * overwrite semantics, source: human_override.
1044
- *
1045
- * Bug7 (memory quality filter): when config.memoryQualityFilter.enabled, the
1046
- * memory is scored after dedupe, before write:
1047
- * score >= degradeThreshold → stored normally (score persisted)
1048
- * archiveThreshold <= score < 60 → persisted + ranked degraded
1049
- * score < archiveThreshold → archived + tagged low_quality (still
1050
- * explicitly searchable via includeArchived)
1051
- * @returns {{action: "created"|"merged", memory: object}}
1052
- */
1053
- function saveWithDedupe(memory) {
1054
- // Bug7: score quality once (after dedupe lookup, before write). Failures
1055
- // inside the evaluator are impossible (pure function), but the write that
1056
- // records the score must never fail the save — wrap defensively.
1057
- const qf = config.memoryQualityFilter;
1058
- let quality = null;
1059
- if (qf?.enabled === true) {
1060
- try {
1061
- const recentContents = store.all().slice(0, 20).map((m) => m.content ?? "");
1062
- quality = evaluateMemoryQuality(memory, {
1063
- minContentLength: qf.minContentLength ?? 10,
1064
- recentContents
1065
- });
1066
- } catch { /* quality scoring is best-effort */ }
1067
- }
1068
- // dream 总览按 source 身份去重,而非本地化标题:memory.language 切换后
1069
- // 标题变化,若仍按 (type, title) 匹配会出现两个活跃的 summary 同时注入。
1070
- // 其余类型维持 (type, title) 匹配不变。
1071
- const candidates = store.list({ type: memory.type, limit: 100 });
1072
- // v0.8.0 A1(issue #17):去重键由 (type, title) 扩展为
1073
- // (type, title, agent_scope, workspace_scope, sensitivity)——不同作用域的同
1074
- // 标题记忆绝不互相物理合并(旧逻辑跨 agent/workspace 并行,事后无法拆分)。
1075
- // NULL 归一为「未标注」:未标注行之间互相匹配(含存量行),与已标注行不
1076
- // 匹配。
1077
- // v0.8.1(issue #170 复核项 1):比较不再受 scopeEnabled 门控——显式声明
1078
- // flag 关也生效(工具层明写「读了自动标注也生效」,去重必须同口径),否则
1079
- // 关自动标注后同标题不同 scope 的行会物理合并进第一行的归属。存量全 NULL
1080
- // 行互相匹配,A1 前行为不变。
1081
- const scopeMatches = (m) =>
1082
- scopeKeyOf(m.agent_scope) === scopeKeyOf(memory.agent_scope) &&
1083
- scopeKeyOf(m.workspace_scope) === scopeKeyOf(memory.workspace_scope) &&
1084
- scopeKeyOf(m.sensitivity) === scopeKeyOf(memory.sensitivity);
1085
- // Issue #127:写入端语义去重命中时,调用方用 _mergeInto 指定并入目标——复用
1086
- // 下面这段并入逻辑(appendContent + content_history + 质量处置),不另写第二份
1087
- // 实现。目标已被并发删除时回落到常规匹配。显式目标同样过 scope 门:跨作用域
1088
- // 的语义相似不并入(防泄漏),回落为独立新行。
1089
- const explicitTarget = memory._mergeInto ? store.getById(memory._mergeInto) : undefined;
1090
- const existing = explicitTarget
1091
- ? (scopeMatches(explicitTarget) ? explicitTarget : undefined)
1092
- : (memory.type === "summary" && memory.source === "dream")
1093
- ? (candidates.find((m) => m.source === "dream" && scopeMatches(m))
1094
- ?? candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim()))
1095
- : candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim());
1096
- if (existing) {
1097
- const newContent = String(memory.content ?? "");
1098
- if (!newContent.trim()) {
1099
- // Nothing to merge: the row stays untouched.
1100
- return { action: "merged", memory: existing };
1101
- }
1102
- const direct = memory._overwrite === true || memory._humanEdited === true;
1103
- const content = direct
1104
- ? newContent
1105
- : appendContent(existing.content, newContent);
1106
- const importance = Math.min(5, Math.max(existing.importance, memory.importance ?? existing.importance));
1107
- // v0.8.1 底座(issue #170):去重命中=scope 值完全同键,此时显式写入把
1108
- // 被并入行的同维来源升级为 explicit(用户刚刚显式声明了同一归属),
1109
- // 并刷新 scope_decided_at。仅升级来源、不改值——值改变会走新行。
1110
- const scopeUpgrade = {};
1111
- if (memory.agent_scope_source === "explicit" && existing.agent_scope_source !== "explicit") {
1112
- scopeUpgrade.agent_scope_source = "explicit";
1113
- }
1114
- if (memory.workspace_scope_source === "explicit" && existing.workspace_scope_source !== "explicit") {
1115
- scopeUpgrade.workspace_scope_source = "explicit";
1116
- }
1117
- if (Object.keys(scopeUpgrade).length) scopeUpgrade.scope_decided_at = new Date().toISOString();
1118
- const merged = store.update(existing.id, {
1119
- content,
1120
- importance,
1121
- tags: memory.tags ?? existing.tags,
1122
- title: memory.title ?? existing.title,
1123
- content_history: pushContentHistory(existing, direct
1124
- ? (memory._humanEdited === true ? "human_override" : "overwrite")
1125
- : "auto_merge"),
1126
- ...scopeUpgrade,
1127
- ...(quality ? { quality_score: quality.score } : {})
1128
- });
1129
- // v0.8.1(issue #170 review 3):来源升级也是归属性质改变——与
1130
- // updateMemory 的显式修正同等待遇,落一行 scope_changes(actor=tool:
1131
- // 只有 memory_save 的显式参数会带 explicit 来源走到这里)。审计失败只
1132
- // warn 不反噬合并。升级只在来源首次变化时触发,不会每次并入都写。
1133
- if (Object.keys(scopeUpgrade).length) {
1134
- try {
1135
- store.saveScopeChange({
1136
- memory_id: existing.id,
1137
- actor: "tool",
1138
- prev_agent_scope: existing.agent_scope ?? null,
1139
- prev_workspace_scope: existing.workspace_scope ?? null,
1140
- next_agent_scope: merged.agent_scope ?? null,
1141
- next_workspace_scope: merged.workspace_scope ?? null,
1142
- agent_scope_source: merged.agent_scope_source ?? null,
1143
- workspace_scope_source: merged.workspace_scope_source ?? null,
1144
- decided_at: scopeUpgrade.scope_decided_at
1145
- });
1146
- } catch (e) {
1147
- try { logger?.warn?.(`[dsh-mneme] scope upgrade audit failed: ${String(e)}`); } catch { /* 不反噬 */ }
1148
- }
1149
- }
1150
- // Bug7: a degraded/archived result is applied on top of the merged row.
1151
- const result = applyQualityDisposition(merged, quality, qf);
1152
- afterSync("write");
1153
- notifyWrite();
1154
- scheduleEmbed(result);
1155
- return { action: "merged", memory: result };
1156
- }
1157
- const created = store.save({
1158
- type: memory.type,
1159
- title: memory.title,
1160
- content: memory.content,
1161
- tags: memory.tags ?? [],
1162
- importance: memory.importance ?? 3,
1163
- source: memory.source ?? "manual",
1164
- // v0.8.0 A1:scope 标注透传(store 端归一化,未标注落 NULL)。
1165
- // v0.8.1 底座:来源(auto/explicit)与决策时间随行透传。
1166
- agent_scope: memory.agent_scope,
1167
- workspace_scope: memory.workspace_scope,
1168
- agent_scope_source: memory.agent_scope_source,
1169
- workspace_scope_source: memory.workspace_scope_source,
1170
- scope_decided_at: memory.scope_decided_at,
1171
- sensitivity: memory.sensitivity,
1172
- occurred_at: memory.occurred_at,
1173
- ...(quality ? { quality_score: quality.score } : {})
1174
- });
1175
- const result = applyQualityDisposition(created, quality, qf);
1176
- afterSync("write");
1177
- notifyWrite();
1178
- scheduleEmbed(result);
1179
- scheduleEntityExtraction(result);
1180
- return { action: "created", memory: result };
1181
- }
1182
-
1183
- /**
1184
- * Bug7: apply the quality verdict to a freshly written row. Below the archive
1185
- * threshold the memory is archived + tagged low_quality (still searchable
1186
- * explicitly via includeArchived); between archive and degrade thresholds the
1187
- * score is already persisted and only the injection ranking is affected
1188
- * (importance × score/100). Best-effort: a disposition write failure must
1189
- * never fail the save. Returns the (possibly refreshed) memory row so callers
1190
- * see the archived/tagged state, not the pre-disposition snapshot.
1191
- *
1192
- * Issue #135 附属发现 1:importance ≥ memoryQualityFilter.exemptImportance
1193
- * (默认 4)的记忆不参与静默自动归档——评分与信号标签照常写入(可观测),
1194
- * 注入排序照常按 importance × score/100 降权,但 setArchived 跳过。报告实测
1195
- * 150 条低分归档里 128 条 importance ≥ 4(51 条 = 5):一条被显式标为重要的
1196
- * 记忆不该被规则分静默归档、无感知无豁免。
1197
- */
1198
- function applyQualityDisposition(memory, quality, qf) {
1199
- if (!quality || qf?.enabled !== true) return memory;
1200
- const archiveThreshold = qf.archiveThreshold ?? 30;
1201
- // Signal tags (meta / repetitive / duplicate / short_content / low_quality)
1202
- // are merged onto the stored row in every assessed band so the verdict is
1203
- // observable, not just the numeric score. Below the archive threshold the
1204
- // memory is additionally archived (still explicitly searchable) — unless
1205
- // its importance reaches the exemption floor (kept active, demoted only).
1206
- const tags = [...new Set([...(memory.tags ?? []), ...(quality.tags ?? [])])];
1207
- const exemptImportance = qf.exemptImportance ?? 4;
1208
- const importanceExempt = Number.isInteger(memory.importance) && memory.importance >= exemptImportance;
1209
- if (tags.length === (memory.tags?.length ?? 0) && (quality.score >= archiveThreshold || importanceExempt)) {
1210
- return memory; // no tag drift and not archived → nothing extra to write
1211
- }
1212
- try {
1213
- store.update(memory.id, { tags, quality_score: quality.score });
1214
- if (quality.score < archiveThreshold && !importanceExempt) {
1215
- store.setArchived(memory.id, true);
1216
- } else if (quality.score < archiveThreshold) {
1217
- logger?.info?.(`[dsh-mneme] quality filter: memory ${memory.id} scored ${quality.score} < ${archiveThreshold} but importance ${memory.importance} >= exemptImportance ${exemptImportance}; demoted, not archived`);
1218
- }
1219
- return store.getById(memory.id);
1220
- } catch {
1221
- return memory;
1222
- }
1223
- }
1224
-
1225
- /**
1226
- * Candidate memories for automatic context injection:
1227
- * summaries first, then all preferences, then non-forgotten items with
1228
- * importance >= threshold. History is never auto-injected. Archived entries
1229
- * are excluded (store.list already filters them by default; the extra
1230
- * !m.archived check is kept as double insurance).
1231
- *
1232
- * Bug4 (hybridInject): when a non-empty `query` is available and a matching
1233
- * semantic recall was cached by the last searchMemories, the vector hits
1234
- * lead the selection (up to maxItems*2 candidates) and the rule-based pick
1235
- * fills + dedupes the remaining slots. Empty query / no cached recall /
1236
- * hybridInject off → pure legacy rule-based selection.
1237
- */
1238
- function injectCandidates({ query = "", maxItems = 5, threshold = 3, queryVector, scope = null, rotate = null, rotateWindow = 0 } = {}) {
1239
- const q = String(query ?? "").trim();
1240
- // codingRetrospect 读取侧门控:编码记忆(rejected_solution / pitfall /
1241
- // constraint)只在编码任务时注入,防噪声污染其他业务;编码任务时按
1242
- // codingBoostFactor 加权,让编码记忆在编码场景更靠前。
1243
- const isCoding = isCodingTask(q, config.codingKeywords ?? []);
1244
- const codingGate = (m) => isCoding || !CODING_MEMORY_TYPES.has(m.type);
1245
- // Bug7: quality-weighted importance in the rule-based tier. Unassessed rows
1246
- // (quality_score null) count as 100 (weight 1), so legacy stores keep their
1247
- // exact summary>preference>importance ordering.
1248
- const qualityWeight = (m) => (m.quality_score != null ? m.quality_score / 100 : 1);
1249
- // Issue #205 补测:候选池必须比轮换窗口大——窗口 N 需要 maxItems×(N+1) 张
1250
- // 不同的牌,池子只有 maxItems×2 时窗口一开就被吃光、只剩回填(重复率的
1251
- // 结构性下限由池子大小决定)。无轮换时保持既有 maxItems×2 不变;规则路
1252
- // 的 200 条上限同样给轮换让路(上限翻倍也只是多读一次 SQL LIMIT)。
1253
- const rotateWindowN = Number.isInteger(rotateWindow) && rotateWindow > 0 ? rotateWindow : 0;
1254
- const poolSize = rotateWindowN > 0
1255
- ? Math.max(maxItems * 2, maxItems * (rotateWindowN + 1))
1256
- : maxItems * 2;
1257
- const items = store.list({ limit: Math.max(200, poolSize), includeForgotten: false })
1258
- .filter((m) => !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
1259
- codingGate(m) &&
1260
- (m.type === "summary" || m.type === "preference" || m.importance >= threshold))
1261
- .sort((a, b) => {
1262
- // 编码记忆在编码任务时优先于普通 decision(与 preference 同级),
1263
- // importance 乘 codingBoostFactor 加权(封顶 5,保持 importance 语义)。
1264
- const priority = (m) => {
1265
- if (m.type === "summary") return 0;
1266
- if (m.type === "preference") return 1;
1267
- if (isCoding && CODING_MEMORY_TYPES.has(m.type)) return 1;
1268
- return 2;
1269
- };
1270
- const effImportance = (m) =>
1271
- (isCoding && CODING_MEMORY_TYPES.has(m.type))
1272
- ? Math.min(5, m.importance * (config.codingBoostFactor ?? 2))
1273
- : m.importance;
1274
- const pa = priority(a);
1275
- const pb = priority(b);
1276
- return pa - pb || (effImportance(b) * qualityWeight(b)) - (effImportance(a) * qualityWeight(a));
1277
- });
1278
- let candidates = items;
1279
- if (config.hybridInject !== false && q) {
1280
- // Bug4: semantic-first recall. Vector hits (queryVector, cached by the
1281
- // injector's async prefetch) lead when present; otherwise the last
1282
- // searchMemories recall for the exact same query is reused. Rule-based
1283
- // items fill + dedupe the remaining slots. Empty query / no vector /
1284
- // no cached recall → pure legacy rule-based selection.
1285
- const semanticItems = [];
1286
- if (Array.isArray(queryVector) && queryVector.length && vectorIndex) {
1287
- try {
1288
- const hits = vectorIndex.search(queryVector, { limit: poolSize, threshold: 0 });
1289
- for (const m of hits) {
1290
- if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten &&
1291
- codingGate(m) &&
1292
- (m.type === "summary" || m.type === "preference" || m.importance >= threshold)) {
1293
- semanticItems.push(m);
1294
- }
1295
- }
1296
- } catch { /* vector unavailable: fall through to the recall cache */ }
1297
- }
1298
- if (!semanticItems.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
1299
- for (const m of lastSemanticRecall.items) {
1300
- if (m && !m.archived && INJECT_TYPES.has(m.type) && !m.forgotten && codingGate(m)) semanticItems.push(m);
1301
- }
1302
- }
1303
- // Issue #198:首轮(无向量、无缓存召回)的同步兜底——BM25 词法召回领位。
1304
- // 注入渲染是同步的(宿主 systemPrompt 的 contexts 不支持异步 text),
1305
- // 查询向量只能异步 prefetch 给下一轮,首轮必 miss;此时纯静态排序会让
1306
- // 老的高重要性 summary/preference 占满槽位,当前话题的 decision/project
1307
- // 进不来。查询文本本身就在手上,BM25 是纯进程内同步计算,词法相关性
1308
- // 足以把当前话题顶到前排;门控与向量路径一致(含 importance 阈值)。
1309
- // 语义向量命中时本分支不参与,行为不变。
1310
- if (!semanticItems.length) {
1311
- for (const hit of bm25Recall(q, poolSize)) {
1312
- if (hit && !hit.archived && INJECT_TYPES.has(hit.type) && !hit.forgotten &&
1313
- codingGate(hit) &&
1314
- (hit.type === "summary" || hit.type === "preference" || hit.importance >= threshold)) {
1315
- semanticItems.push(hit);
1316
- }
1317
- }
1318
- }
1319
- if (semanticItems.length) {
1320
- const seen = new Set();
1321
- const merged = [];
1322
- const push = (m) => {
1323
- if (seen.has(m.id)) return;
1324
- seen.add(m.id);
1325
- merged.push(m);
1326
- };
1327
- for (const m of semanticItems) {
1328
- push(m);
1329
- if (merged.length >= poolSize) break;
1330
- }
1331
- for (const m of items) {
1332
- if (merged.length >= poolSize) break;
1333
- push(m);
1334
- }
1335
- candidates = merged;
1336
- }
1337
- }
1338
- // Topic-ranked selection (v0.5.0 2.2): when the current query's vector is
1339
- // available the whole candidate list is re-ordered by similarity to that
1340
- // vector, so the injected slots go to memories on the current topic
1341
- // rather than to the rule-based order. Rows the index did not return
1342
- // keep their relative order after the scored ones.
1343
- if (config?.selectiveInjectEnabled !== false && Array.isArray(queryVector) && queryVector.length && vectorIndex) {
1344
- try {
1345
- const hits = vectorIndex.search(queryVector, { limit: 200, threshold: 0 });
1346
- const sim = new Map(hits.map((m) => [m.id, m.score ?? 0]));
1347
- if (sim.size) {
1348
- candidates = [...candidates].sort((a, b) => (sim.get(b.id) ?? -1) - (sim.get(a.id) ?? -1));
1349
- }
1350
- } catch { /* topic re-rank unavailable: keep rule-based order */ }
1351
- }
1352
- // v0.8.0 A3(issue #17):strictScope 硬过滤同样作用于自动注入——scoped 记忆
1353
- // 泄进无关注入上下文是最典型的越权通道,检索侧过滤挡不住这里。
1354
- if (config?.strictScope === true && scope) {
1355
- candidates = candidates.filter((m) => isVisibleInScope(m, scope));
1356
- }
1357
- // Issue #205:注入位跨轮轮换。rotate = 最近 N 轮注入过的 id 集合(由注入层
1358
- // 按会话维护并传入):这些条目本轮不再优先——新鲜者前置(各自内部相对次序
1359
- // 保持),不足时按原序回填,槽位数与 touchRecall 语义均不变。rotate 为空时
1360
- // 行为与既有排序完全一致。
1361
- if (rotate && rotate.size > 0 && candidates.length > 0) {
1362
- const fresh = candidates.filter((m) => !rotate.has(m.id));
1363
- if (fresh.length > 0) candidates = [...fresh, ...candidates.filter((m) => rotate.has(m.id))];
1364
- }
1365
- const selected = candidates.slice(0, maxItems);
1366
- touchRecalled(selected);
1367
- return selected;
1368
- }
1369
-
1370
- /**
1371
- * Merge human edits parsed from a mirror file back into the store.
1372
- * Only content/title are taken; structure fields stay machine-owned.
1373
- */
1374
- function mergeHumanEdits(type, edits) {
1375
- let applied = 0;
1376
- for (const edit of edits) {
1377
- if (!edit.id) continue; // corrupt/malformed edit: skip it, keep merging the rest
1378
- const existing = store.getById(edit.id);
1379
- if (!existing || existing.type !== type) continue;
1380
- const patch = {};
1381
- if (typeof edit.title === "string" && edit.title.trim()) patch.title = edit.title.trim();
1382
- if (typeof edit.content === "string" && edit.content.trim()) patch.content = edit.content.trim();
1383
- if (Object.keys(patch).length) {
1384
- // 启动回灌(F-NEW-01):digest 存在且匹配 = 文件自渲染后无人触碰(旧机器
1385
- // 镜像),机器 wins,DB 的 New 必须保留,静默改回 Old 是 bug。
1386
- const digestMatches = typeof edit.digest === "string"
1387
- && typeof edit.title === "string"
1388
- && typeof edit.content === "string"
1389
- && createHash("sha256").update(`${edit.title}\x00${edit.content}`).digest("hex") === edit.digest;
1390
- if (digestMatches) continue;
1391
- // 文件 == store(无实际变化)时不覆盖,也不计入 applied。
1392
- const hasDiff = (patch.title !== undefined && existing.title !== patch.title)
1393
- || (patch.content !== undefined && existing.content !== patch.content);
1394
- if (!hasDiff) continue;
1395
- // 人工编辑回灌后触发 re-embed(issue #3 残留修复):向量必须与
1396
- // 新 title/content 一致。scheduleEmbed 为 fire-and-forget,
1397
- // 内部 try/catch 吞错,失败不影响主流程。
1398
- // Bug5: human edits overwrite directly, but the machine version is
1399
- // archived into content_history (source: human_override) before being
1400
- // replaced, so a manual correction never silently destroys the old value.
1401
- const merged = store.update(edit.id, {
1402
- ...patch,
1403
- content_history: patch.content !== undefined && existing.content !== patch.content
1404
- ? pushContentHistory(existing, "human_override")
1405
- : existing.content_history
1406
- });
1407
- applied++;
1408
- scheduleEmbed(merged);
1409
- }
1410
- }
1411
- if (applied) {
1412
- afterSync("write");
1413
- notifyWrite();
1414
- }
1415
- return applied;
1416
- }
1417
-
1418
- /**
1419
- * v0.8.0 冲突集中处理(issue 反馈:冻结冲突没有人工出口——store 的
1420
- * resolveConflictPending 此前无任何 API/UI 调用)。人工确认一条待审冲突:
1421
- * 盖章 resolved_at/resolved_winner(审计),可选按 dream 非冻结 conflict
1422
- * 路径**同款**处置落地——复用 applyDecisions 的 conflict 分支,CAS 快照 +
1423
- * 事务 + 胜者追加已否决注记 + 败者归档 + 幂等全部同源,不另写第二份裁决。
1424
- *
1425
- * @param {string} id conflict_pending 行 id
1426
- * @param {{winner?: "a"|"b"|null, apply?: boolean}} opts
1427
- * winner: 选哪一方保留(a = memory_a,冻结时 LLM 的建议胜者;b = memory_b);
1428
- * null = 仅盖章标记已读、不落地处置。人工选择是权威裁决:传入
1429
- * applyDecisions 的 config 关掉 trustEpistemicWeighting——可信度自动换位
1430
- * 只应作用于 LLM 提议对,不能推翻人工选择。
1431
- * @returns {object|undefined} 盖章后的冲突行 + disposition 摘要;未知 id 返回 undefined
1432
- */
1433
- function resolveConflictPending(id, { winner = null, apply = true } = {}) {
1434
- const rows = store.listConflictPending({ includeResolved: true, limit: 500 });
1435
- const row = rows.find((r) => r.id === id);
1436
- if (!row) return undefined;
1437
- const winnerId = winner === "a" ? row.memory_a : winner === "b" ? row.memory_b : null;
1438
- const loserId = winner === "a" ? row.memory_b : winner === "b" ? row.memory_a : null;
1439
- let disposition = null;
1440
- if (apply && winnerId && loserId) {
1441
- const snapshot = new Map();
1442
- for (const m of [store.getById(row.memory_a), store.getById(row.memory_b)]) {
1443
- if (m) snapshot.set(m.id, { updated_at: m.updated_at, content: m.content, title: m.title });
1444
- }
1445
- // applyDecisions 需要一个 service 形状的参数(getById/update/setArchived/
1446
- // transaction)——委托给闭包内的同名实现,mirror/通知/嵌入与主写路径同源。
1447
- const applyService = {
1448
- getById: (mid) => store.getById(mid),
1449
- update: (mid, patch) => updateMemory(mid, patch),
1450
- setArchived: (mid, v) => archiveMemory(mid, v),
1451
- transaction: (fn) => transaction(fn)
1452
- };
1453
- const { committed, failures } = applyDecisions(
1454
- [{ action: "conflict", winner: winnerId, loser: loserId, reason: row.reason ?? "" }],
1455
- applyService,
1456
- logger,
1457
- snapshot,
1458
- { ...config, trustEpistemicWeighting: false }
1459
- );
1460
- // Review 发现:处置必须**先于**盖章。原实现先 resolveConflictPending 盖章、
1461
- // 再 applyDecisions——若 CAS 拒掉(确认前双方被并发更新),行已 resolved 从
1462
- // 队列消失、前端仍显示成功,审计却记了"已确认"且无法重试。现在失败时不盖章
1463
- // 直接返回:行留在队列待重试,审计不被污染。
1464
- if (failures.length) {
1465
- return { ...row, disposition: { ok: false, failures } };
1466
- }
1467
- disposition = { ok: true, committed };
1468
- }
1469
- // 处置成功(或纯标记)后才盖章。幂等重放(败者已归档 → skipped)也走这里:
1470
- // 处置本就存在,标记已审即可。
1471
- const stamped = store.resolveConflictPending(id, { winner: winnerId });
1472
- if (!stamped) return undefined;
1473
- return { ...stamped, disposition };
1474
- }
1475
-
1476
- /**
1477
- * v0.8.0 冲突队列视图数据源:未解决冲突行,联表带出双方的当前状态(标题/
1478
- * 内容/类型/重要性/归档标记),面板一处即可集中审阅,不必按徽章逐条找。
1479
- * 参与记忆被删除/失联时该侧返回 {missing: true}(队列不因此丢行)。
1480
- */
1481
- function listConflictQueue({ limit = 50, offset = 0 } = {}) {
1482
- const rows = store.listConflictPending({ limit, offset, includeResolved: false });
1483
- const side = (id) => {
1484
- const m = store.getById(id);
1485
- if (!m) return { id, missing: true };
1486
- return {
1487
- id,
1488
- title: m.title,
1489
- type: m.type,
1490
- importance: m.importance,
1491
- content: m.content,
1492
- archived: m.archived === true,
1493
- forgotten: m.forgotten === true
1494
- };
1495
- };
1496
- return rows.map((r) => ({
1497
- id: r.id,
1498
- run_id: r.run_id ?? null,
1499
- reason: r.reason ?? null,
1500
- created_at: r.created_at,
1501
- memory_a: side(r.memory_a),
1502
- memory_b: side(r.memory_b)
1503
- }));
1504
- }
1505
-
1506
- function toApiList(rows) {
1507
- return rows.map((m) => ({
1508
- id: m.id,
1509
- type: m.type,
1510
- title: m.title,
1511
- content: m.content,
1512
- tags: m.tags,
1513
- importance: m.importance,
1514
- source: m.source,
1515
- created_at: m.created_at,
1516
- updated_at: m.updated_at,
1517
- // v0.8.0 A2:scope 标注与事件发生时间透出——条件展开(未标注行不带键,
1518
- // DTO 与 A2 前逐字节同形;带 undefined 键会被 in-process schema 校验
1519
- // 判违规,JSON 序列化虽会丢弃但形状不稳定)。
1520
- // v0.8.1 底座:scope 来源与决策时间随行透出(同样条件展开)。
1521
- ...(m.agent_scope !== undefined ? { agent_scope: m.agent_scope } : {}),
1522
- ...(m.workspace_scope !== undefined ? { workspace_scope: m.workspace_scope } : {}),
1523
- ...(m.agent_scope_source !== undefined ? { agent_scope_source: m.agent_scope_source } : {}),
1524
- ...(m.workspace_scope_source !== undefined ? { workspace_scope_source: m.workspace_scope_source } : {}),
1525
- ...(m.scope_decided_at !== undefined ? { scope_decided_at: m.scope_decided_at } : {}),
1526
- ...(m.sensitivity !== undefined ? { sensitivity: m.sensitivity } : {}),
1527
- ...(m.occurred_at !== undefined ? { occurred_at: m.occurred_at } : {})
1528
- }));
1529
- }
1530
-
1531
- /**
1532
- * Three-way merge of in-flight human mirror edits before a re-render.
1533
- * Runs on every syncMirror, so a human edit made between two store writes is
1534
- * never silently overwritten by the next sync (human priority is not limited
1535
- * to startup). Per edited entry:
1536
- * - file changed only → human wins; the edit is merged back into the store.
1537
- * - file AND store changed → real three-way conflict: keep the human edit
1538
- * and append a marker preserving the store's concurrent version, so no
1539
- * side is dropped.
1540
- * - store changed only → store wins (the file is simply re-rendered).
1541
- * Only title/content are taken (structure fields stay machine-owned, matching
1542
- * mergeHumanEdits). Returns the memory list to render.
1543
- */
1544
- function reconcileHumanEdits(memories) {
1545
- if (!mirror) return memories;
1546
- const byType = new Map();
1547
- for (const m of memories) {
1548
- if (!byType.has(m.type)) byType.set(m.type, []);
1549
- byType.get(m.type).push(m);
1550
- }
1551
- const result = [];
1552
- for (const type of Object.keys(TYPE_FILE)) {
1553
- const list = byType.get(type) ?? [];
1554
- if (list.length === 0) continue;
1555
- const editsById = new Map(mirror.readHumanEdits(type).map((e) => [e.id, e]));
1556
- for (const m of list) {
1557
- const edit = editsById.get(m.id);
1558
- if (!edit) { result.push(m); continue; }
1559
- const humanChanged = (typeof edit.title === "string" && edit.title !== m.title)
1560
- || (typeof edit.content === "string" && edit.content !== m.content);
1561
- if (!humanChanged) { result.push(m); continue; }
1562
- // 判断文件是否被人工动过:digest 存在且匹配则无人触碰,否则视为人工动过。
1563
- // digest 是渲染时对 sha256(title \x00 content) 的记录;机器 store 更新后
1564
- // 镜像还没重渲染时读到旧内容,digest 仍匹配 → 机器 wins,不会误判为
1565
- // 并发人工编辑导致机器写丢失 + 伪冲突标记。
1566
- const digestMatches = typeof edit.digest === "string"
1567
- && typeof edit.title === "string"
1568
- && typeof edit.content === "string"
1569
- && createHash("sha256").update(`${edit.title}\x00${edit.content}`).digest("hex") === edit.digest;
1570
- if (digestMatches) {
1571
- // 无人触碰,机器 wins,走原样
1572
- result.push(m);
1573
- continue;
1574
- }
1575
- // 人工动过(digest 不存在=老文件/手工文件保守视为人工动过),走三方合并
1576
- // (保留现有 storeChanged 逻辑)
1577
- const storeChanged = edit.updated_at !== undefined && m.updated_at !== edit.updated_at;
1578
- if (storeChanged) {
1579
- // 三方合并保留双方时的冲突批注:随实例语言,写进记忆正文。
1580
- const marker = STR.serviceConflictMarker[language](m.updated_at, m.content);
1581
- store.update(m.id, { title: edit.title, content: `${edit.content}${marker}` });
1582
- } else {
1583
- store.update(m.id, { title: edit.title, content: edit.content });
1584
- }
1585
- const merged = store.getById(m.id);
1586
- scheduleEmbed(merged);
1587
- result.push(merged);
1588
- }
1589
- }
1590
- return result;
1591
- }
1592
-
1593
- /**
1594
- * Re-render the human-editable mirror after any store mutation, merging any
1595
- * in-flight human edits first (never silently overwriting them). Only
1596
- * non-forgotten memories are mirrored: forgotten entries must not reach the
1597
- * human-editable file (a human "edit" could otherwise resurrect them).
1598
- */
1599
- // syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
1600
- // v0.3.6(audit peer 4 阻断):
1601
- // - 开始时 incrementGeneration 绑定本次期望轮次 gen;成功用
1602
- // markMirrorCleanForGeneration(gen, now) CAS/fence 清 dirty——旧 worker
1603
- // (gen 已过期)不会误清另一 worker 未恢复的故障债务;
1604
- // - 失败写 markMirrorDirty(递增 desired 绑定新债务),下次 recover 恢复;
1605
- // - 逐 type 用 setTypeStatus 记录部分成功/失败(type_status JSON);
1606
- // - 所有 store 状态写入各自 try/catch,失败只 warn,绝不向外抛(F-NEW-03)。
1607
- function syncMirror() {
1608
- if (txDepth > 0 || !mirror) return { success: true, deferred: true }; // deferred to the transaction's commit
1609
- const now = new Date().toISOString();
1610
- let gen;
1611
- try {
1612
- // desired generation 已在业务写事务中原子递增(peer blocker 1);这里
1613
- // 直接读当前值作为本次同步的目标轮次,不再自行 incrementGeneration。
1614
- const state = store.getMirrorState();
1615
- gen = state?.generation ?? 0;
1616
- } catch (stateError) {
1617
- logger?.warn?.("syncMirror: getMirrorState failed:", stateError);
1618
- return { success: false, error: stateError?.message ?? String(stateError) };
1619
- }
1620
- // coveredTypes 提到 try 外初始化:即使 store.list 先抛错,catch 分支也有
1621
- // 合法的空 Set 可迭代,保证 syncMirror 自身绝不抛(fail-safe)。
1622
- const coveredTypes = new Set();
1623
- try {
1624
- // 预先获取本次要覆盖的 type 集合(只调一次 store.list)
1625
- const list = store.list({ limit: 500, includeForgotten: false });
1626
- for (const memory of list) {
1627
- if (memory?.type && TYPE_FILE[memory.type]) {
1628
- coveredTypes.add(memory.type);
1629
- }
1630
- }
1631
-
1632
- // Per-type physical outcome (audit peer D): mirror.sync writes each type
1633
- // file independently and reports per-type success/failure. A type whose
1634
- // file was physically committed must be marked committed even when a
1635
- // sibling type errors — the old code batch-failed every type on any error,
1636
- // leaving committed files mislabeled as failed and masking partial state.
1637
- // Absent entries (a type with no memories) count as success: sync prunes
1638
- // the stale file, which is itself a completed physical state.
1639
- let allOk = true;
1640
- const results = mirror.sync(reconcileHumanEdits(list)) ?? {};
1641
- for (const type of Object.keys(TYPE_FILE)) {
1642
- const r = results[type];
1643
- const ok = !r || r.ok === true;
1644
- if (!ok) allOk = false;
1645
- try {
1646
- if (ok) {
1647
- store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
1648
- } else {
1649
- store.setTypeStatus(type, { status: "failed", last_error: r.error ?? "mirror sync failed" });
1650
- }
1651
- } catch (stateError) {
1652
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
1653
- }
1654
- }
1655
-
1656
- // 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
1657
- // 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
1658
- if (allOk) {
1659
- try {
1660
- store.markMirrorCleanForGeneration(gen, now);
1661
- } catch (stateError) {
1662
- logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
1663
- return { success: false, error: stateError?.message ?? String(stateError) };
1664
- }
1665
- return { success: true };
1666
- }
1667
-
1668
- // 部分 type 失败:持久 dirty(债务绑定到新轮次),下次 recover 只补未收敛
1669
- // 的 type。committed 的 type 已应用本轮 gen,不因兄弟失败被回滚。
1670
- const failedTypes = Object.entries(results)
1671
- .filter(([, r]) => r && r.ok === false)
1672
- .map(([t]) => t);
1673
- try {
1674
- store.markMirrorDirty(`mirror sync failed for: ${failedTypes.join(", ")}`, now);
1675
- } catch (stateError) {
1676
- logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
1677
- }
1678
- return { success: false, error: `mirror sync failed for: ${failedTypes.join(", ")}` };
1679
- } catch (error) {
1680
- const errMsg = error?.message ?? String(error);
1681
- logger?.warn?.("syncMirror failed:", error);
1682
- try {
1683
- // 债务绑定到新的一轮(desired generation 原子递增;即便 dirty 写失败,
1684
- // generation 已推进,recoverMirror 仍能捕获,不产生 false-clean)。
1685
- store.markMirrorDirty(errMsg, now);
1686
- } catch (stateError) {
1687
- logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
1688
- }
1689
- // 逐 type 标记为 failed(applied_gen 不动)
1690
- for (const type of coveredTypes) {
1691
- try {
1692
- store.setTypeStatus(type, { status: "failed", last_error: errMsg });
1693
- } catch (stateError) {
1694
- logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
1695
- }
1696
- }
1697
- return { success: false, error: errMsg };
1698
- }
1699
- }
1700
-
1701
- // afterSync: run syncMirror and surface a failure to the operator instead of
1702
- // swallowing it (peer blocker 2 + audit peer B). The mirror debt has already
1703
- // been persisted by markMirrorDirty inside syncMirror, so a restart recovers —
1704
- // but the calling write path must not report clean while the mirror is
1705
- // known-stale. Returns the sync result so the caller can attach an explicit
1706
- // degraded/pending receipt to its return value instead of faking success.
1707
- function afterSync(label) {
1708
- const r = syncMirror();
1709
- if (!r?.success && !r?.deferred) {
1710
- logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
1711
- }
1712
- return r;
1713
- }
1714
-
1715
- // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
1716
- // (F-NEW-03 + v0.3.6)。触发条件不只是 dirty——还检查
1717
- // generation > applied_generation(有未应用的债务),这样 COMMIT→dirty 崩溃
1718
- // 窗口(DB 提交后、markMirrorDirty/clean 前进程退出 → dirty=false 但
1719
- // generation 不一致)也能被捕获。有界重试(最多 3 次)重跑 syncMirror 收敛;
1720
- // 某次成功后 dirty=false 且无更新债务(generation <= applied_generation)
1721
- // 立即停止。返回 { recovered, error } 供 index.js 启动 / api.js health 判断。
1722
- // 一切 fail-safe,绝不向外抛。
1723
- function recoverMirror() {
1724
- const MAX_ATTEMPTS = 3;
1725
- let lastError = null;
1726
- let recovered = false;
1727
-
1728
- try {
1729
- const state = store.getMirrorState();
1730
- // 崩溃窗口检测:dirty 或 generation > applied_generation(COMMIT→dirty 窗口)
1731
- if (!state?.dirty && !(state.generation > state.applied_generation)) {
1732
- // 本来就干净:无需恢复,视为成功
1733
- return { recovered: true, error: null };
1734
- }
1735
-
1736
- // 有 dirty 或有未应用债务:最多尝试 3 次 sync
1737
- for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
1738
- try {
1739
- syncMirror(); // syncMirror 内部已 catch,不会向外抛
1740
- const currentState = store.getMirrorState();
1741
- // 成功条件:dirty 为 false 且没有更新一轮的债务
1742
- // (generation <= applied_generation,恢复后由 syncMirror 里
1743
- // markMirrorCleanForGeneration 自动把 applied 跟上)
1744
- if (!currentState?.dirty && currentState.generation <= currentState.applied_generation) {
1745
- recovered = true;
1746
- lastError = null;
1747
- break;
1748
- }
1749
- // 仍 dirty 或仍有更新债务:记录最后一次错误供重试耗尽后上报。
1750
- // 注意:若别的 worker 又失败产生新债务(dirty 仍 true),这是"新债务"
1751
- // 不是本次失败,继续重试直到耗尽次数。
1752
- lastError = currentState.last_error || `Sync attempt ${attempt + 1} left mirror dirty or has pending debt`;
1753
- } catch (syncError) {
1754
- // syncMirror 理论不抛,fail-safe 兜底
1755
- const errMsg = syncError?.message ?? String(syncError);
1756
- logger?.warn?.("recoverMirror: sync attempt failed:", errMsg);
1757
- lastError = errMsg;
1758
- }
1759
- }
1760
-
1761
- if (!recovered) {
1762
- logger?.warn?.("dsh-mneme mirror: recover failed after", MAX_ATTEMPTS, "attempts");
1763
- } else {
1764
- logger?.warn?.("dsh-mneme mirror: recovered from dirty/pending state");
1765
- }
1766
- } catch (error) {
1767
- // fail-safe:任何意外异常不向外抛
1768
- lastError = error?.message ?? String(error);
1769
- logger?.warn?.("dsh-mneme mirror: recover failed with unexpected error:", error);
1770
- }
1771
-
1772
- return { recovered, error: recovered ? null : lastError };
1773
- }
1774
-
1775
- // getMirrorHealth: 暴露 mirror 同步健康状态,供 api.js /health 使用
1776
- // (F-NEW-03)。把 DB 的 dirty 0/1 转成 boolean;fail-safe,绝不向外抛。
1777
- function getMirrorHealth() {
1778
- try {
1779
- const state = store.getMirrorState();
1780
- if (!state) {
1781
- // 无状态行:返回安全默认值
1782
- return {
1783
- dirty: false,
1784
- last_error: null,
1785
- last_attempt: null,
1786
- success_at: null
1787
- };
1788
- }
1789
- return {
1790
- dirty: Boolean(state.dirty),
1791
- last_error: state.last_error ?? null,
1792
- last_attempt: state.last_attempt ?? null,
1793
- success_at: state.success_at ?? null
1794
- };
1795
- } catch (error) {
1796
- // fail-safe:状态读取失败也不向外抛,但必须显式表达"未知"而非伪装成
1797
- // 干净(peer blocker 5:真实读取失败要显式 unknown,不得归一为 dirty:false)。
1798
- logger?.warn?.("getMirrorHealth failed:", error);
1799
- return {
1800
- dirty: null,
1801
- last_error: error?.message ?? String(error),
1802
- last_attempt: null,
1803
- success_at: null
1804
- };
1805
- }
1806
- }
1807
-
1808
- // 返回字面量与内部裁决器(resolveConflictPending → applyDecisions 的 conflict
1809
- // 处置)共用的写路径:mirror 同步、写通知、嵌入调度全部同源,不能各写一份。
1810
- function updateMemory(id, p, ctx = {}) {
1811
- const old = store.getById(id);
1812
- // Issue #135 附属发现 2:质量过滤器把「为什么被降权/归档」写在系统信号标签
1813
- // 上(SIGNAL_TAGS,applyQualityDisposition 以并集写入),而这里整组替换
1814
- // tags——任何带 tags 的更新都会抹掉这条唯一的审计线索(报告实测 150 条低分
1815
- // 记忆里恰好缺的 2 条就是被 update 过的,并因此误判过归档来源)。把 existing
1816
- // 的系统信号标签并集保留;用户自传的标签照常生效。
1817
- let patch = (p && Array.isArray(p.tags) && old && Array.isArray(old.tags))
1818
- ? { ...p, tags: [...new Set([...p.tags, ...old.tags.filter((t) => SIGNAL_TAGS.includes(t))])] }
1819
- : p;
1820
- // v0.8.1 底座(issue #170):显式 scope 修正。patch 携带 agent_scope /
1821
- // workspace_scope 键(undefined=该维不动)时归一化并盖 explicit 章 +
1822
- // scope_decided_at,随后写 scope_changes 审计行(actor:tool=模型侧 /
1823
- // panel=人工侧)。审计失败只 warn,不反噬主写入。
1824
- const nextAgentScope = p && p.agent_scope !== undefined ? normalizeExplicitScope(p.agent_scope) : undefined;
1825
- const nextWorkspaceScope = p && p.workspace_scope !== undefined ? normalizeExplicitScope(p.workspace_scope) : undefined;
1826
- const scopeChanged = nextAgentScope !== undefined || nextWorkspaceScope !== undefined;
1827
- if (scopeChanged) {
1828
- const decidedAt = new Date().toISOString();
1829
- patch = { ...patch };
1830
- if (nextAgentScope !== undefined) {
1831
- patch.agent_scope = nextAgentScope;
1832
- patch.agent_scope_source = "explicit";
1833
- }
1834
- if (nextWorkspaceScope !== undefined) {
1835
- patch.workspace_scope = nextWorkspaceScope;
1836
- patch.workspace_scope_source = "explicit";
1837
- }
1838
- patch.scope_decided_at = decidedAt;
1839
- }
1840
- const updated = store.update(id, patch);
1841
- if (scopeChanged && old && updated) {
1842
- try {
1843
- store.saveScopeChange({
1844
- memory_id: id,
1845
- actor: ctx.actor === "panel" ? "panel" : "tool",
1846
- prev_agent_scope: old.agent_scope ?? null,
1847
- prev_workspace_scope: old.workspace_scope ?? null,
1848
- next_agent_scope: updated.agent_scope ?? null,
1849
- next_workspace_scope: updated.workspace_scope ?? null,
1850
- agent_scope_source: updated.agent_scope_source ?? null,
1851
- workspace_scope_source: updated.workspace_scope_source ?? null,
1852
- decided_at: patch.scope_decided_at
1853
- });
1854
- } catch (e) {
1855
- try { ctx.logger?.warn?.(`[dsh-mneme] scope change audit failed: ${String(e)}`); } catch { /* 同样不反噬 */ }
1856
- }
1857
- }
1858
- // Record a user correction when any meaningful field changed and the
1859
- // reflection failure tracker is enabled. expected = what it became,
1860
- // actual = what it was before; query (when provided) captures the
1861
- // user's original intent so later reflection can reason about recall.
1862
- const hasMeaningfulChange = old && updated && (
1863
- old.content !== updated.content ||
1864
- old.title !== updated.title ||
1865
- old.importance !== updated.importance
1866
- );
1867
- if (hasMeaningfulChange && config.reflectionFailureTracking) {
1868
- store.saveFailure({
1869
- id: randomUUID(),
1870
- query: ctx.query ?? null,
1871
- expected: updated.content,
1872
- actual: old.content,
1873
- before: { title: old.title, content: old.content, importance: old.importance },
1874
- failure_type: "user_correction",
1875
- memory_id: id
1876
- });
1877
- }
1878
- const sync = afterSync("write");
1879
- notifyWrite();
1880
- scheduleEmbed(updated);
1881
- // Audit peer B: when the mirror sync failed, the store write landed but
1882
- // the mirror did not converge — return an explicit degraded receipt rather
1883
- // than a plain success. Non-enumerable so existing deepEqual assertions on
1884
- // the memory shape keep passing.
1885
- if (!sync?.success && !sync?.deferred) {
1886
- Object.defineProperty(updated, "_mirror", {
1887
- value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
1888
- enumerable: false,
1889
- configurable: true
1890
- });
1891
- }
1892
- return updated;
1893
- }
1894
-
1895
- function archiveMemory(id, f) {
1896
- const updated = store.setArchived(id, f);
1897
- afterSync("write");
1898
- return updated;
1899
- }
1900
-
1901
- return {
1902
- saveWithDedupe,
1903
- recoverMirror,
1904
- getMirrorHealth,
1905
- getMirrorState: () => store.getMirrorState(),
1906
- injectCandidates,
1907
- mergeHumanEdits,
1908
- toApiList,
1909
- isVisibleInScope,
1910
- transaction,
1911
- enqueue,
1912
- setDreamHook(fn) { dreamHook = fn; },
1913
- setSleepHook(fn) { sleepHook = fn; },
1914
- setEmbedder(emb) {
1915
- embedder = emb;
1916
- if (!emb) {
1917
- // embedder removed (init failed in index.js): stop polling and drop
1918
- // queued re-embeds — search just degrades to keyword.
1919
- stopEmbedReadyPolling();
1920
- embedPending = [];
1921
- return;
1922
- }
1923
- if (emb.ready === true) {
1924
- flushEmbedPending();
1925
- return;
1926
- }
1927
- // Async-initializing embedder: poll `ready` until it flips, then flush.
1928
- if ("ready" in emb && embedReadyTimer === null) {
1929
- let attempts = 0;
1930
- embedReadyTimer = setInterval(() => {
1931
- attempts++;
1932
- if (emb.ready === true || attempts >= EMBED_READY_POLL_LIMIT) {
1933
- stopEmbedReadyPolling();
1934
- if (emb.ready === true) flushEmbedPending();
1935
- else embedPending = []; // init never landed: drop the queue
1936
- }
1937
- }, EMBED_READY_POLL_MS);
1938
- }
1939
- },
1940
- setEntityExtractor(fn) { entityExtractor = fn; },
1941
- setVectorIndex(vi) { vectorIndex = vi; },
1942
- setReranker(rn) { reranker = rn; },
1943
- setRecallRecorder(fn) { recallRecorder = fn; },
1944
- searchMemories,
1945
- embedQuery,
1946
- findSessionDuplicate,
1947
- evaluateRetrieval,
1948
- computeRetrievalMetrics,
1949
- // passthroughs used by tools and api layers; mutations keep the mirror in sync
1950
- search: (q, o) => store.search(q, o),
1951
- searchVector: (v, o) => store.searchVector(v, o),
1952
- embeddedCount: () => store.embeddedCount(),
1953
- list: (o) => store.list(o),
1954
- all: () => store.all(),
1955
- count: (type, opts) => store.count(type, opts),
1956
- getById: (id) => store.getById(id),
1957
- remove: (id) => {
1958
- store.remove(id);
1959
- afterSync("write");
1960
- notifyWrite();
1961
- },
1962
- update: updateMemory,
1963
- // v0.8.1 底座:scope 归属修正审计(单条记忆,新→旧)。
1964
- listScopeChanges: (memoryId, opts) => store.listScopeChanges(memoryId, opts),
1965
- // Compare-and-set update: applies the patch only when the row still carries
1966
- // `expectedUpdatedAt`. Returns undefined on a miss (no write) so the caller
1967
- // can re-read and retry — the primitive that prevents lost updates across
1968
- // concurrent read-modify-write (see scripts/stress-dsh.js axis 3).
1969
- compareAndUpdate: (id, expectedUpdatedAt, patch, ctx = {}) => {
1970
- const old = store.getById(id);
1971
- const updated = store.compareAndUpdate(id, expectedUpdatedAt, patch);
1972
- if (updated === undefined) return undefined; // CAS miss: no write, no side effects
1973
- const hasMeaningfulChange = old && updated && (
1974
- old.content !== updated.content ||
1975
- old.title !== updated.title ||
1976
- old.importance !== updated.importance
1977
- );
1978
- if (hasMeaningfulChange && config.reflectionFailureTracking) {
1979
- store.saveFailure({
1980
- id: randomUUID(),
1981
- query: ctx.query ?? null,
1982
- expected: updated.content,
1983
- actual: old.content,
1984
- before: { title: old.title, content: old.content, importance: old.importance },
1985
- failure_type: "user_correction",
1986
- memory_id: id
1987
- });
1988
- }
1989
- const sync = afterSync("write");
1990
- notifyWrite();
1991
- scheduleEmbed(updated);
1992
- // Audit peer B: mirror sync failure on a CAS write must surface too.
1993
- if (!sync?.success && !sync?.deferred) {
1994
- Object.defineProperty(updated, "_mirror", {
1995
- value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
1996
- enumerable: false,
1997
- configurable: true
1998
- });
1999
- }
2000
- return updated;
2001
- },
2002
- setForget: (id, f) => {
2003
- const updated = store.setForget(id, f);
2004
- afterSync("write");
2005
- return updated;
2006
- },
2007
- setArchived: archiveMemory,
2008
- // sleep-mode storage (v0.4.0). demoteToSummary / restoreContent mutate
2009
- // content so they ride the normal write-hook path (mirror re-renders).
2010
- // touchLastAccess is a read-stamp — deliberately NO write hook (a recall
2011
- // must not dirty the mirror). getUnrecalledSince is a pure read.
2012
- demoteToSummary: (id, summary, opts) => {
2013
- const updated = store.demoteToSummary(id, summary, opts);
2014
- afterSync("write");
2015
- return updated;
2016
- },
2017
- restoreContent: (id) => {
2018
- const updated = store.restoreContent(id);
2019
- afterSync("write");
2020
- return updated;
2021
- },
2022
- touchLastAccess: (id, at) => store.touchLastAccess(id, at),
2023
- getUnrecalledSince: (cutMs, opts) => store.getUnrecalledSince(cutMs, opts),
2024
- // autoDream audit trail: passthroughs deliberately bypass write hooks —
2025
- // an audit write is bookkeeping, and notifyWrite would loop back into the
2026
- // dream scheduler that just recorded the run.
2027
- saveDreamRun: (run) => store.saveDreamRun(run),
2028
- getDreamRun: (id) => store.getDreamRun(id),
2029
- listDreamRuns: (opts) => store.listDreamRuns(opts),
2030
- // Per-record receipt chain (same bookkeeping semantics as saveDreamRun: an
2031
- // audit write, never a write-hook-triggering memory mutation).
2032
- saveReceipt: (r) => store.saveReceipt(r),
2033
- getReceipt: (id) => store.getReceipt(id),
2034
- listReceipts: (opts) => store.listReceipts(opts),
2035
- // Conflict freeze bookkeeping (same semantics as the audit passthroughs
2036
- // above: an audit write, never a write-hook-triggering memory mutation).
2037
- saveConflictPending: (r) => store.saveConflictPending(r),
2038
- listConflictPending: (opts) => store.listConflictPending(opts),
2039
- listConflictQueue,
2040
- resolveConflictPending,
2041
- countConflictPending: () => store.countConflictPending(),
2042
- // Recall evaluation trail (方案 B): audit-bookkeeping semantics like the
2043
- // dream/recall passthroughs above — a recall_evals write is a snapshot, not
2044
- // a memory mutation, so it never triggers write hooks.
2045
- saveRecallEval: (r) => store.saveRecallEval(r),
2046
- getRecallEval: (id) => store.getRecallEval(id),
2047
- listRecallEvals: (opts) => store.listRecallEvals(opts),
2048
- // LLM audit trail (Bug8): bookkeeping semantics like the recall/dream
2049
- // passthroughs — a saveLlmAudit write never triggers write hooks.
2050
- saveLlmAudit: (entry) => store.saveLlmAudit(entry),
2051
- listLlmAudits: (opts) => store.listLlmAudits(opts),
2052
- countLlmAudits: (opts) => store.countLlmAudits(opts),
2053
- getLlmAuditStats: (opts) => store.getLlmAuditStats(opts),
2054
- deleteOldLlmAudits: (before) => store.deleteOldLlmAudits(before),
2055
- // Entity gene (v0.3.0) passthroughs for the autoDream apply path
2056
- // (applyDecisions): records supersedes relations after an update and
2057
- // migrates entity_attrs on merge. Bookkeeping writes like the audit
2058
- // passthroughs above — never write-hook-triggering memory mutations.
2059
- saveRelation: (r) => store.saveRelation(r),
2060
- listEntities: (o) => store.listEntities(o),
2061
- getRelations: (id) => store.getRelations(id),
2062
- // v0.7.0 实体热投影:实体热 = 关联记忆 heat 聚合(取 max)。无关联记忆
2063
- // 或 heatEnabled=false 时返回 null;前端据此决定图谱节点大小/明暗。
2064
- entityHeat: (entityId) => {
2065
- if (config.heatEnabled === false) return null;
2066
- const rels = store.getRelations(entityId) ?? [];
2067
- let max = -Infinity;
2068
- for (const rel of rels) {
2069
- if (!rel.memory_id) continue;
2070
- const mem = store.getById(rel.memory_id);
2071
- if (!mem) continue;
2072
- const h = computeHeat(mem, Date.now(), config);
2073
- if (h > max) max = h;
2074
- }
2075
- return max === -Infinity ? null : max;
2076
- },
2077
- saveAttr: (r) => store.saveAttr(r),
2078
- createEntity: (r) => store.createEntity(r),
2079
- findEntityByName: (n) => store.findEntityByName(n),
2080
- findEntityById: (id) => store.findEntityById(id),
2081
- getAttrsByMemory: (id) => store.getAttrsByMemory(id),
2082
- // 记忆详情侧栏:一条记忆关联到的实体(entity_attrs.memory_id 反查,纯读)。
2083
- entitiesForMemory: (id) => store.entitiesForMemory(id),
2084
- getCurrentAttrs: (id) => store.getCurrentAttrs(id),
2085
- migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
2086
- };
2087
- }
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { TYPE_FILE } from "./mirror.js";
3
+ import { updatedAtBounds } from "./store.js";
4
+ import { normalizeExplicitScope, scopeKeyOf } from "./scope.js";
5
+ import { STR, langOf } from "./lang.js";
6
+ import { computeHeat } from "./heat.js";
7
+ import { recallStats } from "./recall-stats.js";
8
+ import { evaluateMemoryQuality } from "./quality-filter.js";
9
+ import { applyDecisions } from "./dream/decisions.js";
10
+ import { createDocumentRegistrar } from "./document.js";
11
+ import { createOrganizer } from "./organize.js";
12
+ import { createBM25Index } from "./search/bm25.js";
13
+ import { adaptiveThreshold } from "./search/adaptive.js";
14
+
15
+ const INJECT_TYPES = new Set(["preference", "project", "decision", "summary", "rejected_solution", "pitfall", "constraint"]);
16
+
17
+ // document 摘要行(#230):仅 documentMemoryEnabled 开启时进注入候选。独立
18
+ // 集合而非常改共享 Set——INJECT_TYPES 本身是「类型可注入」的静态事实,flag
19
+ // 是运行时状态,两者不能搅在一起。
20
+ const INJECT_TYPES_WITH_DOCUMENT = new Set([...INJECT_TYPES, "document"]);
21
+
22
+ // 编码记忆类型(codingRetrospect):rejected_solution / pitfall / constraint
23
+ // 只在编码任务时注入(防噪声污染其他业务),且编码场景下按 codingBoostFactor
24
+ // 加权排序提前。
25
+ const CODING_MEMORY_TYPES = new Set(["rejected_solution", "pitfall", "constraint"]);
26
+
27
+ // pin 池类型(#249 第一批):约束与偏好是「对谁都成立的边界」,需要逐字保真
28
+ // 而不是相关性排序——它们与情景日志同池同速率摘要会被静默降级(立项依据)。
29
+ // 注意 constraint 同时属于 CODING_MEMORY_TYPES,非编码任务里已被 codingGate
30
+ // 滤掉,pin 池同样拿不到它;是否豁免该门控是待维护者拍板的口径问题,本批次
31
+ // 不动既有门控(不放大行为面)。
32
+ const PINNED_MEMORY_TYPES = new Set(["constraint", "preference"]);
33
+
34
+ /**
35
+ * 判断一段文本是否编码类任务(关键词匹配,codingRetrospect 读取侧门控)。
36
+ * 纯函数,无副作用,便于单测。
37
+ */
38
+ export function isCodingTask(text, keywords = []) {
39
+ const t = String(text ?? "").toLowerCase();
40
+ return keywords.some((kw) => t.includes(String(kw).toLowerCase()));
41
+ }
42
+
43
+ // Epistemic trust weights (v0.4.5): when config.trustEpistemicWeighting is on,
44
+ // each recall candidate's existing score is multiplied by the weight of its
45
+ // epistemic_status before ranking — measured facts outrank guesses. Missing /
46
+ // unknown statuses are unscaled (×1). Off by default, so nothing changes.
47
+ const EPISTEMIC_WEIGHTS = { observation: 1.0, inferred: 0.85, subjective: 0.7 };
48
+
49
+ // Bug5: content version history cap (FIFO — the newest 20 versions are kept,
50
+ // older ones dropped). Entries are {content, source, updated_at}; source marks
51
+ // how the version was superseded (auto_merge | human_override | overwrite).
52
+ const CONTENT_HISTORY_MAX = 20;
53
+
54
+ // Issue #135 附属发现 2:质量过滤器写下的系统信号标签(「为什么被降权/归档」的
55
+ // 唯一审计线索)。更新路径整组替换 tags 会把它们抹掉——更新时按此清单并集保留。
56
+ // 清单必须与 quality-filter.js 的写入端对齐(6 个,含 type 自指的 self_referential);
57
+ // evidence_degraded 是 #230 注册端的求交标记(quality-filter 不写它,但同样
58
+ // 不许被更新抹掉)。
59
+ const SIGNAL_TAGS = ["low_quality", "duplicate", "meta", "repetitive", "short_content", "self_referential", "evidence_degraded"];
60
+
61
+ // scopeKeyOf 已上提到 ./scope.js(v0.8.1,issue #170 第 2 步):与 sleep 的跨
62
+ // scope 配对共用同一把比较钥匙,避免两处定义漂移。
63
+
64
+ // v0.8.0 A2(issue #17)scope 检索加权系数:两维都无法确立 foreign 的候选
65
+ // (命中行、未标注行、当前侧维度解析不到的行)→ 加成;只有确立了 foreign
66
+ // (记忆带标注 + 当前维度可解析 + 值不等)→ 降权但保留可见(硬过滤是 A3
67
+ // strictScope)。即未标注行与命中行同列、不吃惩罚,但注意它们吃的是 BOOST
68
+ // 而非 ×1 中性。系数是模块常量而非配置项:A2 只定性行为,数值要等线上检索
69
+ // 质量反馈再调(加配置面=提前优化)。
70
+ const SCOPE_MATCH_BOOST = 1.25;
71
+ const SCOPE_FOREIGN_PENALTY = 0.5;
72
+
73
+ /**
74
+ * 单条候选的 scope 乘数。匹配语义与去重键一致(scopeKeyOf):记忆侧未标注视同
75
+ * 该维度命中(全局记忆对谁都可见);当前会话侧某维度解析不到则该维度不参与
76
+ * 比较(无法比较 ≠ 不匹配)。
77
+ */
78
+ function scopeMultiplier(m, current) {
79
+ const agentForeign = scopeKeyOf(m.agent_scope) !== null
80
+ && current.agent_scope !== null
81
+ && scopeKeyOf(m.agent_scope) !== current.agent_scope;
82
+ const workspaceForeign = scopeKeyOf(m.workspace_scope) !== null
83
+ && current.workspace_scope !== null
84
+ && scopeKeyOf(m.workspace_scope) !== current.workspace_scope;
85
+ return (agentForeign || workspaceForeign) ? SCOPE_FOREIGN_PENALTY : SCOPE_MATCH_BOOST;
86
+ }
87
+
88
+ /**
89
+ * v0.8.0 A2:occurred_at 后置过滤谓词(搜索融合池用)。行侧取
90
+ * COALESCE(occurred_at, created_at)——与 store.list 的 SQL 过滤同口径,未标注
91
+ * 行回退创建时间;时间戳损坏的行保留(宁可放宽也不误杀,同 summarize inWindow)。
92
+ */
93
+ function inOccurredBounds(m, bounds) {
94
+ const t = Date.parse(String(m.occurred_at ?? m.created_at ?? ""));
95
+ if (!Number.isFinite(t)) return true;
96
+ if (bounds.from && t < Date.parse(bounds.from)) return false;
97
+ if (bounds.to && t > Date.parse(bounds.to)) return false;
98
+ return true;
99
+ }
100
+
101
+ /**
102
+ * v0.8.0 A3(issue #17)strictScope 可见性谓词:issue 的四象限可见性公式
103
+ * (agent 不对称可见性)——记忆对当前会话可见 ⇔ (agent 维:未标注 或 命中当前
104
+ * agent) AND (workspace 维:未标注 或 命中当前 workspace)。NULL=未标注=全局;
105
+ * 当前会话某维度解析不到时,该维度带标注的记忆一律不可见(fail-closed:身份
106
+ * 不明的会话只见全局,不冒认)。sensitivity 是标签不参与可见性判定(其语义
107
+ * 留给后续批次)。store.list 的 SQL 过滤与此谓词同口径(见 store.js visibility)。
108
+ */
109
+ function isVisibleInScope(m, current) {
110
+ // v0.8.1 第 3 步(issue #170 4.3,作者已确认):硬过滤只认显式声明。某维
111
+ // 仅当「该维有标注且来源为 explicit」才构成硬墙;auto(含 v0.8.0 存量的
112
+ // NULL 来源行)只吃 A2 软加权(foreign ×0.5 保留可见),不进硬过滤。
113
+ // 显式全局(值 NULL + source explicit)与未标注同形,天然恒可见。
114
+ if (m.agent_scope_source === "explicit") {
115
+ const agent = scopeKeyOf(m.agent_scope);
116
+ if (agent !== null && (current.agent_scope === null || agent !== current.agent_scope)) return false;
117
+ }
118
+ if (m.workspace_scope_source === "explicit") {
119
+ const ws = scopeKeyOf(m.workspace_scope);
120
+ if (ws !== null && (current.workspace_scope === null || ws !== current.workspace_scope)) return false;
121
+ }
122
+ return true;
123
+ }
124
+
125
+ /** Prepend the previous content to a memory's content_history (FIFO capped). */
126
+ function pushContentHistory(existing, source) {
127
+ const history = Array.isArray(existing?.content_history) ? existing.content_history : [];
128
+ return [
129
+ { content: existing?.content ?? "", source, updated_at: new Date().toISOString() },
130
+ ...history
131
+ ].slice(0, CONTENT_HISTORY_MAX);
132
+ }
133
+
134
+ /** Bug5: same-title merge appends the new content under a timestamped `---`
135
+ * separator instead of overwriting, so a re-noted memory never loses history.
136
+ * The `---` line is compatible with the mirror's readHumanEdits (which strips
137
+ * only the LAST structural `---` when parsing the human-editable file). */
138
+ function appendContent(oldContent, newContent) {
139
+ const ts = new Date().toISOString();
140
+ return `${oldContent}\n\n---\n[${ts}] ${newContent}`;
141
+ }
142
+
143
+ /**
144
+ * Standard retrieval-quality metrics over the ordered candidate ids actually
145
+ * returned vs the ids the evaluator marked relevant (方案 B). Pure + total, so
146
+ * callers (and tests) get deterministic numbers without touching a store:
147
+ * precision = |relevant ∩ retrieved| / |retrieved|
148
+ * recall = |relevant ∩ retrieved| / |expected|
149
+ * mrr = 1 / rank of the first relevant doc (0 when none retrieved)
150
+ * hit_count is the raw intersection size. Values are rounded to 4 decimals so
151
+ * repeated divisions (e.g. 1/3) never surface binary-float noise.
152
+ */
153
+ export function computeRetrievalMetrics(actualIds, expectedIds) {
154
+ const expected = new Set(Array.isArray(expectedIds) ? expectedIds : []);
155
+ const actual = Array.isArray(actualIds) ? actualIds : [];
156
+ const relevant = actual.filter((id) => expected.has(id)).length;
157
+ const round4 = (x) => Math.round(x * 10000) / 10000;
158
+ let mrr = 0;
159
+ for (let i = 0; i < actual.length; i++) {
160
+ if (expected.has(actual[i])) { mrr = 1 / (i + 1); break; }
161
+ }
162
+ return {
163
+ precision: round4(actual.length ? relevant / actual.length : 0),
164
+ recall: round4(expected.size ? relevant / expected.size : 0),
165
+ mrr: round4(mrr),
166
+ hit_count: relevant
167
+ };
168
+ }
169
+
170
+ export function createService({ store, mirror, config, onWrite, logger }) {
171
+ const language = langOf(config);
172
+ // Optional dream scheduler hook, installed via setDreamHook after creation
173
+ // (the scheduler holds a reference back to the service, so it cannot be
174
+ // passed in the constructor). Fired on the same write events as onWrite.
175
+ let dreamHook = null;
176
+
177
+ // Optional sleep scheduler hook (v0.4.0), installed via setSleepHook after
178
+ // creation. Fired on the same write events as onWrite: it tells the sleep
179
+ // scheduler the store just changed so the idle-detection clock resets.
180
+ let sleepHook = null;
181
+
182
+ // Optional vector embedder, installed via setEmbedder after creation. After
183
+ // any content write it fire-and-forgets a re-embed of the row so vector
184
+ // search stays in sync; failures are swallowed inside the embedder.
185
+ let embedder = null;
186
+
187
+ // Optional entity extractor, installed via setEntityExtractor after creation
188
+ // (index.js injects it so the service never depends on the LLM directly).
189
+ // After a new memory is saved it fire-and-forgets an extraction pass for the
190
+ // entity gene (v0.3.0); failures are swallowed so a broken extraction never
191
+ // surfaces as a write failure. Extraction only runs when
192
+ // config.entityExtractionEnabled is true.
193
+ let entityExtractor = null;
194
+ let vectorIndex = null;
195
+ let reranker = null;
196
+
197
+ // Optional recall recorder, installed via setRecallRecorder after creation.
198
+ // When searchMemories is called with recordRecall=true it receives the
199
+ // actual merged recall scene (candidates + scores + source + threshold) so
200
+ // the retrieval layer can be audited/replayed — the sibling of the dream
201
+ // judgment-layer audit trail (dream_runs).
202
+ let recallRecorder = null;
203
+
204
+ // Bug4: semantic recall cache for the injection path. The system-prompt
205
+ // interpolator renders context synchronously, so injectCandidates cannot
206
+ // fire a fresh async embed. The most recent searchMemories recall is cached
207
+ // here (query + ordered candidates) and reused when the injection query
208
+ // matches, giving semantic-first injection without breaking the sync render.
209
+ let lastSemanticRecall = null;
210
+
211
+ // Transaction nesting depth. Inside service.transaction the per-mutation side
212
+ // effects (mirror render, write notify, re-embed) are deferred so a ROLLBACK
213
+ // never leaves the mirror file diverged from the database; transaction()
214
+ // replays them exactly once against the committed state.
215
+ let txDepth = 0;
216
+
217
+ // Serial task queue (sleep v0.4.0). Long-running background passes — dream
218
+ // consolidation, sleep cycles — must never overlap: two sleep runs racing
219
+ // would double-demote or double-mint patterns. enqueue chains the task onto
220
+ // a promise tail so N callers can queue work that runs strictly one at a
221
+ // time. A task that rejects doesn't poison the queue (the tail swallows the
222
+ // rejection) but the rejection still propagates to that caller.
223
+ let queueTail = Promise.resolve();
224
+ function enqueue(fn) {
225
+ const next = queueTail.then(fn, fn);
226
+ queueTail = next.catch(() => {});
227
+ return next;
228
+ }
229
+
230
+ // issue #6 (part 2): startup race defense. A local embedder (LocalEmbedder /
231
+ // Ollama) exposes an async init(), so between `setEmbedder` and init()
232
+ // resolving there is a window where embedSingle would throw "not initialized"
233
+ // and the re-embed would be silently dropped. When the embedder carries a
234
+ // `ready` flag we queue writes in embedPending until init sets ready=true,
235
+ // then flush them through the embedder's real interface. Embedders without a
236
+ // `ready` flag (legacy OpenAI, instantly usable) keep their old behavior.
237
+ let embedPending = [];
238
+ let embedReadyTimer = null;
239
+ const EMBED_PENDING_MAX = 100; // bound the queue; drop oldest beyond this
240
+ const EMBED_READY_POLL_MS = 100;
241
+ const EMBED_READY_POLL_LIMIT = 30; // ~3s ceiling; never poll forever
242
+
243
+ /** Flush the queued re-embeds once the embedder is ready. Fail-safe. */
244
+ function flushEmbedPending() {
245
+ if (!embedder || embedPending.length === 0) return;
246
+ const batch = embedPending.splice(0, embedPending.length);
247
+ for (const memory of batch) {
248
+ try {
249
+ if (!memory?.id) continue;
250
+ if (typeof embedder.schedule === "function") {
251
+ embedder.schedule(memory);
252
+ } else if (typeof embedder.embedSingle === "function") {
253
+ const text = [memory.title, memory.content].filter(Boolean).join("\n");
254
+ if (!text) continue;
255
+ embedder
256
+ .embedSingle(text)
257
+ .then((vec) => {
258
+ if (Array.isArray(vec) && vec.length) {
259
+ store.setEmbedding(memory.id, vec);
260
+ }
261
+ })
262
+ .catch((err) => {
263
+ logger?.warn?.("flushEmbedPending embedSingle failed:", err);
264
+ });
265
+ }
266
+ } catch (err) {
267
+ logger?.warn?.("flushEmbedPending failed:", err);
268
+ }
269
+ }
270
+ }
271
+
272
+ function stopEmbedReadyPolling() {
273
+ if (embedReadyTimer) {
274
+ clearInterval(embedReadyTimer);
275
+ embedReadyTimer = null;
276
+ }
277
+ }
278
+
279
+ function scheduleEmbed(memory) {
280
+ try {
281
+ if (txDepth > 0) return; // deferred to the transaction's commit
282
+ if (!embedder || !memory?.id) return;
283
+
284
+ // Readiness gate: embedder exposes `ready` (async init) and is not ready
285
+ // yet — queue instead of firing embedSingle into a half-built extractor.
286
+ const hasReady = "ready" in embedder;
287
+ if (hasReady && embedder.ready !== true) {
288
+ if (embedPending.length >= EMBED_PENDING_MAX) embedPending.shift();
289
+ embedPending.push(memory);
290
+ return;
291
+ }
292
+
293
+ if (typeof embedder.schedule === "function") {
294
+ embedder.schedule(memory);
295
+ return;
296
+ }
297
+
298
+ if (typeof embedder.embedSingle === "function") {
299
+ const text = [memory.title, memory.content].filter(Boolean).join("\n");
300
+ if (!text) return;
301
+
302
+ embedder
303
+ .embedSingle(text)
304
+ .then((vec) => {
305
+ if (Array.isArray(vec) && vec.length) {
306
+ store.setEmbedding(memory.id, vec);
307
+ }
308
+ })
309
+ .catch((err) => {
310
+ logger?.warn?.("scheduleEmbed embedSingle failed:", err);
311
+ });
312
+ }
313
+ } catch (err) {
314
+ logger?.warn?.("scheduleEmbed failed:", err);
315
+ }
316
+ }
317
+
318
+ /**
319
+ * Fire-and-forget entity extraction for a freshly saved memory (entity gene
320
+ * v0.3.0). Opt-in via config.entityExtractionEnabled; the extractor is
321
+ * injected as a hook so the service never needs a direct LLM reference.
322
+ * The hook itself is expected to resolve to { ok:boolean } and never throw;
323
+ * a thrown rejection is swallowed here as a final fail-safe.
324
+ */
325
+ function scheduleEntityExtraction(memory) {
326
+ if (txDepth > 0) return; // deferred to the transaction's commit
327
+ if (!config.entityExtractionEnabled || !entityExtractor) return;
328
+ try {
329
+ entityExtractor(memory).catch((err) => {
330
+ logger?.warn?.("entity extraction failed:", err);
331
+ });
332
+ } catch (err) {
333
+ logger?.warn?.("entity extraction failed:", err);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Cross-encoder rerank over a candidate list (best effort). Reranker
339
+ * failures degrade to the original candidate order — reranking is an
340
+ * accuracy upgrade, never a correctness gate.
341
+ */
342
+ async function rerankCandidates(query, candidates, topK) {
343
+ if (!reranker || !candidates.length) return candidates.slice(0, topK);
344
+ try {
345
+ const scored = await reranker.rerank(query, candidates.map((c) => ({ id: c.id, title: c.title, content: c.content })));
346
+ if (!Array.isArray(scored)) return candidates.slice(0, topK);
347
+ const byId = new Map(candidates.map((c) => [c.id, c]));
348
+ const out = [];
349
+ for (const s of scored) {
350
+ const c = byId.get(s.id);
351
+ if (c) { out.push({ ...c, score: s.score, source: "rerank" }); if (out.length >= topK) break; }
352
+ }
353
+ return out.length ? out : candidates.slice(0, topK);
354
+ } catch {
355
+ return candidates.slice(0, topK);
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Search for memories attached to a named entity (v0.3.0 Phase 3).
361
+ * 合并优先级:entity_attrs.memory_id 精确关联 = 1.0 > 关键词提及 = 0.7;
362
+ * attr 命中不覆盖,keyword 只补充召回,最后按 _score 降序取 topK。
363
+ * @param {string} entityName
364
+ * @param {object} [options]
365
+ * @param {number} [options.topK=20]
366
+ * @returns {any[]}
367
+ */
368
+ function searchByEntity(entityName, { topK = 20 } = {}) {
369
+ const entity = store.findEntityByName(entityName);
370
+ if (!entity) return [];
371
+ const attrs = store.getCurrentAttrs(entity.id);
372
+ const memoryIds = [...new Set(attrs.map((a) => a.memory_id).filter(Boolean))];
373
+ const attrHits = memoryIds.map((id) => store.getById(id)).filter(Boolean);
374
+ const keywordHits = store.search(entityName, { limit: topK });
375
+ const merged = new Map();
376
+ for (const mem of attrHits) merged.set(mem.id, { ...mem, _source: "entity_attr", _score: 1.0 });
377
+ for (const mem of keywordHits) {
378
+ if (!merged.has(mem.id)) merged.set(mem.id, { ...mem, _source: "keyword", _score: 0.7 });
379
+ }
380
+ const hits = Array.from(merged.values()).sort((a, b) => b._score - a._score).slice(0, topK);
381
+ touchRecalled(hits);
382
+ return hits;
383
+ }
384
+
385
+ /**
386
+ * Search for memories by attribute key/value (v0.3.0 Phase 3).
387
+ * value 为空时由 store.findMemoriesByAttr 返回该 key 的全部有效记忆。
388
+ * @param {string} key
389
+ * @param {string | undefined} value
390
+ * @param {object} [options]
391
+ * @param {number} [options.topK=20]
392
+ * @returns {any[]}
393
+ */
394
+ function searchByAttr(key, value, { topK = 20 } = {}) {
395
+ if (!key) return [];
396
+ // value 可能为 undefined(attr:key 无 = 值):归一为空串后交给
397
+ // store.findMemoriesByAttr —— 空 value 契约 = 返回该 attr_key 的全部
398
+ // 当前有效记忆(v0.3.0,store.js 已实现)。
399
+ const rows = store.findMemoriesByAttr(key, value ?? "");
400
+ const hits = rows.slice(0, topK);
401
+ touchRecalled(hits);
402
+ return hits;
403
+ }
404
+
405
+ /**
406
+ * Semantic-aware memory search: keyword recall (store.search) plus optional
407
+ * vector recall + rerank. mode:
408
+ * auto (default) keyword first, vector fills remaining slots (legacy)
409
+ * hybrid vector first, keyword fills remaining slots
410
+ * vector vector only, falls back to keyword when unavailable
411
+ * keyword text only, never touches the embedder
412
+ * useRerank runs the cross-encoder over the merged list when a reranker is
413
+ * installed; results carry an extra `score` when reranked.
414
+ */
415
+ // Weighted blend factor for hybrid search; exposed so callers can tune it.
416
+ const DEFAULT_HYBRID_WEIGHTS = { vector: 0.6, keyword: 0.4 };
417
+
418
+ // Cosine over two plain arrays (shared by the search-time semantic dedup).
419
+ function cosineVec(a, b) {
420
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || !a.length) return 0;
421
+ let dot = 0, na = 0, nb = 0;
422
+ for (let i = 0; i < a.length; i++) {
423
+ dot += a[i] * b[i];
424
+ na += a[i] * a[i];
425
+ nb += b[i] * b[i];
426
+ }
427
+ if (na === 0 || nb === 0) return 0;
428
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
429
+ }
430
+
431
+ /**
432
+ * BM25 third recall path (v0.5.0 1.1). Scores the query tokens against the
433
+ * live non-archived rows and returns the top `limit` hits with scores
434
+ * normalized to [0,1]. Failures degrade to [] — BM25 is a recall booster,
435
+ * never a correctness gate.
436
+ */
437
+ function bm25Recall(q, limit) {
438
+ if (config?.bm25SearchEnabled === false) return [];
439
+ try {
440
+ const docs = store.list({ limit: 500, includeForgotten: false }).filter((m) => !m.archived);
441
+ if (!docs.length) return [];
442
+ return createBM25Index(docs).search(q, { limit });
443
+ } catch {
444
+ return [];
445
+ }
446
+ }
447
+
448
+ /**
449
+ * Graph fourth recall path (issue #219). When the query text mentions a
450
+ * known entity name, memories linked to that entity (attrs + relations)
451
+ * join the fusion pool as a confirm/backfill signal — same standing as
452
+ * BM25, never dominating the semantic ranking. Tier scores: attr-linked
453
+ * 1.0 (direct evidence, same source searchByEntity trusts) > relation
454
+ * edge 0.9. Archived/forgotten rows never participate. Failures degrade
455
+ * to [] — a recall booster, never a correctness gate (same contract as
456
+ * bm25Recall).
457
+ */
458
+ function entityRecall(q, limit) {
459
+ if (config?.entityRecallEnabled !== true) return [];
460
+ try {
461
+ const entities = store.findEntitiesMentionedIn(q);
462
+ if (!entities.length) return [];
463
+ const linked = store.getLinkedMemoryIds(entities.map((e) => e.id));
464
+ if (!linked.size) return [];
465
+ const hits = [];
466
+ for (const [id, tier] of linked) {
467
+ const row = store.getById(id);
468
+ if (!row || row.archived || row.forgotten) continue;
469
+ hits.push({ ...row, score: tier === "attr" ? 1.0 : 0.9, source: "entity" });
470
+ if (hits.length >= limit * 2) break;
471
+ }
472
+ return hits;
473
+ } catch {
474
+ return [];
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Search-time semantic dedup (v0.5.0 2.3): greedy pass dropping candidates
480
+ * whose embedding similarity to an already-kept row exceeds the threshold.
481
+ * Rows without a stored embedding are always kept (no signal = no drop).
482
+ */
483
+ function semanticDeduplicate(candidates) {
484
+ // Opt-in aggressive mode (default off): collapsing near-duplicates can
485
+ // drop legitimately distinct rows on small embedding models, so it ships
486
+ // behind searchSemanticDedup=true.
487
+ if (config?.searchSemanticDedup !== true || candidates.length < 2) return candidates;
488
+ const threshold = config?.searchSemanticDedupThreshold ?? 0.95;
489
+ try {
490
+ const vecs = store.getEmbeddings(candidates.map((c) => c.id));
491
+ if (vecs.size < 2) return candidates;
492
+ const kept = [];
493
+ for (const c of candidates) {
494
+ const v = vecs.get(c.id);
495
+ if (!v) { kept.push(c); continue; }
496
+ const dup = kept.some((k) => {
497
+ const kv = vecs.get(k.id);
498
+ return kv && cosineVec(v, kv) > threshold;
499
+ });
500
+ if (!dup) kept.push(c);
501
+ }
502
+ return kept;
503
+ } catch {
504
+ return candidates;
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Give a keyword-hit row a relevance score in [0,1]: title hits score
510
+ * higher than content hits, then scaled by importance (1-5). This lets
511
+ * keyword results participate in weighted hybrid blends.
512
+ */
513
+ function scoreKeyword(row, q) {
514
+ const ql = q.toLowerCase();
515
+ const title = (row.title ?? "").toLowerCase();
516
+ const content = (row.content ?? "").toLowerCase();
517
+ const titleHit = title.includes(ql);
518
+ const base = titleHit ? 1 : content.includes(ql) ? 0.6 : 0.3;
519
+ return base * (0.5 + (row.importance ?? 3) / 10);
520
+ }
521
+
522
+ /**
523
+ * Recall touch (v0.4.0 sleep; v0.7.0 heat gating): any memory surfaced by
524
+ * recall or auto-injection gets its last_accessed_at bumped, so the "unrecalled
525
+ * N days → demote/archive" tiering counts real access — and the heat clock
526
+ * resets (heat ref = last_accessed_at). Best-effort and gated on
527
+ * config.heatEnabled — when heat is off this is a complete no-op (no writes
528
+ * on the hot recall path). A touch failure must never break search/inject.
529
+ */
530
+ function touchRecalled(memories) {
531
+ if (config?.heatEnabled === false || !Array.isArray(memories) || memories.length === 0) return;
532
+ for (const m of memories) {
533
+ if (!m?.id) continue;
534
+ try {
535
+ store.touchLastAccess(m.id);
536
+ } catch { /* touch is best effort */ }
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Recall fusion (plan #1). Turns the three ranked signal lists (keyword,
542
+ * vector, BM25) into a single merged list. Three recipes, selected by
543
+ * config.recallFusion:
544
+ * - blend (default): legacy behavior — weighted sum for vector/hybrid, union
545
+ * backfill for auto. Byte-identical to pre-fusion code, so enabling the
546
+ * config never regresses anybody.
547
+ * - rrf: Reciprocal Rank Fusion — Σ 1/(k + rank + 1) over each list a row
548
+ * appears in. Rank-based, so the unit mismatch (raw cosine vs keyword
549
+ * score vs normalized IDF) is irrelevant.
550
+ * - minmax: min-max normalize each source list's scores to [0,1] then take
551
+ * the weighted sum — a scale-aware version of `blend`.
552
+ * Returns { merged, signals }, where signals is Map<id, {keyword, vector,
553
+ * bm25}> so searchMemories can decorate rows when signalTransparency is on.
554
+ */
555
+ function fuseRecall({ keyword, vector, bm25, entity, lim, mode, wv, wk, wb, we }) {
556
+ const recipe = config?.recallFusion ?? "blend";
557
+
558
+ // Per-source scores are recorded for every recipe so signalTransparency
559
+ // works regardless of how the ranking was produced.
560
+ const signals = new Map();
561
+ const addSig = (id, field, sc) => {
562
+ const cur = signals.get(id) ?? {};
563
+ cur[field] = sc;
564
+ signals.set(id, cur);
565
+ };
566
+ for (const m of keyword) addSig(m.id, "keyword", m.score ?? 0);
567
+ for (const m of vector) addSig(m.id, "vector", m.score ?? 0);
568
+ for (const m of bm25) addSig(m.id, "bm25", m.score ?? 0);
569
+ for (const m of entity) addSig(m.id, "entity", m.score ?? 0);
570
+
571
+ const vectorIds = new Set(vector.map((m) => m.id));
572
+ const keywordIds = new Set(keyword.map((m) => m.id));
573
+
574
+ // Mode contract (aligns rrf/minmax with blend): keyword-only searches must
575
+ // stay keyword-only regardless of recipe, so enabling an opt-in recipe can
576
+ // never pull vector/BM25 rows into a mode="keyword" request. This mirrors
577
+ // the blend branch's `mode === "keyword"` short-circuit (byte-for-byte).
578
+ if (mode === "keyword") {
579
+ return { merged: keyword.slice(0, lim), signals };
580
+ }
581
+
582
+ let merged;
583
+ if (recipe === "rrf") {
584
+ // Rank-based: only the position of a row inside each surviving source
585
+ // list matters, so no cross-signal scale calibration is needed.
586
+ const k = 60; // standard RRF constant (plan #1 documents k=60)
587
+ const rows = new Map();
588
+ const addList = (list) => list.forEach((m, idx) => {
589
+ const s = 1 / (k + idx + 1);
590
+ const cur = rows.get(m.id);
591
+ if (cur) cur.score += s;
592
+ else rows.set(m.id, { ...m, score: s });
593
+ });
594
+ addList(keyword);
595
+ addList(vector);
596
+ addList(bm25);
597
+ addList(entity);
598
+ merged = [...rows.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, lim);
599
+ } else if (recipe === "minmax") {
600
+ // Scale-aware weighted sum: each source list is min-max normalized to
601
+ // [0,1] before blending, so raw cosine and keyword score live on the
602
+ // same footing.
603
+ const norm = (list) => {
604
+ if (!list.length) return new Map();
605
+ let min = Infinity, max = -Infinity;
606
+ for (const m of list) { const s = m.score ?? 0; if (s < min) min = s; if (s > max) max = s; }
607
+ const range = max - min;
608
+ const out = new Map();
609
+ for (const m of list) out.set(m.id, range > 0 ? ((m.score ?? 0) - min) / range : 0.5);
610
+ return out;
611
+ };
612
+ const kw = norm(keyword), ve = norm(vector), bm = norm(bm25), en = norm(entity);
613
+ const rows = new Map();
614
+ const seed = (m) => { if (!rows.has(m.id)) rows.set(m.id, { ...m, score: 0 }); };
615
+ for (const m of keyword) seed(m);
616
+ for (const m of vector) seed(m);
617
+ for (const m of bm25) seed(m);
618
+ for (const m of entity) seed(m);
619
+ for (const [id, row] of rows) {
620
+ const k = kw.get(id) ?? 0;
621
+ const v = ve.get(id) ?? 0;
622
+ const b = bm.get(id) ?? 0;
623
+ const e = en.get(id) ?? 0;
624
+ row.score = v * wv + k * wk + b * wb + e * we;
625
+ }
626
+ merged = [...rows.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0)).slice(0, lim);
627
+ } else {
628
+ // blend — the pre-existing per-mode behavior, extracted verbatim.
629
+ if (mode === "keyword") {
630
+ merged = keyword;
631
+ } else if (mode === "vector" || mode === "hybrid") {
632
+ const byId = new Map();
633
+ for (const m of vector) {
634
+ const rec = byId.get(m.id);
635
+ byId.set(m.id, rec ? { ...rec, score: Math.max(rec.score ?? 0, m.score ?? 0) } : m);
636
+ }
637
+ for (const m of keyword) {
638
+ const rec = byId.get(m.id);
639
+ if (rec) {
640
+ byId.set(m.id, { ...rec, score: (rec.score ?? 0) * wv + (m.score ?? 0) * wk });
641
+ } else {
642
+ byId.set(m.id, m);
643
+ }
644
+ }
645
+ for (const m of bm25) {
646
+ const rec = byId.get(m.id);
647
+ if (rec) {
648
+ if (keywordIds.has(m.id)) continue;
649
+ byId.set(m.id, { ...rec, score: (rec.score ?? 0) + wb * (m.score ?? 0) });
650
+ } else {
651
+ byId.set(m.id, { ...m, score: wb * (m.score ?? 0) });
652
+ }
653
+ }
654
+ // Entity axis (#219): same confirm/backfill contract as BM25 —
655
+ // seen rows get a weighted bonus, unseen ids backfill at we·score.
656
+ for (const m of entity) {
657
+ const rec = byId.get(m.id);
658
+ if (rec) {
659
+ byId.set(m.id, { ...rec, score: (rec.score ?? 0) + we * (m.score ?? 0) });
660
+ } else {
661
+ byId.set(m.id, { ...m, score: we * (m.score ?? 0) });
662
+ }
663
+ }
664
+ const ranked = [...byId.values()].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
665
+ merged = ranked.slice(0, lim);
666
+ if (merged.length < lim && !merged.length) {
667
+ merged = keyword.slice(0, lim);
668
+ }
669
+ } else {
670
+ // auto: keyword leads, vector + BM25 + entity fill remaining slots.
671
+ merged = keyword.slice(0, lim);
672
+ const seen = new Set(merged.map((m) => m.id));
673
+ for (const m of vector) {
674
+ if (merged.length >= lim) break;
675
+ if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
676
+ }
677
+ for (const m of bm25) {
678
+ if (merged.length >= lim) break;
679
+ if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
680
+ }
681
+ for (const m of entity) {
682
+ if (merged.length >= lim) break;
683
+ if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
684
+ }
685
+ }
686
+ }
687
+
688
+ // Mode contract, auto (aligns rrf/minmax with blend): keyword leads, the
689
+ // recipe fills the remaining slots. blend.auto already front-loads keyword;
690
+ // rrf/minmax rank across sources, so re-apply the same "keyword first"
691
+ // ordering here to preserve the pre-fusion auto contract — the keyword
692
+ // hit list keeps its power, and only slots it couldn't fill go to the
693
+ // recipe's ranking.
694
+ if (recipe !== "blend" && mode === "auto" && keyword.length) {
695
+ const head = keyword.slice(0, lim);
696
+ const seen = new Set(head.map((m) => m.id));
697
+ const tail = merged.filter((m) => !seen.has(m.id)).slice(0, Math.max(0, lim - head.length));
698
+ merged = head.concat(tail);
699
+ }
700
+
701
+ return { merged, signals };
702
+ }
703
+
704
+ /**
705
+ * Search memories for a query. Merges up to four recall sources (keyword,
706
+ * vector, BM25, and — when config.entityRecallEnabled is on — entity-linked
707
+ * memories whose entity names the query mentions; see fuseRecall) according
708
+ * to config.recallFusion (blend/rrf/minmax — see
709
+ * fuseRecall), then optionally decorates rows with per-source signals
710
+ * (config.signalTransparency), applies semantic dedup (non-keyword modes),
711
+ * reranking, and epistemic trust re-weighting, and finally hands the merged
712
+ * list to the recall-layer recorder.
713
+ *
714
+ * options:
715
+ * mode — 'auto' (default) | 'keyword' | 'vector' | 'hybrid'
716
+ * topK — max rows (default 20)
717
+ * threshold — explicit vector score floor (overrides adaptive)
718
+ * useRerank — apply the reranker if available (default true)
719
+ * recordRecall — write a recall_runs audit row (default from config)
720
+ *
721
+ * Returns an array of memory rows { id, title, content, score, source, ... },
722
+ * with `signals` added when config.signalTransparency is on. Never throws:
723
+ * a vector/rerank failure degrades to keyword results.
724
+ */
725
+ async function searchMemories(query, options = {}) {
726
+ const {
727
+ mode = "auto",
728
+ topK = 20,
729
+ threshold,
730
+ useRerank = true,
731
+ // v0.8.0 A2(issue #17):当前会话 scope(工具层从 exec 解析后传入)与
732
+ // occurred_at 时间窗。两者都只在显式传入时生效,既有调用方(注入/梦/评测)
733
+ // 不传 → 行为与 A2 前一致。
734
+ scope = null,
735
+ occurredFrom = null,
736
+ occurredTo = null,
737
+ recordRecall = options.recordRecall ?? (config?.recallRecordDefault ?? true)
738
+ } = options;
739
+ const q = String(query ?? "").trim();
740
+ if (!q) return [];
741
+
742
+ // entity:/attr: 前缀路由(v0.3.0 Phase 3)。entitySearchEnabled 关闭时走原逻辑。
743
+ if (config?.entitySearchEnabled) {
744
+ if (q.startsWith("entity:")) {
745
+ return searchByEntity(q.slice(7).trim(), options);
746
+ }
747
+ if (q.startsWith("attr:")) {
748
+ const [key, value] = q.slice(5).split("=");
749
+ return searchByAttr(key, value, options);
750
+ }
751
+ }
752
+
753
+ const lim = topK > 0 ? topK : 20;
754
+
755
+ // Keyword results, decorated with a score so they can be weight-blended
756
+ // with vector results and reported uniformly. source tracks where each
757
+ // candidate came from for the recall layer receipt.
758
+ const rawKeyword = store.search(q, { limit: lim });
759
+ const keyword = rawKeyword.map((m) => ({ ...m, score: scoreKeyword(m, q), source: "keyword" }));
760
+ const wantVector = mode === "vector" || mode === "hybrid" || (mode === "auto" && !!embedder);
761
+ let vector = [];
762
+ if (wantVector && embedder) {
763
+ try {
764
+ // Legacy embedders expose embed(query); local ones expose embedSingle.
765
+ const embedSingle = typeof embedder.embedSingle === "function"
766
+ ? embedder.embedSingle.bind(embedder)
767
+ : embedder.embed.bind(embedder);
768
+ const qv = await embedSingle(q);
769
+ if (qv?.length) {
770
+ // Adaptive threshold (v0.5.0 1.2): the fetch runs at the loosest
771
+ // branch floor so the head-gap rule can still re-admit the tail;
772
+ // the final cutoff is computed against the fetched score
773
+ // distribution. Explicit `threshold` wins; disabled → legacy 0.
774
+ const adaptive = config?.adaptiveThresholdEnabled !== false;
775
+ const fetchThreshold = adaptive && threshold === undefined
776
+ ? Math.min(0.5, adaptiveThreshold(q))
777
+ : (threshold ?? 0);
778
+ const search = vectorIndex
779
+ ? vectorIndex.search(qv, { limit: lim * 2, threshold: fetchThreshold })
780
+ : store.searchVector(qv, { limit: lim * 2, threshold: fetchThreshold });
781
+ const finalThreshold = adaptive && threshold === undefined
782
+ ? adaptiveThreshold(q, search)
783
+ : (threshold ?? 0);
784
+ vector = search
785
+ .filter((m) => (m.score ?? 1) >= finalThreshold)
786
+ .map((m) => ({ ...m, vector: true, source: "vector" }));
787
+ }
788
+ } catch { /* vector unavailable: keep keyword results */ }
789
+ }
790
+
791
+ // BM25 third path (v0.5.0 1.1): IDF-weighted token overlap recalls rows
792
+ // whose query terms are scattered — the gap LIKE substring matching
793
+ // cannot close. Scores are already normalized to [0,1].
794
+ const bm25 = bm25Recall(q, lim).map((m) => ({ ...m, source: "bm25" }));
795
+ // Loose blend weight: BM25 confirms and backfills, never dominates the
796
+ // semantic signal. Same-memory overlap boosts, unseen ids backfill.
797
+ const wb = 0.3;
798
+
799
+ // Graph fourth path (issue #219, opt-in entityRecallEnabled): query-
800
+ // mentioned entities pull their linked memories into the pool. Keyword
801
+ // mode stays text-only per the mode contract, so the axis is skipped
802
+ // there even when enabled.
803
+ const entity = mode === "keyword" ? [] : entityRecall(q, lim);
804
+ // Entity axis shares BM25's backfill weight (we = wb) until #217 recall
805
+ // data justifies a distinct entity weight.
806
+
807
+ // Hybrid blending weights from config when provided.
808
+ const wv = config?.hybridSearchVectorWeight ?? DEFAULT_HYBRID_WEIGHTS.vector;
809
+ const wk = config?.hybridSearchKeywordWeight ?? DEFAULT_HYBRID_WEIGHTS.keyword;
810
+
811
+ const { merged: fusedMerged, signals } = fuseRecall({ keyword, vector, bm25, entity, lim, mode, wv, wk, wb, we: wb });
812
+ let merged = fusedMerged;
813
+ // v0.8.0 A3(issue #17):strictScope 硬过滤——他 scope 的候选直接出局
814
+ // (区别于 A2 的降权保留可见);未标注行与命中行保留。strict 与 A2 加权
815
+ // 叠加:过滤后剩下的命中行仍吃加成。scope 未传(flag 关)或完全解析不到
816
+ // 时跳过——identity 为空的对象({null,null})按 fail-closed 过滤。
817
+ if (config.strictScope === true && scope) {
818
+ merged = merged.filter((m) => isVisibleInScope(m, scope));
819
+ }
820
+ // v0.8.0 A2:occurred_at 时间过滤——在融合池上先滤再 dedup/slice,rerank
821
+ // 只看窗内候选,topK 槽位不被窗外行占用。
822
+ const occurredBounds = updatedAtBounds(occurredFrom, occurredTo);
823
+ if (occurredBounds.from || occurredBounds.to) {
824
+ merged = merged.filter((m) => inOccurredBounds(m, occurredBounds));
825
+ }
826
+ // Signal transparency (#2): decorate each returned row with its per-source
827
+ // scores and the final fused score. Purely additive — never changes rank.
828
+ if (config?.signalTransparency === true) {
829
+ merged = merged.map((m) => ({ ...m, signals: { ...(signals.get(m.id) ?? {}), final: m.score ?? 0 } }));
830
+ }
831
+
832
+ // Search-time semantic dedup (v0.5.0 2.3): near-duplicate rows are
833
+ // dropped before the reranker sees them, so topK slots carry distinct
834
+ // information instead of the same memory twice. Keyword mode is exempt —
835
+ // it is the documented text-only path and must not be altered by
836
+ // embedding state.
837
+ merged = mode === "keyword" ? merged : semanticDeduplicate(merged);
838
+ merged = merged.slice(0, lim);
839
+ let result = useRerank && reranker && merged.length
840
+ ? await rerankCandidates(q, merged, lim)
841
+ : merged;
842
+ // Epistemic trust (v0.4.5): opt-in re-weighting of the final candidate
843
+ // scores by source credibility. When off (default) `result` is returned
844
+ // untouched — exactly the legacy behavior.
845
+ if (config.trustEpistemicWeighting === true) {
846
+ result = result
847
+ .map((m) => ({
848
+ ...m,
849
+ score: (m.score ?? 0) * (EPISTEMIC_WEIGHTS[m.epistemic_status] ?? 1)
850
+ }))
851
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
852
+ .slice(0, lim);
853
+ }
854
+
855
+ // v0.8.0 A2(issue #17):scope 检索加权——两维都无法确立 foreign 的候选
856
+ // (命中/未标注/当前侧解析不到)加成,确立 foreign 的候选降权但保留可见
857
+ // (硬过滤是 A3 strictScope);未标注行与命中行同列吃 BOOST、不被压制。
858
+ // flag 关或调用方未传 scope 时不动分,排序与 A2 前逐字节一致。与
859
+ // epistemic 加权同款收尾:乘分 → 降序 → 截 topK。
860
+ if (config.scopeEnabled === true && scope && (scope.agent_scope || scope.workspace_scope)) {
861
+ result = result
862
+ .map((m) => ({ ...m, score: (m.score ?? 0) * scopeMultiplier(m, scope) }))
863
+ .sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
864
+ .slice(0, lim);
865
+ }
866
+
867
+ // Recall layer receipt: with recordRecall on, hand the actual merged
868
+ // candidate list (id/title/content/score/source) to the injected recorder
869
+ // before returning, making the retrieval scene replayable — the sibling of
870
+ // the dream judgment-layer audit trail. Recorder failures must never break
871
+ // the search itself.
872
+ if (recordRecall && recallRecorder) {
873
+ try {
874
+ recallRecorder({
875
+ query: q,
876
+ mode,
877
+ topK: lim,
878
+ threshold: threshold ?? null,
879
+ candidates: result.map((m) => ({
880
+ id: m.id,
881
+ title: m.title,
882
+ content: m.content,
883
+ score: m.score ?? null,
884
+ source: m.source ?? "keyword"
885
+ })),
886
+ createdAt: new Date().toISOString()
887
+ });
888
+ } catch { /* recall receipt is best effort */ }
889
+ }
890
+ // Bug4: cache the latest semantic recall so the sync injection path can
891
+ // reuse it when the injection query matches (no async embed available).
892
+ lastSemanticRecall = { query: q, items: result };
893
+ touchRecalled(result);
894
+ return result;
895
+ }
896
+
897
+ /**
898
+ * Retrieval evaluation (方案 B): run one search for `query`, compare the ids
899
+ * it actually returned against `expectedIds`, and return the computed
900
+ * metrics. When persistence is on (config.evalPersistTestResults, or an
901
+ * explicit `persist` override per call) the snapshot is written to the
902
+ * recall_evals table — a SEPARATE store from the recall_runs production audit,
903
+ * so test/eval data never inflates the production trail.
904
+ *
905
+ * options:
906
+ * mode/topK/threshold/useRerank — passed through to searchMemories
907
+ * evalType — label for the snapshot (default 'manual')
908
+ * recordRecall — also write a recall_runs audit row for the
909
+ * same scene and link it via recall_run_id
910
+ * (default false: eval stays unlinked)
911
+ * recallRunId — explicit link to an existing recall_runs id
912
+ * persist — override the config gate for this call
913
+ *
914
+ * Returns { metrics, actualIds, expectedIds, recallRunId, persisted }.
915
+ * Never throws on persistence failures: a broken eval write must not break
916
+ * the retrieval quality measurement.
917
+ */
918
+ async function evaluateRetrieval(query, expectedIds, options = {}) {
919
+ const q = String(query ?? "").trim();
920
+ const expected = Array.isArray(expectedIds) ? expectedIds : [];
921
+ const {
922
+ mode = "auto",
923
+ topK = 20,
924
+ threshold,
925
+ useRerank = true,
926
+ evalType = "manual",
927
+ recordRecall = false,
928
+ recallRunId = null,
929
+ persist = config.evalPersistTestResults === true
930
+ } = options;
931
+ if (!q) {
932
+ const empty = computeRetrievalMetrics([], expected);
933
+ return { metrics: empty, actualIds: [], expectedIds: expected, recallRunId: null, persisted: false };
934
+ }
935
+
936
+ const rows = await searchMemories(q, { mode, topK, threshold, useRerank, recordRecall: false });
937
+ const actualIds = rows.map((m) => m.id);
938
+ const metrics = computeRetrievalMetrics(actualIds, expected);
939
+
940
+ // Optional recall_runs audit for the same scene; the eval row then links to
941
+ // it. Kept separate from the production recorder (which fires only on
942
+ // recordRecall=true inside searchMemories) — eval never double-records.
943
+ // An explicit recallRunId wins; recordRecall only mints a NEW audit run when
944
+ // the caller did not already link one (never clobber an existing link).
945
+ let runId = recallRunId ?? null;
946
+ if (recordRecall && runId === null) {
947
+ try {
948
+ const run = store.saveRecallRun({
949
+ query: q,
950
+ mode,
951
+ topK,
952
+ threshold: threshold ?? null,
953
+ candidates: rows.map((m) => ({
954
+ id: m.id,
955
+ title: m.title,
956
+ content: m.content,
957
+ score: m.score ?? null,
958
+ source: m.source ?? "keyword"
959
+ })),
960
+ created_at: new Date().toISOString()
961
+ });
962
+ runId = run.id;
963
+ } catch { /* non-fatal: the eval itself still succeeds */ }
964
+ }
965
+
966
+ let persisted = false;
967
+ if (persist) {
968
+ try {
969
+ store.saveRecallEval({
970
+ recall_run_id: runId,
971
+ query: q,
972
+ expected_ids: expected,
973
+ actual_ids: actualIds,
974
+ metrics,
975
+ eval_type: evalType,
976
+ created_at: new Date().toISOString()
977
+ });
978
+ persisted = true;
979
+ } catch { /* non-fatal: measurement survives a failed eval write */ }
980
+ }
981
+ return { metrics, actualIds, expectedIds: expected, recallRunId: runId, persisted };
982
+ }
983
+
984
+ /**
985
+ * Fire-and-forget write notification; errors are swallowed to keep write
986
+ * paths clean. The store mutation has already committed, so a throwing
987
+ * subscriber must not surface as a write failure. Archive/forget flags are
988
+ * state toggles, not content writes, so they never notify.
989
+ */
990
+ function notifyWrite() {
991
+ if (txDepth > 0) return; // deferred to the transaction's commit
992
+ if (onWrite) {
993
+ try { onWrite(); } catch { /* ignore */ }
994
+ }
995
+ if (dreamHook) {
996
+ try { dreamHook(); } catch { /* ignore */ }
997
+ }
998
+ if (sleepHook) {
999
+ try { sleepHook(); } catch { /* ignore */ }
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * Run several store mutations atomically (SQLite BEGIN/COMMIT/ROLLBACK) and
1005
+ * fire the deferred side effects once against the committed state. A throwing
1006
+ * body rolls the whole batch back — no partial writes, no diverged mirror.
1007
+ * Errors propagate to the caller. NOTE: the commit path re-renders the mirror
1008
+ * and notifies subscribers, but re-embedding is left to the caller (the dream
1009
+ * flow re-embeds through maintainIndexAfterDream).
1010
+ */
1011
+ function transaction(fn) {
1012
+ store.db.exec("BEGIN");
1013
+ txDepth++;
1014
+ try {
1015
+ const result = fn();
1016
+ store.db.exec("COMMIT");
1017
+ return result;
1018
+ } catch (error) {
1019
+ try { store.db.exec("ROLLBACK"); } catch { /* store may be closed */ }
1020
+ throw error;
1021
+ } finally {
1022
+ txDepth--;
1023
+ // Sync failures are surfaced, not swallowed (peer blocker 2): the mirror
1024
+ // debt was already recorded by markMirrorDirty inside syncMirror, so a
1025
+ // restart recovers — but the operator must see it now, not after restart.
1026
+ const syncResult = syncMirror();
1027
+ if (!syncResult?.success && !syncResult?.deferred) {
1028
+ logger?.warn?.("mirror sync failed after transaction:", syncResult?.error);
1029
+ }
1030
+ notifyWrite();
1031
+ }
1032
+ }
1033
+
1034
+ /**
1035
+ * Embed an arbitrary query text and return its vector (null on failure / no
1036
+ * embedder). Used by the injector to prefetch the semantic-first recall
1037
+ * vector for the current user message — the system-prompt render is
1038
+ * synchronous, so the vector must be cached in advance (Bug4).
1039
+ */
1040
+ async function embedQuery(query) {
1041
+ const q = String(query ?? "").trim();
1042
+ if (!q || !embedder) return null;
1043
+ try {
1044
+ const embedSingle = typeof embedder.embedSingle === "function"
1045
+ ? embedder.embedSingle.bind(embedder)
1046
+ : embedder.embed.bind(embedder);
1047
+ const vector = await embedSingle(q);
1048
+ return Array.isArray(vector) && vector.length ? vector : null;
1049
+ } catch {
1050
+ return null;
1051
+ }
1052
+ }
1053
+
1054
+ /**
1055
+ * 写入端同会话语义去重(Issue #127,opt-in,默认 off):落库前把新条目与
1056
+ * 「同会话 + 时间窗」内已落库的记忆比对,命中即并入既有条目,不再新造一行。
1057
+ *
1058
+ * 分层:title 档走零成本的标题归一化;vector 档复用已有 embedding 列做近邻,
1059
+ * 新条目的向量由 embedder 现算(embedQuery),全程无 LLM 调用。embedder 不可用
1060
+ * 或向量缺失一律返回 undefined 回落正常写入——去重是增强,绝不能成为写入依赖。
1061
+ *
1062
+ * 作用域刻意限定「同会话」:跨会话的同类事实仍交给 autoDream / sleep 的批量裁决。
1063
+ * 比对逻辑与并入动作分别复用 cosineVec 与 saveWithDedupe 的 _mergeInto 分支。
1064
+ *
1065
+ * @returns {{action: "merged", memory: object, sim: number}|undefined} 命中则返回
1066
+ * 并入目标与相似度;未命中(含任何异常)返回 undefined。
1067
+ */
1068
+ async function findSessionDuplicate(memory, { mode = "off", minSim = 0.92, windowHours = 24, source } = {}) {
1069
+ if (mode === "off" || !source || !memory?.title) return undefined;
1070
+ try {
1071
+ const sinceMs = windowHours > 0 ? Date.now() - windowHours * 3600000 : 0;
1072
+ const inWindow = (m) => {
1073
+ if (sinceMs <= 0) return true;
1074
+ const t = Date.parse(m.created_at ?? "");
1075
+ return !Number.isFinite(t) || t >= sinceMs; // 时间戳损坏时不误杀
1076
+ };
1077
+ const candidates = store
1078
+ .list({ type: memory.type, source, limit: 200 })
1079
+ .filter((m) => !m.archived && inWindow(m));
1080
+ if (!candidates.length) return undefined;
1081
+
1082
+ if (mode === "title") {
1083
+ const norm = (s) => String(s ?? "").toLowerCase().replace(/[\s\p{P}]+/gu, "");
1084
+ const key = norm(memory.title);
1085
+ if (!key) return undefined;
1086
+ const hit = candidates.find((m) => norm(m.title) === key);
1087
+ return hit ? { action: "merged", memory: hit, sim: 1 } : undefined;
1088
+ }
1089
+ if (mode !== "vector") return undefined;
1090
+
1091
+ const vecs = store.getEmbeddings(candidates.map((m) => m.id));
1092
+ if (!vecs.size) return undefined;
1093
+ const probe = await embedQuery([memory.title, memory.content].filter(Boolean).join("\n"));
1094
+ if (!probe) return undefined;
1095
+ let best;
1096
+ for (const m of candidates) {
1097
+ const v = vecs.get(m.id);
1098
+ if (!v) continue; // 无向量的既有行不参与比对(无信号 = 不判定)
1099
+ const sim = cosineVec(probe, v);
1100
+ if (sim >= minSim && (!best || sim > best.sim)) best = { action: "merged", memory: m, sim };
1101
+ }
1102
+ return best;
1103
+ } catch {
1104
+ return undefined; // 去重失败绝不阻塞写入
1105
+ }
1106
+ }
1107
+
1108
+ /**
1109
+ * Save a memory, merging into an existing one when title matches within the same type.
1110
+ *
1111
+ * Bug5: a same-title merge no longer overwrites — the new content is appended
1112
+ * under a timestamped `---` separator (`旧内容\n\n---\n[时间戳] 新内容`) and the
1113
+ * previous content is archived into content_history (source: auto_merge, FIFO
1114
+ * capped at 20). importance takes the max of both (capped at 5). Callers that
1115
+ * truly replace a row (dream summary regeneration) pass `_overwrite: true` to
1116
+ * overwrite directly while still archiving the old version (source: overwrite).
1117
+ * mergeHumanEdits entry points pass `_humanEdited: true` — same direct
1118
+ * overwrite semantics, source: human_override.
1119
+ *
1120
+ * Bug7 (memory quality filter): when config.memoryQualityFilter.enabled, the
1121
+ * memory is scored after dedupe, before write:
1122
+ * score >= degradeThreshold → stored normally (score persisted)
1123
+ * archiveThreshold <= score < 60 → persisted + ranked degraded
1124
+ * score < archiveThreshold → archived + tagged low_quality (still
1125
+ * explicitly searchable via includeArchived)
1126
+ * @returns {{action: "created"|"merged", memory: object}}
1127
+ */
1128
+ function saveWithDedupe(memory) {
1129
+ // #230 写入权分离:document 行只能经 registerDocument 铸造(注册校验 +
1130
+ // doc_path + evidence 三样俱全)。通用保存路径(memory_save 工具 / MCP /
1131
+ // standalone API / bootstrap)一律拒绝——防止绕过注册校验造出无指针无
1132
+ // 回链的伪 document 行。与 updateMemory 的同款守卫构成双保险。
1133
+ if (memory?.type === "document") {
1134
+ throw new Error("type 'document' is minted only via registerDocument (summary + doc_path + evidence)");
1135
+ }
1136
+ // Bug7: score quality once (after dedupe lookup, before write). Failures
1137
+ // inside the evaluator are impossible (pure function), but the write that
1138
+ // records the score must never fail the save — wrap defensively.
1139
+ const qf = config.memoryQualityFilter;
1140
+ let quality = null;
1141
+ if (qf?.enabled === true) {
1142
+ try {
1143
+ const recentContents = store.all().slice(0, 20).map((m) => m.content ?? "");
1144
+ quality = evaluateMemoryQuality(memory, {
1145
+ minContentLength: qf.minContentLength ?? 10,
1146
+ recentContents
1147
+ });
1148
+ } catch { /* quality scoring is best-effort */ }
1149
+ }
1150
+ // dream 总览按 source 身份去重,而非本地化标题:memory.language 切换后
1151
+ // 标题变化,若仍按 (type, title) 匹配会出现两个活跃的 summary 同时注入。
1152
+ // 其余类型维持 (type, title) 匹配不变。
1153
+ const candidates = store.list({ type: memory.type, limit: 100 });
1154
+ // v0.8.0 A1(issue #17):去重键由 (type, title) 扩展为
1155
+ // (type, title, agent_scope, workspace_scope, sensitivity)——不同作用域的同
1156
+ // 标题记忆绝不互相物理合并(旧逻辑跨 agent/workspace 并行,事后无法拆分)。
1157
+ // NULL 归一为「未标注」:未标注行之间互相匹配(含存量行),与已标注行不
1158
+ // 匹配。
1159
+ // v0.8.1(issue #170 复核项 1):比较不再受 scopeEnabled 门控——显式声明
1160
+ // flag 关也生效(工具层明写「读了自动标注也生效」,去重必须同口径),否则
1161
+ // 关自动标注后同标题不同 scope 的行会物理合并进第一行的归属。存量全 NULL
1162
+ // 行互相匹配,A1 前行为不变。
1163
+ const scopeMatches = (m) =>
1164
+ scopeKeyOf(m.agent_scope) === scopeKeyOf(memory.agent_scope) &&
1165
+ scopeKeyOf(m.workspace_scope) === scopeKeyOf(memory.workspace_scope) &&
1166
+ scopeKeyOf(m.sensitivity) === scopeKeyOf(memory.sensitivity);
1167
+ // Issue #127:写入端语义去重命中时,调用方用 _mergeInto 指定并入目标——复用
1168
+ // 下面这段并入逻辑(appendContent + content_history + 质量处置),不另写第二份
1169
+ // 实现。目标已被并发删除时回落到常规匹配。显式目标同样过 scope 门:跨作用域
1170
+ // 的语义相似不并入(防泄漏),回落为独立新行。
1171
+ const explicitTarget = memory._mergeInto ? store.getById(memory._mergeInto) : undefined;
1172
+ const existing = explicitTarget
1173
+ ? (scopeMatches(explicitTarget) ? explicitTarget : undefined)
1174
+ : (memory.type === "summary" && memory.source === "dream")
1175
+ ? (candidates.find((m) => m.source === "dream" && scopeMatches(m))
1176
+ ?? candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim()))
1177
+ : (memory.type === "summary" && memory.source === "narrative")
1178
+ // 叙述条(#164)按主题键去重而非本地化标题:标题随语言变
1179
+ // (叙述:X / Narrative: X),title 匹配在语言切换后会残留同 tag 双行。
1180
+ // tag 存于 tags[0](generateNarratives 落库),跨语言稳定 → 原地刷新。
1181
+ ? (candidates.find((m) => m.source === "narrative" && m.tags?.[0] === memory.tags?.[0] && scopeMatches(m))
1182
+ ?? candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim()))
1183
+ : candidates.find((m) => scopeMatches(m) && m.title.trim() === String(memory.title).trim());
1184
+ if (existing) {
1185
+ const newContent = String(memory.content ?? "");
1186
+ if (!newContent.trim()) {
1187
+ // Nothing to merge: the row stays untouched.
1188
+ return { action: "merged", memory: existing };
1189
+ }
1190
+ const direct = memory._overwrite === true || memory._humanEdited === true;
1191
+ const content = direct
1192
+ ? newContent
1193
+ : appendContent(existing.content, newContent);
1194
+ const importance = Math.min(5, Math.max(existing.importance, memory.importance ?? existing.importance));
1195
+ // v0.8.1 底座(issue #170):去重命中=scope 值完全同键,此时显式写入把
1196
+ // 被并入行的同维来源升级为 explicit(用户刚刚显式声明了同一归属),
1197
+ // 并刷新 scope_decided_at。仅升级来源、不改值——值改变会走新行。
1198
+ const scopeUpgrade = {};
1199
+ if (memory.agent_scope_source === "explicit" && existing.agent_scope_source !== "explicit") {
1200
+ scopeUpgrade.agent_scope_source = "explicit";
1201
+ }
1202
+ if (memory.workspace_scope_source === "explicit" && existing.workspace_scope_source !== "explicit") {
1203
+ scopeUpgrade.workspace_scope_source = "explicit";
1204
+ }
1205
+ if (Object.keys(scopeUpgrade).length) scopeUpgrade.scope_decided_at = new Date().toISOString();
1206
+ const merged = store.update(existing.id, {
1207
+ content,
1208
+ importance,
1209
+ tags: memory.tags ?? existing.tags,
1210
+ title: memory.title ?? existing.title,
1211
+ ...(memory.evidence !== undefined ? { evidence: memory.evidence } : {}),
1212
+ content_history: pushContentHistory(existing, direct
1213
+ ? (memory._humanEdited === true ? "human_override" : "overwrite")
1214
+ : "auto_merge"),
1215
+ ...scopeUpgrade,
1216
+ ...(quality ? { quality_score: quality.score } : {})
1217
+ });
1218
+ // v0.8.1(issue #170 review 3):来源升级也是归属性质改变——与
1219
+ // updateMemory 的显式修正同等待遇,落一行 scope_changes(actor=tool:
1220
+ // 只有 memory_save 的显式参数会带 explicit 来源走到这里)。审计失败只
1221
+ // warn 不反噬合并。升级只在来源首次变化时触发,不会每次并入都写。
1222
+ if (Object.keys(scopeUpgrade).length) {
1223
+ try {
1224
+ store.saveScopeChange({
1225
+ memory_id: existing.id,
1226
+ actor: "tool",
1227
+ prev_agent_scope: existing.agent_scope ?? null,
1228
+ prev_workspace_scope: existing.workspace_scope ?? null,
1229
+ next_agent_scope: merged.agent_scope ?? null,
1230
+ next_workspace_scope: merged.workspace_scope ?? null,
1231
+ agent_scope_source: merged.agent_scope_source ?? null,
1232
+ workspace_scope_source: merged.workspace_scope_source ?? null,
1233
+ decided_at: scopeUpgrade.scope_decided_at
1234
+ });
1235
+ } catch (e) {
1236
+ try { logger?.warn?.(`[dsh-mneme] scope upgrade audit failed: ${String(e)}`); } catch { /* 不反噬 */ }
1237
+ }
1238
+ }
1239
+ // Bug7: a degraded/archived result is applied on top of the merged row.
1240
+ const result = applyQualityDisposition(merged, quality, qf);
1241
+ afterSync("write");
1242
+ notifyWrite();
1243
+ scheduleEmbed(result);
1244
+ return { action: "merged", memory: result };
1245
+ }
1246
+ const created = store.save({
1247
+ type: memory.type,
1248
+ title: memory.title,
1249
+ content: memory.content,
1250
+ tags: memory.tags ?? [],
1251
+ importance: memory.importance ?? 3,
1252
+ source: memory.source ?? "manual",
1253
+ ...(memory.evidence !== undefined ? { evidence: memory.evidence } : {}),
1254
+ // v0.8.0 A1:scope 标注透传(store 端归一化,未标注落 NULL)。
1255
+ // v0.8.1 底座:来源(auto/explicit)与决策时间随行透传。
1256
+ agent_scope: memory.agent_scope,
1257
+ workspace_scope: memory.workspace_scope,
1258
+ agent_scope_source: memory.agent_scope_source,
1259
+ workspace_scope_source: memory.workspace_scope_source,
1260
+ scope_decided_at: memory.scope_decided_at,
1261
+ sensitivity: memory.sensitivity,
1262
+ occurred_at: memory.occurred_at,
1263
+ ...(quality ? { quality_score: quality.score } : {})
1264
+ });
1265
+ const result = applyQualityDisposition(created, quality, qf);
1266
+ afterSync("write");
1267
+ notifyWrite();
1268
+ scheduleEmbed(result);
1269
+ scheduleEntityExtraction(result);
1270
+ return { action: "created", memory: result };
1271
+ }
1272
+
1273
+ /**
1274
+ * Bug7: apply the quality verdict to a freshly written row. Below the archive
1275
+ * threshold the memory is archived + tagged low_quality (still searchable
1276
+ * explicitly via includeArchived); between archive and degrade thresholds the
1277
+ * score is already persisted and only the injection ranking is affected
1278
+ * (importance × score/100). Best-effort: a disposition write failure must
1279
+ * never fail the save. Returns the (possibly refreshed) memory row so callers
1280
+ * see the archived/tagged state, not the pre-disposition snapshot.
1281
+ *
1282
+ * Issue #135 附属发现 1:importance ≥ memoryQualityFilter.exemptImportance
1283
+ * (默认 4)的记忆不参与静默自动归档——评分与信号标签照常写入(可观测),
1284
+ * 注入排序照常按 importance × score/100 降权,但 setArchived 跳过。报告实测
1285
+ * 150 条低分归档里 128 条 importance ≥ 4(51 条 = 5):一条被显式标为重要的
1286
+ * 记忆不该被规则分静默归档、无感知无豁免。
1287
+ */
1288
+ function applyQualityDisposition(memory, quality, qf) {
1289
+ if (!quality || qf?.enabled !== true) return memory;
1290
+ const archiveThreshold = qf.archiveThreshold ?? 30;
1291
+ // Signal tags (meta / repetitive / duplicate / short_content / low_quality)
1292
+ // are merged onto the stored row in every assessed band so the verdict is
1293
+ // observable, not just the numeric score. Below the archive threshold the
1294
+ // memory is additionally archived (still explicitly searchable) — unless
1295
+ // its importance reaches the exemption floor (kept active, demoted only).
1296
+ const tags = [...new Set([...(memory.tags ?? []), ...(quality.tags ?? [])])];
1297
+ const exemptImportance = qf.exemptImportance ?? 4;
1298
+ const importanceExempt = Number.isInteger(memory.importance) && memory.importance >= exemptImportance;
1299
+ if (tags.length === (memory.tags?.length ?? 0) && (quality.score >= archiveThreshold || importanceExempt)) {
1300
+ return memory; // no tag drift and not archived → nothing extra to write
1301
+ }
1302
+ try {
1303
+ store.update(memory.id, { tags, quality_score: quality.score });
1304
+ if (quality.score < archiveThreshold && !importanceExempt) {
1305
+ store.setArchived(memory.id, true);
1306
+ } else if (quality.score < archiveThreshold) {
1307
+ logger?.info?.(`[dsh-mneme] quality filter: memory ${memory.id} scored ${quality.score} < ${archiveThreshold} but importance ${memory.importance} >= exemptImportance ${exemptImportance}; demoted, not archived`);
1308
+ }
1309
+ return store.getById(memory.id);
1310
+ } catch {
1311
+ return memory;
1312
+ }
1313
+ }
1314
+
1315
+ /**
1316
+ * Candidate memories for automatic context injection:
1317
+ * summaries first, then all preferences, then non-forgotten items with
1318
+ * importance >= threshold. History is never auto-injected. Archived entries
1319
+ * are excluded (store.list already filters them by default; the extra
1320
+ * !m.archived check is kept as double insurance).
1321
+ *
1322
+ * Bug4 (hybridInject): when a non-empty `query` is available and a matching
1323
+ * semantic recall was cached by the last searchMemories, the vector hits
1324
+ * lead the selection (up to maxItems*2 candidates) and the rule-based pick
1325
+ * fills + dedupes the remaining slots. Empty query / no cached recall /
1326
+ * hybridInject off → pure legacy rule-based selection.
1327
+ *
1328
+ * #249 第一批(B1 pin 池):`pinnedInjectBudget` > 0 时,约束/偏好类先按相关性
1329
+ * 取满独立预算、再从候选里摘除(于是轮换重排碰不到它们),由调用方前置到块
1330
+ * 头。`pinnedStats` 是可选出参:回报实际 pin 条数与超预算未展示条数,不改变
1331
+ * 本函数「返回数组」的既有契约。预算为 0 时整段不执行,行为逐字节不变。
1332
+ */
1333
+ function injectCandidates({ query = "", maxItems = 5, threshold = 3, queryVector, scope = null, rotate = null, rotateWindow = 0, pinnedStats = null } = {}) {
1334
+ const q = String(query ?? "").trim();
1335
+ // codingRetrospect 读取侧门控:编码记忆(rejected_solution / pitfall /
1336
+ // constraint)只在编码任务时注入,防噪声污染其他业务;编码任务时按
1337
+ // codingBoostFactor 加权,让编码记忆在编码场景更靠前。
1338
+ const isCoding = isCodingTask(q, config.codingKeywords ?? []);
1339
+ const codingGate = (m) => isCoding || !CODING_MEMORY_TYPES.has(m.type);
1340
+ // Bug7: quality-weighted importance in the rule-based tier. Unassessed rows
1341
+ // (quality_score null) count as 100 (weight 1), so legacy stores keep their
1342
+ // exact summary>preference>importance ordering.
1343
+ const qualityWeight = (m) => (m.quality_score != null ? m.quality_score / 100 : 1);
1344
+ // Issue #205 补测:候选池必须比轮换窗口大——窗口 N 需要 maxItems×(N+1) 张
1345
+ // 不同的牌,池子只有 maxItems×2 时窗口一开就被吃光、只剩回填(重复率的
1346
+ // 结构性下限由池子大小决定)。无轮换时保持既有 maxItems×2 不变;规则路
1347
+ // 的 200 条上限同样给轮换让路(上限翻倍也只是多读一次 SQL LIMIT)。
1348
+ const rotateWindowN = Number.isInteger(rotateWindow) && rotateWindow > 0 ? rotateWindow : 0;
1349
+ const poolSize = rotateWindowN > 0
1350
+ ? Math.max(maxItems * 2, maxItems * (rotateWindowN + 1))
1351
+ : maxItems * 2;
1352
+ // #230 拍板:注入档位合并设计(叙述条次优先档 + document 摘要行预算)。
1353
+ // ①叙述条(source=narrative)从纯按需解禁进注入候选,落次优先档——但受
1354
+ // 生成总闸 dreamNarrativeEnabled 约束(flag 关 = 该类行不再注入,存量行
1355
+ // 仍可检索);②document 摘要行同落次优先档,另有独立预算(见下方选取)。
1356
+ const injectTypes = config?.documentMemoryEnabled === true ? INJECT_TYPES_WITH_DOCUMENT : INJECT_TYPES;
1357
+ // #230 拍板:叙述条解禁进注入受生成总闸约束——门必须对全部候选路径一致
1358
+ // (规则/向量/缓存/BM25),只锁规则路的话语义路仍会漏进 narrative 行。
1359
+ const allowNarrative = config?.dreamNarrativeEnabled === true;
1360
+ const filtered = store.list({ limit: Math.max(200, poolSize), includeForgotten: false })
1361
+ .filter((m) => !m.archived && injectTypes.has(m.type) && !m.forgotten &&
1362
+ // 叙述条:#228 落地为纯按需检索;#230 合并拍板解禁为次优先档注入
1363
+ // (per-topic 叙述常驻位仍只留给 dream 总览 source=dream)。
1364
+ (m.source !== "narrative" || allowNarrative) &&
1365
+ codingGate(m) &&
1366
+ (m.type === "summary" || m.type === "preference" || m.importance >= threshold));
1367
+ // #218 v1: heat 乘数——heatEnabled 时在优先级层内给 importance×quality 乘
1368
+ // heat(同级内的乘数;priority 分层、store 的 order=chrono 分页序与召回融
1369
+ // 合序都不动)。heat 关闭时不建表、不算 heat,权重恒 1,排序与改动前一致。
1370
+ // selectiveInject 相似度重排(下方)开启且向量可用时相似度优先——heat 只
1371
+ // 生效于规则路排序与语义路回填序。
1372
+ const heatMap = config?.heatEnabled === true
1373
+ ? new Map(filtered.map((m) => [m.id, computeHeat(m, Date.now(), config)]))
1374
+ : null;
1375
+ const heatOf = (m) => (heatMap ? heatMap.get(m.id) ?? 1 : 1);
1376
+ const items = filtered.sort((a, b) => {
1377
+ // 编码记忆在编码任务时优先于普通 decision(与 preference 同级),
1378
+ // importance 乘 codingBoostFactor 加权(封顶 5,保持 importance 语义)。
1379
+ const priority = (m) => {
1380
+ // #230:叙述条与 document 摘要行同为次优先档——蒸馏摘要(0)仍最
1381
+ // 先,指针型聚合产物(1)先于普通 project/decision(2)。叙述条是
1382
+ // type=summary,判 source 必须在判 type 之前。
1383
+ if (m.source === "narrative") return 1;
1384
+ if (m.type === "summary") return 0;
1385
+ if (m.type === "preference") return 1;
1386
+ if (m.type === "document") return 1;
1387
+ if (isCoding && CODING_MEMORY_TYPES.has(m.type)) return 1;
1388
+ return 2;
1389
+ };
1390
+ const effImportance = (m) =>
1391
+ (isCoding && CODING_MEMORY_TYPES.has(m.type))
1392
+ ? Math.min(5, m.importance * (config.codingBoostFactor ?? 2))
1393
+ : m.importance;
1394
+ const pa = priority(a);
1395
+ const pb = priority(b);
1396
+ return pa - pb ||
1397
+ (effImportance(b) * qualityWeight(b) * heatOf(b)) - (effImportance(a) * qualityWeight(a) * heatOf(a));
1398
+ });
1399
+ let candidates = items;
1400
+ if (config.hybridInject !== false && q) {
1401
+ // Bug4: semantic-first recall. Vector hits (queryVector, cached by the
1402
+ // injector's async prefetch) lead when present; otherwise the last
1403
+ // searchMemories recall for the exact same query is reused. Rule-based
1404
+ // items fill + dedupe the remaining slots. Empty query / no vector /
1405
+ // no cached recall → pure legacy rule-based selection.
1406
+ const semanticItems = [];
1407
+ if (Array.isArray(queryVector) && queryVector.length && vectorIndex) {
1408
+ try {
1409
+ const hits = vectorIndex.search(queryVector, { limit: poolSize, threshold: 0 });
1410
+ for (const m of hits) {
1411
+ if (m && !m.archived && injectTypes.has(m.type) && !m.forgotten &&
1412
+ (m.source !== "narrative" || allowNarrative) &&
1413
+ codingGate(m) &&
1414
+ (m.type === "summary" || m.type === "preference" || m.importance >= threshold)) {
1415
+ semanticItems.push(m);
1416
+ }
1417
+ }
1418
+ } catch { /* vector unavailable: fall through to the recall cache */ }
1419
+ }
1420
+ if (!semanticItems.length && lastSemanticRecall?.query === q && lastSemanticRecall.items?.length) {
1421
+ for (const m of lastSemanticRecall.items) {
1422
+ if (m && !m.archived && injectTypes.has(m.type) && !m.forgotten &&
1423
+ (m.source !== "narrative" || allowNarrative) && codingGate(m)) semanticItems.push(m);
1424
+ }
1425
+ }
1426
+ // Issue #198:首轮(无向量、无缓存召回)的同步兜底——BM25 词法召回领位。
1427
+ // 注入渲染是同步的(宿主 systemPrompt 的 contexts 不支持异步 text),
1428
+ // 查询向量只能异步 prefetch 给下一轮,首轮必 miss;此时纯静态排序会让
1429
+ // 老的高重要性 summary/preference 占满槽位,当前话题的 decision/project
1430
+ // 进不来。查询文本本身就在手上,BM25 是纯进程内同步计算,词法相关性
1431
+ // 足以把当前话题顶到前排;门控与向量路径一致(含 importance 阈值)。
1432
+ // 语义向量命中时本分支不参与,行为不变。
1433
+ if (!semanticItems.length) {
1434
+ for (const hit of bm25Recall(q, poolSize)) {
1435
+ if (hit && !hit.archived && injectTypes.has(hit.type) && !hit.forgotten &&
1436
+ (hit.source !== "narrative" || allowNarrative) &&
1437
+ codingGate(hit) &&
1438
+ (hit.type === "summary" || hit.type === "preference" || hit.importance >= threshold)) {
1439
+ semanticItems.push(hit);
1440
+ }
1441
+ }
1442
+ }
1443
+ if (semanticItems.length) {
1444
+ const seen = new Set();
1445
+ const merged = [];
1446
+ const push = (m) => {
1447
+ if (seen.has(m.id)) return;
1448
+ seen.add(m.id);
1449
+ merged.push(m);
1450
+ };
1451
+ for (const m of semanticItems) {
1452
+ push(m);
1453
+ if (merged.length >= poolSize) break;
1454
+ }
1455
+ for (const m of items) {
1456
+ if (merged.length >= poolSize) break;
1457
+ push(m);
1458
+ }
1459
+ candidates = merged;
1460
+ }
1461
+ }
1462
+ // Topic-ranked selection (v0.5.0 2.2): when the current query's vector is
1463
+ // available the whole candidate list is re-ordered by similarity to that
1464
+ // vector, so the injected slots go to memories on the current topic
1465
+ // rather than to the rule-based order. Rows the index did not return
1466
+ // keep their relative order after the scored ones.
1467
+ if (config?.selectiveInjectEnabled !== false && Array.isArray(queryVector) && queryVector.length && vectorIndex) {
1468
+ try {
1469
+ const hits = vectorIndex.search(queryVector, { limit: 200, threshold: 0 });
1470
+ const sim = new Map(hits.map((m) => [m.id, m.score ?? 0]));
1471
+ if (sim.size) {
1472
+ candidates = [...candidates].sort((a, b) => (sim.get(b.id) ?? -1) - (sim.get(a.id) ?? -1));
1473
+ }
1474
+ } catch { /* topic re-rank unavailable: keep rule-based order */ }
1475
+ }
1476
+ // v0.8.0 A3(issue #17):strictScope 硬过滤同样作用于自动注入——scoped 记忆
1477
+ // 泄进无关注入上下文是最典型的越权通道,检索侧过滤挡不住这里。
1478
+ if (config?.strictScope === true && scope) {
1479
+ candidates = candidates.filter((m) => isVisibleInScope(m, scope));
1480
+ }
1481
+ // #249 第一批:B1 pin 池。取在相关性排序之后、轮换之前——取谁按此刻的候选
1482
+ // 次序(即相关性次序),取到后从候选中摘除,于是下面的轮换重排碰不到它们
1483
+ // (验收:pin 不参与跨轮轮换)。独立预算的两层意义:pin 既不占 maxItems
1484
+ // 名额、也不被 document 预算截断,因此不会把当前任务需要的情景候选挤出去;
1485
+ // 超预算的条数回报给调用方,在块内如实标注(绝不静默省略)。
1486
+ const pinnedBudget = Math.max(0, Math.min(5, Math.floor(config?.pinnedInjectBudget ?? 0)));
1487
+ // eligible 留到块外:未展示条数要等 general 槽选完才算得准(见 selected 之后)。
1488
+ const eligible = pinnedBudget > 0 ? candidates.filter((m) => PINNED_MEMORY_TYPES.has(m.type)) : [];
1489
+ let pinned = [];
1490
+ if (pinnedBudget > 0 && eligible.length > 0) {
1491
+ pinned = eligible.slice(0, pinnedBudget);
1492
+ const pinnedIds = new Set(pinned.map((m) => m.id));
1493
+ candidates = candidates.filter((m) => !pinnedIds.has(m.id));
1494
+ }
1495
+ // Issue #205:注入位跨轮轮换。rotate = 最近 N 轮注入过的 id 集合(由注入层
1496
+ // 按会话维护并传入):这些条目本轮不再优先——新鲜者前置(各自内部相对次序
1497
+ // 保持),不足时按原序回填,槽位数与 touchRecall 语义均不变。rotate 为空时
1498
+ // 行为与既有排序完全一致。
1499
+ if (rotate && rotate.size > 0 && candidates.length > 0) {
1500
+ const fresh = candidates.filter((m) => !rotate.has(m.id));
1501
+ if (fresh.length > 0) candidates = [...fresh, ...candidates.filter((m) => rotate.has(m.id))];
1502
+ }
1503
+ // #230 拍板:document 摘要行预算——次优先档内最多 documentInjectBudget
1504
+ // 条,超预算的 document 行跳过、由后续候选补位(不占槽)。预算只约束
1505
+ // 注入不约束检索:指针行价值在「按需读全文」,常驻注入若不设界就会把
1506
+ // 注入块变成文档目录(#164 失败判据:批量把历史塞进上下文)。
1507
+ const documentBudget = config?.documentInjectBudget ?? 2;
1508
+ let documentSeen = 0;
1509
+ const general = [];
1510
+ for (const m of candidates) {
1511
+ if (general.length >= maxItems) break;
1512
+ if (m.type === "document") {
1513
+ if (documentSeen >= documentBudget) continue;
1514
+ documentSeen++;
1515
+ }
1516
+ general.push(m);
1517
+ }
1518
+ // #249 第一批:pin 前置到块内相关性排序之前(验收项),且不占 maxItems 名额。
1519
+ // pinned 为空时 selected 就是 general 本身——关闭态与改动前逐字节一致。
1520
+ const selected = pinned.length > 0 ? [...pinned, ...general] : general;
1521
+ if (pinnedStats) {
1522
+ // 「未展示」只数**真的没进块**的 pin 类条目:被 pin 预算挤下来的条目会回到
1523
+ // 候选池,仍可能被 general 槽选中——那就是展示了。按 eligible - pinned 直接
1524
+ // 相减会把它们也算成未展示,块头那一行于是虚报(评审实测:pref#2 已在块内,
1525
+ // 仍报「另有 2 条未展示」)。所以统一按「有没有进 selected」判。
1526
+ const shownIds = new Set(selected.map((m) => m.id));
1527
+ pinnedStats.shown = pinned.length;
1528
+ pinnedStats.suppressed = eligible.filter((m) => !shownIds.has(m.id)).length;
1529
+ }
1530
+ touchRecalled(selected);
1531
+ // #217 口径(2026-09-19 拍板):注入是曝光型访问事件,与检索命中同表分账
1532
+ // (mode='inject',candidates 存实际注入集)。跟随 recallRecordDefault——
1533
+ // 与检索侧同门,不设新配置键;heat 关闭时照写,留痕与消费解耦(两 issue
1534
+ // 独立验收)。空选不记(没有访问发生);记账失败不影响注入本身。
1535
+ if ((config?.recallRecordDefault ?? true) && recallRecorder && selected.length > 0) {
1536
+ try {
1537
+ recallRecorder({
1538
+ // recall_runs.query 是 NOT NULL(store.js:61):注入是主动曝光、
1539
+ // 没有查询词,用空串而非 null——SQLite 改列约束需重建表,不值得。
1540
+ query: "",
1541
+ mode: "inject",
1542
+ topK: maxItems,
1543
+ threshold: null,
1544
+ candidates: selected.map((m) => ({
1545
+ id: m.id,
1546
+ title: m.title,
1547
+ content: m.content,
1548
+ score: m.score ?? null,
1549
+ source: m.source ?? "keyword"
1550
+ })),
1551
+ createdAt: new Date().toISOString()
1552
+ });
1553
+ } catch { /* recall receipt is best effort */ }
1554
+ }
1555
+ return selected;
1556
+ }
1557
+
1558
+ /**
1559
+ * Merge human edits parsed from a mirror file back into the store.
1560
+ * Only content/title are taken; structure fields stay machine-owned.
1561
+ */
1562
+ function mergeHumanEdits(type, edits) {
1563
+ let applied = 0;
1564
+ for (const edit of edits) {
1565
+ if (!edit.id) continue; // corrupt/malformed edit: skip it, keep merging the rest
1566
+ const existing = store.getById(edit.id);
1567
+ if (!existing || existing.type !== type) continue;
1568
+ const patch = {};
1569
+ if (typeof edit.title === "string" && edit.title.trim()) patch.title = edit.title.trim();
1570
+ if (typeof edit.content === "string" && edit.content.trim()) patch.content = edit.content.trim();
1571
+ if (Object.keys(patch).length) {
1572
+ // 启动回灌(F-NEW-01):digest 存在且匹配 = 文件自渲染后无人触碰(旧机器
1573
+ // 镜像),机器 wins,DB 的 New 必须保留,静默改回 Old 是 bug。
1574
+ const digestMatches = typeof edit.digest === "string"
1575
+ && typeof edit.title === "string"
1576
+ && typeof edit.content === "string"
1577
+ && createHash("sha256").update(`${edit.title}\x00${edit.content}`).digest("hex") === edit.digest;
1578
+ if (digestMatches) continue;
1579
+ // 文件 == store(无实际变化)时不覆盖,也不计入 applied。
1580
+ const hasDiff = (patch.title !== undefined && existing.title !== patch.title)
1581
+ || (patch.content !== undefined && existing.content !== patch.content);
1582
+ if (!hasDiff) continue;
1583
+ // 人工编辑回灌后触发 re-embed(issue #3 残留修复):向量必须与
1584
+ // 新 title/content 一致。scheduleEmbed 为 fire-and-forget,
1585
+ // 内部 try/catch 吞错,失败不影响主流程。
1586
+ // Bug5: human edits overwrite directly, but the machine version is
1587
+ // archived into content_history (source: human_override) before being
1588
+ // replaced, so a manual correction never silently destroys the old value.
1589
+ const merged = store.update(edit.id, {
1590
+ ...patch,
1591
+ content_history: patch.content !== undefined && existing.content !== patch.content
1592
+ ? pushContentHistory(existing, "human_override")
1593
+ : existing.content_history
1594
+ });
1595
+ applied++;
1596
+ scheduleEmbed(merged);
1597
+ }
1598
+ }
1599
+ if (applied) {
1600
+ afterSync("write");
1601
+ notifyWrite();
1602
+ }
1603
+ return applied;
1604
+ }
1605
+
1606
+ /**
1607
+ * v0.8.0 冲突集中处理(issue 反馈:冻结冲突没有人工出口——store 的
1608
+ * resolveConflictPending 此前无任何 API/UI 调用)。人工确认一条待审冲突:
1609
+ * 盖章 resolved_at/resolved_winner(审计),可选按 dream 非冻结 conflict
1610
+ * 路径**同款**处置落地——复用 applyDecisions 的 conflict 分支,CAS 快照 +
1611
+ * 事务 + 胜者追加已否决注记 + 败者归档 + 幂等全部同源,不另写第二份裁决。
1612
+ *
1613
+ * @param {string} id conflict_pending 行 id
1614
+ * @param {{winner?: "a"|"b"|null, apply?: boolean}} opts
1615
+ * winner: 选哪一方保留(a = memory_a,冻结时 LLM 的建议胜者;b = memory_b);
1616
+ * null = 仅盖章标记已读、不落地处置。人工选择是权威裁决:传入
1617
+ * applyDecisions 的 config 关掉 trustEpistemicWeighting——可信度自动换位
1618
+ * 只应作用于 LLM 提议对,不能推翻人工选择。
1619
+ * @returns {object|undefined} 盖章后的冲突行 + disposition 摘要;未知 id 返回 undefined
1620
+ */
1621
+ function resolveConflictPending(id, { winner = null, apply = true } = {}) {
1622
+ const rows = store.listConflictPending({ includeResolved: true, limit: 500 });
1623
+ const row = rows.find((r) => r.id === id);
1624
+ if (!row) return undefined;
1625
+ const winnerId = winner === "a" ? row.memory_a : winner === "b" ? row.memory_b : null;
1626
+ const loserId = winner === "a" ? row.memory_b : winner === "b" ? row.memory_a : null;
1627
+ let disposition = null;
1628
+ if (apply && winnerId && loserId) {
1629
+ const snapshot = new Map();
1630
+ for (const m of [store.getById(row.memory_a), store.getById(row.memory_b)]) {
1631
+ if (m) snapshot.set(m.id, { updated_at: m.updated_at, content: m.content, title: m.title });
1632
+ }
1633
+ // applyDecisions 需要一个 service 形状的参数(getById/update/setArchived/
1634
+ // transaction)——委托给闭包内的同名实现,mirror/通知/嵌入与主写路径同源。
1635
+ const applyService = {
1636
+ getById: (mid) => store.getById(mid),
1637
+ update: (mid, patch) => updateMemory(mid, patch),
1638
+ setArchived: (mid, v) => archiveMemory(mid, v),
1639
+ transaction: (fn) => transaction(fn)
1640
+ };
1641
+ const { committed, failures } = applyDecisions(
1642
+ [{ action: "conflict", winner: winnerId, loser: loserId, reason: row.reason ?? "" }],
1643
+ applyService,
1644
+ logger,
1645
+ snapshot,
1646
+ { ...config, trustEpistemicWeighting: false }
1647
+ );
1648
+ // Review 发现:处置必须**先于**盖章。原实现先 resolveConflictPending 盖章、
1649
+ // 再 applyDecisions——若 CAS 拒掉(确认前双方被并发更新),行已 resolved 从
1650
+ // 队列消失、前端仍显示成功,审计却记了"已确认"且无法重试。现在失败时不盖章
1651
+ // 直接返回:行留在队列待重试,审计不被污染。
1652
+ if (failures.length) {
1653
+ return { ...row, disposition: { ok: false, failures } };
1654
+ }
1655
+ disposition = { ok: true, committed };
1656
+ }
1657
+ // 处置成功(或纯标记)后才盖章。幂等重放(败者已归档 → skipped)也走这里:
1658
+ // 处置本就存在,标记已审即可。
1659
+ const stamped = store.resolveConflictPending(id, { winner: winnerId });
1660
+ if (!stamped) return undefined;
1661
+ return { ...stamped, disposition };
1662
+ }
1663
+
1664
+ /**
1665
+ * v0.8.0 冲突队列视图数据源:未解决冲突行,联表带出双方的当前状态(标题/
1666
+ * 内容/类型/重要性/归档标记),面板一处即可集中审阅,不必按徽章逐条找。
1667
+ * 参与记忆被删除/失联时该侧返回 {missing: true}(队列不因此丢行)。
1668
+ */
1669
+ function listConflictQueue({ limit = 50, offset = 0 } = {}) {
1670
+ const rows = store.listConflictPending({ limit, offset, includeResolved: false });
1671
+ const side = (id) => {
1672
+ const m = store.getById(id);
1673
+ if (!m) return { id, missing: true };
1674
+ return {
1675
+ id,
1676
+ title: m.title,
1677
+ type: m.type,
1678
+ importance: m.importance,
1679
+ content: m.content,
1680
+ archived: m.archived === true,
1681
+ forgotten: m.forgotten === true
1682
+ };
1683
+ };
1684
+ return rows.map((r) => ({
1685
+ id: r.id,
1686
+ run_id: r.run_id ?? null,
1687
+ reason: r.reason ?? null,
1688
+ created_at: r.created_at,
1689
+ memory_a: side(r.memory_a),
1690
+ memory_b: side(r.memory_b)
1691
+ }));
1692
+ }
1693
+
1694
+ function toApiList(rows) {
1695
+ return rows.map((m) => ({
1696
+ id: m.id,
1697
+ type: m.type,
1698
+ title: m.title,
1699
+ content: m.content,
1700
+ tags: m.tags,
1701
+ importance: m.importance,
1702
+ source: m.source,
1703
+ created_at: m.created_at,
1704
+ updated_at: m.updated_at,
1705
+ // v0.8.0 A2:scope 标注与事件发生时间透出——条件展开(未标注行不带键,
1706
+ // DTO 与 A2 前逐字节同形;带 undefined 键会被 in-process schema 校验
1707
+ // 判违规,JSON 序列化虽会丢弃但形状不稳定)。
1708
+ // v0.8.1 底座:scope 来源与决策时间随行透出(同样条件展开)。
1709
+ ...(m.agent_scope !== undefined ? { agent_scope: m.agent_scope } : {}),
1710
+ ...(m.workspace_scope !== undefined ? { workspace_scope: m.workspace_scope } : {}),
1711
+ ...(m.agent_scope_source !== undefined ? { agent_scope_source: m.agent_scope_source } : {}),
1712
+ ...(m.workspace_scope_source !== undefined ? { workspace_scope_source: m.workspace_scope_source } : {}),
1713
+ ...(m.scope_decided_at !== undefined ? { scope_decided_at: m.scope_decided_at } : {}),
1714
+ ...(m.sensitivity !== undefined ? { sensitivity: m.sensitivity } : {}),
1715
+ ...(m.occurred_at !== undefined ? { occurred_at: m.occurred_at } : {}),
1716
+ // #230:document 指针行的文件定位随行透出——「全文按需读」的入口就是
1717
+ // 这个路径;普通行恒不带键,DTO 与 #230 前逐字节同形。
1718
+ ...(m.doc_path !== undefined && m.doc_path !== null ? { doc_path: m.doc_path } : {})
1719
+ }));
1720
+ }
1721
+
1722
+ /**
1723
+ * Three-way merge of in-flight human mirror edits before a re-render.
1724
+ * Runs on every syncMirror, so a human edit made between two store writes is
1725
+ * never silently overwritten by the next sync (human priority is not limited
1726
+ * to startup). Per edited entry:
1727
+ * - file changed only → human wins; the edit is merged back into the store.
1728
+ * - file AND store changed → real three-way conflict: keep the human edit
1729
+ * and append a marker preserving the store's concurrent version, so no
1730
+ * side is dropped.
1731
+ * - store changed only → store wins (the file is simply re-rendered).
1732
+ * Only title/content are taken (structure fields stay machine-owned, matching
1733
+ * mergeHumanEdits). Returns the memory list to render.
1734
+ */
1735
+ function reconcileHumanEdits(memories) {
1736
+ if (!mirror) return memories;
1737
+ const byType = new Map();
1738
+ for (const m of memories) {
1739
+ if (!byType.has(m.type)) byType.set(m.type, []);
1740
+ byType.get(m.type).push(m);
1741
+ }
1742
+ const result = [];
1743
+ for (const type of Object.keys(TYPE_FILE)) {
1744
+ const list = byType.get(type) ?? [];
1745
+ if (list.length === 0) continue;
1746
+ const editsById = new Map(mirror.readHumanEdits(type).map((e) => [e.id, e]));
1747
+ for (const m of list) {
1748
+ const edit = editsById.get(m.id);
1749
+ if (!edit) { result.push(m); continue; }
1750
+ const humanChanged = (typeof edit.title === "string" && edit.title !== m.title)
1751
+ || (typeof edit.content === "string" && edit.content !== m.content);
1752
+ if (!humanChanged) { result.push(m); continue; }
1753
+ // 判断文件是否被人工动过:digest 存在且匹配则无人触碰,否则视为人工动过。
1754
+ // digest 是渲染时对 sha256(title \x00 content) 的记录;机器 store 更新后
1755
+ // 镜像还没重渲染时读到旧内容,digest 仍匹配 → 机器 wins,不会误判为
1756
+ // 并发人工编辑导致机器写丢失 + 伪冲突标记。
1757
+ const digestMatches = typeof edit.digest === "string"
1758
+ && typeof edit.title === "string"
1759
+ && typeof edit.content === "string"
1760
+ && createHash("sha256").update(`${edit.title}\x00${edit.content}`).digest("hex") === edit.digest;
1761
+ if (digestMatches) {
1762
+ // 无人触碰,机器 wins,走原样
1763
+ result.push(m);
1764
+ continue;
1765
+ }
1766
+ // 人工动过(digest 不存在=老文件/手工文件保守视为人工动过),走三方合并
1767
+ // (保留现有 storeChanged 逻辑)
1768
+ const storeChanged = edit.updated_at !== undefined && m.updated_at !== edit.updated_at;
1769
+ if (storeChanged) {
1770
+ // 三方合并保留双方时的冲突批注:随实例语言,写进记忆正文。
1771
+ const marker = STR.serviceConflictMarker[language](m.updated_at, m.content);
1772
+ store.update(m.id, { title: edit.title, content: `${edit.content}${marker}` });
1773
+ } else {
1774
+ store.update(m.id, { title: edit.title, content: edit.content });
1775
+ }
1776
+ const merged = store.getById(m.id);
1777
+ scheduleEmbed(merged);
1778
+ result.push(merged);
1779
+ }
1780
+ }
1781
+ return result;
1782
+ }
1783
+
1784
+ /**
1785
+ * Re-render the human-editable mirror after any store mutation, merging any
1786
+ * in-flight human edits first (never silently overwriting them). Only
1787
+ * non-forgotten memories are mirrored: forgotten entries must not reach the
1788
+ * human-editable file (a human "edit" could otherwise resurrect them).
1789
+ */
1790
+ // syncMirror: 同步 mirror,并在失败/成功时持久记录 dirty 状态;保证自身不抛出。
1791
+ // v0.3.6(audit peer 4 阻断):
1792
+ // - 开始时 incrementGeneration 绑定本次期望轮次 gen;成功用
1793
+ // markMirrorCleanForGeneration(gen, now) CAS/fence 清 dirty——旧 worker
1794
+ // (gen 已过期)不会误清另一 worker 未恢复的故障债务;
1795
+ // - 失败写 markMirrorDirty(递增 desired 绑定新债务),下次 recover 恢复;
1796
+ // - 逐 type 用 setTypeStatus 记录部分成功/失败(type_status JSON);
1797
+ // - 所有 store 状态写入各自 try/catch,失败只 warn,绝不向外抛(F-NEW-03)。
1798
+ function syncMirror() {
1799
+ if (txDepth > 0 || !mirror) return { success: true, deferred: true }; // deferred to the transaction's commit
1800
+ const now = new Date().toISOString();
1801
+ let gen;
1802
+ try {
1803
+ // desired generation 已在业务写事务中原子递增(peer blocker 1);这里
1804
+ // 直接读当前值作为本次同步的目标轮次,不再自行 incrementGeneration。
1805
+ const state = store.getMirrorState();
1806
+ gen = state?.generation ?? 0;
1807
+ } catch (stateError) {
1808
+ logger?.warn?.("syncMirror: getMirrorState failed:", stateError);
1809
+ return { success: false, error: stateError?.message ?? String(stateError) };
1810
+ }
1811
+ // coveredTypes 提到 try 外初始化:即使 store.list 先抛错,catch 分支也有
1812
+ // 合法的空 Set 可迭代,保证 syncMirror 自身绝不抛(fail-safe)。
1813
+ const coveredTypes = new Set();
1814
+ try {
1815
+ // 预先获取本次要覆盖的 type 集合(只调一次 store.list)
1816
+ const list = store.list({ limit: 500, includeForgotten: false });
1817
+ for (const memory of list) {
1818
+ if (memory?.type && TYPE_FILE[memory.type]) {
1819
+ coveredTypes.add(memory.type);
1820
+ }
1821
+ }
1822
+
1823
+ // Per-type physical outcome (audit peer D): mirror.sync writes each type
1824
+ // file independently and reports per-type success/failure. A type whose
1825
+ // file was physically committed must be marked committed even when a
1826
+ // sibling type errors — the old code batch-failed every type on any error,
1827
+ // leaving committed files mislabeled as failed and masking partial state.
1828
+ // Absent entries (a type with no memories) count as success: sync prunes
1829
+ // the stale file, which is itself a completed physical state.
1830
+ let allOk = true;
1831
+ const results = mirror.sync(reconcileHumanEdits(list)) ?? {};
1832
+ for (const type of Object.keys(TYPE_FILE)) {
1833
+ const r = results[type];
1834
+ const ok = !r || r.ok === true;
1835
+ if (!ok) allOk = false;
1836
+ try {
1837
+ if (ok) {
1838
+ store.setTypeStatus(type, { status: "committed", applied_gen: gen, last_error: null });
1839
+ } else {
1840
+ store.setTypeStatus(type, { status: "failed", last_error: r.error ?? "mirror sync failed" });
1841
+ }
1842
+ } catch (stateError) {
1843
+ logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
1844
+ }
1845
+ }
1846
+
1847
+ // 全部 type 物理收敛:CAS/fence 绑定到本地 gen,旧 worker(gen 已过期)会被
1848
+ // 拦截。此步失败说明核心 clean 状态没写成功,向上层报失败(不再静默)。
1849
+ if (allOk) {
1850
+ try {
1851
+ store.markMirrorCleanForGeneration(gen, now);
1852
+ } catch (stateError) {
1853
+ logger?.warn?.("syncMirror: markMirrorCleanForGeneration failed:", stateError);
1854
+ return { success: false, error: stateError?.message ?? String(stateError) };
1855
+ }
1856
+ return { success: true };
1857
+ }
1858
+
1859
+ // 部分 type 失败:持久 dirty(债务绑定到新轮次),下次 recover 只补未收敛
1860
+ // 的 type。committed 的 type 已应用本轮 gen,不因兄弟失败被回滚。
1861
+ const failedTypes = Object.entries(results)
1862
+ .filter(([, r]) => r && r.ok === false)
1863
+ .map(([t]) => t);
1864
+ try {
1865
+ store.markMirrorDirty(`mirror sync failed for: ${failedTypes.join(", ")}`, now);
1866
+ } catch (stateError) {
1867
+ logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
1868
+ }
1869
+ return { success: false, error: `mirror sync failed for: ${failedTypes.join(", ")}` };
1870
+ } catch (error) {
1871
+ const errMsg = error?.message ?? String(error);
1872
+ logger?.warn?.("syncMirror failed:", error);
1873
+ try {
1874
+ // 债务绑定到新的一轮(desired generation 原子递增;即便 dirty 写失败,
1875
+ // generation 已推进,recoverMirror 仍能捕获,不产生 false-clean)。
1876
+ store.markMirrorDirty(errMsg, now);
1877
+ } catch (stateError) {
1878
+ logger?.warn?.("syncMirror: markMirrorDirty failed:", stateError);
1879
+ }
1880
+ // 逐 type 标记为 failed(applied_gen 不动)
1881
+ for (const type of coveredTypes) {
1882
+ try {
1883
+ store.setTypeStatus(type, { status: "failed", last_error: errMsg });
1884
+ } catch (stateError) {
1885
+ logger?.warn?.(`syncMirror: setTypeStatus(${type}) failed:`, stateError);
1886
+ }
1887
+ }
1888
+ return { success: false, error: errMsg };
1889
+ }
1890
+ }
1891
+
1892
+ // afterSync: run syncMirror and surface a failure to the operator instead of
1893
+ // swallowing it (peer blocker 2 + audit peer B). The mirror debt has already
1894
+ // been persisted by markMirrorDirty inside syncMirror, so a restart recovers —
1895
+ // but the calling write path must not report clean while the mirror is
1896
+ // known-stale. Returns the sync result so the caller can attach an explicit
1897
+ // degraded/pending receipt to its return value instead of faking success.
1898
+ function afterSync(label) {
1899
+ const r = syncMirror();
1900
+ if (!r?.success && !r?.deferred) {
1901
+ logger?.warn?.(`${label}: mirror sync failed (will recover on restart):`, r?.error);
1902
+ }
1903
+ return r;
1904
+ }
1905
+
1906
+ // recoverMirror: 启动/手动 reconcile 时根据持久 dirty 状态决定是否恢复同步
1907
+ // (F-NEW-03 + v0.3.6)。触发条件不只是 dirty——还检查
1908
+ // generation > applied_generation(有未应用的债务),这样 COMMIT→dirty 崩溃
1909
+ // 窗口(DB 提交后、markMirrorDirty/clean 前进程退出 → dirty=false 但
1910
+ // generation 不一致)也能被捕获。有界重试(最多 3 次)重跑 syncMirror 收敛;
1911
+ // 某次成功后 dirty=false 且无更新债务(generation <= applied_generation)
1912
+ // 立即停止。返回 { recovered, error } 供 index.js 启动 / api.js health 判断。
1913
+ // 一切 fail-safe,绝不向外抛。
1914
+ function recoverMirror() {
1915
+ const MAX_ATTEMPTS = 3;
1916
+ let lastError = null;
1917
+ let recovered = false;
1918
+
1919
+ try {
1920
+ const state = store.getMirrorState();
1921
+ // 崩溃窗口检测:dirty 或 generation > applied_generation(COMMIT→dirty 窗口)
1922
+ if (!state?.dirty && !(state.generation > state.applied_generation)) {
1923
+ // 本来就干净:无需恢复,视为成功
1924
+ return { recovered: true, error: null };
1925
+ }
1926
+
1927
+ // 有 dirty 或有未应用债务:最多尝试 3 次 sync
1928
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
1929
+ try {
1930
+ syncMirror(); // syncMirror 内部已 catch,不会向外抛
1931
+ const currentState = store.getMirrorState();
1932
+ // 成功条件:dirty 为 false 且没有更新一轮的债务
1933
+ // (generation <= applied_generation,恢复后由 syncMirror 里
1934
+ // markMirrorCleanForGeneration 自动把 applied 跟上)
1935
+ if (!currentState?.dirty && currentState.generation <= currentState.applied_generation) {
1936
+ recovered = true;
1937
+ lastError = null;
1938
+ break;
1939
+ }
1940
+ // 仍 dirty 或仍有更新债务:记录最后一次错误供重试耗尽后上报。
1941
+ // 注意:若别的 worker 又失败产生新债务(dirty 仍 true),这是"新债务"
1942
+ // 不是本次失败,继续重试直到耗尽次数。
1943
+ lastError = currentState.last_error || `Sync attempt ${attempt + 1} left mirror dirty or has pending debt`;
1944
+ } catch (syncError) {
1945
+ // syncMirror 理论不抛,fail-safe 兜底
1946
+ const errMsg = syncError?.message ?? String(syncError);
1947
+ logger?.warn?.("recoverMirror: sync attempt failed:", errMsg);
1948
+ lastError = errMsg;
1949
+ }
1950
+ }
1951
+
1952
+ if (!recovered) {
1953
+ logger?.warn?.("dsh-mneme mirror: recover failed after", MAX_ATTEMPTS, "attempts");
1954
+ } else {
1955
+ logger?.warn?.("dsh-mneme mirror: recovered from dirty/pending state");
1956
+ }
1957
+ } catch (error) {
1958
+ // fail-safe:任何意外异常不向外抛
1959
+ lastError = error?.message ?? String(error);
1960
+ logger?.warn?.("dsh-mneme mirror: recover failed with unexpected error:", error);
1961
+ }
1962
+
1963
+ return { recovered, error: recovered ? null : lastError };
1964
+ }
1965
+
1966
+ // getMirrorHealth: 暴露 mirror 同步健康状态,供 api.js /health 使用
1967
+ // (F-NEW-03)。把 DB 的 dirty 0/1 转成 boolean;fail-safe,绝不向外抛。
1968
+ function getMirrorHealth() {
1969
+ try {
1970
+ const state = store.getMirrorState();
1971
+ if (!state) {
1972
+ // 无状态行:返回安全默认值
1973
+ return {
1974
+ dirty: false,
1975
+ last_error: null,
1976
+ last_attempt: null,
1977
+ success_at: null
1978
+ };
1979
+ }
1980
+ return {
1981
+ dirty: Boolean(state.dirty),
1982
+ last_error: state.last_error ?? null,
1983
+ last_attempt: state.last_attempt ?? null,
1984
+ success_at: state.success_at ?? null
1985
+ };
1986
+ } catch (error) {
1987
+ // fail-safe:状态读取失败也不向外抛,但必须显式表达"未知"而非伪装成
1988
+ // 干净(peer blocker 5:真实读取失败要显式 unknown,不得归一为 dirty:false)。
1989
+ logger?.warn?.("getMirrorHealth failed:", error);
1990
+ return {
1991
+ dirty: null,
1992
+ last_error: error?.message ?? String(error),
1993
+ last_attempt: null,
1994
+ success_at: null
1995
+ };
1996
+ }
1997
+ }
1998
+
1999
+ // 返回字面量与内部裁决器(resolveConflictPending → applyDecisions 的 conflict
2000
+ // 处置)共用的写路径:mirror 同步、写通知、嵌入调度全部同源,不能各写一份。
2001
+ function updateMemory(id, p, ctx = {}) {
2002
+ const old = store.getById(id);
2003
+ // #230 写入权分离(与 saveWithDedupe 同款守卫):document 行只能经
2004
+ // registerDocument 铸造。既有 document 行的摘要修复(memory_update 改
2005
+ // content/title——设计定案的更新通道)照常放行,type 不许改入 document。
2006
+ if (p?.type === "document" && old?.type !== "document") {
2007
+ throw new Error("type 'document' is minted only via registerDocument (summary + doc_path + evidence)");
2008
+ }
2009
+ // Issue #135 附属发现 2:质量过滤器把「为什么被降权/归档」写在系统信号标签
2010
+ // 上(SIGNAL_TAGS,applyQualityDisposition 以并集写入),而这里整组替换
2011
+ // tags——任何带 tags 的更新都会抹掉这条唯一的审计线索(报告实测 150 条低分
2012
+ // 记忆里恰好缺的 2 条就是被 update 过的,并因此误判过归档来源)。把 existing
2013
+ // 的系统信号标签并集保留;用户自传的标签照常生效。
2014
+ let patch = (p && Array.isArray(p.tags) && old && Array.isArray(old.tags))
2015
+ ? { ...p, tags: [...new Set([...p.tags, ...old.tags.filter((t) => SIGNAL_TAGS.includes(t))])] }
2016
+ : p;
2017
+ // v0.8.1 底座(issue #170):显式 scope 修正。patch 携带 agent_scope /
2018
+ // workspace_scope 键(undefined=该维不动)时归一化并盖 explicit 章 +
2019
+ // scope_decided_at,随后写 scope_changes 审计行(actor:tool=模型侧 /
2020
+ // panel=人工侧)。审计失败只 warn,不反噬主写入。
2021
+ const nextAgentScope = p && p.agent_scope !== undefined ? normalizeExplicitScope(p.agent_scope) : undefined;
2022
+ const nextWorkspaceScope = p && p.workspace_scope !== undefined ? normalizeExplicitScope(p.workspace_scope) : undefined;
2023
+ const scopeChanged = nextAgentScope !== undefined || nextWorkspaceScope !== undefined;
2024
+ if (scopeChanged) {
2025
+ const decidedAt = new Date().toISOString();
2026
+ patch = { ...patch };
2027
+ if (nextAgentScope !== undefined) {
2028
+ patch.agent_scope = nextAgentScope;
2029
+ patch.agent_scope_source = "explicit";
2030
+ }
2031
+ if (nextWorkspaceScope !== undefined) {
2032
+ patch.workspace_scope = nextWorkspaceScope;
2033
+ patch.workspace_scope_source = "explicit";
2034
+ }
2035
+ patch.scope_decided_at = decidedAt;
2036
+ }
2037
+ const updated = store.update(id, patch);
2038
+ if (scopeChanged && old && updated) {
2039
+ try {
2040
+ store.saveScopeChange({
2041
+ memory_id: id,
2042
+ actor: ctx.actor === "panel" ? "panel" : "tool",
2043
+ prev_agent_scope: old.agent_scope ?? null,
2044
+ prev_workspace_scope: old.workspace_scope ?? null,
2045
+ next_agent_scope: updated.agent_scope ?? null,
2046
+ next_workspace_scope: updated.workspace_scope ?? null,
2047
+ agent_scope_source: updated.agent_scope_source ?? null,
2048
+ workspace_scope_source: updated.workspace_scope_source ?? null,
2049
+ decided_at: patch.scope_decided_at
2050
+ });
2051
+ } catch (e) {
2052
+ try { ctx.logger?.warn?.(`[dsh-mneme] scope change audit failed: ${String(e)}`); } catch { /* 同样不反噬 */ }
2053
+ }
2054
+ }
2055
+ // Record a user correction when any meaningful field changed and the
2056
+ // reflection failure tracker is enabled. expected = what it became,
2057
+ // actual = what it was before; query (when provided) captures the
2058
+ // user's original intent so later reflection can reason about recall.
2059
+ const hasMeaningfulChange = old && updated && (
2060
+ old.content !== updated.content ||
2061
+ old.title !== updated.title ||
2062
+ old.importance !== updated.importance
2063
+ );
2064
+ if (hasMeaningfulChange && config.reflectionFailureTracking) {
2065
+ store.saveFailure({
2066
+ id: randomUUID(),
2067
+ query: ctx.query ?? null,
2068
+ expected: updated.content,
2069
+ actual: old.content,
2070
+ before: { title: old.title, content: old.content, importance: old.importance },
2071
+ failure_type: "user_correction",
2072
+ memory_id: id
2073
+ });
2074
+ }
2075
+ const sync = afterSync("write");
2076
+ notifyWrite();
2077
+ scheduleEmbed(updated);
2078
+ // Audit peer B: when the mirror sync failed, the store write landed but
2079
+ // the mirror did not converge — return an explicit degraded receipt rather
2080
+ // than a plain success. Non-enumerable so existing deepEqual assertions on
2081
+ // the memory shape keep passing.
2082
+ if (!sync?.success && !sync?.deferred) {
2083
+ Object.defineProperty(updated, "_mirror", {
2084
+ value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
2085
+ enumerable: false,
2086
+ configurable: true
2087
+ });
2088
+ }
2089
+ return updated;
2090
+ }
2091
+
2092
+ function archiveMemory(id, f) {
2093
+ const updated = store.setArchived(id, f);
2094
+ afterSync("write");
2095
+ return updated;
2096
+ }
2097
+
2098
+ // document 型记忆注册(#230):内聚块在 src/document.js(AGENTS.md 尺寸
2099
+ // 约定,同 recallStats 先例),这里只做依赖注入 + barrel 出口,调用方零改动。
2100
+ // 写后语只做重嵌入:镜像同步与写通知由 transaction 的 commit 路径统一执行
2101
+ // (notifyWrite 在 txDepth>0 时 deferred 到 finally),这里再调就是双份。
2102
+ const registerDocument = createDocumentRegistrar({
2103
+ store,
2104
+ config,
2105
+ embedQuery,
2106
+ pushContentHistory,
2107
+ transaction,
2108
+ finalize: (rows) => {
2109
+ for (const row of rows) scheduleEmbed(row);
2110
+ }
2111
+ });
2112
+
2113
+ // agent 主动整理接口(#231):dryRun 比对报告 → agent 判断 → apply 落库,筛除项
2114
+ // 进归档不删,全程复用 dream_runs 的 receipt 语义(不新建审计面)。走
2115
+ // saveWithDedupe 落库 = 复用常规写路径的镜像/通知语;重嵌入由 finalize 在事务
2116
+ // 提交后补(事务里的 scheduleEmbed 被 txDepth 挡掉)——document 同款先例。
2117
+ const { organize } = createOrganizer({
2118
+ store,
2119
+ embedQuery,
2120
+ saveWithDedupe,
2121
+ transaction,
2122
+ finalize: (rows) => {
2123
+ for (const row of rows) scheduleEmbed(row);
2124
+ }
2125
+ });
2126
+
2127
+ return {
2128
+ saveWithDedupe,
2129
+ registerDocument,
2130
+ // agent 主动整理接口(#231):内聚块在 src/organize.js,这里只做依赖注入 +
2131
+ // barrel 出口。刻意不加 opt-in 开关(维护者口径:功能本体不做开关,与 #249
2132
+ // 同批暴露时再定配置面),也不进工具列表——工具注册在 #249 那批。
2133
+ organize,
2134
+ recoverMirror,
2135
+ getMirrorHealth,
2136
+ getMirrorState: () => store.getMirrorState(),
2137
+ injectCandidates,
2138
+ mergeHumanEdits,
2139
+ toApiList,
2140
+ isVisibleInScope,
2141
+ transaction,
2142
+ getDistillCursor: (sessionId) => store.getDistillCursor(sessionId),
2143
+ setDistillCursor: (sessionId, lastSeq) => store.setDistillCursor(sessionId, lastSeq),
2144
+ enqueue,
2145
+ setDreamHook(fn) { dreamHook = fn; },
2146
+ setSleepHook(fn) { sleepHook = fn; },
2147
+ setEmbedder(emb) {
2148
+ embedder = emb;
2149
+ if (!emb) {
2150
+ // embedder removed (init failed in index.js): stop polling and drop
2151
+ // queued re-embeds — search just degrades to keyword.
2152
+ stopEmbedReadyPolling();
2153
+ embedPending = [];
2154
+ return;
2155
+ }
2156
+ if (emb.ready === true) {
2157
+ flushEmbedPending();
2158
+ return;
2159
+ }
2160
+ // Async-initializing embedder: poll `ready` until it flips, then flush.
2161
+ if ("ready" in emb && embedReadyTimer === null) {
2162
+ let attempts = 0;
2163
+ embedReadyTimer = setInterval(() => {
2164
+ attempts++;
2165
+ if (emb.ready === true || attempts >= EMBED_READY_POLL_LIMIT) {
2166
+ stopEmbedReadyPolling();
2167
+ if (emb.ready === true) flushEmbedPending();
2168
+ else embedPending = []; // init never landed: drop the queue
2169
+ }
2170
+ }, EMBED_READY_POLL_MS);
2171
+ }
2172
+ },
2173
+ setEntityExtractor(fn) { entityExtractor = fn; },
2174
+ setVectorIndex(vi) { vectorIndex = vi; },
2175
+ setReranker(rn) { reranker = rn; },
2176
+ setRecallRecorder(fn) { recallRecorder = fn; },
2177
+ searchMemories,
2178
+ // recallStats(#217)实现在 src/recall-stats.js:service.js 过 2000 行参考线,
2179
+ // 新内聚块独立成模块(AGENTS.md 尺寸约定);这里只留 barrel 出口,调用方
2180
+ // (api 层)零改动。纯读聚合,见模块注释的口径说明。
2181
+ recallStats: (options) => recallStats(store, options),
2182
+ embedQuery,
2183
+ findSessionDuplicate,
2184
+ evaluateRetrieval,
2185
+ computeRetrievalMetrics,
2186
+ // passthroughs used by tools and api layers; mutations keep the mirror in sync
2187
+ search: (q, o) => store.search(q, o),
2188
+ searchVector: (v, o) => store.searchVector(v, o),
2189
+ embeddedCount: () => store.embeddedCount(),
2190
+ list: (o) => store.list(o),
2191
+ all: () => store.all(),
2192
+ count: (type, opts) => store.count(type, opts),
2193
+ getById: (id) => store.getById(id),
2194
+ remove: (id) => {
2195
+ store.remove(id);
2196
+ afterSync("write");
2197
+ notifyWrite();
2198
+ },
2199
+ update: updateMemory,
2200
+ // v0.8.1 底座:scope 归属修正审计(单条记忆,新→旧)。
2201
+ listScopeChanges: (memoryId, opts) => store.listScopeChanges(memoryId, opts),
2202
+ // Compare-and-set update: applies the patch only when the row still carries
2203
+ // `expectedUpdatedAt`. Returns undefined on a miss (no write) so the caller
2204
+ // can re-read and retry — the primitive that prevents lost updates across
2205
+ // concurrent read-modify-write (see scripts/stress-dsh.js axis 3).
2206
+ compareAndUpdate: (id, expectedUpdatedAt, patch, ctx = {}) => {
2207
+ const old = store.getById(id);
2208
+ const updated = store.compareAndUpdate(id, expectedUpdatedAt, patch);
2209
+ if (updated === undefined) return undefined; // CAS miss: no write, no side effects
2210
+ const hasMeaningfulChange = old && updated && (
2211
+ old.content !== updated.content ||
2212
+ old.title !== updated.title ||
2213
+ old.importance !== updated.importance
2214
+ );
2215
+ if (hasMeaningfulChange && config.reflectionFailureTracking) {
2216
+ store.saveFailure({
2217
+ id: randomUUID(),
2218
+ query: ctx.query ?? null,
2219
+ expected: updated.content,
2220
+ actual: old.content,
2221
+ before: { title: old.title, content: old.content, importance: old.importance },
2222
+ failure_type: "user_correction",
2223
+ memory_id: id
2224
+ });
2225
+ }
2226
+ const sync = afterSync("write");
2227
+ notifyWrite();
2228
+ scheduleEmbed(updated);
2229
+ // Audit peer B: mirror sync failure on a CAS write must surface too.
2230
+ if (!sync?.success && !sync?.deferred) {
2231
+ Object.defineProperty(updated, "_mirror", {
2232
+ value: { status: "degraded", error: sync?.error ?? "mirror sync failed" },
2233
+ enumerable: false,
2234
+ configurable: true
2235
+ });
2236
+ }
2237
+ return updated;
2238
+ },
2239
+ setForget: (id, f) => {
2240
+ const updated = store.setForget(id, f);
2241
+ afterSync("write");
2242
+ return updated;
2243
+ },
2244
+ setArchived: archiveMemory,
2245
+ // sleep-mode storage (v0.4.0). demoteToSummary / restoreContent mutate
2246
+ // content so they ride the normal write-hook path (mirror re-renders).
2247
+ // touchLastAccess is a read-stamp — deliberately NO write hook (a recall
2248
+ // must not dirty the mirror). getUnrecalledSince is a pure read.
2249
+ demoteToSummary: (id, summary, opts) => {
2250
+ const updated = store.demoteToSummary(id, summary, opts);
2251
+ afterSync("write");
2252
+ return updated;
2253
+ },
2254
+ restoreContent: (id) => {
2255
+ const updated = store.restoreContent(id);
2256
+ afterSync("write");
2257
+ return updated;
2258
+ },
2259
+ touchLastAccess: (id, at) => store.touchLastAccess(id, at),
2260
+ getUnrecalledSince: (cutMs, opts) => store.getUnrecalledSince(cutMs, opts),
2261
+ // autoDream audit trail: passthroughs deliberately bypass write hooks —
2262
+ // an audit write is bookkeeping, and notifyWrite would loop back into the
2263
+ // dream scheduler that just recorded the run.
2264
+ saveDreamRun: (run) => store.saveDreamRun(run),
2265
+ getDreamRun: (id) => store.getDreamRun(id),
2266
+ listDreamRuns: (opts) => store.listDreamRuns(opts),
2267
+ // Per-record receipt chain (same bookkeeping semantics as saveDreamRun: an
2268
+ // audit write, never a write-hook-triggering memory mutation).
2269
+ saveReceipt: (r) => store.saveReceipt(r),
2270
+ getReceipt: (id) => store.getReceipt(id),
2271
+ listReceipts: (opts) => store.listReceipts(opts),
2272
+ // Conflict freeze bookkeeping (same semantics as the audit passthroughs
2273
+ // above: an audit write, never a write-hook-triggering memory mutation).
2274
+ saveConflictPending: (r) => store.saveConflictPending(r),
2275
+ listConflictPending: (opts) => store.listConflictPending(opts),
2276
+ listConflictQueue,
2277
+ resolveConflictPending,
2278
+ countConflictPending: () => store.countConflictPending(),
2279
+ // Recall evaluation trail (方案 B): audit-bookkeeping semantics like the
2280
+ // dream/recall passthroughs above — a recall_evals write is a snapshot, not
2281
+ // a memory mutation, so it never triggers write hooks.
2282
+ saveRecallEval: (r) => store.saveRecallEval(r),
2283
+ getRecallEval: (id) => store.getRecallEval(id),
2284
+ listRecallEvals: (opts) => store.listRecallEvals(opts),
2285
+ // LLM audit trail (Bug8): bookkeeping semantics like the recall/dream
2286
+ // passthroughs — a saveLlmAudit write never triggers write hooks.
2287
+ saveLlmAudit: (entry) => store.saveLlmAudit(entry),
2288
+ listLlmAudits: (opts) => store.listLlmAudits(opts),
2289
+ countLlmAudits: (opts) => store.countLlmAudits(opts),
2290
+ getLlmAuditStats: (opts) => store.getLlmAuditStats(opts),
2291
+ deleteOldLlmAudits: (before) => store.deleteOldLlmAudits(before),
2292
+ // Entity gene (v0.3.0) passthroughs for the autoDream apply path
2293
+ // (applyDecisions): records supersedes relations after an update and
2294
+ // migrates entity_attrs on merge. Bookkeeping writes like the audit
2295
+ // passthroughs above — never write-hook-triggering memory mutations.
2296
+ saveRelation: (r) => store.saveRelation(r),
2297
+ listEntities: (o) => store.listEntities(o),
2298
+ getRelations: (id) => store.getRelations(id),
2299
+ // v0.7.0 实体热投影:实体热 = 关联记忆 heat 聚合(取 max)。无关联记忆
2300
+ // 或 heatEnabled=false 时返回 null;前端据此决定图谱节点大小/明暗。
2301
+ entityHeat: (entityId) => {
2302
+ if (config.heatEnabled === false) return null;
2303
+ const rels = store.getRelations(entityId) ?? [];
2304
+ let max = -Infinity;
2305
+ for (const rel of rels) {
2306
+ if (!rel.memory_id) continue;
2307
+ const mem = store.getById(rel.memory_id);
2308
+ if (!mem) continue;
2309
+ const h = computeHeat(mem, Date.now(), config);
2310
+ if (h > max) max = h;
2311
+ }
2312
+ return max === -Infinity ? null : max;
2313
+ },
2314
+ saveAttr: (r) => store.saveAttr(r),
2315
+ createEntity: (r) => store.createEntity(r),
2316
+ findEntityByName: (n) => store.findEntityByName(n),
2317
+ findEntityById: (id) => store.findEntityById(id),
2318
+ getAttrsByMemory: (id) => store.getAttrsByMemory(id),
2319
+ // 记忆详情侧栏:一条记忆关联到的实体(entity_attrs.memory_id 反查,纯读)。
2320
+ entitiesForMemory: (id) => store.entitiesForMemory(id),
2321
+ getCurrentAttrs: (id) => store.getCurrentAttrs(id),
2322
+ migrateAttrsToMemory: (fromId, toId, now) => store.migrateAttrsToMemory(fromId, toId, now)
2323
+ };
2324
+ }