recallnest 3.0.1 → 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 (74) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +42 -17
  3. package/README_CN.md +31 -15
  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 +10 -2
  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 +434 -68
  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.ts +11 -2
  28. package/src/distill-lock.ts +246 -74
  29. package/src/dream-pipeline.ts +3 -1
  30. package/src/env-config.ts +118 -0
  31. package/src/flock.ts +97 -0
  32. package/src/forget-engine.ts +342 -58
  33. package/src/gateway-server.ts +4 -0
  34. package/src/ingest-plan.ts +56 -0
  35. package/src/ingest.ts +69 -104
  36. package/src/kg-contradiction.ts +753 -0
  37. package/src/kg-store.ts +1 -1
  38. package/src/known-item-eval.ts +455 -0
  39. package/src/llm-client.ts +13 -26
  40. package/src/llm-consolidation.ts +2 -2
  41. package/src/mcp-tool-deps.ts +6 -0
  42. package/src/mcp-tools-advanced.ts +80 -45
  43. package/src/mcp-tools-core.ts +23 -17
  44. package/src/mcp-tools-governance.ts +13 -7
  45. package/src/memory-assets.ts +144 -8
  46. package/src/memory-boundaries.ts +19 -0
  47. package/src/memory-evolution.ts +9 -0
  48. package/src/memory-lint.ts +1 -1
  49. package/src/memory-output.ts +276 -14
  50. package/src/memory-reconcile.ts +1437 -0
  51. package/src/memory-schema.ts +11 -0
  52. package/src/memory-store-port.ts +1 -0
  53. package/src/minis-archive.ts +151 -0
  54. package/src/noise-filter.ts +15 -2
  55. package/src/pii-detector.ts +31 -1
  56. package/src/pivot-apply.ts +3 -4
  57. package/src/preference-matcher.ts +40 -28
  58. package/src/prospective-memory.ts +7 -4
  59. package/src/retriever.ts +297 -57
  60. package/src/retroactive-boost.ts +3 -2
  61. package/src/runtime-config.ts +6 -1
  62. package/src/scope-policy.ts +1 -0
  63. package/src/session-distiller.ts +6 -2
  64. package/src/session-output.ts +11 -0
  65. package/src/session-schema.ts +5 -2
  66. package/src/session-store.ts +57 -9
  67. package/src/store-receipts.ts +109 -0
  68. package/src/store-write-budget.ts +37 -0
  69. package/src/store.ts +298 -57
  70. package/src/text-fingerprint.ts +25 -0
  71. package/src/trigger-store.ts +443 -0
  72. package/src/ui-server.ts +22 -21
  73. package/src/version-manager.ts +19 -5
  74. package/src/write-verifier.ts +16 -11
@@ -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,
@@ -471,7 +704,21 @@ export async function writeDurableEntry(
471
704
  */
472
705
  supersededPrevious?: boolean;
473
706
  }> {
474
- 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);
475
722
  const categoryMatches = matches.filter((entry) => entry.category === params.category);
476
723
  const crossCategoryMatches = matches.filter((entry) => entry.category !== params.category);
477
724
  const normalizedIncoming = normalizeMemoryText(params.text);
@@ -530,7 +777,6 @@ export async function writeDurableEntry(
530
777
  };
531
778
  }
532
779
 
533
- const conflictPolicy = getConflictPolicyForCategory(params.category);
534
780
  if (conflictPolicy === "latest-wins" && categoryMatches.length > 0 && deps.store.update) {
535
781
  const latest = [...categoryMatches].sort((a, b) => b.timestamp - a.timestamp)[0];
536
782
  if (params.promotedFrom) {
@@ -585,37 +831,8 @@ export async function writeDurableEntry(
585
831
  }
586
832
  }
587
833
 
588
- // latest-wins is where beliefs change. Archive the version being replaced first —
589
- // overwriting in place used to delete the old text + vector outright, which is what
590
- // made "what did I believe last month?" unanswerable no matter how retrieval queried.
591
- const now = Date.now();
592
- const previousEvo = parseEvolution(latest.metadata, latest.timestamp);
593
- const { historyId } = await archiveBeliefVersion(
594
- { store: deps.store, embedder: deps.embedder },
595
- latest,
596
- { now },
597
- );
598
-
599
- const updated = await deps.store.update(latest.id, {
600
- text: params.text,
601
- vector: params.vector,
602
- importance: params.importance,
603
- category: params.category,
604
- // Merge rather than replace: a bare params.metadata would wipe the evolution block
605
- // (version, access stats, supersede links) along with the text.
606
- metadata: buildSupersedingBeliefMetadata(params.metadata, previousEvo, historyId, now),
607
- timestamp: now,
608
- ...(params.language ? { language: params.language } : {}),
609
- ...(params.fts_text ? { fts_text: params.fts_text } : {}),
610
- });
611
- if (!updated) {
612
- throw new Error(`Failed to update canonical memory ${latest.id}`);
613
- }
614
- return {
615
- entry: updated,
616
- disposition: params.promotedFrom ? "promoted" : "updated",
617
- supersededPrevious: true,
618
- };
834
+ // latest-wins is where beliefs change: archive the version being replaced, then write in place.
835
+ return await replaceBeliefInPlace(deps, latest, params);
619
836
  }
620
837
 
621
838
  // No canonical match was found, so this falls through to store(). With a canonicalKey the
@@ -631,7 +848,53 @@ export async function writeDurableEntry(
631
848
 
632
849
  if (targetId && deps.store.getById) {
633
850
  const rowAtTargetId = await deps.store.getById(targetId);
634
- 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) {
635
898
  const now = Date.now();
636
899
  const previousEvo = parseEvolution(rowAtTargetId.metadata, rowAtTargetId.timestamp);
637
900
  const { historyId } = await archiveBeliefVersion(
@@ -703,6 +966,9 @@ function inferPromotedCategory(
703
966
  function buildStoreMemoryMetadata(input: StoreMemoryInput, canonicalKey: string): string {
704
967
  const anchor = generateAnchor(input.text);
705
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;
706
972
  // HP-ethics: Include privacyTier in metadata if non-default
707
973
  const privacyExtra = input.privacyTier && input.privacyTier !== "durable"
708
974
  ? { privacyTier: input.privacyTier }
@@ -713,7 +979,7 @@ function buildStoreMemoryMetadata(input: StoreMemoryInput, canonicalKey: string)
713
979
  capture: "store_memory_schema_v1",
714
980
  category: input.category,
715
981
  canonicalKey,
716
- extra: mergeExtra(buildPreferenceSlotExtra(input.category, input.text), anchorExtra, privacyExtra),
982
+ extra: mergeExtra(buildPreferenceSlotExtra(input.category, input.text), anchorExtra, privacyExtra, triggerExtra),
717
983
  narrativeInput: { scope: input.scope, text: input.text },
718
984
  });
719
985
  }
@@ -814,10 +1080,62 @@ function buildPromotionMetadata(
814
1080
  });
815
1081
  }
816
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
+
817
1135
  export async function persistMemory(
818
1136
  deps: PersistMemoryDeps,
819
1137
  rawInput: unknown,
820
- ): Promise<StoredMemoryRecord> {
1138
+ ): Promise<PersistedMemoryRecord> {
821
1139
  let input = StoreMemoryInputSchema.parse(rawInput);
822
1140
 
823
1141
  // F-3b: Scan THEN redact, before the text reaches any external surface —
@@ -825,9 +1143,8 @@ export async function persistMemory(
825
1143
  // Scan first so the piiWarning below still reflects what the raw text held.
826
1144
  const piiScan = scanForPII(input.text);
827
1145
  const redaction = redactSecrets(input.text);
828
- if (redaction.redacted > 0) {
829
- input = { ...input, text: redaction.text };
830
- }
1146
+ // tags / 显式 canonicalKey / topicTag / triggers 同样会进 metadata、侧表与外部嵌入接口
1147
+ input = redactStoreMemoryFields({ ...input, text: redaction.text });
831
1148
 
832
1149
  // F3: Extract temporal validity params before Zod strips them
833
1150
  const rawObj = rawInput as Record<string, unknown> | null;
@@ -916,6 +1233,7 @@ export async function persistMemory(
916
1233
  storedAt: new Date().toISOString(),
917
1234
  disposition: "rejected" as const,
918
1235
  ...(admission.reason ? { rejectionReason: admission.reason } : {}),
1236
+ triggersWritten: 0,
919
1237
  };
920
1238
  }
921
1239
 
@@ -938,11 +1256,30 @@ export async function persistMemory(
938
1256
  canonicalKey: input.canonicalKey,
939
1257
  });
940
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
+
941
1277
  // Tier 3.6: For preferences, check if similar preference already exists.
942
1278
  // If matched, merge into existing or skip — avoids duplicate accumulation.
943
1279
  if (
944
1280
  input.category === "preferences" &&
945
- deps.store.vectorSearch
1281
+ deps.store.vectorSearch &&
1282
+ !revisionTarget
946
1283
  ) {
947
1284
  const matchResult = await matchPreference(
948
1285
  input.text,
@@ -959,6 +1296,8 @@ export async function persistMemory(
959
1296
  { embedder: deps.embedder },
960
1297
  );
961
1298
  if (applied.handled) {
1299
+ // skip 时 entry 是被命中的那一行(回执报它的 id);这条分支不写 trigger,triggersWritten 记 0。
1300
+ // 兜底的 "skipped" 只给不带 matchedEntry 的手工 skip 结果,matchPreference 自己产出的都带。
962
1301
  const entry = applied.entry ?? {
963
1302
  id: "skipped",
964
1303
  text: input.text,
@@ -969,12 +1308,15 @@ export async function persistMemory(
969
1308
  timestamp: Date.now(),
970
1309
  metadata: "{}",
971
1310
  };
972
- return toStoredRecord(
973
- input,
974
- entry,
975
- matchResult.action === "merge" ? "updated" : "deduped",
976
- canonicalKey,
977
- );
1311
+ return {
1312
+ ...toStoredRecord(
1313
+ input,
1314
+ entry,
1315
+ matchResult.action === "merge" ? "updated" : "deduped",
1316
+ canonicalKey,
1317
+ ),
1318
+ triggersWritten: 0,
1319
+ };
978
1320
  }
979
1321
  }
980
1322
  }
@@ -1010,10 +1352,13 @@ export async function persistMemory(
1010
1352
  if (decision.action === "MERGE") {
1011
1353
  const oldEntry = supersedeTargets[0].entry;
1012
1354
  const supersededMeta = buildSupersedeMetadata(oldEntry.metadata, deterministicId(resolvedScope, input.text));
1013
- await deps.store.update(oldEntry.id, { metadata: supersededMeta });
1014
- // HP-1: Track for bidirectional link
1015
- supersededOldId = oldEntry.id;
1016
- 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
+ }
1017
1362
  }
1018
1363
  }
1019
1364
  // CREATE or error → proceed normally (store new memory as-is)
@@ -1130,8 +1475,15 @@ export async function persistMemory(
1130
1475
  source: input.source,
1131
1476
  language,
1132
1477
  fts_text,
1478
+ ...(revisionTarget ? { revisionTarget } : {}),
1479
+ ...(sameKey ? { precomputedMatches: sameKey.matches } : {}),
1133
1480
  });
1134
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
+
1135
1487
  // Tier 4.1: Async KG triple extraction (non-blocking)
1136
1488
  // HP-ethics: Skip KG extraction for ephemeral/private memories — they must not leave graph traces
1137
1489
  const entryPrivacyTier = parsePrivacyTier(metadata);
@@ -1181,22 +1533,32 @@ export async function persistMemory(
1181
1533
  .catch(() => {}); // Must never block
1182
1534
  }
1183
1535
 
1184
- // LME-1: Implicit preference dual-write — when a non-preference memory
1185
- // 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.),
1186
1538
  // store an additional preferences copy so it can be recalled via the
1187
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).
1188
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.
1189
1546
  if (
1190
1547
  disposition !== "deduped" &&
1191
- input.category !== "preferences" &&
1192
- inferImplicitUsageSlot(input.text)
1548
+ disposition !== "conflict" &&
1549
+ shouldDeriveImplicitPreference(input.category, input.text)
1193
1550
  ) {
1194
1551
  const prefCanonicalKey = buildDefaultCanonicalKey({ category: "preferences", text: input.text });
1195
1552
  const prefSlot = inferImplicitUsageSlot(input.text);
1196
1553
  const prefMetadata = buildStructuredMetadata({
1197
1554
  source: input.source,
1198
- tags: [...(input.tags ?? []), "derived-preference", `derived-from:${entry.id}`],
1199
- 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",
1200
1562
  category: "preferences",
1201
1563
  canonicalKey: prefCanonicalKey,
1202
1564
  extra: prefSlot ? { preferenceSlot: prefSlot } : undefined,
@@ -1216,17 +1578,18 @@ export async function persistMemory(
1216
1578
  }).catch(() => {}); // Dual-write must never block the primary write
1217
1579
  }
1218
1580
 
1219
- return toStoredRecord(input, entry, disposition, canonicalKey, conflictId);
1581
+ return { ...toStoredRecord(input, entry, disposition, canonicalKey, conflictId), triggersWritten };
1220
1582
  }
1221
1583
 
1222
1584
  export async function persistWorkflowPattern(
1223
1585
  deps: PersistMemoryDeps,
1224
1586
  rawInput: unknown,
1225
1587
  ): Promise<StoredWorkflowPatternRecord> {
1226
- 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));
1227
1591
  const resolvedScope = resolveScope(input);
1228
1592
  const tags = mergeTags(input.tags, ["workflow", "pattern"]);
1229
- // F-3b: scrub before embedding/storage (this path bypasses persistMemory)
1230
1593
  const text = redactSecrets(buildWorkflowPatternText(input)).text;
1231
1594
  const vector = await deps.embedder.embedPassage(text);
1232
1595
  const language = detectLang(text);
@@ -1268,10 +1631,11 @@ export async function persistCaseMemory(
1268
1631
  deps: PersistMemoryDeps,
1269
1632
  rawInput: unknown,
1270
1633
  ): Promise<StoredCaseMemoryRecord> {
1271
- 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));
1272
1637
  const resolvedScope = resolveScope(input);
1273
1638
  const tags = mergeTags(input.tags, ["case", "solution"]);
1274
- // F-3b: scrub before embedding/storage (this path bypasses persistMemory)
1275
1639
  const text = redactSecrets(buildCaseMemoryText(input)).text;
1276
1640
  const vector = await deps.embedder.embedPassage(text);
1277
1641
  const language = detectLang(text);
@@ -1313,7 +1677,9 @@ export async function promoteMemory(
1313
1677
  deps: PersistMemoryDeps,
1314
1678
  rawInput: unknown,
1315
1679
  ): Promise<StoredPromotedMemoryRecord> {
1316
- 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) });
1317
1683
  const sourceEntry = deps.store.get
1318
1684
  ? await deps.store.get(input.memoryId)
1319
1685
  : deps.store.getById
@@ -1381,7 +1747,7 @@ export async function promoteMemory(
1381
1747
  export async function persistMemoryBatch(
1382
1748
  deps: PersistMemoryDeps,
1383
1749
  rawInput: unknown,
1384
- ): Promise<StoredMemoryRecord[]> {
1750
+ ): Promise<PersistedMemoryRecord[]> {
1385
1751
  const input = CaptureMemoryInputSchema.parse(rawInput);
1386
1752
  const normalizedItems = input.memories.map((memory) =>
1387
1753
  StoreMemoryInputSchema.parse({
@@ -1400,7 +1766,7 @@ export async function persistMemoryBatch(
1400
1766
  // persistMemoryBatch (typically <10 items), per-item conflict detection (A-2)
1401
1767
  // in persistMemory handles dedup adequately without double-embedding cost.
1402
1768
 
1403
- const persisted: StoredMemoryRecord[] = [];
1769
+ const persisted: PersistedMemoryRecord[] = [];
1404
1770
  for (const memory of normalizedItems) {
1405
1771
  persisted.push(await persistMemory(deps, memory));
1406
1772
  }