dsh-prime-memory 0.26.5-beta.0 → 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 (66) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.en.md +62 -1
  3. package/README.md +52 -1
  4. package/dist/client.js +36785 -134
  5. package/dist/config.d.ts +9 -0
  6. package/dist/config.js +8 -0
  7. package/dist/contract.d.ts +76 -0
  8. package/dist/embedding-child.cjs +192 -0
  9. package/dist/embedding-worker.cjs +2 -0
  10. package/dist/graph/apply.d.ts +9 -0
  11. package/dist/graph/apply.js +12 -2
  12. package/dist/graph/constraints.d.ts +3 -1
  13. package/dist/graph/constraints.js +4 -2
  14. package/dist/graph/preview.d.ts +92 -0
  15. package/dist/graph/preview.js +186 -0
  16. package/dist/graph/search.d.ts +32 -6
  17. package/dist/graph/search.js +102 -12
  18. package/dist/graph/types.d.ts +26 -1
  19. package/dist/graph/types.js +26 -1
  20. package/dist/hooks/recall.js +36 -2
  21. package/dist/index.js +23 -3
  22. package/dist/llm.d.ts +7 -2
  23. package/dist/llm.js +14 -22
  24. package/dist/pipeline/graph.d.ts +26 -1
  25. package/dist/pipeline/graph.js +74 -4
  26. package/dist/pipeline/l1.js +87 -26
  27. package/dist/pipeline/md-sync.js +4 -2
  28. package/dist/pipeline/runner.d.ts +26 -0
  29. package/dist/pipeline/runner.js +74 -7
  30. package/dist/pipeline/slot-migrate.d.ts +3 -2
  31. package/dist/pipeline/slot-migrate.js +3 -2
  32. package/dist/projection/recall.d.ts +10 -3
  33. package/dist/projection/recall.js +14 -0
  34. package/dist/settings.js +3 -0
  35. package/dist/stats.d.ts +1 -1
  36. package/dist/stats.js +47 -12
  37. package/dist/store/embedding-source.d.ts +4 -2
  38. package/dist/store/embedding-source.js +5 -2
  39. package/dist/store/graph-store.d.ts +111 -4
  40. package/dist/store/graph-store.js +324 -15
  41. package/dist/store/l1.d.ts +17 -9
  42. package/dist/store/l1.js +55 -18
  43. package/dist/store/local-embedding.d.ts +9 -5
  44. package/dist/store/local-embedding.js +137 -17
  45. package/dist/store/memory-md-disable.d.ts +3 -2
  46. package/dist/store/memory-md.d.ts +3 -3
  47. package/dist/store/memory-md.js +6 -4
  48. package/dist/store/runtime-installer.d.ts +13 -1
  49. package/dist/store/runtime-installer.js +40 -2
  50. package/dist/store/session-recalled.d.ts +9 -3
  51. package/dist/store/session-recalled.js +24 -5
  52. package/dist/store/sqlite.d.ts +24 -12
  53. package/dist/store/sqlite.js +97 -31
  54. package/dist/store/supersede.d.ts +10 -2
  55. package/dist/store/supersede.js +10 -2
  56. package/dist/store/validity.d.ts +150 -0
  57. package/dist/store/validity.js +209 -0
  58. package/dist/tools/index.d.ts +15 -1
  59. package/dist/tools/index.js +393 -11
  60. package/dist/types.d.ts +9 -0
  61. package/dist/util/json-repair.d.ts +48 -0
  62. package/dist/util/json-repair.js +285 -0
  63. package/dist/validity-backfill.d.ts +78 -0
  64. package/dist/validity-backfill.js +160 -0
  65. package/dsh.plugin.json +2 -2
  66. package/package.json +5 -4
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
  }
@@ -25,11 +25,36 @@ export declare const GRAPH_CONTEXT_EDGE_LIMIT = 120;
25
25
  export declare function selectContextNodes(records: readonly MemoryRecord[], nodes: readonly GraphNode[], limit?: number): GraphNode[];
26
26
  /** 选择进入上下文的边:两端都入选节点的 active 边,按更新时间新→旧,≤120。 */
27
27
  export declare function selectContextEdges(nodes: readonly GraphNode[], edges: readonly GraphEdge[], limit?: number): GraphEdge[];
28
+ /**
29
+ * 瞬态失败判据(与永久错误分流,由 GraphStore.fail 选不同重试封顶)。
30
+ *
31
+ * 真机归因(2026-10-20 审计):14 个 dead 全是 `llm aborted: Request was aborted` ×13 +
32
+ * `llm error: terminated` ×1——零校验类失败,全部是网关负载/超时把在飞请求掉断。
33
+ * 这类错误与 prompt/数据无关,退避后重试即可恢复,不该和“模型真的产坏了”同封顶。
34
+ * 匹配串源自 callLLM/callRoute/callDirect 的实际抛出形态(小写子串包含)。
35
+ */
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>;
28
51
  /**
29
52
  * 执行一次图谱投影(claim 一个 job → LLM → 校验落库;无 job 时静默返回)。
30
53
  * 返回是否实际认领了任务(泵据此决定是否续排)。失败转 fail(退避/封顶由
31
54
  * GraphStore 管),不向 runner 上抛。
32
55
  */
33
- 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>;
34
59
  /** 投影输出解析与形状归一:非对象/缺数组字段回空结构(校验侧再逐条把关)。 */
35
60
  export declare function parseProjection(raw: string, logger?: MemoryLogger): GraphProjectionResult;
@@ -2,6 +2,7 @@ import { callLLM, parseJsonLogged, resolveLayerTokens } from '../llm.js';
2
2
  import { buildGraphProjectionPrompt, getGraphProjectionSystemPrompt, } from '../prompts/graph-projection.js';
3
3
  import { normalizeEntityName } from '../graph/apply.js';
4
4
  import { tokenize } from '../util/text.js';
5
+ import { GRAPH_PROJECTION_CALL_TIMEOUT_MS } from '../graph/types.js';
5
6
  import { errDetail } from '../util/filelog.js';
6
7
  /** claim 上下文装配的节点上限(词法相关 + 最近更新,总量封顶)。 */
7
8
  export const GRAPH_CONTEXT_NODE_LIMIT = 80;
@@ -64,12 +65,73 @@ export function selectContextEdges(nodes, edges, limit = GRAPH_CONTEXT_EDGE_LIMI
64
65
  .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0))
65
66
  .slice(0, limit);
66
67
  }
68
+ /**
69
+ * 瞬态失败判据(与永久错误分流,由 GraphStore.fail 选不同重试封顶)。
70
+ *
71
+ * 真机归因(2026-10-20 审计):14 个 dead 全是 `llm aborted: Request was aborted` ×13 +
72
+ * `llm error: terminated` ×1——零校验类失败,全部是网关负载/超时把在飞请求掉断。
73
+ * 这类错误与 prompt/数据无关,退避后重试即可恢复,不该和“模型真的产坏了”同封顶。
74
+ * 匹配串源自 callLLM/callRoute/callDirect 的实际抛出形态(小写子串包含)。
75
+ */
76
+ export function isTransientGraphFailure(msg) {
77
+ const low = msg.toLowerCase();
78
+ return (low.includes('aborted') ||
79
+ low.includes('timeout') ||
80
+ low.includes('timed out') ||
81
+ low.includes('terminated') ||
82
+ low.includes('empty output') ||
83
+ low.includes('econn') ||
84
+ low.includes('etimedout') ||
85
+ low.includes('fetch failed') ||
86
+ low.includes('socket hang') ||
87
+ low.includes('network'));
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
+ }
67
127
  /**
68
128
  * 执行一次图谱投影(claim 一个 job → LLM → 校验落库;无 job 时静默返回)。
69
129
  * 返回是否实际认领了任务(泵据此决定是否续排)。失败转 fail(退避/封顶由
70
130
  * GraphStore 管),不向 runner 上抛。
71
131
  */
72
- export async function runGraphProjection(ctx, cfg, graphs, logger) {
132
+ export async function runGraphProjection(ctx, cfg, graphs, logger,
133
+ /** 节点向量补算的嵌入缝(runner 接 stores.l1.embedText;缺席/未就绪 = 不补)。 */
134
+ embedNode) {
73
135
  const claim = graphs.claimNext();
74
136
  if (!claim)
75
137
  return false;
@@ -84,12 +146,20 @@ export async function runGraphProjection(ctx, cfg, graphs, logger) {
84
146
  maxTokens: resolveLayerTokens(cfg, 'graph'),
85
147
  layer: 'graph',
86
148
  logger,
149
+ // 自带宽超时信号:不传则沿用 cfg.llm.timeoutMs(120s)——图谱批 prompt 在本地网关上
150
+ // 实测 ~2min/job,正好撞线(14 dead 根因);照 backfill callTimeoutMs 先例给 240s。
151
+ signal: AbortSignal.timeout(GRAPH_PROJECTION_CALL_TIMEOUT_MS),
87
152
  });
88
- 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);
89
157
  }
90
158
  catch (err) {
91
- logger.warn(`[memory] 图谱投影失败(job=${job.id},attempts=${job.attempts}): ${errDetail(err)}`);
92
- graphs.fail(job.id, errDetail(err));
159
+ const detail = errDetail(err);
160
+ logger.warn(`[memory] 图谱投影失败(job=${job.id},attempts=${job.attempts}): ${detail}`);
161
+ // 瞬态(网关掉断/超时/空输出)与永久(校验/解析)分档——前者多给 3 次退避重试机会。
162
+ graphs.fail(job.id, detail, { transient: isTransientGraphFailure(detail) });
93
163
  }
94
164
  return true;
95
165
  }
@@ -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
  /** 一条可迁移槽位(预览用,执行前即可展示)。 */
@@ -1,4 +1,5 @@
1
1
  import { errDetail } from '../util/filelog.js';
2
+ import { readSupersedeMarker } from '../store/supersede.js';
2
3
  /** 判定 + 挑选可迁移槽位(纯读,无副作用;面板预览与反刍执行共用)。 */
3
4
  export function pickMigratableSlots(slots, l1) {
4
5
  if (l1 === undefined)
@@ -6,9 +7,9 @@ export function pickMigratableSlots(slots, l1) {
6
7
  const open = slots.filter((s) => s.status === 'open' && s.pinned === true && s.kind === 'rule');
7
8
  if (open.length === 0)
8
9
  return [];
9
- // 一次批量取全部 refs,避免 N 次查询。
10
+ // 一次批量取全部 refs,避免 N 次查询。存活 = 无退场 marker(门1 解耦同口径)。
10
11
  const allRefs = [...new Set(open.flatMap((s) => s.refs ?? []))];
11
- const alive = new Set(l1.getByIds(allRefs).filter((r) => r.validTo === undefined || r.validTo === null).map((r) => r.id));
12
+ const alive = new Set(l1.getByIds(allRefs).filter((r) => readSupersedeMarker(r.metadata) === undefined).map((r) => r.id));
12
13
  const out = [];
13
14
  for (const s of open) {
14
15
  const carrier = (s.refs ?? []).find((r) => alive.has(r));
@@ -58,6 +58,13 @@ export interface RecallItemView {
58
58
  * 读侧据此把「已被摘要取代的旧召回」与「此刻仍在上下文里的召回」区分开。
59
59
  */
60
60
  superseded?: true;
61
+ /**
62
+ * 时效定性快照(task_3.5,expiry-tiering 门2 展示面):仅在 `expired`/`expired_suspect`
63
+ * 两档写键(泡泡如实标"已于 X 失效/疑似失效");current/historical 等缺席=旧帧兼容。
64
+ */
65
+ validity?: import('../store/validity.js').ValidityState;
66
+ /** 现实有效期(ISO):与 `validity` 同进同退,只在写 validity 时带上。 */
67
+ validUntil?: string;
61
68
  }
62
69
  /** 持久态:本会话的活召回 id 集 + 判脏序号。 */
63
70
  export interface RecallProjectionState {
@@ -89,13 +96,13 @@ interface SchemaLike<T> {
89
96
  }
90
97
  /** state 校验(registry 用它挡掉损坏/过期的 checkpoint 行)。 */
91
98
  export declare const stateSchema: SchemaLike<RecallProjectionState>;
92
- /** view 校验(出网前调用;未知键容忍,同 slots 投影的语义边界)。 */
93
99
  export declare const viewSchema: SchemaLike<RecallView>;
94
100
  /** 投影需要的 L1 只读面(结构化声明,避免跨模块类型耦合)。
95
- * 判退场要 `validTo`/`metadata`,故直接 Pick 自 MemoryRecord,不在投影里自写时间比较。
101
+ * 判退场要 `validTo`/`metadata`,时效定性还要 `persistence`/`validFrom`,
102
+ * 故直接 Pick 自 MemoryRecord,不在投影里自写时间比较。
96
103
  * `name` 是名称蒸馏产出的短标题,优先作为展示标题。 */
97
104
  export interface RecallL1Face {
98
- getByIds(ids: string[]): Array<Pick<MemoryRecord, 'id' | 'type' | 'content' | 'priority' | 'metadata' | 'validTo' | 'name'>>;
105
+ getByIds(ids: string[]): Array<Pick<MemoryRecord, 'id' | 'type' | 'content' | 'priority' | 'metadata' | 'validFrom' | 'validTo' | 'persistence' | 'name'>>;
99
106
  }
100
107
  /** 投影需要的反馈只读面(缺席 = view 不带 rating,旧行为)。 */
101
108
  export interface RecallFeedbackFace {