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
@@ -1,12 +1,19 @@
1
1
  import type { Embedder } from "./embedder.js";
2
2
  import { detectLang, tokenizeFts } from "./language-hook.js";
3
3
  import { generateAnchor } from "./anchor-generator.js";
4
+ import { normalizeTriggerTexts } from "./trigger-store.js";
4
5
  import { extractErrorSignatures } from "./error-signature.js";
5
6
  import { verifyWrite } from "./write-verifier.js";
6
7
  // batchInternalDedup is available in ingest.ts for large-batch scenarios;
7
8
  // persistMemoryBatch relies on per-item conflict detection (A-2) instead.
8
- import { defaultEvolution, buildSupersedeMetadata, buildSupersedeMetadataForNew, buildPendingReviewMetadata, isActiveMemory, parseEvolution, patchEvolution } from "./memory-evolution.js";
9
- import { archiveBeliefVersion, buildSupersedingBeliefMetadata } from "./belief-history.js";
9
+ import { defaultEvolution, buildSupersedeMetadata, buildSupersedeMetadataForNew, buildPendingReviewMetadata, isActiveMemory, parseEvolution, patchEvolution, type EvolutionMetadata } from "./memory-evolution.js";
10
+ import {
11
+ archiveBeliefVersion,
12
+ BeliefTargetMissingError,
13
+ buildSupersedingBeliefMetadata,
14
+ replaceBeliefVersion,
15
+ type BeliefReplaceDeps,
16
+ } from "./belief-history.js";
10
17
  import { detectTopicTag, injectTopicTag } from "./topic-tag.js";
11
18
  import { assignDefaultConfidence, type ConfidenceMetadata } from "./confidence-tracker.js";
12
19
  import { parseDependsOnInput } from "./freshness.js";
@@ -34,6 +41,7 @@ import { deterministicId, type MemoryEntry, type MemoryStore } from "./store.js"
34
41
  import {
35
42
  buildDefaultCanonicalKey,
36
43
  buildStructuredMemoryBoundary,
44
+ CHECKPOINT_MIRROR_SCOPE,
37
45
  extractBoundaryMetadata,
38
46
  extractCanonicalKey,
39
47
  extractPromotedFrom,
@@ -64,7 +72,7 @@ import { checkAdmission, type ScopeRateLimiter, type AdmissionConfig } from "./a
64
72
  import { resolveNoisePrototypeBank, type NoisePrototypeBank } from "./noise-prototype-bank.js";
65
73
  import { tagNarrativeIfEnabled } from "./narrative-tagger.js";
66
74
 
67
- type StoreDeps = Pick<MemoryStore, "store"> & Partial<Pick<MemoryStore, "list" | "update" | "getById" | "get" | "vectorSearch" | "upsert">>;
75
+ type StoreDeps = Pick<MemoryStore, "store"> & Partial<Pick<MemoryStore, "list" | "update" | "getById" | "get" | "vectorSearch" | "upsert" | "replaceWithArchivedVersion">>;
68
76
  type ConflictStoreDeps = Pick<ConflictCandidateStore, "save" | "replace" | "getOpenByFingerprint" | "getLatestByFingerprint">;
69
77
 
70
78
  export interface PersistMemoryDeps {
@@ -87,6 +95,29 @@ export interface PersistMemoryDeps {
87
95
  * Shadow-only: it observes and logs, it never rejects.
88
96
  */
89
97
  noisePrototypeBank?: NoisePrototypeBank | null;
98
+ /**
99
+ * T-Mem triggers 侧表(2026-09-22)。写入失败只 warn 不阻塞主写入:
100
+ * trigger 是召回入口不是证据,丢一次入口比丢一条记忆便宜得多。
101
+ */
102
+ triggerStore?: TriggerStoreDeps | null;
103
+ /** 给 trigger 用的 query 侧批量嵌入;缺省退回逐条 embedPassage */
104
+ embedQueryBatch?: (texts: string[]) => Promise<number[][]>;
105
+ }
106
+
107
+ /**
108
+ * persistMemory 的结果:记录本身加上这次实际挂到宿主上的 trigger 条数。
109
+ * 记录里的 `triggers` 是调用方传入的问法;有没有写进侧表只看 `triggersWritten`
110
+ * (rejected / conflict / 偏好判重或合并时都是 0),回执按它报数。
111
+ */
112
+ export type PersistedMemoryRecord = StoredMemoryRecord & { triggersWritten: number };
113
+
114
+ export interface TriggerStoreDeps {
115
+ upsertForMemory(
116
+ memoryId: string,
117
+ scope: string,
118
+ texts: readonly string[],
119
+ embed: (texts: string[]) => Promise<number[][]>,
120
+ ): Promise<number>;
90
121
  }
91
122
 
92
123
  export interface DurableWriteInput {
@@ -103,6 +134,14 @@ export interface DurableWriteInput {
103
134
  sourceBoundary?: ReturnType<typeof extractBoundaryMetadata>;
104
135
  language?: string;
105
136
  fts_text?: string;
137
+ /**
138
+ * 显式同 key 修订的目标行(2026-09-24):persistMemory 在本 scope 找到后传入。
139
+ * 传入时写入端只拿这一行做全文判等与 latest-wins,不再全局选行——findCanonicalMatches 不分 scope,
140
+ * 窗口外的确定性 id 分支遇同文会用新 metadata 覆盖版本链,修订路径不能走那两条。
141
+ */
142
+ revisionTarget?: MemoryEntry;
143
+ /** persistMemory 已扫过的同 key 结果;传入时写入端不再自扫,一次写入只扫一遍整表。 */
144
+ precomputedMatches?: MemoryEntry[];
106
145
  }
107
146
 
108
147
  const CANONICAL_SCAN_LIMIT = 1000;
@@ -149,6 +188,93 @@ function mergeTags(...groups: string[][]): string[] {
149
188
  return merged.slice(0, 8);
150
189
  }
151
190
 
191
+ // F-3b 覆盖结构化字段(2026-10-17):原先只有拼好的正文过 redactSecrets,case / pattern 的
192
+ // metadata、tags、显式 canonicalKey、trigger 都是原始入参,密钥原样进库、进外部嵌入接口。
193
+ // scope 与 source 不动:scope 是路由键,source 是枚举。
194
+ // 这些字段里放的是名字(key、标签、问法),`workflow.database.recovery` 这类点分标识符照原样留下;
195
+ // 正文仍走默认规则,逐字节不变(已存行的确定性 id 靠它对得上)。
196
+ const FIELD_REDACTION = { keepIdentifiers: true } as const;
197
+
198
+ function redactField(value: string): string {
199
+ return redactSecrets(value, FIELD_REDACTION).text;
200
+ }
201
+
202
+ /** 只在字段存在时给出覆盖项,没传的可选字段不多出一个值为 undefined 的键。 */
203
+ function redactOptional<K extends string>(key: K, value: string | undefined): Partial<Record<K, string>> {
204
+ return value === undefined ? {} : ({ [key]: redactField(value) } as Partial<Record<K, string>>);
205
+ }
206
+
207
+ /** 逐项脱敏后按不分大小写去重:两个不同的密钥脱敏后是同一个占位。 */
208
+ function redactList(values: readonly string[]): string[] {
209
+ const out: string[] = [];
210
+ const seen = new Set<string>();
211
+ for (const value of values) {
212
+ const redacted = redactField(value);
213
+ const key = redacted.toLowerCase();
214
+ if (seen.has(key)) continue;
215
+ seen.add(key);
216
+ out.push(redacted);
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /**
222
+ * 显式 canonicalKey 里有密钥就整个丢掉、退回按正文生成的默认 key,而不是换成占位:
223
+ * 两条不相干的记忆脱敏后会撞成同一个 key,同 key 修订会把其中一条原位改写掉。
224
+ * 必须在 normalizeCanonicalKey 之前做——规整会把 `ghp_` 改成 `ghp-`,之后就认不出了。
225
+ */
226
+ function withoutSecretCanonicalKey<T extends { canonicalKey?: string }>(input: T): T {
227
+ if (input.canonicalKey === undefined || redactSecrets(input.canonicalKey, FIELD_REDACTION).redacted === 0) return input;
228
+ const { canonicalKey: _dropped, ...rest } = input;
229
+ return rest as T;
230
+ }
231
+
232
+ function redactStoreMemoryFields(input: StoreMemoryInput): StoreMemoryInput {
233
+ return withoutSecretCanonicalKey({
234
+ ...input,
235
+ tags: redactList(input.tags),
236
+ ...redactOptional("topicTag", input.topicTag),
237
+ ...(input.triggers ? { triggers: input.triggers.map(redactField) } : {}),
238
+ });
239
+ }
240
+
241
+ function redactWorkflowPatternInput(input: WorkflowPatternInput): WorkflowPatternInput {
242
+ return withoutSecretCanonicalKey({
243
+ ...input,
244
+ title: redactField(input.title),
245
+ trigger: redactField(input.trigger),
246
+ steps: input.steps.map(redactField),
247
+ ...redactOptional("outcome", input.outcome),
248
+ tools: redactList(input.tools),
249
+ tags: redactList(input.tags),
250
+ });
251
+ }
252
+
253
+ function redactCaseMemoryInput(input: CaseMemoryInput): CaseMemoryInput {
254
+ const { debugFraming } = input;
255
+ return withoutSecretCanonicalKey({
256
+ ...input,
257
+ title: redactField(input.title),
258
+ problem: redactField(input.problem),
259
+ ...redactOptional("context", input.context),
260
+ solutionSteps: input.solutionSteps.map(redactField),
261
+ ...redactOptional("outcome", input.outcome),
262
+ tools: redactList(input.tools),
263
+ tags: redactList(input.tags),
264
+ ...(debugFraming
265
+ ? {
266
+ debugFraming: {
267
+ ...debugFraming,
268
+ ...redactOptional("whyPriorFixFailed", debugFraming.whyPriorFixFailed),
269
+ ...redactOptional("defense", debugFraming.defense),
270
+ ...redactOptional("systematicExtension", debugFraming.systematicExtension),
271
+ ...redactOptional("knowledgeFix", debugFraming.knowledgeFix),
272
+ },
273
+ }
274
+ : {}),
275
+ });
276
+ }
277
+
152
278
  export function buildStructuredMetadata(params: {
153
279
  source: string;
154
280
  tags: string[];
@@ -442,7 +568,9 @@ async function findCanonicalMatches(
442
568
  canonicalKey: string,
443
569
  ): Promise<MemoryEntry[]> {
444
570
  if (!store.list) return [];
445
- const entries = await store.list(undefined, undefined, CANONICAL_SCAN_LIMIT, 0);
571
+ // Checkpoint-mirror rows are session layer (never a match below) but carry recent timestamps;
572
+ // keep them out of the newest-N window so they cannot push durable rows out of it.
573
+ const entries = await store.list(undefined, undefined, CANONICAL_SCAN_LIMIT, 0, undefined, [CHECKPOINT_MIRROR_SCOPE]);
446
574
  return entries.filter((entry) => {
447
575
  const boundary = extractBoundaryMetadata(entry.metadata);
448
576
  if (boundary?.layer !== "durable") return false;
@@ -454,6 +582,111 @@ async function findCanonicalMatches(
454
582
  });
455
583
  }
456
584
 
585
+ /**
586
+ * 「显式同 key 修订」的判定(2026-09-24,互审见 AI产出/2026-09-24-recallnest-同key偏好修订/)。
587
+ *
588
+ * 只扫一次整表;扫描结果不论是否找到目标行都交给 writeDurableEntry 复用,写入端不再自扫。
589
+ * 目标行 = 同 scope、同 canonicalKey、同类别、活跃、durable。同 scope 里若还有同 key 的
590
+ * 其他类别活跃行,不当修订(target=null),让原路径照旧产生跨类别冲突。
591
+ */
592
+ async function resolveSameKeyRevision(
593
+ store: StoreDeps,
594
+ scope: string,
595
+ canonicalKey: string,
596
+ category: DurableMemoryCategory,
597
+ ): Promise<{ matches: MemoryEntry[]; target: MemoryEntry | null }> {
598
+ const matches = await findCanonicalMatches(store, canonicalKey);
599
+ const inScope = matches.filter((entry) => entry.scope === scope);
600
+ if (inScope.some((entry) => entry.category !== category)) return { matches, target: null };
601
+ const inScan = inScope
602
+ .filter((entry) => entry.category === category)
603
+ .sort((a, b) => b.timestamp - a.timestamp)[0];
604
+ if (inScan) return { matches, target: inScan };
605
+ // 扫描窗口(最近 CANONICAL_SCAN_LIMIT 行,store.list 按 timestamp 倒序)外的旧行:按确定性 id 直查。
606
+ // durable 判定比 writeDurableEntry 自己的 id 回查严格,有意为之——只把确认合格的行当修订目标;
607
+ // 判定失败时 target=null,写入照旧走原逻辑。
608
+ if (!store.getById) return { matches, target: null };
609
+ const row = await store.getById(deterministicId(scope, canonicalKey));
610
+ const qualifies =
611
+ row !== null &&
612
+ row.scope === scope &&
613
+ row.category === category &&
614
+ extractBoundaryMetadata(row.metadata)?.layer === "durable" &&
615
+ isActiveMemory(row.metadata);
616
+ return { matches, target: qualifies ? row : null };
617
+ }
618
+
619
+ /** store 能在一次持锁里归档并覆盖(真实 MemoryStore)时给出 replaceBeliefVersion 的依赖;窄 store(测试替身)返回 null */
620
+ function atomicBeliefDeps(deps: PersistMemoryDeps): BeliefReplaceDeps | null {
621
+ const { store } = deps;
622
+ const replace = store.replaceWithArchivedVersion;
623
+ if (!replace) return null;
624
+ return {
625
+ store: {
626
+ replaceWithArchivedVersion: replace.bind(store),
627
+ ...(store.getById ? { getById: store.getById.bind(store) } : {}),
628
+ },
629
+ embedder: deps.embedder,
630
+ };
631
+ }
632
+
633
+ /**
634
+ * latest-wins 的原位替换:先把旧版本归档成 superseded 历史行,再把新文本写回同一 id。
635
+ * 原地覆盖曾经连旧文本带向量一起删掉,「上个月我信的是什么」因此无从回答。
636
+ *
637
+ * 2026-10-17:store 有 replaceWithArchivedVersion(真实 MemoryStore)时,重读原行、写历史行、覆盖原行在同一次
638
+ * store-write 持锁里完成。原行在找到它之后被别的进程删掉(forget 等)→ 什么都不写,抛 BeliefTargetMissingError;
639
+ * 原先会先留下一条指向不存在原行的历史行,再报 `Failed to update canonical memory`。
640
+ * 只有 update 的窄 store(测试替身)退回原来的两次写。
641
+ */
642
+ async function replaceBeliefInPlace(
643
+ deps: PersistMemoryDeps,
644
+ latest: MemoryEntry,
645
+ params: DurableWriteInput,
646
+ ): Promise<{ entry: MemoryEntry; disposition: "updated" | "promoted"; supersededPrevious: true }> {
647
+ const disposition = params.promotedFrom ? "promoted" : "updated";
648
+ const buildUpdates = (previousEvo: EvolutionMetadata, historyId: string, now: number) => ({
649
+ text: params.text,
650
+ vector: params.vector,
651
+ importance: params.importance,
652
+ category: params.category,
653
+ // Merge rather than replace: a bare params.metadata would wipe the evolution block
654
+ // (version, access stats, supersede links) along with the text.
655
+ metadata: buildSupersedingBeliefMetadata(params.metadata, previousEvo, historyId, now),
656
+ timestamp: now,
657
+ ...(params.language ? { language: params.language } : {}),
658
+ ...(params.fts_text ? { fts_text: params.fts_text } : {}),
659
+ });
660
+
661
+ const atomic = atomicBeliefDeps(deps);
662
+ if (atomic) {
663
+ const result = await replaceBeliefVersion(
664
+ atomic,
665
+ latest,
666
+ ({ previousEvolution, historyId, now }) => buildUpdates(previousEvolution, historyId, now),
667
+ );
668
+ if (result.status !== "replaced") throw new BeliefTargetMissingError(latest.id);
669
+ return { entry: result.entry, disposition, supersededPrevious: true };
670
+ }
671
+
672
+ if (!deps.store.update) {
673
+ throw new Error(`store.update is required to replace canonical memory ${latest.id}`);
674
+ }
675
+ const now = Date.now();
676
+ const previousEvo = parseEvolution(latest.metadata, latest.timestamp);
677
+ const { historyId } = await archiveBeliefVersion(
678
+ { store: deps.store, embedder: deps.embedder },
679
+ latest,
680
+ { now },
681
+ );
682
+
683
+ const updated = await deps.store.update(latest.id, buildUpdates(previousEvo, historyId, now));
684
+ if (!updated) {
685
+ throw new Error(`Failed to update canonical memory ${latest.id}`);
686
+ }
687
+ return { entry: updated, disposition, supersededPrevious: true };
688
+ }
689
+
457
690
  export async function writeDurableEntry(
458
691
  deps: PersistMemoryDeps,
459
692
  params: DurableWriteInput,
@@ -461,8 +694,31 @@ export async function writeDurableEntry(
461
694
  entry: MemoryEntry;
462
695
  disposition: "stored" | "updated" | "deduped" | "promoted" | "conflict";
463
696
  conflictId?: string;
697
+ /**
698
+ * True when this write REPLACED the content of an existing canonical row
699
+ * (a belief version was archived). Two different paths can do that — the
700
+ * latest-wins branch reports `updated`, while the deterministic-id branch
701
+ * below still reports `stored` — so disposition alone cannot tell them
702
+ * apart. Consumers that mirror memory content elsewhere (KG triples) must
703
+ * drop the old projection when this is set.
704
+ */
705
+ supersededPrevious?: boolean;
464
706
  }> {
465
- const matches = await findCanonicalMatches(deps.store, params.canonicalKey);
707
+ const conflictPolicy = getConflictPolicyForCategory(params.category);
708
+
709
+ // 显式同 key 修订:只拿 persistMemory 在本 scope 找到的那一行做判等与原位替换。
710
+ // 写之前按 id 重读一次——F2 干扰预警可能刚改过它的 metadata;已不再活跃就退回原逻辑。
711
+ if (params.revisionTarget && !params.promotedFrom && conflictPolicy === "latest-wins" && deps.store.update) {
712
+ const fresh = deps.store.getById ? await deps.store.getById(params.revisionTarget.id) : params.revisionTarget;
713
+ if (fresh && isActiveMemory(fresh.metadata)) {
714
+ if (normalizeMemoryText(fresh.text) === normalizeMemoryText(params.text)) {
715
+ return { entry: fresh, disposition: "deduped" };
716
+ }
717
+ return await replaceBeliefInPlace(deps, fresh, params);
718
+ }
719
+ }
720
+
721
+ const matches = params.precomputedMatches ?? await findCanonicalMatches(deps.store, params.canonicalKey);
466
722
  const categoryMatches = matches.filter((entry) => entry.category === params.category);
467
723
  const crossCategoryMatches = matches.filter((entry) => entry.category !== params.category);
468
724
  const normalizedIncoming = normalizeMemoryText(params.text);
@@ -521,7 +777,6 @@ export async function writeDurableEntry(
521
777
  };
522
778
  }
523
779
 
524
- const conflictPolicy = getConflictPolicyForCategory(params.category);
525
780
  if (conflictPolicy === "latest-wins" && categoryMatches.length > 0 && deps.store.update) {
526
781
  const latest = [...categoryMatches].sort((a, b) => b.timestamp - a.timestamp)[0];
527
782
  if (params.promotedFrom) {
@@ -576,36 +831,8 @@ export async function writeDurableEntry(
576
831
  }
577
832
  }
578
833
 
579
- // latest-wins is where beliefs change. Archive the version being replaced first —
580
- // overwriting in place used to delete the old text + vector outright, which is what
581
- // made "what did I believe last month?" unanswerable no matter how retrieval queried.
582
- const now = Date.now();
583
- const previousEvo = parseEvolution(latest.metadata, latest.timestamp);
584
- const { historyId } = await archiveBeliefVersion(
585
- { store: deps.store, embedder: deps.embedder },
586
- latest,
587
- { now },
588
- );
589
-
590
- const updated = await deps.store.update(latest.id, {
591
- text: params.text,
592
- vector: params.vector,
593
- importance: params.importance,
594
- category: params.category,
595
- // Merge rather than replace: a bare params.metadata would wipe the evolution block
596
- // (version, access stats, supersede links) along with the text.
597
- metadata: buildSupersedingBeliefMetadata(params.metadata, previousEvo, historyId, now),
598
- timestamp: now,
599
- ...(params.language ? { language: params.language } : {}),
600
- ...(params.fts_text ? { fts_text: params.fts_text } : {}),
601
- });
602
- if (!updated) {
603
- throw new Error(`Failed to update canonical memory ${latest.id}`);
604
- }
605
- return {
606
- entry: updated,
607
- disposition: params.promotedFrom ? "promoted" : "updated",
608
- };
834
+ // latest-wins is where beliefs change: archive the version being replaced, then write in place.
835
+ return await replaceBeliefInPlace(deps, latest, params);
609
836
  }
610
837
 
611
838
  // No canonical match was found, so this falls through to store(). With a canonicalKey the
@@ -617,10 +844,57 @@ export async function writeDurableEntry(
617
844
  ? deterministicId(params.scope, params.canonicalKey)
618
845
  : undefined;
619
846
  let metadataForStore = params.metadata;
847
+ let supersededPrevious = false;
620
848
 
621
849
  if (targetId && deps.store.getById) {
622
850
  const rowAtTargetId = await deps.store.getById(targetId);
623
- if (rowAtTargetId && normalizeMemoryText(rowAtTargetId.text) !== normalizeMemoryText(params.text)) {
851
+ const normalizedIncomingText = normalizeMemoryText(params.text);
852
+ // 那一行活跃、且已经是这段正文:与上面 exact 命中同样处理(promotedFrom 时追加晋升来源、报 promoted,否则 deduped),
853
+ // 不落到 store()——store() 用 params.metadata 整行覆盖,会抹掉它的 evolution(version / supersedes)。
854
+ // 扫描窗口外的 canonical 行只会走到这里,exact 分支看不见它(2026-10-17 owner review)。
855
+ // 不活跃的同文行(auto-gc / 对账原地 archived、consolidated,id 不变)不去重:findCanonicalMatches 把它们滤掉了才会
856
+ // 走到这里,去重会让它照旧不活跃、默认召回看不见。它和异文一样走下面的替换——留历史行、原行设回 active
857
+ // (promotedFrom 也一样:appendPromotionObservationToExistingEntry 不会把它重新激活)。
858
+ const sameText = async (existing: MemoryEntry) => params.promotedFrom
859
+ ? { entry: await appendPromotionObservationToExistingEntry(deps.store, existing, params.metadata), disposition: "promoted" as const }
860
+ : { entry: existing, disposition: "deduped" as const };
861
+ const isLiveSameText = (row: MemoryEntry) =>
862
+ isActiveMemory(row.metadata) && normalizeMemoryText(row.text) === normalizedIncomingText;
863
+ if (rowAtTargetId && isLiveSameText(rowAtTargetId)) {
864
+ return await sameText(rowAtTargetId);
865
+ }
866
+ const atomic = atomicBeliefDeps(deps);
867
+ if (rowAtTargetId && atomic) {
868
+ // 归档与覆盖同一次持锁(2026-10-17)。写入的字段与默认值照下面 store() 的那一份:text 换了,
869
+ // language / fts_text 不能沿用旧行的。原行在锁内重读时已不在(被 forget 等删掉)→ 不留历史行,
870
+ // 落到下面的 store() 当新行写入——原先这里会把已删内容写成一条历史行;锁内重读发现正文已与新的相同 → deduped。
871
+ const replaced = await replaceBeliefVersion(
872
+ atomic,
873
+ rowAtTargetId,
874
+ ({ previousEvolution, historyId, now }) => ({
875
+ text: params.text,
876
+ vector: params.vector,
877
+ category: params.category,
878
+ importance: params.importance,
879
+ metadata: buildSupersedingBeliefMetadata(params.metadata, previousEvolution, historyId, now),
880
+ timestamp: now,
881
+ language: params.language || "en",
882
+ fts_text: params.fts_text || params.text,
883
+ }),
884
+ { skip: isLiveSameText },
885
+ );
886
+ if (replaced.status === "replaced") {
887
+ return {
888
+ entry: replaced.entry,
889
+ disposition: params.promotedFrom ? "promoted" : "stored",
890
+ supersededPrevious: true,
891
+ };
892
+ }
893
+ if (replaced.status === "unchanged") {
894
+ // 锁内重读发现那一行活跃、且已经是这段正文(别的进程刚写过同文、带着它自己的版本链):同上,不再 store()
895
+ return await sameText(replaced.current);
896
+ }
897
+ } else if (rowAtTargetId) {
624
898
  const now = Date.now();
625
899
  const previousEvo = parseEvolution(rowAtTargetId.metadata, rowAtTargetId.timestamp);
626
900
  const { historyId } = await archiveBeliefVersion(
@@ -629,6 +903,7 @@ export async function writeDurableEntry(
629
903
  { now },
630
904
  );
631
905
  metadataForStore = buildSupersedingBeliefMetadata(params.metadata, previousEvo, historyId, now);
906
+ supersededPrevious = true;
632
907
  }
633
908
  }
634
909
 
@@ -647,6 +922,7 @@ export async function writeDurableEntry(
647
922
  return {
648
923
  entry: stored,
649
924
  disposition: params.promotedFrom ? "promoted" : "stored",
925
+ supersededPrevious,
650
926
  };
651
927
  }
652
928
 
@@ -690,6 +966,9 @@ function inferPromotedCategory(
690
966
  function buildStoreMemoryMetadata(input: StoreMemoryInput, canonicalKey: string): string {
691
967
  const anchor = generateAnchor(input.text);
692
968
  const anchorExtra = anchor ? { anchor } : undefined;
969
+ // T-Mem triggers:文本副本进 metadata(侧表可由它重建),向量进 memory_triggers 侧表
970
+ const triggers = normalizeTriggerTexts(input.triggers);
971
+ const triggerExtra = triggers.length > 0 ? { triggers } : undefined;
693
972
  // HP-ethics: Include privacyTier in metadata if non-default
694
973
  const privacyExtra = input.privacyTier && input.privacyTier !== "durable"
695
974
  ? { privacyTier: input.privacyTier }
@@ -700,7 +979,7 @@ function buildStoreMemoryMetadata(input: StoreMemoryInput, canonicalKey: string)
700
979
  capture: "store_memory_schema_v1",
701
980
  category: input.category,
702
981
  canonicalKey,
703
- extra: mergeExtra(buildPreferenceSlotExtra(input.category, input.text), anchorExtra, privacyExtra),
982
+ extra: mergeExtra(buildPreferenceSlotExtra(input.category, input.text), anchorExtra, privacyExtra, triggerExtra),
704
983
  narrativeInput: { scope: input.scope, text: input.text },
705
984
  });
706
985
  }
@@ -801,10 +1080,62 @@ function buildPromotionMetadata(
801
1080
  });
802
1081
  }
803
1082
 
1083
+ /**
1084
+ * T-Mem triggers(2026-09-22):宿主写完再写入口。deduped(同文本已存在)也写——老条目补 trigger
1085
+ * 正是回填的常态,此时顺手把文本副本补进旧行 metadata(侧表可由它重建)。失败只 warn:入口丢一次
1086
+ * 比记忆丢一条便宜。
1087
+ * 返回侧表实际写入的条数(没有侧表、没有 trigger、写入失败都是 0),回执按它报数。
1088
+ */
1089
+ async function writeTriggersForEntry(
1090
+ deps: PersistMemoryDeps,
1091
+ entry: MemoryEntry,
1092
+ resolvedScope: string,
1093
+ triggerTexts: string[],
1094
+ backfillMetadata: boolean,
1095
+ ): Promise<number> {
1096
+ if (!deps.triggerStore || triggerTexts.length === 0) return 0;
1097
+ let written = 0;
1098
+ try {
1099
+ const embedBatch = deps.embedQueryBatch
1100
+ ?? (async (texts: string[]) => Promise.all(texts.map((t) => deps.embedder.embedPassage(t))));
1101
+ written = await deps.triggerStore.upsertForMemory(entry.id, entry.scope || resolvedScope, triggerTexts, embedBatch);
1102
+ if (backfillMetadata && deps.store.update) {
1103
+ const parsedMeta: Record<string, unknown> = JSON.parse(entry.metadata || "{}");
1104
+ const prev = Array.isArray(parsedMeta.triggers) ? (parsedMeta.triggers as unknown[]).join("\u0000") : "";
1105
+ if (prev !== triggerTexts.join("\u0000")) {
1106
+ parsedMeta.triggers = triggerTexts;
1107
+ await deps.store.update(entry.id, { metadata: JSON.stringify(parsedMeta) });
1108
+ }
1109
+ }
1110
+ return written;
1111
+ } catch (err) {
1112
+ console.error(`[recallnest] trigger write failed for ${entry.id} (memory stored, trigger 入口未写):`, err instanceof Error ? err.message : String(err));
1113
+ // 侧表已写、只是 metadata 副本没回填时,written 仍是侧表里的条数
1114
+ return written;
1115
+ }
1116
+ }
1117
+
1118
+ /**
1119
+ * Longest text the implicit preference dual-write will copy, in UTF-16 code
1120
+ * units (`String.length`) of the schema-normalized text — a rough cap, not a
1121
+ * precise character count.
1122
+ * The 13 derived rows found in the production table on 2026-10-10 were all
1123
+ * 446–1,653 characters of narrative; the statements the feature is for
1124
+ * ("I use Figma for design") are a single short sentence.
1125
+ */
1126
+ export const IMPLICIT_PREFERENCE_MAX_TEXT_CHARS = 120;
1127
+
1128
+ /** Whether a just-stored memory should also get a derived preferences copy. */
1129
+ export function shouldDeriveImplicitPreference(category: string, text: string): boolean {
1130
+ if (category === "preferences") return false;
1131
+ if (text.length > IMPLICIT_PREFERENCE_MAX_TEXT_CHARS) return false;
1132
+ return inferImplicitUsageSlot(text) !== null;
1133
+ }
1134
+
804
1135
  export async function persistMemory(
805
1136
  deps: PersistMemoryDeps,
806
1137
  rawInput: unknown,
807
- ): Promise<StoredMemoryRecord> {
1138
+ ): Promise<PersistedMemoryRecord> {
808
1139
  let input = StoreMemoryInputSchema.parse(rawInput);
809
1140
 
810
1141
  // F-3b: Scan THEN redact, before the text reaches any external surface —
@@ -812,9 +1143,8 @@ export async function persistMemory(
812
1143
  // Scan first so the piiWarning below still reflects what the raw text held.
813
1144
  const piiScan = scanForPII(input.text);
814
1145
  const redaction = redactSecrets(input.text);
815
- if (redaction.redacted > 0) {
816
- input = { ...input, text: redaction.text };
817
- }
1146
+ // tags / 显式 canonicalKey / topicTag / triggers 同样会进 metadata、侧表与外部嵌入接口
1147
+ input = redactStoreMemoryFields({ ...input, text: redaction.text });
818
1148
 
819
1149
  // F3: Extract temporal validity params before Zod strips them
820
1150
  const rawObj = rawInput as Record<string, unknown> | null;
@@ -903,6 +1233,7 @@ export async function persistMemory(
903
1233
  storedAt: new Date().toISOString(),
904
1234
  disposition: "rejected" as const,
905
1235
  ...(admission.reason ? { rejectionReason: admission.reason } : {}),
1236
+ triggersWritten: 0,
906
1237
  };
907
1238
  }
908
1239
 
@@ -925,11 +1256,30 @@ export async function persistMemory(
925
1256
  canonicalKey: input.canonicalKey,
926
1257
  });
927
1258
 
1259
+ // 显式同 key 修订(2026-09-24):调用方带着显式 key 改写本 scope 已有的同类别偏好时,
1260
+ // 不再交给 Tier 3.6 做语义判重——相似度 ≥0.92 会被直接跳过,修订因此丢掉;改由
1261
+ // writeDurableEntry 对目标行做全文判等与原位替换。新 key、无 key 的写入照旧判重。
1262
+ const explicitKey = input.canonicalKey ? normalizeCanonicalKey(input.canonicalKey) : "";
1263
+ const sameKey =
1264
+ input.category === "preferences" && explicitKey !== ""
1265
+ ? await resolveSameKeyRevision(deps.store, resolvedScope, canonicalKey, input.category)
1266
+ : null;
1267
+ const revisionTarget = sameKey?.target ?? null;
1268
+
1269
+ // 同 key 且全文相同:赶在 A-2 / F2 之前按 deduped 返回。原版在 Tier 3.6 判重处就提前返回,这类写入
1270
+ // 从不触发 F2 干扰预警;放到后面,一次 deduped 写入会把别的行标成 pending_review(实施后 Codex 审查,
1271
+ // 真实临时库复现)。triggers 回填照做。
1272
+ if (revisionTarget && normalizeMemoryText(revisionTarget.text) === normalizeMemoryText(input.text)) {
1273
+ const triggersWritten = await writeTriggersForEntry(deps, revisionTarget, resolvedScope, normalizeTriggerTexts(input.triggers), true);
1274
+ return { ...toStoredRecord(input, revisionTarget, "deduped", canonicalKey), triggersWritten };
1275
+ }
1276
+
928
1277
  // Tier 3.6: For preferences, check if similar preference already exists.
929
1278
  // If matched, merge into existing or skip — avoids duplicate accumulation.
930
1279
  if (
931
1280
  input.category === "preferences" &&
932
- deps.store.vectorSearch
1281
+ deps.store.vectorSearch &&
1282
+ !revisionTarget
933
1283
  ) {
934
1284
  const matchResult = await matchPreference(
935
1285
  input.text,
@@ -946,6 +1296,8 @@ export async function persistMemory(
946
1296
  { embedder: deps.embedder },
947
1297
  );
948
1298
  if (applied.handled) {
1299
+ // skip 时 entry 是被命中的那一行(回执报它的 id);这条分支不写 trigger,triggersWritten 记 0。
1300
+ // 兜底的 "skipped" 只给不带 matchedEntry 的手工 skip 结果,matchPreference 自己产出的都带。
949
1301
  const entry = applied.entry ?? {
950
1302
  id: "skipped",
951
1303
  text: input.text,
@@ -956,12 +1308,15 @@ export async function persistMemory(
956
1308
  timestamp: Date.now(),
957
1309
  metadata: "{}",
958
1310
  };
959
- return toStoredRecord(
960
- input,
961
- entry,
962
- matchResult.action === "merge" ? "updated" : "deduped",
963
- canonicalKey,
964
- );
1311
+ return {
1312
+ ...toStoredRecord(
1313
+ input,
1314
+ entry,
1315
+ matchResult.action === "merge" ? "updated" : "deduped",
1316
+ canonicalKey,
1317
+ ),
1318
+ triggersWritten: 0,
1319
+ };
965
1320
  }
966
1321
  }
967
1322
  }
@@ -997,10 +1352,13 @@ export async function persistMemory(
997
1352
  if (decision.action === "MERGE") {
998
1353
  const oldEntry = supersedeTargets[0].entry;
999
1354
  const supersededMeta = buildSupersedeMetadata(oldEntry.metadata, deterministicId(resolvedScope, input.text));
1000
- await deps.store.update(oldEntry.id, { metadata: supersededMeta });
1001
- // HP-1: Track for bidirectional link
1002
- supersededOldId = oldEntry.id;
1003
- supersededReason = decision.reason;
1355
+ const marked = await deps.store.update(oldEntry.id, { metadata: supersededMeta });
1356
+ // HP-1: Track for bidirectional link — only when the old row was actually marked;
1357
+ // update() returns null when it was deleted meanwhile, and the new row must not point at it.
1358
+ if (marked) {
1359
+ supersededOldId = oldEntry.id;
1360
+ supersededReason = decision.reason;
1361
+ }
1004
1362
  }
1005
1363
  }
1006
1364
  // CREATE or error → proceed normally (store new memory as-is)
@@ -1106,7 +1464,7 @@ export async function persistMemory(
1106
1464
  metadata = JSON.stringify(parsed);
1107
1465
  }
1108
1466
 
1109
- const { entry, disposition, conflictId } = await writeDurableEntry(deps, {
1467
+ const { entry, disposition, conflictId, supersededPrevious } = await writeDurableEntry(deps, {
1110
1468
  text: input.text,
1111
1469
  vector,
1112
1470
  category: input.category,
@@ -1117,16 +1475,32 @@ export async function persistMemory(
1117
1475
  source: input.source,
1118
1476
  language,
1119
1477
  fts_text,
1478
+ ...(revisionTarget ? { revisionTarget } : {}),
1479
+ ...(sameKey ? { precomputedMatches: sameKey.matches } : {}),
1120
1480
  });
1121
1481
 
1482
+ // T-Mem triggers:conflict 时宿主没变、不写;其余见 writeTriggersForEntry。
1483
+ const triggersWritten = disposition === "conflict"
1484
+ ? 0
1485
+ : await writeTriggersForEntry(deps, entry, resolvedScope, normalizeTriggerTexts(input.triggers), disposition === "deduped");
1486
+
1122
1487
  // Tier 4.1: Async KG triple extraction (non-blocking)
1123
1488
  // HP-ethics: Skip KG extraction for ephemeral/private memories — they must not leave graph traces
1124
1489
  const entryPrivacyTier = parsePrivacyTier(metadata);
1125
1490
  const kgAllowed = entryPrivacyTier !== "ephemeral" && entryPrivacyTier !== "private";
1126
1491
  if (deps.kgExtractor && disposition !== "deduped" && kgAllowed) {
1127
- deps.kgExtractor
1128
- .extractAndStore(input.text, entry.id, resolvedScope)
1129
- .catch(() => {}); // Silently ignore — KG extraction must never block memory writes
1492
+ const kgExtractor = deps.kgExtractor;
1493
+ // When this write replaced existing content, the edges extracted from the OLD
1494
+ // text are still in the graph: triple ids are content-derived, so re-extracting
1495
+ // merely adds new rows beside the stale ones and both compete during traversal.
1496
+ // Purge first, then re-extract — strictly ordered, or the purge would race ahead
1497
+ // and delete what we just wrote.
1498
+ const purged = supersededPrevious
1499
+ ? kgExtractor.purgeForMemory(entry.id)
1500
+ : Promise.resolve();
1501
+ purged
1502
+ .then(() => kgExtractor.extractAndStore(input.text, entry.id, resolvedScope))
1503
+ .catch(() => {}); // Silently ignore — KG upkeep must never block memory writes
1130
1504
  }
1131
1505
 
1132
1506
  // F-1: Audit log — record store operation (non-blocking, silent on failure)
@@ -1159,22 +1533,32 @@ export async function persistMemory(
1159
1533
  .catch(() => {}); // Must never block
1160
1534
  }
1161
1535
 
1162
- // LME-1: Implicit preference dual-write — when a non-preference memory
1163
- // contains an implicit usage signal ("I use X", "I have X", etc.),
1536
+ // LME-1: Implicit preference dual-write — when a short non-preference memory
1537
+ // is itself an implicit usage statement ("I use X", "I have X", etc.),
1164
1538
  // store an additional preferences copy so it can be recalled via the
1165
1539
  // low-threshold preference retrieval path (hardMinScore 0.25 vs 0.35).
1540
+ // Only short texts qualify: a long narrative that happens to contain one
1541
+ // "我用 X 做了…" clause is not a usage statement, and copying it whole into
1542
+ // preferences only duplicates the source row (see shouldDeriveImplicitPreference).
1166
1543
  // Async & non-blocking: the primary write is already done.
1544
+ // Not on "conflict" either: the incoming text was parked in the conflict
1545
+ // store, nothing was written, and `entry` is the existing row it collided with.
1167
1546
  if (
1168
1547
  disposition !== "deduped" &&
1169
- input.category !== "preferences" &&
1170
- inferImplicitUsageSlot(input.text)
1548
+ disposition !== "conflict" &&
1549
+ shouldDeriveImplicitPreference(input.category, input.text)
1171
1550
  ) {
1172
1551
  const prefCanonicalKey = buildDefaultCanonicalKey({ category: "preferences", text: input.text });
1173
1552
  const prefSlot = inferImplicitUsageSlot(input.text);
1174
1553
  const prefMetadata = buildStructuredMetadata({
1175
1554
  source: input.source,
1176
- tags: [...(input.tags ?? []), "derived-preference", `derived-from:${entry.id}`],
1177
- capture: "implicit_preference_dual_write_v1",
1555
+ // The source row's own tags stay on the source row. `pinned` would exempt
1556
+ // the copy from decay and business tags would make it count as a second
1557
+ // record of whatever the source row records.
1558
+ tags: ["derived-preference", `derived-from:${entry.id}`],
1559
+ // v2 (2026-10-10): length cap, no inherited tags. v1 rows copied any length
1560
+ // and carried every tag of the source row.
1561
+ capture: "implicit_preference_dual_write_v2",
1178
1562
  category: "preferences",
1179
1563
  canonicalKey: prefCanonicalKey,
1180
1564
  extra: prefSlot ? { preferenceSlot: prefSlot } : undefined,
@@ -1194,17 +1578,18 @@ export async function persistMemory(
1194
1578
  }).catch(() => {}); // Dual-write must never block the primary write
1195
1579
  }
1196
1580
 
1197
- return toStoredRecord(input, entry, disposition, canonicalKey, conflictId);
1581
+ return { ...toStoredRecord(input, entry, disposition, canonicalKey, conflictId), triggersWritten };
1198
1582
  }
1199
1583
 
1200
1584
  export async function persistWorkflowPattern(
1201
1585
  deps: PersistMemoryDeps,
1202
1586
  rawInput: unknown,
1203
1587
  ): Promise<StoredWorkflowPatternRecord> {
1204
- const input = WorkflowPatternInputSchema.parse(rawInput);
1588
+ // F-3b: scrub every field before embedding/storage — the structured fields land in
1589
+ // metadata verbatim (this path bypasses persistMemory)
1590
+ const input = redactWorkflowPatternInput(WorkflowPatternInputSchema.parse(rawInput));
1205
1591
  const resolvedScope = resolveScope(input);
1206
1592
  const tags = mergeTags(input.tags, ["workflow", "pattern"]);
1207
- // F-3b: scrub before embedding/storage (this path bypasses persistMemory)
1208
1593
  const text = redactSecrets(buildWorkflowPatternText(input)).text;
1209
1594
  const vector = await deps.embedder.embedPassage(text);
1210
1595
  const language = detectLang(text);
@@ -1246,10 +1631,11 @@ export async function persistCaseMemory(
1246
1631
  deps: PersistMemoryDeps,
1247
1632
  rawInput: unknown,
1248
1633
  ): Promise<StoredCaseMemoryRecord> {
1249
- const input = CaseMemoryInputSchema.parse(rawInput);
1634
+ // F-3b: scrub every field before embedding/storage — the structured fields land in
1635
+ // metadata verbatim (this path bypasses persistMemory)
1636
+ const input = redactCaseMemoryInput(CaseMemoryInputSchema.parse(rawInput));
1250
1637
  const resolvedScope = resolveScope(input);
1251
1638
  const tags = mergeTags(input.tags, ["case", "solution"]);
1252
- // F-3b: scrub before embedding/storage (this path bypasses persistMemory)
1253
1639
  const text = redactSecrets(buildCaseMemoryText(input)).text;
1254
1640
  const vector = await deps.embedder.embedPassage(text);
1255
1641
  const language = detectLang(text);
@@ -1291,7 +1677,9 @@ export async function promoteMemory(
1291
1677
  deps: PersistMemoryDeps,
1292
1678
  rawInput: unknown,
1293
1679
  ): Promise<StoredPromotedMemoryRecord> {
1294
- const input = PromoteMemoryInputSchema.parse(rawInput);
1680
+ const parsedInput = PromoteMemoryInputSchema.parse(rawInput);
1681
+ // F-3b: tags and an explicit canonicalKey go into metadata as given
1682
+ const input = withoutSecretCanonicalKey({ ...parsedInput, tags: redactList(parsedInput.tags) });
1295
1683
  const sourceEntry = deps.store.get
1296
1684
  ? await deps.store.get(input.memoryId)
1297
1685
  : deps.store.getById
@@ -1359,7 +1747,7 @@ export async function promoteMemory(
1359
1747
  export async function persistMemoryBatch(
1360
1748
  deps: PersistMemoryDeps,
1361
1749
  rawInput: unknown,
1362
- ): Promise<StoredMemoryRecord[]> {
1750
+ ): Promise<PersistedMemoryRecord[]> {
1363
1751
  const input = CaptureMemoryInputSchema.parse(rawInput);
1364
1752
  const normalizedItems = input.memories.map((memory) =>
1365
1753
  StoreMemoryInputSchema.parse({
@@ -1378,7 +1766,7 @@ export async function persistMemoryBatch(
1378
1766
  // persistMemoryBatch (typically <10 items), per-item conflict detection (A-2)
1379
1767
  // in persistMemory handles dedup adequately without double-embedding cost.
1380
1768
 
1381
- const persisted: StoredMemoryRecord[] = [];
1769
+ const persisted: PersistedMemoryRecord[] = [];
1382
1770
  for (const memory of normalizedItems) {
1383
1771
  persisted.push(await persistMemory(deps, memory));
1384
1772
  }