recallnest 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +251 -258
  3. package/README_CN.md +226 -247
  4. package/assets/ui/app.js +6 -1
  5. package/bin/recallnest.mjs +20 -37
  6. package/docs/api-reference.md +8 -0
  7. package/docs/architecture.md +18 -13
  8. package/eval/cases-canary.json +107 -9
  9. package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
  10. package/integrations/examples/langchain/memory-chain.py +4 -0
  11. package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
  12. package/package.json +12 -3
  13. package/src/activity-counter.ts +1 -0
  14. package/src/adaptive-retrieval.ts +23 -0
  15. package/src/api-server.ts +13 -11
  16. package/src/asset-sync.ts +26 -2
  17. package/src/audit-log.ts +2 -0
  18. package/src/auto-gc.ts +4 -3
  19. package/src/belief-history.ts +137 -31
  20. package/src/capture-engine.ts +459 -71
  21. package/src/cascade-forget.ts +7 -3
  22. package/src/checkpoint-mirror.ts +417 -0
  23. package/src/cli.ts +574 -54
  24. package/src/conflict-engine.ts +43 -15
  25. package/src/consolidation-engine.ts +24 -3
  26. package/src/context-collapse-renderer.ts +8 -1
  27. package/src/context-composer-stable.ts +5 -1
  28. package/src/context-composer.ts +11 -2
  29. package/src/distill-lock.ts +246 -74
  30. package/src/dream-pipeline.ts +3 -1
  31. package/src/embedder.ts +24 -0
  32. package/src/env-config.ts +136 -0
  33. package/src/flock.ts +97 -0
  34. package/src/forget-engine.ts +342 -58
  35. package/src/gateway-server.ts +4 -0
  36. package/src/ingest-plan.ts +56 -0
  37. package/src/ingest.ts +294 -121
  38. package/src/kg-contradiction.ts +753 -0
  39. package/src/kg-extractor.ts +17 -0
  40. package/src/kg-store.ts +1 -1
  41. package/src/known-item-eval.ts +455 -0
  42. package/src/llm-client.ts +13 -26
  43. package/src/llm-consolidation.ts +2 -2
  44. package/src/mcp-tool-deps.ts +6 -0
  45. package/src/mcp-tools-advanced.ts +80 -45
  46. package/src/mcp-tools-core.ts +23 -17
  47. package/src/mcp-tools-governance.ts +13 -7
  48. package/src/memory-assets.ts +144 -8
  49. package/src/memory-boundaries.ts +19 -0
  50. package/src/memory-evolution.ts +9 -0
  51. package/src/memory-lint.ts +1 -1
  52. package/src/memory-output.ts +311 -14
  53. package/src/memory-reconcile.ts +1437 -0
  54. package/src/memory-schema.ts +11 -0
  55. package/src/memory-store-port.ts +1 -0
  56. package/src/minis-archive.ts +151 -0
  57. package/src/noise-filter.ts +15 -2
  58. package/src/pii-detector.ts +31 -1
  59. package/src/pivot-apply.ts +55 -5
  60. package/src/pivot-evidence.ts +128 -0
  61. package/src/ppr-traversal.ts +25 -1
  62. package/src/preference-matcher.ts +40 -28
  63. package/src/prospective-memory.ts +7 -4
  64. package/src/retriever.ts +297 -57
  65. package/src/retroactive-boost.ts +3 -2
  66. package/src/runtime-config.ts +15 -1
  67. package/src/scope-policy.ts +1 -0
  68. package/src/session-distiller.ts +6 -2
  69. package/src/session-engine.ts +11 -4
  70. package/src/session-output.ts +20 -1
  71. package/src/session-schema.ts +5 -2
  72. package/src/session-store.ts +59 -12
  73. package/src/skill-promotion.ts +67 -1
  74. package/src/store-receipts.ts +109 -0
  75. package/src/store-write-budget.ts +37 -0
  76. package/src/store.ts +298 -57
  77. package/src/text-fingerprint.ts +25 -0
  78. package/src/trigger-store.ts +443 -0
  79. package/src/ui-server.ts +22 -21
  80. package/src/version-manager.ts +19 -5
  81. package/src/write-verifier.ts +16 -11
  82. package/src/__tests__/access-tracker-novelty.test.ts +0 -324
  83. package/src/__tests__/activity-counter.test.ts +0 -125
  84. package/src/__tests__/adaptive-fidelity.test.ts +0 -102
  85. package/src/__tests__/admission-control.test.ts +0 -206
  86. package/src/__tests__/age-label.test.ts +0 -33
  87. package/src/__tests__/anchor-generator.test.ts +0 -67
  88. package/src/__tests__/api-server-security.test.ts +0 -102
  89. package/src/__tests__/auto-consolidation.test.ts +0 -168
  90. package/src/__tests__/auto-recall.test.ts +0 -131
  91. package/src/__tests__/babel-integration.test.ts +0 -144
  92. package/src/__tests__/batch-dedup.test.ts +0 -65
  93. package/src/__tests__/belief-history.test.ts +0 -220
  94. package/src/__tests__/canary-eval.test.ts +0 -114
  95. package/src/__tests__/capture-engine.test.ts +0 -1328
  96. package/src/__tests__/capture-heuristic.test.ts +0 -171
  97. package/src/__tests__/cascade-forget.test.ts +0 -210
  98. package/src/__tests__/category-min-scores.test.ts +0 -116
  99. package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
  100. package/src/__tests__/circuit-breaker.test.ts +0 -99
  101. package/src/__tests__/cluster-consolidation.test.ts +0 -1236
  102. package/src/__tests__/confidence-metadata.test.ts +0 -125
  103. package/src/__tests__/confidence-tracker.test.ts +0 -261
  104. package/src/__tests__/conflict-advisor.test.ts +0 -206
  105. package/src/__tests__/conflict-escalation.test.ts +0 -149
  106. package/src/__tests__/conflict-lifecycle.test.ts +0 -83
  107. package/src/__tests__/conflict-output.test.ts +0 -85
  108. package/src/__tests__/conflict-store.test.ts +0 -418
  109. package/src/__tests__/connector-v1.test.ts +0 -181
  110. package/src/__tests__/consolidation-engine.test.ts +0 -520
  111. package/src/__tests__/constructive-retrieval.test.ts +0 -509
  112. package/src/__tests__/context-collapse-renderer.test.ts +0 -214
  113. package/src/__tests__/context-composer-pins.test.ts +0 -64
  114. package/src/__tests__/context-composer-task-selection.test.ts +0 -24
  115. package/src/__tests__/context-reconstructor.test.ts +0 -65
  116. package/src/__tests__/context-renderer.test.ts +0 -91
  117. package/src/__tests__/continuity-eval.test.ts +0 -275
  118. package/src/__tests__/continuity-schema.test.ts +0 -97
  119. package/src/__tests__/contracts.test.ts +0 -262
  120. package/src/__tests__/conversation-importer.test.ts +0 -692
  121. package/src/__tests__/core-recall-eval.test.ts +0 -235
  122. package/src/__tests__/core-summary.test.ts +0 -109
  123. package/src/__tests__/data-checkup.test.ts +0 -304
  124. package/src/__tests__/decay-exemption.test.ts +0 -129
  125. package/src/__tests__/dedup-actions.test.ts +0 -58
  126. package/src/__tests__/dedup-guard.test.ts +0 -39
  127. package/src/__tests__/deterministic-id.test.ts +0 -39
  128. package/src/__tests__/distill-lock.test.ts +0 -305
  129. package/src/__tests__/distill-session-argshape.test.ts +0 -79
  130. package/src/__tests__/doctor.test.ts +0 -319
  131. package/src/__tests__/dream-pipeline.test.ts +0 -820
  132. package/src/__tests__/dream-scope-isolation.test.ts +0 -386
  133. package/src/__tests__/dream-write-batching.test.ts +0 -211
  134. package/src/__tests__/drill-down.test.ts +0 -108
  135. package/src/__tests__/embedder.test.ts +0 -122
  136. package/src/__tests__/emotion-decay.test.ts +0 -46
  137. package/src/__tests__/emotion-detector.test.ts +0 -56
  138. package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
  139. package/src/__tests__/emotion-retrieval.test.ts +0 -56
  140. package/src/__tests__/entity-resolver.test.ts +0 -58
  141. package/src/__tests__/env-config-invariant.test.ts +0 -80
  142. package/src/__tests__/env-config.test.ts +0 -211
  143. package/src/__tests__/error-signature-boost.test.ts +0 -87
  144. package/src/__tests__/error-signature.test.ts +0 -95
  145. package/src/__tests__/error-taxonomy.test.ts +0 -129
  146. package/src/__tests__/ethics-layer.test.ts +0 -476
  147. package/src/__tests__/event-segmenter.test.ts +0 -153
  148. package/src/__tests__/feature-flags-integration.test.ts +0 -54
  149. package/src/__tests__/frequency-tracker.test.ts +0 -113
  150. package/src/__tests__/freshness.test.ts +0 -186
  151. package/src/__tests__/graph-export.test.ts +0 -489
  152. package/src/__tests__/hotness-blend.test.ts +0 -283
  153. package/src/__tests__/ingest-chunking.test.ts +0 -44
  154. package/src/__tests__/ingest-scope-prefix.test.ts +0 -69
  155. package/src/__tests__/ingest.test.ts +0 -360
  156. package/src/__tests__/interference-detector.test.ts +0 -203
  157. package/src/__tests__/kg-extractor.test.ts +0 -192
  158. package/src/__tests__/kg-store.test.ts +0 -300
  159. package/src/__tests__/llm-client-pivot.test.ts +0 -628
  160. package/src/__tests__/llm-consolidation.test.ts +0 -226
  161. package/src/__tests__/maintenance-locks.test.ts +0 -197
  162. package/src/__tests__/mcp-efficiency.test.ts +0 -288
  163. package/src/__tests__/mcp-related-scopes.test.ts +0 -151
  164. package/src/__tests__/memory-boundaries.test.ts +0 -282
  165. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -362
  166. package/src/__tests__/memory-evolution.test.ts +0 -271
  167. package/src/__tests__/memory-health-rebalance.test.ts +0 -144
  168. package/src/__tests__/memory-lint.test.ts +0 -421
  169. package/src/__tests__/memory-output.test.ts +0 -224
  170. package/src/__tests__/memory-promotion.test.ts +0 -315
  171. package/src/__tests__/memory-utility.test.ts +0 -256
  172. package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
  173. package/src/__tests__/multi-vector.test.ts +0 -242
  174. package/src/__tests__/narrative-architecture.test.ts +0 -456
  175. package/src/__tests__/noise-filter.test.ts +0 -205
  176. package/src/__tests__/noise-prototype-bank.test.ts +0 -280
  177. package/src/__tests__/obsidian-connector.test.ts +0 -363
  178. package/src/__tests__/omitted-reporting.test.ts +0 -98
  179. package/src/__tests__/openai-contract.test.ts +0 -513
  180. package/src/__tests__/pending-review.test.ts +0 -77
  181. package/src/__tests__/pii-redact.test.ts +0 -333
  182. package/src/__tests__/pivot-apply.test.ts +0 -373
  183. package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
  184. package/src/__tests__/pivot-distill.test.ts +0 -1178
  185. package/src/__tests__/post-retrieval-filter.test.ts +0 -178
  186. package/src/__tests__/ppr-traversal.test.ts +0 -256
  187. package/src/__tests__/prediction-engine.test.ts +0 -349
  188. package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
  189. package/src/__tests__/preference-guard-smoke.test.ts +0 -211
  190. package/src/__tests__/preference-matcher.test.ts +0 -282
  191. package/src/__tests__/preference-slots.test.ts +0 -205
  192. package/src/__tests__/promotion-distinct-sources.test.ts +0 -162
  193. package/src/__tests__/prospective-memory.test.ts +0 -283
  194. package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
  195. package/src/__tests__/query-entity-detector.test.ts +0 -171
  196. package/src/__tests__/query-expander-alias.test.ts +0 -146
  197. package/src/__tests__/query-expander.test.ts +0 -34
  198. package/src/__tests__/recall-gate.test.ts +0 -266
  199. package/src/__tests__/recall-governor.test.ts +0 -227
  200. package/src/__tests__/recall-mode.test.ts +0 -64
  201. package/src/__tests__/resolve-db-path.test.ts +0 -130
  202. package/src/__tests__/resolve-tier.test.ts +0 -64
  203. package/src/__tests__/result-synthesizer.test.ts +0 -198
  204. package/src/__tests__/resume-context.test.ts +0 -4898
  205. package/src/__tests__/retrieval-eval.test.ts +0 -163
  206. package/src/__tests__/retrieval-trace.test.ts +0 -213
  207. package/src/__tests__/retrieve-audit-revision.test.ts +0 -255
  208. package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
  209. package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
  210. package/src/__tests__/retroactive-boost.test.ts +0 -223
  211. package/src/__tests__/salience-scoring.test.ts +0 -106
  212. package/src/__tests__/scope-inventory.test.ts +0 -200
  213. package/src/__tests__/scope-policy.test.ts +0 -69
  214. package/src/__tests__/scope-suggester.test.ts +0 -80
  215. package/src/__tests__/session-checkpoint.test.ts +0 -291
  216. package/src/__tests__/session-distiller.test.ts +0 -640
  217. package/src/__tests__/session-gc.test.ts +0 -215
  218. package/src/__tests__/skill-engine.test.ts +0 -552
  219. package/src/__tests__/skill-promotion.test.ts +0 -654
  220. package/src/__tests__/skill-verifier.test.ts +0 -122
  221. package/src/__tests__/source-heartbeat.test.ts +0 -254
  222. package/src/__tests__/stable-context-length-contract.test.ts +0 -62
  223. package/src/__tests__/store-patch-metadata.test.ts +0 -235
  224. package/src/__tests__/store-read-consistency.test.ts +0 -122
  225. package/src/__tests__/store-schema-migration.test.ts +0 -87
  226. package/src/__tests__/store-scope-audit.test.ts +0 -81
  227. package/src/__tests__/store-upsert.test.ts +0 -201
  228. package/src/__tests__/supersede-chain.test.ts +0 -154
  229. package/src/__tests__/synthesis-contract.test.ts +0 -252
  230. package/src/__tests__/synthesis-promotion.test.ts +0 -369
  231. package/src/__tests__/temporal-parser.test.ts +0 -241
  232. package/src/__tests__/temporal-validity.test.ts +0 -160
  233. package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
  234. package/src/__tests__/term-registry.test.ts +0 -121
  235. package/src/__tests__/tool-error-surfacing.test.ts +0 -98
  236. package/src/__tests__/tool-output-compressor.test.ts +0 -160
  237. package/src/__tests__/topic-tag.test.ts +0 -113
  238. package/src/__tests__/trusted-memory.test.ts +0 -867
  239. package/src/__tests__/ui-server-security.test.ts +0 -93
  240. package/src/__tests__/usage-tracker.test.ts +0 -287
  241. package/src/__tests__/utility-weight.test.ts +0 -57
  242. package/src/__tests__/verbatim-self-recall.test.ts +0 -279
  243. package/src/__tests__/version-manager.test.ts +0 -248
  244. package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
  245. package/src/__tests__/workflow-observation.test.ts +0 -433
  246. package/src/__tests__/write-verifier.test.ts +0 -122
@@ -119,6 +119,16 @@ export const CaseMemorySolutionStepsSchema = normalizedStringListSchema("solutio
119
119
  export const CaseMemoryOutcomeSchema = optionalBoundedStringSchema(240);
120
120
  export const CaseMemoryToolsSchema = normalizedStringListSchema("tools", 6, 60);
121
121
 
122
+ /**
123
+ * 写入时预演触发器(T-Mem 借鉴,2026-09-22):这条记忆「她以后会怎么问起」的几句口语问法。
124
+ * 只参与召回、不进正文、不进证据;单独嵌入存侧表 memory_triggers。
125
+ * 2–5 条、各说一个角度、别互为变体、别复述正文(T-Mem 三条禁令)。
126
+ */
127
+ export const MemoryTriggersSchema = z
128
+ .array(z.string().min(2).max(200))
129
+ .max(6)
130
+ .optional();
131
+
122
132
  export const StoreMemoryInputSchema = z.object({
123
133
  text: MemoryTextSchema,
124
134
  category: DurableMemoryCategorySchema.default("events"),
@@ -129,6 +139,7 @@ export const StoreMemoryInputSchema = z.object({
129
139
  canonicalKey: CanonicalKeySchema,
130
140
  topicTag: z.string().min(1).max(60).optional(),
131
141
  privacyTier: PrivacyTierSchema.default("durable"),
142
+ triggers: MemoryTriggersSchema,
132
143
  });
133
144
 
134
145
  // Keep in sync with RejectionReason in admission-control.ts
@@ -71,5 +71,6 @@ export interface MemoryStorePort {
71
71
  minScore?: number,
72
72
  scopeFilter?: string[],
73
73
  scopeMatch?: ScopeMatchMode,
74
+ excludeScopes?: string[],
74
75
  ): Promise<MemorySearchResult[]>;
75
76
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Minis 对话入库后的存档。
3
+ *
4
+ * 投递目录(config.sources.minis.path,iCloud 桌面上的 minis-outbox/ingest)里的 jsonl
5
+ * 一旦入库,就挪进 data/minis-archive:
6
+ * - 投递目录空了 = 都收到了。文件堆在桌面上时分不清哪些处理过(2026-09-11 Alice:「不然我会搞糊涂的」)。
7
+ * - 存档目录由 Deja 统一树读取(refresh-conversation-truth.py 的 claude 组,分组名 minis),
8
+ * 原文因此能在 Deja 里找到;recallnest 的 ingest 源清单里没有这个目录,
9
+ * 所以记忆库只收投递目录那一次,挂 minis: 前缀。
10
+ *
11
+ * 先复制、逐字节核对,再删原件,不直接 rename:投递目录是 iCloud 容器,
12
+ * 把文件 rename 到容器外实测会让它直接消失(2026-08-20 踩过,见 minis-inbox/00-KEEP-收工时导出对话.md)。
13
+ * 原件删掉后的副本就是存档本身(每日 restic 备份覆盖 ~/recallnest/data)。
14
+ *
15
+ * 只挪「台账里记着、大小和 mtime 都没变、且至少有一行可用对话」的文件:
16
+ * 还没入库的、入库后又被同名覆盖的留给下一轮;格式不合规的留在原处报错。
17
+ * 注意台账在某个 embedding 批次报错时也会记成已处理(ingestCCTranscripts 的既有行为),
18
+ * 那种文件同样会被挪走——原文在存档里,要补导就从存档拷回投递目录。
19
+ *
20
+ * 2026-10-06 起对话原文默认不再入库(env-config.ts `transcriptIngest`),上面说的「入库」这一步默认不跑:
21
+ * cli.ts 这时传进来的 `isIngested` 是 `settledCheck()`(文件至少 60 秒没被改过),于是规则变成
22
+ * 「放稳了、整个文件写完了(`isCompleteJsonl`)、且至少有一行可用对话的就挪」,其余不变
23
+ * (同名不同内容另存 -2、格式不合规的留在原处报错、先复制核对再删原件)。记忆库不再收 Minis 的对话,
24
+ * Deja 照旧从存档读。把入库重新打开(RECALLNEST_TRANSCRIPT_INGEST=on)就回到上面那套按台账挪的规则。
25
+ */
26
+ import {
27
+ existsSync,
28
+ mkdirSync,
29
+ readdirSync,
30
+ readFileSync,
31
+ renameSync,
32
+ statSync,
33
+ unlinkSync,
34
+ utimesSync,
35
+ writeFileSync,
36
+ } from "node:fs";
37
+ import { extname, join } from "node:path";
38
+
39
+ export interface MinisArchiveResult {
40
+ /** 已挪进存档的目标路径 */
41
+ archived: string[];
42
+ /** 还没入库(或入库后又变了)的文件名,留在投递目录等下一轮 */
43
+ pending: string[];
44
+ /** 出错、原件留在原处的文件名和原因 */
45
+ errors: string[];
46
+ }
47
+
48
+ export type IngestedCheck = (filePath: string, size: number, mtimeMs: number) => boolean;
49
+
50
+ /** 对话入库关着时,文件至少这么久没被改过才挪(毫秒) */
51
+ export const MINIS_SETTLE_MS = 60_000;
52
+
53
+ /**
54
+ * 对话入库关着时用的判据:文件已经至少 quietMs 没被改过。
55
+ * 入库开着的时候,一个文件要先被整个读完、切片、嵌入、记进台账,之后大小和 mtime 都没变才会被挪——
56
+ * 这段时间等于一个隐含的静置期,正在写的文件过不了。关掉入库后这段时间没有了,这里把它显式补回来:
57
+ * 刚落地、可能还在写或还在同步的文件留给下一轮(下一轮它的 mtime 自然就够老了)。
58
+ */
59
+ export function settledCheck(quietMs: number = MINIS_SETTLE_MS, now: () => number = Date.now): IngestedCheck {
60
+ return (_filePath, _size, mtimeMs) => now() - mtimeMs >= quietMs;
61
+ }
62
+
63
+ /** 同名但内容不同(同一场对话后来又导出过一版)时另起 -2、-3,两版都留着;内容相同就复用。 */
64
+ function pickTarget(archiveDir: string, name: string, bytes: Buffer): { path: string; alreadyThere: boolean } {
65
+ const ext = extname(name);
66
+ const stem = name.slice(0, name.length - ext.length);
67
+ for (let n = 1; ; n++) {
68
+ const candidate = join(archiveDir, n === 1 ? name : `${stem}-${n}${ext}`);
69
+ if (!existsSync(candidate)) return { path: candidate, alreadyThere: false };
70
+ if (readFileSync(candidate).equals(bytes)) return { path: candidate, alreadyThere: true };
71
+ }
72
+ }
73
+
74
+ /**
75
+ * 一个 jsonl 是不是写完了:以换行结尾,且每个非空行都解析得出 JSON。
76
+ * 对话入库关着时挪存档之前用它把关——解析对话的函数会跳过残缺行,一个只写了一半的文件照样「有可用的对话行」,
77
+ * 不看这一条的话,半截文件会被当成完整的收走、原件删掉。读不了(比如 iCloud 占位文件)时照旧抛错,由调用方记进 errors。
78
+ */
79
+ export function isCompleteJsonl(filePath: string): boolean {
80
+ const text = readFileSync(filePath, "utf-8");
81
+ if (!text.endsWith("\n")) return false;
82
+ for (const line of text.split("\n")) {
83
+ if (!line.trim()) continue;
84
+ try { JSON.parse(line); } catch { return false; }
85
+ }
86
+ return true;
87
+ }
88
+
89
+ export function archiveIngestedMinisFiles(
90
+ dropDir: string,
91
+ archiveDir: string,
92
+ isIngested: IngestedCheck,
93
+ hasConversation: (filePath: string) => boolean = () => true,
94
+ ): MinisArchiveResult {
95
+ const result: MinisArchiveResult = { archived: [], pending: [], errors: [] };
96
+ if (!existsSync(dropDir)) return result;
97
+
98
+ const names = readdirSync(dropDir).filter((f) => f.endsWith(".jsonl")).sort();
99
+ for (const name of names) {
100
+ const source = join(dropDir, name);
101
+ try {
102
+ const before = statSync(source);
103
+ if (!before.isFile() || !isIngested(source, before.size, before.mtimeMs)) {
104
+ result.pending.push(name);
105
+ continue;
106
+ }
107
+
108
+ // 一行可用对话都没有的文件,台账也会记成已处理(0 chunks)。这种不收走,留在投递目录报错:
109
+ // 悄悄挪走的话,Minis 导出格式哪天变了,谁都看不见
110
+ if (!hasConversation(source)) {
111
+ result.errors.push(`${name}: 没有可用的对话行(格式不合规或还没写完),留在原处`);
112
+ continue;
113
+ }
114
+
115
+ // iCloud 占位文件(dataless)在这里会抛 EDEADLK,原件不动,下一轮再来
116
+ const bytes = readFileSync(source);
117
+ if (bytes.length !== before.size) {
118
+ result.errors.push(`${name}: 读取时文件在变(${before.size} → ${bytes.length} 字节)`);
119
+ continue;
120
+ }
121
+
122
+ mkdirSync(archiveDir, { recursive: true });
123
+ const target = pickTarget(archiveDir, name, bytes);
124
+ if (!target.alreadyThere) {
125
+ const partial = `${target.path}.partial`;
126
+ // 沿用原件权限:Minis 写出来是 600,默认 umask 会把副本放宽成 644
127
+ writeFileSync(partial, bytes, { mode: before.mode & 0o777 });
128
+ renameSync(partial, target.path);
129
+ // 存档沿用原件 mtime,Deja 里显示的时间才是对话时间而不是搬运时间
130
+ utimesSync(target.path, before.atime, before.mtime);
131
+ if (!readFileSync(target.path).equals(bytes)) {
132
+ result.errors.push(`${name}: 存档回读与原件不一致,原件保留`);
133
+ continue;
134
+ }
135
+ }
136
+
137
+ // 复制期间原件被同名覆盖(Minis 又导出了一版):不删,下一轮先把新版入库
138
+ const after = statSync(source);
139
+ if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
140
+ result.pending.push(name);
141
+ continue;
142
+ }
143
+
144
+ unlinkSync(source);
145
+ result.archived.push(target.path);
146
+ } catch (err) {
147
+ result.errors.push(`${name}: ${err instanceof Error ? err.message : String(err)}`);
148
+ }
149
+ }
150
+ return result;
151
+ }
@@ -65,6 +65,11 @@ const SHORT_BOILERPLATE_PATTERNS = [
65
65
  ];
66
66
  const BOILERPLATE_MAX_LENGTH = 10;
67
67
 
68
+ // Denial / meta-question patterns describe a whole short utterance ("我没有相关记忆" /
69
+ // "你还记得吗"), so they only apply up to this length. Unanchored substring matching on
70
+ // long text drops real content — see the note in isNoise().
71
+ const UTTERANCE_MAX_LENGTH = 200;
72
+
68
73
  // OpenClaw v3.2+ injected metadata headers (backport from v1.0.29)
69
74
  const METADATA_HEADER_PATTERNS = [
70
75
  /^Conversation info \(untrusted metadata\)/i,
@@ -150,11 +155,19 @@ export function isNoise(text: string, options: NoiseFilterOptions = {}): boolean
150
155
  return true;
151
156
  }
152
157
 
153
- if (opts.filterDenials && DENIAL_PATTERNS.some(p => p.test(trimmed))) {
158
+ // 否认句 / 问记忆的问句说的是「一整句短话」(助手说没有相关记忆、用户问你还记得吗)。
159
+ // 不锚定的子串匹配放到长文本上会误伤正文:2026-09-11 改前改后全库对比,有 7 段文档导入块
160
+ // (983–1,487 字,4 段 open-loops.md、3 段 feedback/reference 规则文件——其中两段讲的恰是
161
+ // 「"我没找到"不等于"它不存在"」)只因含「我没找到」「我没有数据」被检索层整段丢掉;一条 500 字的
162
+ // store_memory 因为含「没有记忆实现」被写入闸拒掉。长文本里夹着这类子串,不再算噪声。
163
+ // (同一作用域另有约 245 段被 bridge 提示词规则判掉,全来自 session-index 回填文件,那组规则
164
+ // 故意不锚定、不适用长度门槛,不在这里处理。)
165
+ const isShortUtterance = trimmed.length <= UTTERANCE_MAX_LENGTH;
166
+ if (opts.filterDenials && isShortUtterance && DENIAL_PATTERNS.some(p => p.test(trimmed))) {
154
167
  logInfo(`[INFO] noise-filter: denial pattern matched: "${redactForLog(trimmed)}..."`);
155
168
  return true;
156
169
  }
157
- if (opts.filterMetaQuestions && META_QUESTION_PATTERNS.some(p => p.test(trimmed))) {
170
+ if (opts.filterMetaQuestions && isShortUtterance && META_QUESTION_PATTERNS.some(p => p.test(trimmed))) {
158
171
  logInfo(`[INFO] noise-filter: meta-question filtered: "${redactForLog(trimmed)}..."`);
159
172
  return true;
160
173
  }
@@ -48,6 +48,32 @@ interface PIIRule {
48
48
  * true (scan still warns regardless). Keeps snowflake IDs / order numbers
49
49
  * from being destructively rewritten. */
50
50
  validate?: (match: string) => boolean;
51
+ /** With `{ keepIdentifiers: true }`, leave matches this returns true for
52
+ * intact — for fields that hold names (keys, tags, triggers), not prose. */
53
+ isIdentifier?: (match: string) => boolean;
54
+ }
55
+
56
+ export interface RedactOptions {
57
+ /**
58
+ * Skip matches that are ordinary dotted identifiers (`workflow.database.recovery`)
59
+ * rather than credentials. Off by default: memory text keeps the original rule
60
+ * set byte for byte, so deterministic ids of already-stored rows still match.
61
+ */
62
+ keepIdentifiers?: boolean;
63
+ }
64
+
65
+ /**
66
+ * A dot-separated name whose parts are plain words: lowercase, camelCase,
67
+ * PascalCase or all-caps letters, joined by `_`/`-`, plus short version-style
68
+ * numbers (`v2`, `2026`). Opaque token segments mix letters and digits or
69
+ * flip case mid-run, so they fail this.
70
+ */
71
+ function isOrdinaryDottedIdentifier(match: string): boolean {
72
+ return match.split(".").every((segment) => {
73
+ const pieces = segment.split(/[_-]+/).filter(Boolean);
74
+ return pieces.length > 0 && pieces.every((piece) =>
75
+ /^(?:[A-Z]?[a-z]+(?:[A-Z][a-z]+)*|[A-Z]+|v?\d{1,4})$/.test(piece));
76
+ });
51
77
  }
52
78
 
53
79
  /** GB 11643 checksum (ISO 7064 MOD 11-2) for 18-digit Chinese national IDs. */
@@ -157,6 +183,7 @@ const PII_RULES: PIIRule[] = [
157
183
  severity: "high",
158
184
  pattern: /\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
159
185
  redact: true,
186
+ isIdentifier: isOrdinaryDottedIdentifier,
160
187
  },
161
188
  {
162
189
  type: "bearer_token",
@@ -265,7 +292,7 @@ export function scanForPII(text: string): PIIScanResult {
265
292
  * Replacement is `[REDACTED:<type>]`; assignment-style rules keep the key
266
293
  * name (`password=[REDACTED:password]`) so the memory stays readable.
267
294
  */
268
- export function redactSecrets(text: string): RedactResult {
295
+ export function redactSecrets(text: string, options: RedactOptions = {}): RedactResult {
269
296
  let out = text;
270
297
  let redacted = 0;
271
298
 
@@ -276,6 +303,9 @@ export function redactSecrets(text: string): RedactResult {
276
303
  if (rule.validate && !rule.validate(match)) {
277
304
  return match; // checksum failed → leave intact (detection-only)
278
305
  }
306
+ if (options.keepIdentifiers && rule.isIdentifier?.(match)) {
307
+ return match;
308
+ }
279
309
  redacted++;
280
310
  if (rule.replacement) return rule.replacement(match);
281
311
  if (rule.keepPrefix) {
@@ -36,9 +36,17 @@ import {
36
36
  import { assignDefaultConfidence } from "./confidence-tracker.js";
37
37
  import { detectLang, tokenizeFts } from "./language-hook.js";
38
38
  import { scanForPII, redactSecrets } from "./pii-detector.js";
39
+ import {
40
+ EvidenceCoordinateSchema,
41
+ EvidenceWindowSchema,
42
+ PIVOT_EVIDENCE_CONTRACT_VERSION,
43
+ PivotEvidenceProvenanceSchema,
44
+ evidenceWindowIssues,
45
+ } from "./pivot-evidence.js";
39
46
 
40
47
  export const PIVOT_APPLY_SOURCE = "session_distill";
41
48
  export const PIVOT_APPLY_CAPTURE = "pivot_apply_v1";
49
+ export const PIVOT_APPLY_EVIDENCE_CAPTURE = "pivot_apply_v2";
42
50
  export const PIVOT_SCOPE = "memory:pivot";
43
51
 
44
52
  const SHA256_HEX = /^[0-9a-f]{64}$/;
@@ -77,12 +85,46 @@ export const PivotCandidateInputSchema = z
77
85
  candidateHash: z.string().regex(SHA256_HEX, "candidateHash must be 64-char sha256 hex"),
78
86
  batchId: z.string().min(1).max(64),
79
87
  importance: z.number().min(0).max(1),
88
+ evidenceContractVersion: z.literal(PIVOT_EVIDENCE_CONTRACT_VERSION).optional(),
89
+ sourceFingerprint: z.string().regex(SHA256_HEX).optional(),
90
+ anchorCoordinate: EvidenceCoordinateSchema.optional(),
91
+ evidenceWindows: z.array(EvidenceWindowSchema).max(16).optional(),
80
92
  })
81
93
  .refine((c) => KIND_CATEGORY[c.kind] === c.proposedCategory, {
82
94
  message: "proposedCategory does not match kind (corpus-verified mapping violated)",
95
+ })
96
+ .superRefine((candidate, context) => {
97
+ const fields = [
98
+ candidate.evidenceContractVersion,
99
+ candidate.sourceFingerprint,
100
+ candidate.anchorCoordinate,
101
+ candidate.evidenceWindows,
102
+ ];
103
+ if (fields.every((field) => field === undefined)) return;
104
+ const provenance = PivotEvidenceProvenanceSchema.safeParse({
105
+ evidenceContractVersion: candidate.evidenceContractVersion,
106
+ sourceFingerprint: candidate.sourceFingerprint,
107
+ anchorCoordinate: candidate.anchorCoordinate,
108
+ evidenceWindows: candidate.evidenceWindows,
109
+ });
110
+ if (!provenance.success) {
111
+ context.addIssue({
112
+ code: z.ZodIssueCode.custom,
113
+ message: "evidence provenance must be complete and match contract version 1",
114
+ });
115
+ return;
116
+ }
117
+ for (const issue of evidenceWindowIssues(provenance.data, candidate.evidence.length, candidate.sessionId)) {
118
+ context.addIssue({ code: z.ZodIssueCode.custom, message: issue });
119
+ }
83
120
  });
84
121
  export type PivotCandidateInput = z.infer<typeof PivotCandidateInputSchema>;
85
122
 
123
+ export const PivotEvidenceCandidateInputSchema = PivotCandidateInputSchema.refine(
124
+ (candidate) => candidate.evidenceContractVersion === PIVOT_EVIDENCE_CONTRACT_VERSION,
125
+ { message: "evidence-coordinate apply requires contract version 1 provenance" },
126
+ );
127
+
86
128
  export type PivotApplyDisposition =
87
129
  | "stored"
88
130
  | "updated"
@@ -195,10 +237,18 @@ export async function persistPivotCandidate(
195
237
  const language = detectLang(text);
196
238
  const fts_text = tokenizeFts(text, language);
197
239
 
240
+ const evidenceProvenance = input.evidenceContractVersion === PIVOT_EVIDENCE_CONTRACT_VERSION
241
+ ? PivotEvidenceProvenanceSchema.parse({
242
+ evidenceContractVersion: input.evidenceContractVersion,
243
+ sourceFingerprint: input.sourceFingerprint,
244
+ anchorCoordinate: input.anchorCoordinate,
245
+ evidenceWindows: input.evidenceWindows,
246
+ })
247
+ : null;
198
248
  let metadata = buildStructuredMetadata({
199
249
  source: PIVOT_APPLY_SOURCE,
200
250
  tags: [...input.tags, "pivot-apply", `batch:${input.batchId}`],
201
- capture: PIVOT_APPLY_CAPTURE,
251
+ capture: evidenceProvenance ? PIVOT_APPLY_EVIDENCE_CAPTURE : PIVOT_APPLY_CAPTURE,
202
252
  category: input.proposedCategory,
203
253
  canonicalKey: input.canonicalKey,
204
254
  extra: {
@@ -210,6 +260,7 @@ export async function persistPivotCandidate(
210
260
  reportId: input.reportId,
211
261
  candidateHash: input.candidateHash,
212
262
  batchId: input.batchId,
263
+ ...(evidenceProvenance ?? {}),
213
264
  },
214
265
  },
215
266
  });
@@ -255,10 +306,9 @@ export async function persistPivotCandidate(
255
306
 
256
307
  // Post-write verification, synchronous on purpose: a bulk apply needs the
257
308
  // verdict inside the ledger row, not on a console later. Deliberately NOT
258
- // verifyWrite(): its checkEntry expects scope/importance inside metadata,
259
- // which buildStructuredMetadata-shaped rows never carry (a pre-existing
260
- // mismatch its async caller silently swallows). This check asserts the claim
261
- // that matters instead: the row at entry.id holds THIS candidate's content.
309
+ // verifyWrite(): that one only checks structural presence (vector, text,
310
+ // scope, importance, parseable metadata). This check asserts the claim that
311
+ // matters here: the row at entry.id holds THIS candidate's content.
262
312
  let verified = true;
263
313
  let verifyIssues: string[] = [];
264
314
  if ((disposition === "stored" || disposition === "updated") && deps.store.get) {
@@ -0,0 +1,128 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { z } from "zod";
4
+
5
+ export const PIVOT_EVIDENCE_CONTRACT_VERSION = 1 as const;
6
+
7
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
8
+
9
+ export const EvidenceCoordinateSchema = z.object({
10
+ sessionId: z.string().min(1).max(200),
11
+ ordinal: z.number().int().nonnegative(),
12
+ role: z.enum(["user", "assistant"]),
13
+ contentDigest: z.string().regex(SHA256_HEX),
14
+ }).strict();
15
+ export type EvidenceCoordinate = z.infer<typeof EvidenceCoordinateSchema>;
16
+
17
+ export const EvidenceWindowTurnSchema = z.object({
18
+ ordinal: z.number().int().nonnegative(),
19
+ role: z.enum(["user", "assistant"]),
20
+ contentDigest: z.string().regex(SHA256_HEX),
21
+ evidenceIndexes: z.array(z.number().int().nonnegative()).min(1).max(16),
22
+ }).strict();
23
+ export type EvidenceWindowTurn = z.infer<typeof EvidenceWindowTurnSchema>;
24
+
25
+ export const EvidenceWindowSchema = z.object({
26
+ sessionId: z.string().min(1).max(200),
27
+ startOrdinal: z.number().int().nonnegative(),
28
+ endOrdinal: z.number().int().nonnegative(),
29
+ turns: z.array(EvidenceWindowTurnSchema).min(1).max(16),
30
+ }).strict();
31
+ export type EvidenceWindow = z.infer<typeof EvidenceWindowSchema>;
32
+
33
+ export const PivotEvidenceProvenanceSchema = z.object({
34
+ evidenceContractVersion: z.literal(PIVOT_EVIDENCE_CONTRACT_VERSION),
35
+ sourceFingerprint: z.string().regex(SHA256_HEX),
36
+ anchorCoordinate: EvidenceCoordinateSchema,
37
+ evidenceWindows: z.array(EvidenceWindowSchema).max(16),
38
+ }).strict();
39
+ export type PivotEvidenceProvenance = z.infer<typeof PivotEvidenceProvenanceSchema>;
40
+
41
+ export function turnContentDigest(input: {
42
+ sessionId: string;
43
+ ordinal: number;
44
+ role: "user" | "assistant";
45
+ text: string;
46
+ }): string {
47
+ const canonical = JSON.stringify({
48
+ schemaVersion: PIVOT_EVIDENCE_CONTRACT_VERSION,
49
+ sessionId: input.sessionId,
50
+ ordinal: input.ordinal,
51
+ role: input.role,
52
+ text: input.text.replace(/\u0000/g, "").trim(),
53
+ });
54
+ return createHash("sha256").update(canonical, "utf8").digest("hex");
55
+ }
56
+
57
+ export function evidenceWindowIssues(
58
+ provenance: PivotEvidenceProvenance,
59
+ evidenceCount: number,
60
+ expectedSessionId: string,
61
+ ): string[] {
62
+ const issues: string[] = [];
63
+ if (provenance.anchorCoordinate.sessionId !== expectedSessionId) {
64
+ issues.push("anchor coordinate sessionId does not match candidate sessionId");
65
+ }
66
+ if (provenance.anchorCoordinate.role !== "user") {
67
+ issues.push("anchor coordinate must point to a user turn");
68
+ }
69
+
70
+ const seenEvidence = new Set<number>();
71
+ let previousEnd = -2;
72
+ for (const [windowIndex, window] of provenance.evidenceWindows.entries()) {
73
+ if (window.sessionId !== expectedSessionId) {
74
+ issues.push(`evidence window ${windowIndex} sessionId does not match candidate sessionId`);
75
+ }
76
+ const first = window.turns[0];
77
+ const last = window.turns[window.turns.length - 1];
78
+ if (window.startOrdinal !== first.ordinal || window.endOrdinal !== last.ordinal) {
79
+ issues.push(`evidence window ${windowIndex} bounds do not match its turns`);
80
+ }
81
+ if (window.startOrdinal <= previousEnd + 1) {
82
+ issues.push(`evidence window ${windowIndex} overlaps or should be merged with the previous window`);
83
+ }
84
+ previousEnd = window.endOrdinal;
85
+
86
+ for (let turnIndex = 0; turnIndex < window.turns.length; turnIndex++) {
87
+ const turn = window.turns[turnIndex];
88
+ if (turn.ordinal !== window.startOrdinal + turnIndex) {
89
+ issues.push(`evidence window ${windowIndex} contains a non-contiguous ordinal range`);
90
+ }
91
+ const localIndexes = new Set<number>();
92
+ for (const evidenceIndex of turn.evidenceIndexes) {
93
+ if (localIndexes.has(evidenceIndex)) {
94
+ issues.push(`evidence index ${evidenceIndex} is repeated inside one turn`);
95
+ }
96
+ localIndexes.add(evidenceIndex);
97
+ if (evidenceIndex < 0 || evidenceIndex >= evidenceCount) {
98
+ issues.push(`evidence index ${evidenceIndex} is outside the candidate evidence array`);
99
+ } else if (seenEvidence.has(evidenceIndex)) {
100
+ issues.push(`evidence index ${evidenceIndex} is mapped more than once`);
101
+ } else {
102
+ seenEvidence.add(evidenceIndex);
103
+ }
104
+ }
105
+ }
106
+ }
107
+ for (let index = 0; index < evidenceCount; index++) {
108
+ if (!seenEvidence.has(index)) issues.push(`evidence index ${index} has no source coordinate`);
109
+ }
110
+ return issues;
111
+ }
112
+
113
+ /**
114
+ * Add provenance to content-addressed payloads only when the v1 evidence
115
+ * contract is present. Historical 2026-08-03 candidates remain byte-for-byte
116
+ * compatible; a partial or malformed new contract fails closed.
117
+ */
118
+ export function evidenceIdentityPayload(value: unknown): Record<string, unknown> {
119
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
120
+ const record = value as Record<string, unknown>;
121
+ if (record.evidenceContractVersion === undefined) return {};
122
+ return PivotEvidenceProvenanceSchema.parse({
123
+ evidenceContractVersion: record.evidenceContractVersion,
124
+ sourceFingerprint: record.sourceFingerprint,
125
+ anchorCoordinate: record.anchorCoordinate,
126
+ evidenceWindows: record.evidenceWindows,
127
+ });
128
+ }
@@ -10,7 +10,11 @@ import type { KGTriple, NeighborhoodResult } from "./kg-store.js";
10
10
  // ============================================================================
11
11
 
12
12
  export interface PPRConfig {
13
- /** Teleport probability back to seed nodes (default: 0.85) */
13
+ /**
14
+ * Probability of CONTINUING to diffuse along an edge at each step.
15
+ * The teleport-back-to-seed probability is the complement: (1 - dampingFactor).
16
+ * (The previous version of this comment had the two reversed.)
17
+ */
14
18
  dampingFactor: number;
15
19
  /** Max power-iteration rounds (default: 10) */
16
20
  maxIterations: number;
@@ -31,6 +35,26 @@ export interface PPRResult {
31
35
  path: string[];
32
36
  }
33
37
 
38
+ /**
39
+ * NOTE on dampingFactor = 0.85 (measured 2026-08-28, deliberately NOT changed):
40
+ *
41
+ * 0.85 is Brin & Page's default, tuned for billion-node web link graphs.
42
+ * HippoRAG 2 (arXiv 2502.14802, Table 13) uses 0.5 for knowledge graphs.
43
+ *
44
+ * Sensitivity measured on our own production KG (60,536 nodes / 66,332 edges,
45
+ * 10 real seeds, Top-10 Jaccard against alpha=0.85):
46
+ * alpha=0.30 -> 0.617 | 0.50 -> 0.648 | 0.70 -> 0.760 | 0.95 -> 0.598
47
+ * alpha=0.30 vs alpha=0.95 -> 0.371 (~63% of results differ)
48
+ * So the parameter IS sensitive. It is nevertheless left at 0.85, because
49
+ * tuning it does not fix the actual failure mode: at every alpha tested,
50
+ * generic hub nodes ("user" / "assistant" / "Alice") take ~30% of the top-20
51
+ * and land at ranks 2-4. Changing alpha only reshuffles the hubs among
52
+ * themselves.
53
+ *
54
+ * Fix the graph first (entity normalization + hub suppression), then tune.
55
+ * Revisit only after the top-20 hub share drops materially; any change must
56
+ * go through a shadow run first.
57
+ */
34
58
  export const DEFAULT_PPR_CONFIG: PPRConfig = {
35
59
  dampingFactor: 0.85,
36
60
  maxIterations: 10,
@@ -15,8 +15,8 @@ import type { MemoryStore, MemoryEntry } from "./store.js";
15
15
  import type { Embedder } from "./embedder.js";
16
16
  import type { LLMClient } from "./llm-client.js";
17
17
  import { logInfo } from "./stderr-log.js";
18
- import { isActiveMemory, parseEvolution } from "./memory-evolution.js";
19
- import { archiveBeliefVersion, buildSupersedingBeliefMetadata } from "./belief-history.js";
18
+ import { isActiveMemory, parseEvolution, type EvolutionMetadata } from "./memory-evolution.js";
19
+ import { archiveBeliefVersion, buildSupersedingBeliefMetadata, replaceBeliefVersion } from "./belief-history.js";
20
20
  import { detectLang, tokenizeFts } from "./language-hook.js";
21
21
 
22
22
  // ============================================================================
@@ -46,6 +46,8 @@ export interface PreferenceMatchResult {
46
46
  mergeTargetId?: string;
47
47
  /** Merged text (when action=merge, LLM-generated) */
48
48
  mergedText?: string;
49
+ /** The existing preference the incoming one duplicates (when action=skip) — reported back to the caller */
50
+ matchedEntry?: MemoryEntry;
49
51
  /** Reason for the decision */
50
52
  reason: string;
51
53
  }
@@ -61,7 +63,7 @@ export interface PreferenceMatchResult {
61
63
  * @param vector Pre-computed embedding vector for the text
62
64
  * @param scope The scope to search within
63
65
  * @param store Memory store
64
- * @param llm LLM client (null = always create)
66
+ * @param llm LLM client (null → no LLM: top similarity ≥0.92 skips, otherwise create)
65
67
  * @param config Optional config overrides
66
68
  */
67
69
  export async function matchPreference(
@@ -96,7 +98,7 @@ export async function matchPreference(
96
98
  if (!llm) {
97
99
  const topScore = prefMatches[0].score;
98
100
  if (topScore >= 0.92) {
99
- return { action: "skip", reason: "high-similarity-no-llm" };
101
+ return { action: "skip", reason: "high-similarity-no-llm", matchedEntry: prefMatches[0].entry };
100
102
  }
101
103
  return { action: "create", reason: "moderate-similarity-no-llm" };
102
104
  }
@@ -110,6 +112,7 @@ export async function matchPreference(
110
112
  return {
111
113
  action: "skip",
112
114
  reason: `llm-skip: ${decision.reason}`,
115
+ matchedEntry: topMatch.entry,
113
116
  };
114
117
  }
115
118
 
@@ -144,7 +147,7 @@ export async function applyPreferenceMatch(
144
147
  ): Promise<{ handled: boolean; entry?: MemoryEntry }> {
145
148
  if (result.action === "skip") {
146
149
  logInfo(`[INFO] Preference skipped (duplicate): ${result.reason}`);
147
- return { handled: true };
150
+ return result.matchedEntry ? { handled: true, entry: result.matchedEntry } : { handled: true };
148
151
  }
149
152
 
150
153
  if (result.action === "merge" && result.mergeTargetId && result.mergedText) {
@@ -153,31 +156,40 @@ export async function applyPreferenceMatch(
153
156
 
154
157
  // Merging rewrites the target's text, which is a belief change like any other —
155
158
  // archive the version being replaced before overwriting it.
156
- const now = Date.now();
157
- const previousEvo = parseEvolution(existing.metadata, existing.timestamp);
158
- const { historyId } = await archiveBeliefVersion({ store, embedder: deps.embedder }, existing, { now });
159
-
160
159
  // The old code updated `text` alone, leaving vector and fts_text describing the
161
160
  // pre-merge wording — so a merged preference stayed unfindable by its own new text.
162
- const language = detectLang(result.mergedText);
163
- const updated = await store.update(
164
- result.mergeTargetId,
165
- {
166
- text: result.mergedText,
167
- vector: await deps.embedder.embedPassage(result.mergedText),
168
- metadata: buildSupersedingBeliefMetadata(
169
- existing.metadata,
170
- previousEvo,
171
- historyId,
172
- now,
173
- `preference-merge:${result.reason}`,
174
- ),
175
- timestamp: now,
176
- language,
177
- fts_text: tokenizeFts(result.mergedText, language),
178
- },
179
- [scope],
180
- );
161
+ const mergedText = result.mergedText;
162
+ const reason = result.reason;
163
+ const language = detectLang(mergedText);
164
+ const vector = await deps.embedder.embedPassage(mergedText);
165
+ const buildUpdates = (metadata: string | undefined, previousEvo: EvolutionMetadata, historyId: string, now: number) => ({
166
+ text: mergedText,
167
+ vector,
168
+ metadata: buildSupersedingBeliefMetadata(metadata, previousEvo, historyId, now, `preference-merge:${reason}`),
169
+ timestamp: now,
170
+ language,
171
+ fts_text: tokenizeFts(mergedText, language),
172
+ });
173
+
174
+ let updated: MemoryEntry | null;
175
+ // Real MemoryStore: archive and overwrite under one store-write lock, row re-read inside it
176
+ // (2026-10-17). Target deleted since the lookup above → nothing written, not handled, and
177
+ // the caller creates the preference as new — previously a history row was left behind first.
178
+ if (typeof store.replaceWithArchivedVersion === "function") {
179
+ const replaced = await replaceBeliefVersion(
180
+ { store, embedder: deps.embedder },
181
+ existing,
182
+ ({ current, previousEvolution, historyId, now }) => buildUpdates(current.metadata, previousEvolution, historyId, now),
183
+ { scopeFilter: [scope] },
184
+ );
185
+ updated = replaced.status === "replaced" ? replaced.entry : null;
186
+ } else {
187
+ // Narrow store doubles: the two-write form.
188
+ const now = Date.now();
189
+ const previousEvo = parseEvolution(existing.metadata, existing.timestamp);
190
+ const { historyId } = await archiveBeliefVersion({ store, embedder: deps.embedder }, existing, { now });
191
+ updated = await store.update(result.mergeTargetId, buildUpdates(existing.metadata, previousEvo, historyId, now), [scope]);
192
+ }
181
193
  if (updated) {
182
194
  logInfo(`[INFO] Preference merged into ${result.mergeTargetId.slice(0, 8)}: ${result.reason}`);
183
195
  return { handled: true, entry: updated };