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.
- package/CHANGELOG.md +203 -0
- package/README.md +42 -17
- package/README_CN.md +31 -15
- package/assets/ui/app.js +6 -1
- package/bin/recallnest.mjs +20 -37
- package/docs/api-reference.md +8 -0
- package/docs/architecture.md +18 -13
- package/eval/cases-canary.json +107 -9
- package/integrations/examples/claude-agent-sdk/memory-agent.ts +2 -0
- package/integrations/examples/langchain/memory-chain.py +4 -0
- package/integrations/examples/openai-agents-sdk/memory-agent.py +4 -0
- package/package.json +10 -2
- package/src/activity-counter.ts +1 -0
- package/src/adaptive-retrieval.ts +23 -0
- package/src/api-server.ts +13 -11
- package/src/asset-sync.ts +26 -2
- package/src/audit-log.ts +2 -0
- package/src/auto-gc.ts +4 -3
- package/src/belief-history.ts +137 -31
- package/src/capture-engine.ts +434 -68
- package/src/cascade-forget.ts +7 -3
- package/src/checkpoint-mirror.ts +417 -0
- package/src/cli.ts +574 -54
- package/src/conflict-engine.ts +43 -15
- package/src/consolidation-engine.ts +24 -3
- package/src/context-collapse-renderer.ts +8 -1
- package/src/context-composer.ts +11 -2
- package/src/distill-lock.ts +246 -74
- package/src/dream-pipeline.ts +3 -1
- package/src/env-config.ts +118 -0
- package/src/flock.ts +97 -0
- package/src/forget-engine.ts +342 -58
- package/src/gateway-server.ts +4 -0
- package/src/ingest-plan.ts +56 -0
- package/src/ingest.ts +69 -104
- package/src/kg-contradiction.ts +753 -0
- package/src/kg-store.ts +1 -1
- package/src/known-item-eval.ts +455 -0
- package/src/llm-client.ts +13 -26
- package/src/llm-consolidation.ts +2 -2
- package/src/mcp-tool-deps.ts +6 -0
- package/src/mcp-tools-advanced.ts +80 -45
- package/src/mcp-tools-core.ts +23 -17
- package/src/mcp-tools-governance.ts +13 -7
- package/src/memory-assets.ts +144 -8
- package/src/memory-boundaries.ts +19 -0
- package/src/memory-evolution.ts +9 -0
- package/src/memory-lint.ts +1 -1
- package/src/memory-output.ts +276 -14
- package/src/memory-reconcile.ts +1437 -0
- package/src/memory-schema.ts +11 -0
- package/src/memory-store-port.ts +1 -0
- package/src/minis-archive.ts +151 -0
- package/src/noise-filter.ts +15 -2
- package/src/pii-detector.ts +31 -1
- package/src/pivot-apply.ts +3 -4
- package/src/preference-matcher.ts +40 -28
- package/src/prospective-memory.ts +7 -4
- package/src/retriever.ts +297 -57
- package/src/retroactive-boost.ts +3 -2
- package/src/runtime-config.ts +6 -1
- package/src/scope-policy.ts +1 -0
- package/src/session-distiller.ts +6 -2
- package/src/session-output.ts +11 -0
- package/src/session-schema.ts +5 -2
- package/src/session-store.ts +57 -9
- package/src/store-receipts.ts +109 -0
- package/src/store-write-budget.ts +37 -0
- package/src/store.ts +298 -57
- package/src/text-fingerprint.ts +25 -0
- package/src/trigger-store.ts +443 -0
- package/src/ui-server.ts +22 -21
- package/src/version-manager.ts +19 -5
- package/src/write-verifier.ts +16 -11
package/src/capture-engine.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
589
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
829
|
-
|
|
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
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1192
|
-
|
|
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
|
-
|
|
1199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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<
|
|
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:
|
|
1769
|
+
const persisted: PersistedMemoryRecord[] = [];
|
|
1404
1770
|
for (const memory of normalizedItems) {
|
|
1405
1771
|
persisted.push(await persistMemory(deps, memory));
|
|
1406
1772
|
}
|