dsh-prime-memory 0.26.5 → 0.26.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.en.md +62 -1
  3. package/README.md +52 -1
  4. package/dist/config.d.ts +9 -0
  5. package/dist/config.js +8 -0
  6. package/dist/contract.d.ts +2 -0
  7. package/dist/embedding-child.cjs +192 -0
  8. package/dist/embedding-worker.cjs +2 -0
  9. package/dist/graph/apply.d.ts +9 -0
  10. package/dist/graph/apply.js +12 -2
  11. package/dist/graph/search.d.ts +11 -2
  12. package/dist/graph/search.js +38 -10
  13. package/dist/hooks/recall.js +28 -1
  14. package/dist/index.js +8 -1
  15. package/dist/llm.d.ts +7 -2
  16. package/dist/llm.js +14 -22
  17. package/dist/pipeline/graph.d.ts +17 -1
  18. package/dist/pipeline/graph.js +45 -2
  19. package/dist/pipeline/l1.js +87 -26
  20. package/dist/pipeline/md-sync.js +4 -2
  21. package/dist/pipeline/runner.d.ts +26 -0
  22. package/dist/pipeline/runner.js +74 -7
  23. package/dist/pipeline/slot-migrate.d.ts +3 -2
  24. package/dist/pipeline/slot-migrate.js +3 -2
  25. package/dist/projection/recall.d.ts +10 -3
  26. package/dist/projection/recall.js +14 -0
  27. package/dist/settings.js +3 -0
  28. package/dist/stats.d.ts +1 -1
  29. package/dist/stats.js +17 -11
  30. package/dist/store/embedding-source.d.ts +4 -2
  31. package/dist/store/embedding-source.js +5 -2
  32. package/dist/store/graph-store.d.ts +71 -1
  33. package/dist/store/graph-store.js +221 -8
  34. package/dist/store/l1.d.ts +5 -0
  35. package/dist/store/l1.js +18 -2
  36. package/dist/store/local-embedding.d.ts +9 -5
  37. package/dist/store/local-embedding.js +137 -17
  38. package/dist/store/memory-md-disable.d.ts +3 -2
  39. package/dist/store/memory-md.d.ts +3 -3
  40. package/dist/store/memory-md.js +6 -4
  41. package/dist/store/runtime-installer.d.ts +13 -1
  42. package/dist/store/runtime-installer.js +40 -2
  43. package/dist/store/sqlite.d.ts +24 -12
  44. package/dist/store/sqlite.js +97 -31
  45. package/dist/store/supersede.d.ts +10 -2
  46. package/dist/store/supersede.js +10 -2
  47. package/dist/store/validity.d.ts +150 -0
  48. package/dist/store/validity.js +209 -0
  49. package/dist/tools/index.d.ts +15 -1
  50. package/dist/tools/index.js +393 -11
  51. package/dist/util/json-repair.d.ts +48 -0
  52. package/dist/util/json-repair.js +285 -0
  53. package/dist/validity-backfill.d.ts +78 -0
  54. package/dist/validity-backfill.js +160 -0
  55. package/dsh.plugin.json +2 -2
  56. package/package.json +2 -2
@@ -1,6 +1,7 @@
1
1
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { trace } from '../store/trace.js';
4
+ import { filterInjectCandidates } from '../store/validity.js';
4
5
  import { RecallDedupeStore } from '../store/recall-dedupe.js';
5
6
  import { OccupancyStore } from '../store/occupancy.js';
6
7
  import { expirePendingInjections, registerPendingInjection, setInjectionMarker, } from './recall-ack.js';
@@ -459,6 +460,29 @@ usage) {
459
460
  scoped = kept;
460
461
  }
461
462
  }
463
+ // ── 门2 被动注入硬当前态(expiry-tiering task_3.3/3.4,灰度闸**默认关**):
464
+ // 开时按 classifyValidity 现算态剔除 `expired`(仍在索引可检,只是不再自动
465
+ // 浮现进模型上下文);`expired_suspect` 保留并在注入行带"疑似失效"标注;
466
+ // superseded/retired 由门1 索引层结构性进不到候选(不在本层重复过滤)。
467
+ // 判据单源 `stateOfRecord`,本层不再写一份 valid_to/marker 比较(计划 §4 约束)。
468
+ // 关时不进分支零 getByIds——注入逐字与改前一致(零漂移)。
469
+ let suspectIds = new Set();
470
+ const tieringOn = cfg.validity?.tieringEnabled === true || live.get().validityTiering === true;
471
+ if (tieringOn && scoped.length > 0) {
472
+ const nowV = Date.now();
473
+ const byIdV = new Map(stores.l1.getByIds(scoped.map((h) => h.id)).map((r) => [r.id, r]));
474
+ // 主体收敛在 validity.filterInjectCandidates(纯函数可单测,task_3.6)——
475
+ // 这里只做数据装配与日志,不内联第二份判据。
476
+ const filtered = filterInjectCandidates(scoped, (id) => byIdV.get(id), nowV);
477
+ suspectIds = filtered.suspectIds;
478
+ if (filtered.exited > 0) {
479
+ logger.warn(`[memory] 门2 防御性拦截退场态候选 ${filtered.exited} 条(门1 应已挡住,出现即泄漏信号)`);
480
+ }
481
+ if (filtered.kept.length !== scoped.length) {
482
+ logger.info(`[memory] 门2 时效过滤:剔除 expired ${scoped.length - filtered.kept.length - filtered.exited} 条(agent=${payload.agent.id})`);
483
+ }
484
+ scoped = filtered.kept;
485
+ }
462
486
  // 召回去重:同会话已注入过的记录不再重复注入(模型上下文已持有,省 token)。
463
487
  // 纯过滤——剩几条注几条,全量压制(0 条新鲜命中)是正确状态而非未命中。
464
488
  // §E:压缩后增强轮跳过压制(压缩把已注入内容逐出了模型上下文)。
@@ -485,7 +509,10 @@ usage) {
485
509
  usage?.recordAttempt(scoped.map((h) => h.id), []);
486
510
  return decision;
487
511
  }
488
- const lines = applyRecallBudget(fresh.map((h) => `- [${h.scene_name ? `${h.type}|${h.scene_name}` : h.type}] ${h.content}`), { maxCharsPerMemory: cfg.recall.maxCharsPerMemory, maxTotalRecallChars: cfg.recall.maxTotalRecallChars });
512
+ const lines = applyRecallBudget(fresh.map((h) => `- [${h.scene_name ? `${h.type}|${h.scene_name}` : h.type}] ${h.content}` +
513
+ // 门2 task_3.4:疑而不隐——valid_to 来源不可信的行保留注入但带标注,
514
+ // 人/模型自行裁量。灰度关时 suspectIds 恒空,行形状逐字不变。
515
+ (suspectIds.has(h.id) ? '(疑似失效:有效期来源不可信)' : '')), { maxCharsPerMemory: cfg.recall.maxCharsPerMemory, maxTotalRecallChars: cfg.recall.maxTotalRecallChars });
489
516
  if (lines.length === 0) {
490
517
  // §F 追踪:预算清零轮(截断后无完整行)
491
518
  trace({
package/dist/index.js CHANGED
@@ -399,6 +399,8 @@ export async function apply(ctx, config) {
399
399
  runner.startIdleTimer();
400
400
  // 图谱周期补投影(cfg.graph.enabled 门控;30 分钟一次低速收敛)
401
401
  runner.startGraphBackfillTimer();
402
+ // 时效标注存量回填(expiry-tiering task_6.3:tieringEnabled 门控,低频、让位蒸馏)
403
+ runner.startValidityBackfillTimer();
402
404
  // 重建控制器(存储降级时不建——RPC 端点走 supported=false 分支)
403
405
  const rebuild = storageOk && !db.isDegraded()
404
406
  ? new RebuildController(ctx, config, stores, db, runner, logger, live)
@@ -468,7 +470,12 @@ export async function apply(ctx, config) {
468
470
  stores.l1.setFileSyncHook(fileSync);
469
471
  const recall = registerRecall(ctx, config, stores, logger, live, modes, dataDir, recallUsage);
470
472
  runner.setAfterRun(recall.invalidateProfile);
471
- const toolSeals = registerMemoryTools(ctx, config, stores, logger, modes, live, ruminate, storageOk ? roomRegistry : undefined);
473
+ const toolSeals = registerMemoryTools(ctx, config, stores, logger, modes, live, ruminate, storageOk ? roomRegistry : undefined, {
474
+ // 确定性写面钩子(graph-write-plane R2/R3):runner 公开方法直连,工具三路径与
475
+ // distill 分支共用同一个入队实现,不存在第二套优先级/去重逻辑。
476
+ enqueueGraph: (ids) => runner.enqueueGraphFor(ids),
477
+ reprojectGraph: (ids) => runner.reprojectGraph(ids),
478
+ });
472
479
  // 激活槽位(active slot):工具面 + 常驻注入 + 服务端投影(均走 ctx.effect,可撤销)
473
480
  registerSlotTools(ctx, config, stores.slots, logger, modes, live, stores.l1);
474
481
  const slotRecall = registerSlotRecall(ctx, config, stores.slots, logger, live);
package/dist/llm.d.ts CHANGED
@@ -156,7 +156,12 @@ export declare function planDistillEffort(ctx: Context, provider: string, model:
156
156
  * 适配器异常地没有发 block-end 时兜底。两者都累计会把输出翻倍。
157
157
  */
158
158
  export declare function callLLM(ctx: Context, cfg: MemoryConfig, opts: LlmCallOptions): Promise<string>;
159
- /** 带诊断日志的 parseJson:解析失败时记录原始输出摘录(模型输出异常排查的关键信息)。 */
159
+ /** 带诊断日志的 parseJson:解析失败时记录原始输出摘录(模型输出异常排查的关键信息);修复/救援命中时 warn 一行。 */
160
160
  export declare function parseJsonLogged<T>(raw: string, what: string, logger?: MemoryLogger): T;
161
- /** 容错地解析 LLM 输出的 JSON(剥掉可能的 ```json 围栏)。 */
161
+ /**
162
+ * 容错地解析 LLM 输出的 JSON。
163
+ *
164
+ * 旧版行为:剥 ```json``` 围栏 + 切到首个 `[`/`{` 到末尾 `]`/`}`,一次 JSON.parse。
165
+ * 新版同路径 + 三级容错(strict → repaired → salvaged),内部实现见 util/json-repair.ts。
166
+ */
162
167
  export declare function parseJson<T>(raw: string): T;
package/dist/llm.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
2
2
  import { errDetail } from './util/filelog.js';
3
+ import { parseJsonTolerant } from './util/json-repair.js';
3
4
  import { recordDistillCall } from './llm-usage.js';
4
5
  import { recordCostCall } from './token-cost.js';
5
6
  // ── 分层输出预算(规格 C 节):结构化蒸馏用不到总闸级预算,逐层设护栏;
@@ -499,35 +500,26 @@ async function callRoute(ctx, cfg, opts, route) {
499
500
  opts.logger?.info(`[memory] LLM 调用 ${provider}/${model}:输入 ${user.length} 字符 → 输出 ${out.length} 字符(${((Date.now() - startedAt) / 1000).toFixed(1)}s,finish=${finishKind || '无'})`);
500
501
  return out;
501
502
  }
502
- /** 带诊断日志的 parseJson:解析失败时记录原始输出摘录(模型输出异常排查的关键信息)。 */
503
+ /** 带诊断日志的 parseJson:解析失败时记录原始输出摘录(模型输出异常排查的关键信息);修复/救援命中时 warn 一行。 */
503
504
  export function parseJsonLogged(raw, what, logger) {
504
505
  try {
505
- return parseJson(raw);
506
+ const { value, level, notes } = parseJsonTolerant(raw);
507
+ if (level !== 'strict') {
508
+ logger?.warn(`[memory] ${what} JSON 已容错处理(${level}: ${notes.join('、') || '无动作'}),原文 ${raw.length} 字符`);
509
+ }
510
+ return value;
506
511
  }
507
512
  catch (err) {
508
513
  logger?.error(`[memory] ${what} JSON 解析失败(${errDetail(err)}),原始输出前 400 字符: ${raw.slice(0, 400)}`);
509
514
  throw new Error(`${what} 输出无法解析为 JSON`);
510
515
  }
511
516
  }
512
- /** 容错地解析 LLM 输出的 JSON(剥掉可能的 ```json 围栏)。 */
517
+ /**
518
+ * 容错地解析 LLM 输出的 JSON。
519
+ *
520
+ * 旧版行为:剥 ```json``` 围栏 + 切到首个 `[`/`{` 到末尾 `]`/`}`,一次 JSON.parse。
521
+ * 新版同路径 + 三级容错(strict → repaired → salvaged),内部实现见 util/json-repair.ts。
522
+ */
513
523
  export function parseJson(raw) {
514
- let s = raw.trim();
515
- if (s.startsWith('```')) {
516
- s = s.replace(/^```[a-zA-Z]*\n?/, '').replace(/```$/, '').trim();
517
- }
518
- const start = s.indexOf('[');
519
- const brace = s.indexOf('{');
520
- let begin;
521
- if (start === -1)
522
- begin = brace;
523
- else if (brace === -1)
524
- begin = start;
525
- else
526
- begin = Math.min(start, brace);
527
- if (begin > 0)
528
- s = s.slice(begin);
529
- const end = s.lastIndexOf(']') > s.lastIndexOf('}') ? s.lastIndexOf(']') + 1 : s.lastIndexOf('}') + 1;
530
- if (end > 0)
531
- s = s.slice(0, end);
532
- return JSON.parse(s);
524
+ return parseJsonTolerant(raw).value;
533
525
  }
@@ -34,11 +34,27 @@ export declare function selectContextEdges(nodes: readonly GraphNode[], edges: r
34
34
  * 匹配串源自 callLLM/callRoute/callDirect 的实际抛出形态(小写子串包含)。
35
35
  */
36
36
  export declare function isTransientGraphFailure(msg: string): boolean;
37
+ /**
38
+ * 节点向量化的嵌入文本(单一形态定义,读写两侧共用):
39
+ * 实体名 + 别名 + 当前状态串。读侧种子查询用用户 query 的文本向量,
40
+ * 两边同嵌入源同空间即可——不追求"同一句话",追求"同一语义面"。
41
+ */
42
+ export declare function nodeEmbedText(node: GraphNode): string;
43
+ /**
44
+ * 投影收尾的节点向量补写(G-vec 缺陷修:此前 upsertNodeVectors 生产零调用点,
45
+ * 嵌入开着时 graph_node_vec 永远是 0 行,"向量种子"读路结构性空转)。
46
+ *
47
+ * 失败隔离:嵌入失败/未就绪只 warn——向量是召回增益,缺了它图谱词法路照常,
48
+ * 绝不能把一个旁路补算弄挂投影主流程(与 L1 侧 restore 的向量纪律同向)。
49
+ */
50
+ export declare function writeNodeVectorsAfterComplete(graphs: GraphStore, touched: readonly GraphNode[], embed: ((text: string) => Promise<Float32Array | undefined>) | undefined, logger: MemoryLogger): Promise<number>;
37
51
  /**
38
52
  * 执行一次图谱投影(claim 一个 job → LLM → 校验落库;无 job 时静默返回)。
39
53
  * 返回是否实际认领了任务(泵据此决定是否续排)。失败转 fail(退避/封顶由
40
54
  * GraphStore 管),不向 runner 上抛。
41
55
  */
42
- export declare function runGraphProjection(ctx: Context, cfg: MemoryConfig, graphs: GraphStore, logger: MemoryLogger): Promise<boolean>;
56
+ export declare function runGraphProjection(ctx: Context, cfg: MemoryConfig, graphs: GraphStore, logger: MemoryLogger,
57
+ /** 节点向量补算的嵌入缝(runner 接 stores.l1.embedText;缺席/未就绪 = 不补)。 */
58
+ embedNode?: (text: string) => Promise<Float32Array | undefined>): Promise<boolean>;
43
59
  /** 投影输出解析与形状归一:非对象/缺数组字段回空结构(校验侧再逐条把关)。 */
44
60
  export declare function parseProjection(raw: string, logger?: MemoryLogger): GraphProjectionResult;
@@ -86,12 +86,52 @@ export function isTransientGraphFailure(msg) {
86
86
  low.includes('socket hang') ||
87
87
  low.includes('network'));
88
88
  }
89
+ /**
90
+ * 节点向量化的嵌入文本(单一形态定义,读写两侧共用):
91
+ * 实体名 + 别名 + 当前状态串。读侧种子查询用用户 query 的文本向量,
92
+ * 两边同嵌入源同空间即可——不追求"同一句话",追求"同一语义面"。
93
+ */
94
+ export function nodeEmbedText(node) {
95
+ return [node.name, ...node.aliases, node.currentState].filter((s) => s && s.trim()).join(' ');
96
+ }
97
+ /**
98
+ * 投影收尾的节点向量补写(G-vec 缺陷修:此前 upsertNodeVectors 生产零调用点,
99
+ * 嵌入开着时 graph_node_vec 永远是 0 行,"向量种子"读路结构性空转)。
100
+ *
101
+ * 失败隔离:嵌入失败/未就绪只 warn——向量是召回增益,缺了它图谱词法路照常,
102
+ * 绝不能把一个旁路补算弄挂投影主流程(与 L1 侧 restore 的向量纪律同向)。
103
+ */
104
+ export async function writeNodeVectorsAfterComplete(graphs, touched, embed, logger) {
105
+ if (!graphs.nodeVecReady || !embed || touched.length === 0)
106
+ return 0;
107
+ const rows = [];
108
+ for (const n of touched) {
109
+ if (n.status !== 'active' && n.status !== 'disputed')
110
+ continue;
111
+ try {
112
+ const v = await embed(nodeEmbedText(n));
113
+ if (v && v.length > 0)
114
+ rows.push({ nodeId: n.id, embedding: v, updatedAt: n.updatedAt });
115
+ }
116
+ catch (err) {
117
+ logger.warn(`[memory] 节点向量计算失败(该节点跳过,词法路不受影响): ${err instanceof Error ? err.message : String(err)}`);
118
+ }
119
+ }
120
+ if (rows.length === 0)
121
+ return 0;
122
+ const written = graphs.upsertNodeVectors(rows);
123
+ if (written > 0)
124
+ logger.debug?.(`[memory] 节点向量随投影写入 ${written} 条`);
125
+ return written;
126
+ }
89
127
  /**
90
128
  * 执行一次图谱投影(claim 一个 job → LLM → 校验落库;无 job 时静默返回)。
91
129
  * 返回是否实际认领了任务(泵据此决定是否续排)。失败转 fail(退避/封顶由
92
130
  * GraphStore 管),不向 runner 上抛。
93
131
  */
94
- export async function runGraphProjection(ctx, cfg, graphs, logger) {
132
+ export async function runGraphProjection(ctx, cfg, graphs, logger,
133
+ /** 节点向量补算的嵌入缝(runner 接 stores.l1.embedText;缺席/未就绪 = 不补)。 */
134
+ embedNode) {
95
135
  const claim = graphs.claimNext();
96
136
  if (!claim)
97
137
  return false;
@@ -110,7 +150,10 @@ export async function runGraphProjection(ctx, cfg, graphs, logger) {
110
150
  // 实测 ~2min/job,正好撞线(14 dead 根因);照 backfill callTimeoutMs 先例给 240s。
111
151
  signal: AbortSignal.timeout(GRAPH_PROJECTION_CALL_TIMEOUT_MS),
112
152
  });
113
- graphs.complete(job.id, parseProjection(raw, logger), { now: new Date().toISOString() });
153
+ const touched = graphs.complete(job.id, parseProjection(raw, logger), { now: new Date().toISOString() });
154
+ // 投影落库后的向量收尾(G-vec 缺陷修):事务外做——嵌入是网络/推理调用,
155
+ // LLM 与嵌入永不进事务缝是本文件头注释写死的既有纪律。
156
+ await writeNodeVectorsAfterComplete(graphs, touched, embedNode, logger);
114
157
  }
115
158
  catch (err) {
116
159
  const detail = errDetail(err);
@@ -16,9 +16,29 @@ import { normTags, normUsageScenario, readCognitiveHall, readWing } from '../met
16
16
  import { acceptTitle } from '../util/md-name.js';
17
17
  import { formatBatchConflictPrompt, getConflictDetectionSystemPrompt } from '../prompts/l1-dedup.js';
18
18
  import { resolveSourceAnchors, withSourceAnchors } from './anchors.js';
19
+ import { stampValidity, validToSourceOf } from '../store/validity.js';
19
20
  import { familyForType, normPersistence, normScope, resolveRecordFamily, resolveRecordScope, WING_FALLBACK } from '../types.js';
20
21
  import { normApplicability, resolveRepoScope } from '../repo-scope.js';
21
22
  import { routeWorkspace } from '../workspace-route.js';
23
+ /**
24
+ * L1 抽取单块字符硬顶(修复 #10 分批过大)
25
+ *
26
+ * `cfg.llm.maxInputChars` 的语义是「单次 LLM 调用输入闸门」(默认 700k),被复用为
27
+ * 分块预算时算出 658k/块——一次输出须在同一 JSON 数组写完全部抽出记忆,块越大字符串越多,
28
+ * 任一处长中文里未转义 ASCII `"` 即整批 parse 失败、该块记忆全丢;慢模型更直接撞 120s 超时。
29
+ * 砍到 24k 后输出 ≈2–4k tokens,仍远低于 LAYER_MAX_TOKENS_EXTRACT(16k),不会引入截断,
30
+ * 单块失败半径从「几百条」缩到「几十条」。代价是多几次小调用,但每次 5–15s 完成。
31
+ */
32
+ const MAX_EXTRACT_CHUNK_CHARS = 24_000;
33
+ /**
34
+ * L1 去重单批发起上限(与抽取同精神)
35
+ *
36
+ * 旧版一次将整批 extracted[] 丢给 formatBatchConflictPrompt 发单次 LLM 调用,候选召回默认
37
+ * 每新记忆 10 个 → batch=100 时 prompt 轻松上 500k 字符,同样命中 #10 的失败面。按
38
+ * 固定条数切块(同族内不跨子集),失败半径缩到 ≤20 条,且每块上下文仍足够。
39
+ * 上限 20 为工程默认,后续可提到配置。
40
+ */
41
+ const MAX_DEDUP_BATCH_SIZE = 20;
22
42
  /** 解析 ISO/epoch 时间证据,非法或非正值一律 undefined——不猜测。 */
23
43
  function parseTimeEvidence(raw) {
24
44
  const t = typeof raw === 'string' ? Date.parse(raw) : typeof raw === 'number' ? raw : Number.NaN;
@@ -29,28 +49,39 @@ function parseTimeEvidence(raw) {
29
49
  *
30
50
  * `activity_start_time`/`activity_end_time` 是这两列的前身(prompt 已在产出),
31
51
  * 这里把它们提升为结构化字段;`persistence` 由 prompt 产出,缺省即未判定。
52
+ * valid_to 源自 activity_* 证据链 → `validToSource='evidence'`(task_2.5 打标)。
32
53
  */
33
54
  function temporalOf(meta) {
55
+ const validTo = parseTimeEvidence(meta?.activity_end_time);
34
56
  return {
35
57
  validFrom: parseTimeEvidence(meta?.activity_start_time),
36
- validTo: parseTimeEvidence(meta?.activity_end_time),
58
+ validTo,
37
59
  persistence: normPersistence(meta?.persistence),
60
+ ...(validTo !== undefined ? { validToSource: 'evidence' } : {}),
38
61
  };
39
62
  }
40
63
  /**
41
64
  * 合并(merge/update)时的有效期并集:
42
65
  * 起 = 两侧最早;止 = 任一侧未闭合则仍未闭合(undefined),否则取最晚。
43
66
  * 持续性以新记忆为准(合并产物描述的是当前认知),新记忆未判定时继承旧记录。
67
+ * validToSource 随**胜出值**的来源走(task_2.5):最终 valid_to 取自哪一侧,
68
+ * 证据等级就读哪一侧的(旧行取其 `dsh_validity` 标注,未标注算 'unknown'——
69
+ * 宁判疑不判死,fail-open 与 classify 同向)。
44
70
  */
45
71
  function mergeTemporal(self, targets) {
46
72
  const froms = [self.validFrom, ...targets.map((r) => r.validFrom)].filter((t) => t !== undefined);
47
- const tos = [self.validTo, ...targets.map((r) => r.validTo)];
48
- const anyOpen = tos.some((t) => t === undefined);
49
- const closed = tos.filter((t) => t !== undefined);
73
+ const tos = [
74
+ { v: self.validTo, s: self.validToSource ?? 'unknown' },
75
+ ...targets.map((r) => ({ v: r.validTo, s: validToSourceOf(r.metadata) })),
76
+ ];
77
+ const anyOpen = tos.some((e) => e.v === undefined);
78
+ const closed = tos.filter((e) => e.v !== undefined);
79
+ const winner = closed.length > 0 ? closed.reduce((a, b) => (b.v > a.v ? b : a)) : undefined;
50
80
  return {
51
81
  validFrom: froms.length > 0 ? Math.min(...froms) : undefined,
52
- validTo: anyOpen || closed.length === 0 ? undefined : Math.max(...closed),
82
+ validTo: anyOpen || winner === undefined ? undefined : winner.v,
53
83
  persistence: self.persistence ?? targets.find((r) => r.persistence)?.persistence,
84
+ ...(!anyOpen && winner !== undefined ? { validToSource: winner.s } : {}),
54
85
  };
55
86
  }
56
87
  /**
@@ -152,7 +183,10 @@ export function toStoreRecord(m, now, ts, anchorMap) {
152
183
  // 过不了就**不写键**,交给 `L1Store.assignMissingNames`——判据只留一份,
153
184
  // 不在这里养第二套“看起来差不多”的名字清洗。
154
185
  const titled = m.title === undefined ? undefined : acceptTitle(m.title);
155
- return {
186
+ // 时间轴派生单独算:validToSource 不进记录形状(它不是 MemoryRecord 字段,随展开
187
+ // 落库会改 JSONL 形状),只喂给 stampValidity 盖进 dsh_validity(task_2.5/3.2 写侧标注)。
188
+ const t = temporalOf(m.metadata);
189
+ const rec = {
156
190
  id: m.record_id,
157
191
  content: m.content,
158
192
  type: m.type,
@@ -179,8 +213,13 @@ export function toStoreRecord(m, now, ts, anchorMap) {
179
213
  // 文件身份键不在这里分:**起名只有一个真源**(`L1Store.assignMissingNames`)。
180
214
  // 以前这里自己分一份、库里再分一份,两套判据迟早漂移,而漂移的后果是同一个基底
181
215
  // 洗出两个名字(文件跳名)或两个记录抢一个名字(整条写不进去)。
182
- ...temporalOf(m.metadata),
216
+ validFrom: t.validFrom,
217
+ validTo: t.validTo,
218
+ persistence: t.persistence,
183
219
  };
220
+ // 写侧标注(task_3.2 派生时机之一):管线盖时源头已知 evidence(LLM 直填/工具路径另算)。
221
+ stampValidity(rec, now, t.validToSource);
222
+ return rec;
184
223
  }
185
224
  function newId(prefix) {
186
225
  return `${prefix}_${Date.now()}_${randomBytes(3).toString('hex')}`;
@@ -274,7 +313,8 @@ mergeRekey) {
274
313
  // ── Step 1: 抽取(输入按 llm.maxInputChars 预算分块,情境链式衔接,不丢消息) ──
275
314
  const backgroundMsgs = background.slice(-cfg.extract.backgroundMessages);
276
315
  // 预留:背景消息(≤10 条 ×4000 字)+ prompt 脚手架
277
- const perChunk = Math.max(20_000, cfg.llm.maxInputChars - 42_000);
316
+ // 硬顶 24k(见 MAX_EXTRACT_CHUNK_CHARS 注释);若配置更小则跟随配置
317
+ const perChunk = Math.min(MAX_EXTRACT_CHUNK_CHARS, Math.max(20_000, cfg.llm.maxInputChars - 42_000));
278
318
  const chunks = chunkByCharBudget(pending, perChunk);
279
319
  if (chunks.length > 1) {
280
320
  logger.info(`[memory] L1 输入超预算(${pending.length} 条消息),分 ${chunks.length} 块抽取(每块 ≤${perChunk} 字符)`);
@@ -367,19 +407,32 @@ mergeRekey) {
367
407
  newMemory: m,
368
408
  candidates: await store.searchCandidates(m.content, cfg.extract.candidatePool, m.family, wsFilter),
369
409
  })));
370
- const dedupPrompt = formatBatchConflictPrompt(matches, {
371
- conflictFreeze: cfg.conflictFreeze.enabled,
372
- });
373
- const dedupRaw = await callLLM(ctx, cfg, {
374
- system: getConflictDetectionSystemPrompt(mode, { conflictFreeze: cfg.conflictFreeze.enabled, title: cfg.naming?.enabled === true }),
375
- user: dedupPrompt,
376
- maxTokens: resolveLayerTokens(cfg, 'dedup'),
377
- layer: 'l1-dedup',
378
- logger,
379
- });
380
- const decisions = parseJsonLogged(dedupRaw, 'L1 去重判定', logger);
410
+ // 按固定条数切块发送,失败半径与上下文同时可控(#10 去重侧同精)
411
+ const dedupBatches = [];
412
+ for (let i = 0; i < matches.length; i += MAX_DEDUP_BATCH_SIZE) {
413
+ dedupBatches.push(matches.slice(i, i + MAX_DEDUP_BATCH_SIZE));
414
+ }
415
+ if (dedupBatches.length > 1) {
416
+ logger.info(`[memory] L1 去重分为 ${dedupBatches.length} 批发送(每批 ≤${MAX_DEDUP_BATCH_SIZE} 条候选记忆)`);
417
+ }
418
+ const decisionsFlat = [];
419
+ for (const [bi, batch] of dedupBatches.entries()) {
420
+ const batchPrompt = formatBatchConflictPrompt(batch, {
421
+ conflictFreeze: cfg.conflictFreeze.enabled,
422
+ });
423
+ const batchRaw = await callLLM(ctx, cfg, {
424
+ system: getConflictDetectionSystemPrompt(mode, { conflictFreeze: cfg.conflictFreeze.enabled, title: cfg.naming?.enabled === true }),
425
+ user: batchPrompt,
426
+ maxTokens: resolveLayerTokens(cfg, 'dedup'),
427
+ layer: 'l1-dedup',
428
+ logger,
429
+ });
430
+ const batchDecisions = parseJsonLogged(batchRaw, `L1 去重判定${dedupBatches.length > 1 ? `(批 ${bi + 1}/${dedupBatches.length})` : ''}`, logger);
431
+ if (Array.isArray(batchDecisions))
432
+ decisionsFlat.push(...batchDecisions);
433
+ }
381
434
  const byRecord = new Map();
382
- for (const d of Array.isArray(decisions) ? decisions : []) {
435
+ for (const d of decisionsFlat) {
383
436
  if (d && typeof d.record_id === 'string')
384
437
  byRecord.set(d.record_id, d);
385
438
  }
@@ -601,7 +654,10 @@ mergeRekey) {
601
654
  const mergedTitle = mergedName === undefined || mergedName.length === 0
602
655
  ? acceptTitle(decision.merged_title)
603
656
  : mergedName;
604
- added.push({
657
+ // 合并的有效期取并集:起 = 两侧最早;止 = 任一侧未闭合则仍未闭合(undefined)。
658
+ // validToSource 随胜出侧走(task_2.5),同样不进记录形状、只喂标注。
659
+ const mt = mergeTemporal(temporalOf(m.metadata), targets.map((id) => byId.get(id)).filter((r) => r !== undefined));
660
+ const mergedRec = {
605
661
  id: m.record_id,
606
662
  content: mergedContent,
607
663
  type: decision.merged_type || m.type,
@@ -628,14 +684,19 @@ mergeRekey) {
628
684
  repoKeyName: m.repoKeyName,
629
685
  repoKeyOwner: m.repoKeyOwner,
630
686
  applicability: m.applicability,
631
- // 合并的有效期取并集:起 = 两侧最早;止 = 任一侧未闭合则仍未闭合(undefined)。
632
- ...mergeTemporal(temporalOf(m.metadata), targets.map((id) => byId.get(id)).filter((r) => r !== undefined)),
633
- });
687
+ validFrom: mt.validFrom,
688
+ validTo: mt.validTo,
689
+ persistence: mt.persistence,
690
+ };
691
+ // 写侧标注(task_3.2):merge 产物的证据链随胜出侧透传。
692
+ stampValidity(mergedRec, now, mt.validToSource);
693
+ added.push(mergedRec);
634
694
  }
635
695
  await store.appendNew(added);
636
696
  if (supersededBy.size > 0) {
637
- // **软删**(取代):不是物理删除——主表行保留 + `valid_to` 闭合 + 取代标记,
638
- // FTS/向量撤出检索面。于是"合并错了"也能恢复,而不必去 `records/*.jsonl` 手工捞。
697
+ // **软删**(取代):不是物理删除——主表行保留 + 取代标记(marker 单源,门1 解耦后
698
+ // 不再闭合 valid_to),FTS/向量撤出检索面。于是"合并错了"也能恢复,而不必去
699
+ // `records/*.jsonl` 手工捞。
639
700
  // 按取代者分组落盘(一次事务一组),避免逐条开事务。
640
701
  const retiredAt = new Date(now).toISOString();
641
702
  const byNewRecord = new Map();
@@ -32,7 +32,7 @@
32
32
  */
33
33
  import { opendir, readFile, realpath, stat } from 'node:fs/promises';
34
34
  import { join, resolve, sep } from 'node:path';
35
- import { DISABLE_SUFFIX, digest, recordDigest, targetFor } from '../store/memory-md.js';
35
+ import { DISABLE_SUFFIX, digest, isRetiredRecord, recordDigest, targetFor } from '../store/memory-md.js';
36
36
  import { exportRecords } from '../store/memory-md-export.js';
37
37
  import { parseMdFile } from '../store/memory-md-parse.js';
38
38
  import { normalizeContentMd } from '../util/md-normalize.js';
@@ -360,7 +360,9 @@ export async function syncFromFiles(deps) {
360
360
  // 自校验(N13):`.disable` 也带 `hash`。**只留痕、不改裁决**——后缀即“人已判过时”,
361
361
  // 若因凭证正文被手改一字节就把退场锁死,等于让人改坏凭证就丢掉处置意图。
362
362
  noteHash(parseMdFile(text), abs);
363
- if (rec.validTo !== undefined) {
363
+ // 门1 解耦:退场判据是 marker 单源(`isRetiredRecord` 与库侧同口径)——
364
+ // retire 不再闭 valid_to,拿 validTo 判"已退场"会让第二轮重复计数、永不收敛到 same。
365
+ if (isRetiredRecord(rec)) {
364
366
  report.same += 1;
365
367
  if (entry === undefined)
366
368
  await stampDisabled(ledger, rec, abs, text);
@@ -158,6 +158,32 @@ export declare class MemoryRunner {
158
158
  * 每轮 drain 至多消费一个 graph 任务且永远让位 live)。
159
159
  */
160
160
  startGraphBackfillTimer(): void;
161
+ /**
162
+ * validity 标注存量回填的接线(task_6.3):低频、让位于蒸馏——启动 3 分钟后首轮,
163
+ * 之后每 6h 一次(首轮收敛后多数轮是纯扫描 no-op;defer 的部分自动续上)。
164
+ * 门控跟灰度闸走:tieringEnabled(部署 ∨ 面板)关时不进任务——功能关着时
165
+ * 存量 metadata 逐字不变(零漂移,与 keyword-backfill 的门控同法)。
166
+ */
167
+ startValidityBackfillTimer(): void;
168
+ private runValidityBackfillTick;
169
+ /**
170
+ * 确定性写面的公共入队钩子(graph-write-plane R2):非蒸馏写入路径
171
+ * (`memory_add`/`memory_import`/`memory_update`)落库成功后把涉及记录投进
172
+ * 投影队——与 distill 分支同源同优先级,闭合"只有蒸馏才投影"的旁路缺口
173
+ * (用户手加记录长期 projected=None 的真根因)。部署未开图谱时 no-op,
174
+ * 用户后续开启由启动/周期补投影兜底(与 distill 分支同一口径)。
175
+ */
176
+ enqueueGraphFor(recordIds: readonly string[]): void;
177
+ /**
178
+ * 强制重投的写面入口(graph-write-plane R3):把上游 R10 的内部
179
+ * `forceReprojectRecord` 接到泵上——删凭证、高优入队、拉泵;幂等由
180
+ * 在途 mapping 的 PK 去重保证。图谱未启用时直接回零(不开"投了永不泵"
181
+ * 的陈旧队列口子,纠正通道的语义前提是图谱通路存在)。
182
+ */
183
+ reprojectGraph(recordIds: readonly string[]): {
184
+ queued: number;
185
+ jobIds: string[];
186
+ };
161
187
  /**
162
188
  * 图谱投影泵入队判定:
163
189
  * - 双门:部署级 cfg.graph.enabled + 运行时 live.enabled/distill,任一为假不入队;
@@ -1,5 +1,6 @@
1
1
  import { resolveDataDir } from '../config.js';
2
2
  import { GRAPH_PRIORITY_NEW } from '../graph/types.js';
3
+ import { startValidityBackfill } from '../validity-backfill.js';
3
4
  import { drscRowToConversationMessage } from '../clients/drsc.js';
4
5
  import { trace } from '../store/trace.js';
5
6
  import { emptyPending, freshWarmup, groupPendingBySession, loadPending, PENDING_MODES, pendingPathFor, savePending, } from '../store/pending.js';
@@ -136,6 +137,11 @@ export function effectiveCfg(cfg, live) {
136
137
  const mpOverride = s?.memportEnabled !== undefined
137
138
  ? { memport: { ...(cfg.memport ?? {}), enabled: cfg.memport?.enabled === true || s.memportEnabled === true } }
138
139
  : null;
140
+ // 时效分级门2 灰度闸:live.validityTiering 与部署 cfg.validity.tieringEnabled 取或
141
+ // (同一先例)。两字都假时回 `{tieringEnabled:false}` 与缺省形状等价,注入侧零漂移不破。
142
+ const vtOverride = s?.validityTiering !== undefined
143
+ ? { validity: { ...(cfg.validity ?? {}), tieringEnabled: cfg.validity?.tieringEnabled === true || s.validityTiering === true } }
144
+ : null;
139
145
  // md 投射/后缀收敛的单次动作护栏运行时覆盖(0 = 跟随部署 cfg.memExport.maxRecords)。
140
146
  // 只覆盖条数护栏,不动 enabled/root——开关与根路径仍属部署配置。
141
147
  const mdx = s?.mdExportMaxRecords ?? 0;
@@ -144,7 +150,7 @@ export function effectiveCfg(cfg, live) {
144
150
  // 不动 maxRecords/timeBudgetMs/callTimeoutMs——那三个仍属部署护栏,要改得改 YAML。
145
151
  const bf = s?.backfillConcurrency ?? 0;
146
152
  const backfillOverride = bf > 0 ? { backfill: { ...(cfg.backfill ?? {}), concurrency: bf } } : null;
147
- if (!override && !budgets && !maxInput && !fallbacksTakeover && !chainEffort && !chainFallbacks && !effortInject && !layerChains && !hasChannel && !embedOverride && !cfOverride && !memExportOverride && !backfillOverride && !kwOverride && !rdOverride && !mdsOverride && !mpOverride)
153
+ if (!override && !budgets && !maxInput && !fallbacksTakeover && !chainEffort && !chainFallbacks && !effortInject && !layerChains && !hasChannel && !embedOverride && !cfOverride && !memExportOverride && !backfillOverride && !kwOverride && !rdOverride && !mdsOverride && !mpOverride && !vtOverride)
148
154
  return cfg;
149
155
  return {
150
156
  ...cfg,
@@ -154,6 +160,7 @@ export function effectiveCfg(cfg, live) {
154
160
  ...(rdOverride ?? {}),
155
161
  ...(mdsOverride ?? {}),
156
162
  ...(mpOverride ?? {}),
163
+ ...(vtOverride ?? {}),
157
164
  ...(memExportOverride ?? {}),
158
165
  ...(backfillOverride ?? {}),
159
166
  llm: {
@@ -454,6 +461,65 @@ export class MemoryRunner {
454
461
  return () => clearInterval(timer);
455
462
  });
456
463
  }
464
+ /**
465
+ * validity 标注存量回填的接线(task_6.3):低频、让位于蒸馏——启动 3 分钟后首轮,
466
+ * 之后每 6h 一次(首轮收敛后多数轮是纯扫描 no-op;defer 的部分自动续上)。
467
+ * 门控跟灰度闸走:tieringEnabled(部署 ∨ 面板)关时不进任务——功能关着时
468
+ * 存量 metadata 逐字不变(零漂移,与 keyword-backfill 的门控同法)。
469
+ */
470
+ startValidityBackfillTimer() {
471
+ this.ctx.effect(() => {
472
+ let loop = null;
473
+ const first = setTimeout(() => {
474
+ this.runValidityBackfillTick();
475
+ loop = setInterval(() => this.runValidityBackfillTick(), 6 * 3_600_000);
476
+ }, 180_000);
477
+ return () => { clearTimeout(first); if (loop !== null)
478
+ clearInterval(loop); };
479
+ });
480
+ }
481
+ runValidityBackfillTick() {
482
+ if (this.stopped)
483
+ return;
484
+ const eff = effectiveCfg(this.cfg, this.live);
485
+ if (eff.validity?.tieringEnabled !== true)
486
+ return;
487
+ startValidityBackfill({
488
+ l1: this.stores.l1,
489
+ logger: this.logger,
490
+ // task_7.7 接缝:回填改过标注的记录顺手重投保鲜图谱(图谱未启用时 reprojectGraph 自己回零)。
491
+ reproject: (ids) => { this.reprojectGraph(ids); },
492
+ shouldCancel: () => this.stopped,
493
+ });
494
+ }
495
+ /**
496
+ * 确定性写面的公共入队钩子(graph-write-plane R2):非蒸馏写入路径
497
+ * (`memory_add`/`memory_import`/`memory_update`)落库成功后把涉及记录投进
498
+ * 投影队——与 distill 分支同源同优先级,闭合"只有蒸馏才投影"的旁路缺口
499
+ * (用户手加记录长期 projected=None 的真根因)。部署未开图谱时 no-op,
500
+ * 用户后续开启由启动/周期补投影兜底(与 distill 分支同一口径)。
501
+ */
502
+ enqueueGraphFor(recordIds) {
503
+ if (recordIds.length === 0 || !this.stores.graph || !this.cfg.graph?.enabled)
504
+ return;
505
+ this.stores.graph.queueGraphProjection([...recordIds], GRAPH_PRIORITY_NEW);
506
+ this.maybeQueueGraphTask();
507
+ }
508
+ /**
509
+ * 强制重投的写面入口(graph-write-plane R3):把上游 R10 的内部
510
+ * `forceReprojectRecord` 接到泵上——删凭证、高优入队、拉泵;幂等由
511
+ * 在途 mapping 的 PK 去重保证。图谱未启用时直接回零(不开"投了永不泵"
512
+ * 的陈旧队列口子,纠正通道的语义前提是图谱通路存在)。
513
+ */
514
+ reprojectGraph(recordIds) {
515
+ const graphs = this.stores.graph;
516
+ if (!graphs || !this.cfg.graph?.enabled || recordIds.length === 0)
517
+ return { queued: 0, jobIds: [] };
518
+ const r = graphs.forceReprojectRecord(recordIds);
519
+ if (r.queued > 0)
520
+ this.maybeQueueGraphTask();
521
+ return r;
522
+ }
457
523
  /**
458
524
  * 图谱投影泵入队判定:
459
525
  * - 双门:部署级 cfg.graph.enabled + 运行时 live.enabled/distill,任一为假不入队;
@@ -478,7 +544,10 @@ export class MemoryRunner {
478
544
  this.graphPumpQueued = false;
479
545
  if (this.stopped)
480
546
  return;
481
- const had = await runGraphProjection(this.ctx, effectiveCfg(this.cfg, this.live), graphs, this.logger);
547
+ const had = await runGraphProjection(this.ctx, effectiveCfg(this.cfg, this.live), graphs, this.logger,
548
+ // G-vec 缺陷修的嵌入缝:惰性取 L1 同一嵌入源(活切换不被钉死),
549
+ // 未就绪/失败 embedText 回 undefined = 不补写(词法路不受影响)。
550
+ (t) => this.stores.l1.embedText(t));
482
551
  // 有实际投影(说明队列还有积压)才续排;claim 落空则停泵,等下一个触发源
483
552
  if (had)
484
553
  this.maybeQueueGraphTask();
@@ -572,11 +641,9 @@ export class MemoryRunner {
572
641
  if ((opts?.force || sliceLen >= effective) && !backedOff) {
573
642
  newRecords = await this.extractSessionSlice(sessionId, mode, cfg, effective, opts);
574
643
  // 新记录入投影队列(优先级恒高于存量补投影)并拉起图谱泵;入队本身廉价,
575
- // 部署未开图谱(cfg.graph.enabled)时不入队——用户后续开启由启动补投影兜底
576
- if (newRecords.length > 0 && this.stores.graph && this.cfg.graph?.enabled) {
577
- this.stores.graph.queueGraphProjection(newRecords.map((r) => r.id), GRAPH_PRIORITY_NEW);
578
- this.maybeQueueGraphTask();
579
- }
644
+ // 部署未开图谱(cfg.graph.enabled)时不入队——用户后续开启由启动补投影兜底。
645
+ // (写面 R2:分支本体抽成 enqueueGraphFor,与工具三路径同源,不再两处漂移。)
646
+ this.enqueueGraphFor(newRecords.map((r) => r.id));
580
647
  }
581
648
  else {
582
649
  this.logger.debug?.(backedOff
@@ -22,11 +22,12 @@ export interface SlotMigrateStoreFace {
22
22
  list(): Slot[];
23
23
  close(id: string, status: 'done' | 'dropped'): Promise<boolean>;
24
24
  }
25
- /** 迁移需要的 L1 只读面(validTo 非空 = 已退场(epoch ms),不作为信息载体)。 */
25
+ /** 迁移需要的 L1 只读面(门1 解耦:退场判据是 marker 单源——metadata 带 `dsh_superseded`
26
+ * 即已退场;valid_to 只是现实有效期,带着它不算退场)。 */
26
27
  export interface SlotMigrateL1Face {
27
28
  getByIds(ids: string[]): Array<{
28
29
  id: string;
29
- validTo?: number | null;
30
+ metadata?: unknown;
30
31
  }>;
31
32
  }
32
33
  /** 一条可迁移槽位(预览用,执行前即可展示)。 */