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.
- package/CHANGELOG.md +304 -0
- package/README.md +251 -258
- package/README_CN.md +226 -247
- 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 +12 -3
- 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 +459 -71
- 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-stable.ts +5 -1
- package/src/context-composer.ts +11 -2
- package/src/distill-lock.ts +246 -74
- package/src/dream-pipeline.ts +3 -1
- package/src/embedder.ts +24 -0
- package/src/env-config.ts +136 -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 +294 -121
- package/src/kg-contradiction.ts +753 -0
- package/src/kg-extractor.ts +17 -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 +311 -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 +55 -5
- package/src/pivot-evidence.ts +128 -0
- package/src/ppr-traversal.ts +25 -1
- 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 +15 -1
- package/src/scope-policy.ts +1 -0
- package/src/session-distiller.ts +6 -2
- package/src/session-engine.ts +11 -4
- package/src/session-output.ts +20 -1
- package/src/session-schema.ts +5 -2
- package/src/session-store.ts +59 -12
- package/src/skill-promotion.ts +67 -1
- 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/__tests__/access-tracker-novelty.test.ts +0 -324
- package/src/__tests__/activity-counter.test.ts +0 -125
- package/src/__tests__/adaptive-fidelity.test.ts +0 -102
- package/src/__tests__/admission-control.test.ts +0 -206
- package/src/__tests__/age-label.test.ts +0 -33
- package/src/__tests__/anchor-generator.test.ts +0 -67
- package/src/__tests__/api-server-security.test.ts +0 -102
- package/src/__tests__/auto-consolidation.test.ts +0 -168
- package/src/__tests__/auto-recall.test.ts +0 -131
- package/src/__tests__/babel-integration.test.ts +0 -144
- package/src/__tests__/batch-dedup.test.ts +0 -65
- package/src/__tests__/belief-history.test.ts +0 -220
- package/src/__tests__/canary-eval.test.ts +0 -114
- package/src/__tests__/capture-engine.test.ts +0 -1328
- package/src/__tests__/capture-heuristic.test.ts +0 -171
- package/src/__tests__/cascade-forget.test.ts +0 -210
- package/src/__tests__/category-min-scores.test.ts +0 -116
- package/src/__tests__/cc8-cc1-essential-context.test.ts +0 -298
- package/src/__tests__/circuit-breaker.test.ts +0 -99
- package/src/__tests__/cluster-consolidation.test.ts +0 -1236
- package/src/__tests__/confidence-metadata.test.ts +0 -125
- package/src/__tests__/confidence-tracker.test.ts +0 -261
- package/src/__tests__/conflict-advisor.test.ts +0 -206
- package/src/__tests__/conflict-escalation.test.ts +0 -149
- package/src/__tests__/conflict-lifecycle.test.ts +0 -83
- package/src/__tests__/conflict-output.test.ts +0 -85
- package/src/__tests__/conflict-store.test.ts +0 -418
- package/src/__tests__/connector-v1.test.ts +0 -181
- package/src/__tests__/consolidation-engine.test.ts +0 -520
- package/src/__tests__/constructive-retrieval.test.ts +0 -509
- package/src/__tests__/context-collapse-renderer.test.ts +0 -214
- package/src/__tests__/context-composer-pins.test.ts +0 -64
- package/src/__tests__/context-composer-task-selection.test.ts +0 -24
- package/src/__tests__/context-reconstructor.test.ts +0 -65
- package/src/__tests__/context-renderer.test.ts +0 -91
- package/src/__tests__/continuity-eval.test.ts +0 -275
- package/src/__tests__/continuity-schema.test.ts +0 -97
- package/src/__tests__/contracts.test.ts +0 -262
- package/src/__tests__/conversation-importer.test.ts +0 -692
- package/src/__tests__/core-recall-eval.test.ts +0 -235
- package/src/__tests__/core-summary.test.ts +0 -109
- package/src/__tests__/data-checkup.test.ts +0 -304
- package/src/__tests__/decay-exemption.test.ts +0 -129
- package/src/__tests__/dedup-actions.test.ts +0 -58
- package/src/__tests__/dedup-guard.test.ts +0 -39
- package/src/__tests__/deterministic-id.test.ts +0 -39
- package/src/__tests__/distill-lock.test.ts +0 -305
- package/src/__tests__/distill-session-argshape.test.ts +0 -79
- package/src/__tests__/doctor.test.ts +0 -319
- package/src/__tests__/dream-pipeline.test.ts +0 -820
- package/src/__tests__/dream-scope-isolation.test.ts +0 -386
- package/src/__tests__/dream-write-batching.test.ts +0 -211
- package/src/__tests__/drill-down.test.ts +0 -108
- package/src/__tests__/embedder.test.ts +0 -122
- package/src/__tests__/emotion-decay.test.ts +0 -46
- package/src/__tests__/emotion-detector.test.ts +0 -56
- package/src/__tests__/emotion-phase1-integration.test.ts +0 -153
- package/src/__tests__/emotion-retrieval.test.ts +0 -56
- package/src/__tests__/entity-resolver.test.ts +0 -58
- package/src/__tests__/env-config-invariant.test.ts +0 -80
- package/src/__tests__/env-config.test.ts +0 -211
- package/src/__tests__/error-signature-boost.test.ts +0 -87
- package/src/__tests__/error-signature.test.ts +0 -95
- package/src/__tests__/error-taxonomy.test.ts +0 -129
- package/src/__tests__/ethics-layer.test.ts +0 -476
- package/src/__tests__/event-segmenter.test.ts +0 -153
- package/src/__tests__/feature-flags-integration.test.ts +0 -54
- package/src/__tests__/frequency-tracker.test.ts +0 -113
- package/src/__tests__/freshness.test.ts +0 -186
- package/src/__tests__/graph-export.test.ts +0 -489
- package/src/__tests__/hotness-blend.test.ts +0 -283
- package/src/__tests__/ingest-chunking.test.ts +0 -44
- package/src/__tests__/ingest-scope-prefix.test.ts +0 -69
- package/src/__tests__/ingest.test.ts +0 -360
- package/src/__tests__/interference-detector.test.ts +0 -203
- package/src/__tests__/kg-extractor.test.ts +0 -192
- package/src/__tests__/kg-store.test.ts +0 -300
- package/src/__tests__/llm-client-pivot.test.ts +0 -628
- package/src/__tests__/llm-consolidation.test.ts +0 -226
- package/src/__tests__/maintenance-locks.test.ts +0 -197
- package/src/__tests__/mcp-efficiency.test.ts +0 -288
- package/src/__tests__/mcp-related-scopes.test.ts +0 -151
- package/src/__tests__/memory-boundaries.test.ts +0 -282
- package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +0 -362
- package/src/__tests__/memory-evolution.test.ts +0 -271
- package/src/__tests__/memory-health-rebalance.test.ts +0 -144
- package/src/__tests__/memory-lint.test.ts +0 -421
- package/src/__tests__/memory-output.test.ts +0 -224
- package/src/__tests__/memory-promotion.test.ts +0 -315
- package/src/__tests__/memory-utility.test.ts +0 -256
- package/src/__tests__/migrate-dedup-ids.test.ts +0 -65
- package/src/__tests__/multi-vector.test.ts +0 -242
- package/src/__tests__/narrative-architecture.test.ts +0 -456
- package/src/__tests__/noise-filter.test.ts +0 -205
- package/src/__tests__/noise-prototype-bank.test.ts +0 -280
- package/src/__tests__/obsidian-connector.test.ts +0 -363
- package/src/__tests__/omitted-reporting.test.ts +0 -98
- package/src/__tests__/openai-contract.test.ts +0 -513
- package/src/__tests__/pending-review.test.ts +0 -77
- package/src/__tests__/pii-redact.test.ts +0 -333
- package/src/__tests__/pivot-apply.test.ts +0 -373
- package/src/__tests__/pivot-distill-supervisor.test.ts +0 -457
- package/src/__tests__/pivot-distill.test.ts +0 -1178
- package/src/__tests__/post-retrieval-filter.test.ts +0 -178
- package/src/__tests__/ppr-traversal.test.ts +0 -256
- package/src/__tests__/prediction-engine.test.ts +0 -349
- package/src/__tests__/predictive-prospective-memory.test.ts +0 -416
- package/src/__tests__/preference-guard-smoke.test.ts +0 -211
- package/src/__tests__/preference-matcher.test.ts +0 -282
- package/src/__tests__/preference-slots.test.ts +0 -205
- package/src/__tests__/promotion-distinct-sources.test.ts +0 -162
- package/src/__tests__/prospective-memory.test.ts +0 -283
- package/src/__tests__/pull-from-macbook-sync-only.test.ts +0 -679
- package/src/__tests__/query-entity-detector.test.ts +0 -171
- package/src/__tests__/query-expander-alias.test.ts +0 -146
- package/src/__tests__/query-expander.test.ts +0 -34
- package/src/__tests__/recall-gate.test.ts +0 -266
- package/src/__tests__/recall-governor.test.ts +0 -227
- package/src/__tests__/recall-mode.test.ts +0 -64
- package/src/__tests__/resolve-db-path.test.ts +0 -130
- package/src/__tests__/resolve-tier.test.ts +0 -64
- package/src/__tests__/result-synthesizer.test.ts +0 -198
- package/src/__tests__/resume-context.test.ts +0 -4898
- package/src/__tests__/retrieval-eval.test.ts +0 -163
- package/src/__tests__/retrieval-trace.test.ts +0 -213
- package/src/__tests__/retrieve-audit-revision.test.ts +0 -255
- package/src/__tests__/retriever-boundary-weight.test.ts +0 -136
- package/src/__tests__/retriever-lazy-init-dispatch.test.ts +0 -123
- package/src/__tests__/retroactive-boost.test.ts +0 -223
- package/src/__tests__/salience-scoring.test.ts +0 -106
- package/src/__tests__/scope-inventory.test.ts +0 -200
- package/src/__tests__/scope-policy.test.ts +0 -69
- package/src/__tests__/scope-suggester.test.ts +0 -80
- package/src/__tests__/session-checkpoint.test.ts +0 -291
- package/src/__tests__/session-distiller.test.ts +0 -640
- package/src/__tests__/session-gc.test.ts +0 -215
- package/src/__tests__/skill-engine.test.ts +0 -552
- package/src/__tests__/skill-promotion.test.ts +0 -654
- package/src/__tests__/skill-verifier.test.ts +0 -122
- package/src/__tests__/source-heartbeat.test.ts +0 -254
- package/src/__tests__/stable-context-length-contract.test.ts +0 -62
- package/src/__tests__/store-patch-metadata.test.ts +0 -235
- package/src/__tests__/store-read-consistency.test.ts +0 -122
- package/src/__tests__/store-schema-migration.test.ts +0 -87
- package/src/__tests__/store-scope-audit.test.ts +0 -81
- package/src/__tests__/store-upsert.test.ts +0 -201
- package/src/__tests__/supersede-chain.test.ts +0 -154
- package/src/__tests__/synthesis-contract.test.ts +0 -252
- package/src/__tests__/synthesis-promotion.test.ts +0 -369
- package/src/__tests__/temporal-parser.test.ts +0 -241
- package/src/__tests__/temporal-validity.test.ts +0 -160
- package/src/__tests__/term-registry-terminal-roster.test.ts +0 -127
- package/src/__tests__/term-registry.test.ts +0 -121
- package/src/__tests__/tool-error-surfacing.test.ts +0 -98
- package/src/__tests__/tool-output-compressor.test.ts +0 -160
- package/src/__tests__/topic-tag.test.ts +0 -113
- package/src/__tests__/trusted-memory.test.ts +0 -867
- package/src/__tests__/ui-server-security.test.ts +0 -93
- package/src/__tests__/usage-tracker.test.ts +0 -287
- package/src/__tests__/utility-weight.test.ts +0 -57
- package/src/__tests__/verbatim-self-recall.test.ts +0 -279
- package/src/__tests__/version-manager.test.ts +0 -248
- package/src/__tests__/workflow-observation-skill-outcome.test.ts +0 -352
- package/src/__tests__/workflow-observation.test.ts +0 -433
- package/src/__tests__/write-verifier.test.ts +0 -122
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,
|
|
@@ -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
|
|
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
|
|
580
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
816
|
-
|
|
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
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
1003
|
-
|
|
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
|
-
|
|
1129
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1170
|
-
|
|
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
|
-
|
|
1177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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<
|
|
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:
|
|
1769
|
+
const persisted: PersistedMemoryRecord[] = [];
|
|
1382
1770
|
for (const memory of normalizedItems) {
|
|
1383
1771
|
persisted.push(await persistMemory(deps, memory));
|
|
1384
1772
|
}
|