dsh-prime-memory 0.26.5 → 0.26.9

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 (64) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.en.md +62 -1
  3. package/README.md +60 -2
  4. package/dist/client.js +45 -5
  5. package/dist/config.d.ts +40 -0
  6. package/dist/config.js +33 -3
  7. package/dist/contract.d.ts +14 -1
  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/search.d.ts +11 -2
  13. package/dist/graph/search.js +38 -10
  14. package/dist/hooks/recall.js +28 -1
  15. package/dist/index.js +14 -1
  16. package/dist/llm.d.ts +21 -2
  17. package/dist/llm.js +56 -25
  18. package/dist/pipeline/graph.d.ts +17 -1
  19. package/dist/pipeline/graph.js +45 -2
  20. package/dist/pipeline/l1.d.ts +15 -2
  21. package/dist/pipeline/l1.js +161 -47
  22. package/dist/pipeline/l2.js +72 -50
  23. package/dist/pipeline/md-sync.js +4 -2
  24. package/dist/pipeline/ruminate.js +2 -0
  25. package/dist/pipeline/runner.d.ts +32 -0
  26. package/dist/pipeline/runner.js +109 -11
  27. package/dist/pipeline/slot-migrate.d.ts +3 -2
  28. package/dist/pipeline/slot-migrate.js +3 -2
  29. package/dist/projection/recall.d.ts +10 -3
  30. package/dist/projection/recall.js +14 -0
  31. package/dist/settings.js +6 -0
  32. package/dist/stats.d.ts +1 -1
  33. package/dist/stats.js +35 -14
  34. package/dist/store/embedding-source.d.ts +4 -2
  35. package/dist/store/embedding-source.js +5 -2
  36. package/dist/store/file-versions.d.ts +2 -0
  37. package/dist/store/file-versions.js +2 -0
  38. package/dist/store/graph-store.d.ts +71 -1
  39. package/dist/store/graph-store.js +221 -8
  40. package/dist/store/l1.d.ts +5 -0
  41. package/dist/store/l1.js +18 -2
  42. package/dist/store/local-embedding.d.ts +9 -5
  43. package/dist/store/local-embedding.js +137 -17
  44. package/dist/store/memory-md-disable.d.ts +3 -2
  45. package/dist/store/memory-md.d.ts +3 -3
  46. package/dist/store/memory-md.js +6 -4
  47. package/dist/store/route-health.d.ts +38 -0
  48. package/dist/store/route-health.js +115 -0
  49. package/dist/store/runtime-installer.d.ts +13 -1
  50. package/dist/store/runtime-installer.js +40 -2
  51. package/dist/store/sqlite.d.ts +24 -12
  52. package/dist/store/sqlite.js +97 -31
  53. package/dist/store/supersede.d.ts +10 -2
  54. package/dist/store/supersede.js +10 -2
  55. package/dist/store/validity.d.ts +150 -0
  56. package/dist/store/validity.js +209 -0
  57. package/dist/tools/index.d.ts +15 -1
  58. package/dist/tools/index.js +393 -11
  59. package/dist/util/json-repair.d.ts +48 -0
  60. package/dist/util/json-repair.js +285 -0
  61. package/dist/validity-backfill.d.ts +78 -0
  62. package/dist/validity-backfill.js +160 -0
  63. package/dsh.plugin.json +2 -2
  64. package/package.json +3 -3
@@ -1,71 +1,93 @@
1
1
  import { callLLM, parseJsonLogged, resolveLayerTokens } from '../llm.js';
2
2
  import { buildScenePrompt, formatSceneSummaries } from '../prompts/scene.js';
3
3
  import { Bm25Index } from '../store/bm25.js';
4
+ import { chunkByCharBudget, MAX_SCENE_CHUNK_CHARS } from './l1.js';
4
5
  const REQUEST_RE = /\[PERSONA_UPDATE_REQUEST\]\s*reason:\s*([\s\S]*?)\[\/PERSONA_UPDATE_REQUEST\]/;
5
6
  export async function runSceneConsolidation(ctx, cfg, scenes, newMemories, logger, family) {
6
7
  if (!cfg.l2.enabled || newMemories.length === 0) {
7
8
  return { changed: 0 };
8
9
  }
9
10
  const summaries = await scenes.list();
10
- // ── 组装 prompt ──
11
- const memoriesJson = JSON.stringify(newMemories.map((m) => ({
12
- record_id: m.id,
13
- content: m.content,
14
- type: m.type,
15
- priority: m.priority,
16
- scene_name: m.scene_name,
17
- // repo 归属(ADR-0015 T1.9/T1.10):L2 场景归属决策可见项目边界——
18
- // this-repo 记录不得并入跨项目场景;'' = 未归属(不引导分组)。
19
- repoKeyName: m.repoKeyName ?? '',
20
- applicability: m.applicability ?? '',
21
- timestamps: m.timestamps.map((t) => new Date(t).toISOString()),
22
- })), null, 2);
11
+ // 跨块共享的上下文:相关场景全文只选一次(BM25 按全量新记忆选),各块复用。
23
12
  const sceneSummaries = formatSceneSummaries(summaries);
24
13
  const sceneContents = await pickSceneContents(scenes, summaries, newMemories, cfg.l2.sceneContextLimit);
25
- const { systemPrompt, userPrompt } = buildScenePrompt({
26
- memoriesJson,
27
- sceneSummaries,
28
- sceneContents,
29
- currentTimestamp: new Date().toISOString(),
30
- existingSceneFiles: summaries.map((s) => s.path),
31
- maxScenes: cfg.l2.maxScenes,
32
- family,
33
- });
34
- const raw = await callLLM(ctx, cfg, {
35
- system: systemPrompt,
36
- user: userPrompt,
37
- maxTokens: resolveLayerTokens(cfg, 'l2'),
38
- layer: 'l2',
39
- logger,
40
- });
41
- // ── 解析:先取 PERSONA_UPDATE_REQUEST 标记,再解析操作数组 ──
42
- const reqMatch = REQUEST_RE.exec(raw);
43
- const personaRequestedReason = reqMatch?.[1]?.trim() || undefined;
44
- const opsRaw = reqMatch ? raw.replace(REQUEST_RE, '') : raw;
45
- const ops = parseJsonLogged(opsRaw, 'L2 场景操作', logger);
46
- if (!Array.isArray(ops))
47
- throw new Error('L2 输出不是操作数组');
14
+ // R1 分块分载:把 newMemories 按字符预算切块逐块整合(旧版一次塞整族 backlog → 大库必撞超时)。
15
+ // 单块(小 backlog)时与改前逐字一致(零漂移);多块时跨块合并判断为近似(诚实降级)。
16
+ const perChunk = Math.min(MAX_SCENE_CHUNK_CHARS, Math.max(20_000, cfg.llm.maxInputChars - 42_000));
17
+ const chunks = chunkByCharBudget(newMemories, perChunk);
18
+ if (chunks.length > 1) {
19
+ logger.info(`[memory] L2 输入超预算(${newMemories.length} 条新记忆),分 ${chunks.length} 块整合(每块 ≤${perChunk} 字符)`);
20
+ }
48
21
  let changed = 0;
49
- for (const op of ops) {
50
- if (!op || typeof op.path !== 'string')
51
- continue;
22
+ let failedChunks = 0;
23
+ let personaRequestedReason;
24
+ for (const chunk of chunks) {
25
+ const memoriesJson = JSON.stringify(chunk.map((m) => ({
26
+ record_id: m.id,
27
+ content: m.content,
28
+ type: m.type,
29
+ priority: m.priority,
30
+ scene_name: m.scene_name,
31
+ // repo 归属(ADR-0015 T1.9/T1.10):L2 场景归属决策可见项目边界——
32
+ // this-repo 记录不得并入跨项目场景;'' = 未归属(不引导分组)。
33
+ repoKeyName: m.repoKeyName ?? '',
34
+ applicability: m.applicability ?? '',
35
+ timestamps: m.timestamps.map((t) => new Date(t).toISOString()),
36
+ })), null, 2);
37
+ const { systemPrompt, userPrompt } = buildScenePrompt({
38
+ memoriesJson,
39
+ sceneSummaries,
40
+ sceneContents,
41
+ currentTimestamp: new Date().toISOString(),
42
+ existingSceneFiles: summaries.map((s) => s.path),
43
+ maxScenes: cfg.l2.maxScenes,
44
+ family,
45
+ });
52
46
  try {
53
- if (op.op === 'write' && typeof op.content === 'string') {
54
- await scenes.write(op.path, op.content);
55
- changed++;
56
- logger.debug?.(`[memory] L2 写入场景 ${op.path} (${op.content.length} 字符)`);
57
- }
58
- else if (op.op === 'delete') {
59
- await scenes.write(op.path, '[DELETED]');
60
- changed++;
61
- logger.debug?.(`[memory] L2 删除场景 ${op.path}`);
47
+ const raw = await callLLM(ctx, cfg, {
48
+ system: systemPrompt,
49
+ user: userPrompt,
50
+ maxTokens: resolveLayerTokens(cfg, 'l2'),
51
+ layer: 'l2',
52
+ logger,
53
+ });
54
+ // 解析:先取 PERSONA_UPDATE_REQUEST 标记,再解析操作数组
55
+ const reqMatch = REQUEST_RE.exec(raw);
56
+ if (reqMatch?.[1]?.trim())
57
+ personaRequestedReason = reqMatch[1].trim();
58
+ const opsRaw = reqMatch ? raw.replace(REQUEST_RE, '') : raw;
59
+ const ops = parseJsonLogged(opsRaw, 'L2 场景操作', logger);
60
+ if (!Array.isArray(ops))
61
+ throw new Error('L2 输出不是操作数组');
62
+ for (const op of ops) {
63
+ if (!op || typeof op.path !== 'string')
64
+ continue;
65
+ try {
66
+ if (op.op === 'write' && typeof op.content === 'string') {
67
+ await scenes.write(op.path, op.content);
68
+ changed++;
69
+ logger.debug?.(`[memory] L2 写入场景 ${op.path} (${op.content.length} 字符)`);
70
+ }
71
+ else if (op.op === 'delete') {
72
+ await scenes.write(op.path, '[DELETED]');
73
+ changed++;
74
+ logger.debug?.(`[memory] L2 删除场景 ${op.path}`);
75
+ }
76
+ }
77
+ catch (err) {
78
+ logger.warn(`[memory] L2 操作失败 ${op.path}: ${err instanceof Error ? err.message : String(err)}`);
79
+ }
62
80
  }
63
81
  }
64
82
  catch (err) {
65
- logger.warn(`[memory] L2 操作失败 ${op.path}: ${err instanceof Error ? err.message : String(err)}`);
83
+ // R1 块级容错:单块超时/崩只丢这块,不掀全族(旧行为是整抛)
84
+ failedChunks++;
85
+ logger.warn(`[memory] L2 分块整合失败(family=${family},${chunk.length} 条记忆本块丢弃): ${err instanceof Error ? err.message : String(err)}`);
86
+ continue;
66
87
  }
67
88
  }
68
- logger.info(`[memory] L2 场景整合完成(family=${family}):${newMemories.length} 条新记忆 → ${changed} 个文件操作(场景总数 ${summaries.length})` +
89
+ logger.info(`[memory] L2 场景整合完成(family=${family}):${newMemories.length} 条新记忆 → ${changed} 个文件操作(场景总数 ${summaries.length}` +
90
+ (chunks.length > 1 ? `,分 ${chunks.length} 块、失败 ${failedChunks} 块` : '') + `)` +
69
91
  (personaRequestedReason ? `,请求 L3 更新:${personaRequestedReason}` : ''));
70
92
  return { changed, personaRequestedReason };
71
93
  }
@@ -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);
@@ -167,6 +167,8 @@ export class RuminateController {
167
167
  // 经 runTurn 的 `bucket.push(...messages)` 会重复入桶 → 反刍后待处理计数“还原/变多”。)
168
168
  this.runner.enqueue(session.sessionId, [], session.mode, {
169
169
  force: true,
170
+ // R6:反刍与 rebuild 同为用户显式动作 → 越过抽取退避,保证"催一把"能真跑不被跳过
171
+ bypassBackoff: true,
170
172
  onTurnDone: (records) => {
171
173
  this.totalL1 += records;
172
174
  this.status.recordsBuilt = this.totalL1;
@@ -22,6 +22,7 @@ import type { SceneStore } from '../store/scenes.js';
22
22
  import type { StateStore } from '../store/state.js';
23
23
  import type { ConversationMessage, ExtractMode, MemoryFamily, MemoryLogger } from '../types.js';
24
24
  import type { FamilyStates } from './l1.js';
25
+ import type { RouteHealthStore } from '../store/route-health.js';
25
26
  export interface MemoryStores {
26
27
  /**
27
28
  * DRSC 客户端(T8.4 读切):蒸馏背景窗的历史供给面(op=session&tail&redact=1)。
@@ -44,6 +45,8 @@ export interface MemoryStores {
44
45
  feedback?: {
45
46
  rekey(oldId: string, newId: string): boolean;
46
47
  };
48
+ /** R9 路由健康学习(可选):未传则 runExtraction 不启用自适应块长学习。 */
49
+ routeHealth?: RouteHealthStore;
47
50
  }
48
51
  /** 管线任务(优先级调度:live > graph > rebuild)。 */
49
52
  export interface PipelineTask {
@@ -97,6 +100,8 @@ export declare class MemoryRunner {
97
100
  private extractFailures;
98
101
  /** 图谱泵在队列中的占位标志(同一时刻至多一个 graph 任务;护栏四件套之一)。 */
99
102
  private graphPumpQueued;
103
+ /** R7:已入队、尚未消费的 force-flush 键集(`session:mode`),同键不重复入队(治积压刷屏)。 */
104
+ private flushQueued;
100
105
  private readonly pendingFile;
101
106
  /** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
102
107
  states: FamilyStates;
@@ -132,6 +137,7 @@ export declare class MemoryRunner {
132
137
  * onTurnDone 在该任务真正跑完后回调,携带本轮新增记录数(供反刍统计真实产出)。 */
133
138
  enqueue(sessionId: string, messages: ConversationMessage[], mode: ExtractMode, opts?: {
134
139
  force?: boolean;
140
+ bypassBackoff?: boolean;
135
141
  onTurnDone?: (records: number) => void;
136
142
  }): void;
137
143
  /** 重建任务入队(低优先级:让位于正常轮次;由 RebuildController 分块驱动)。 */
@@ -158,6 +164,32 @@ export declare class MemoryRunner {
158
164
  * 每轮 drain 至多消费一个 graph 任务且永远让位 live)。
159
165
  */
160
166
  startGraphBackfillTimer(): void;
167
+ /**
168
+ * validity 标注存量回填的接线(task_6.3):低频、让位于蒸馏——启动 3 分钟后首轮,
169
+ * 之后每 6h 一次(首轮收敛后多数轮是纯扫描 no-op;defer 的部分自动续上)。
170
+ * 门控跟灰度闸走:tieringEnabled(部署 ∨ 面板)关时不进任务——功能关着时
171
+ * 存量 metadata 逐字不变(零漂移,与 keyword-backfill 的门控同法)。
172
+ */
173
+ startValidityBackfillTimer(): void;
174
+ private runValidityBackfillTick;
175
+ /**
176
+ * 确定性写面的公共入队钩子(graph-write-plane R2):非蒸馏写入路径
177
+ * (`memory_add`/`memory_import`/`memory_update`)落库成功后把涉及记录投进
178
+ * 投影队——与 distill 分支同源同优先级,闭合"只有蒸馏才投影"的旁路缺口
179
+ * (用户手加记录长期 projected=None 的真根因)。部署未开图谱时 no-op,
180
+ * 用户后续开启由启动/周期补投影兜底(与 distill 分支同一口径)。
181
+ */
182
+ enqueueGraphFor(recordIds: readonly string[]): void;
183
+ /**
184
+ * 强制重投的写面入口(graph-write-plane R3):把上游 R10 的内部
185
+ * `forceReprojectRecord` 接到泵上——删凭证、高优入队、拉泵;幂等由
186
+ * 在途 mapping 的 PK 去重保证。图谱未启用时直接回零(不开"投了永不泵"
187
+ * 的陈旧队列口子,纠正通道的语义前提是图谱通路存在)。
188
+ */
189
+ reprojectGraph(recordIds: readonly string[]): {
190
+ queued: number;
191
+ jobIds: string[];
192
+ };
161
193
  /**
162
194
  * 图谱投影泵入队判定:
163
195
  * - 双门:部署级 cfg.graph.enabled + 运行时 live.enabled/distill,任一为假不入队;
@@ -1,5 +1,7 @@
1
1
  import { resolveDataDir } from '../config.js';
2
2
  import { GRAPH_PRIORITY_NEW } from '../graph/types.js';
3
+ import { NoDistillRouteError } from '../llm.js';
4
+ import { startValidityBackfill } from '../validity-backfill.js';
3
5
  import { drscRowToConversationMessage } from '../clients/drsc.js';
4
6
  import { trace } from '../store/trace.js';
5
7
  import { emptyPending, freshWarmup, groupPendingBySession, loadPending, PENDING_MODES, pendingPathFor, savePending, } from '../store/pending.js';
@@ -82,6 +84,8 @@ export function effectiveCfg(cfg, live) {
82
84
  : null;
83
85
  const layerChains = layerPick && Object.keys(layerPick).length ? layerPick : null;
84
86
  const maxInput = s && s.distillMaxInputChars > 0 ? s.distillMaxInputChars : null;
87
+ // R10 蒸馏超时运行时覆盖:>0 才注入 llm.timeoutMs,否则跟随静态配置
88
+ const timeoutOv = s && s.distillTimeoutMs > 0 ? s.distillTimeoutMs : null;
85
89
  // 旧档位键的全局接管(含给静态回退条目盖章):仅非链模式保留(旧存量值兼容)
86
90
  const fallbacksTakeover = !chainMode && eff && cfg.llm?.fallbacks?.length
87
91
  ? cfg.llm.fallbacks.map((f) => ({ ...f, reasoningEffort: eff }))
@@ -136,6 +140,11 @@ export function effectiveCfg(cfg, live) {
136
140
  const mpOverride = s?.memportEnabled !== undefined
137
141
  ? { memport: { ...(cfg.memport ?? {}), enabled: cfg.memport?.enabled === true || s.memportEnabled === true } }
138
142
  : null;
143
+ // 时效分级门2 灰度闸:live.validityTiering 与部署 cfg.validity.tieringEnabled 取或
144
+ // (同一先例)。两字都假时回 `{tieringEnabled:false}` 与缺省形状等价,注入侧零漂移不破。
145
+ const vtOverride = s?.validityTiering !== undefined
146
+ ? { validity: { ...(cfg.validity ?? {}), tieringEnabled: cfg.validity?.tieringEnabled === true || s.validityTiering === true } }
147
+ : null;
139
148
  // md 投射/后缀收敛的单次动作护栏运行时覆盖(0 = 跟随部署 cfg.memExport.maxRecords)。
140
149
  // 只覆盖条数护栏,不动 enabled/root——开关与根路径仍属部署配置。
141
150
  const mdx = s?.mdExportMaxRecords ?? 0;
@@ -144,7 +153,7 @@ export function effectiveCfg(cfg, live) {
144
153
  // 不动 maxRecords/timeBudgetMs/callTimeoutMs——那三个仍属部署护栏,要改得改 YAML。
145
154
  const bf = s?.backfillConcurrency ?? 0;
146
155
  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)
156
+ if (!override && !budgets && !maxInput && !fallbacksTakeover && !chainEffort && !chainFallbacks && !effortInject && !layerChains && !hasChannel && !embedOverride && !cfOverride && !memExportOverride && !backfillOverride && !kwOverride && !rdOverride && !mdsOverride && !mpOverride && !vtOverride)
148
157
  return cfg;
149
158
  return {
150
159
  ...cfg,
@@ -154,6 +163,7 @@ export function effectiveCfg(cfg, live) {
154
163
  ...(rdOverride ?? {}),
155
164
  ...(mdsOverride ?? {}),
156
165
  ...(mpOverride ?? {}),
166
+ ...(vtOverride ?? {}),
157
167
  ...(memExportOverride ?? {}),
158
168
  ...(backfillOverride ?? {}),
159
169
  llm: {
@@ -162,6 +172,7 @@ export function effectiveCfg(cfg, live) {
162
172
  ...(override ?? {}),
163
173
  ...(budgets ? { budgets } : {}),
164
174
  ...(maxInput ? { maxInputChars: maxInput } : {}),
175
+ ...(timeoutOv ? { timeoutMs: timeoutOv } : {}),
165
176
  ...(fallbacksTakeover ? { fallbacks: fallbacksTakeover } : {}),
166
177
  ...(chainEffort ? { primaryEffort: chainEffort } : {}),
167
178
  ...(chainFallbacks ? { fallbacks: chainFallbacks } : {}),
@@ -205,6 +216,8 @@ export class MemoryRunner {
205
216
  extractFailures = new Map();
206
217
  /** 图谱泵在队列中的占位标志(同一时刻至多一个 graph 任务;护栏四件套之一)。 */
207
218
  graphPumpQueued = false;
219
+ /** R7:已入队、尚未消费的 force-flush 键集(`session:mode`),同键不重复入队(治积压刷屏)。 */
220
+ flushQueued = new Set();
208
221
  pendingFile;
209
222
  /** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
210
223
  states;
@@ -350,9 +363,25 @@ export class MemoryRunner {
350
363
  /** 一轮对话结束后入队(L0 落盘由 capture 在 turn/end 即时完成,不排蒸馏队列)。
351
364
  * onTurnDone 在该任务真正跑完后回调,携带本轮新增记录数(供反刍统计真实产出)。 */
352
365
  enqueue(sessionId, messages, mode, opts) {
366
+ // R7:闲置/反刍/换档触发的 force-flush(空消息 + force)按 (session,mode) 去重,
367
+ // 同键未消费前不重复入队(一次长阻塞不再积压几十个 no-op 排空刷屏)。
368
+ const isFlush = opts?.force === true && messages.length === 0;
369
+ const fkey = isFlush ? `${sessionId}:${mode}` : '';
370
+ if (isFlush && this.flushQueued.has(fkey))
371
+ return;
372
+ if (isFlush)
373
+ this.flushQueued.add(fkey);
353
374
  this.pushTask({
354
375
  kind: 'live',
355
- run: () => this.runTurn(sessionId, messages, mode, opts),
376
+ run: async () => {
377
+ try {
378
+ return await this.runTurn(sessionId, messages, mode, opts);
379
+ }
380
+ finally {
381
+ if (isFlush)
382
+ this.flushQueued.delete(fkey); // 无论成败都释占,避免键泄漏永久屏蔽后续 flush
383
+ }
384
+ },
356
385
  ...(opts?.onTurnDone ? { onDone: (r) => opts.onTurnDone?.(typeof r === 'number' ? r : 0) } : {}),
357
386
  });
358
387
  }
@@ -454,6 +483,65 @@ export class MemoryRunner {
454
483
  return () => clearInterval(timer);
455
484
  });
456
485
  }
486
+ /**
487
+ * validity 标注存量回填的接线(task_6.3):低频、让位于蒸馏——启动 3 分钟后首轮,
488
+ * 之后每 6h 一次(首轮收敛后多数轮是纯扫描 no-op;defer 的部分自动续上)。
489
+ * 门控跟灰度闸走:tieringEnabled(部署 ∨ 面板)关时不进任务——功能关着时
490
+ * 存量 metadata 逐字不变(零漂移,与 keyword-backfill 的门控同法)。
491
+ */
492
+ startValidityBackfillTimer() {
493
+ this.ctx.effect(() => {
494
+ let loop = null;
495
+ const first = setTimeout(() => {
496
+ this.runValidityBackfillTick();
497
+ loop = setInterval(() => this.runValidityBackfillTick(), 6 * 3_600_000);
498
+ }, 180_000);
499
+ return () => { clearTimeout(first); if (loop !== null)
500
+ clearInterval(loop); };
501
+ });
502
+ }
503
+ runValidityBackfillTick() {
504
+ if (this.stopped)
505
+ return;
506
+ const eff = effectiveCfg(this.cfg, this.live);
507
+ if (eff.validity?.tieringEnabled !== true)
508
+ return;
509
+ startValidityBackfill({
510
+ l1: this.stores.l1,
511
+ logger: this.logger,
512
+ // task_7.7 接缝:回填改过标注的记录顺手重投保鲜图谱(图谱未启用时 reprojectGraph 自己回零)。
513
+ reproject: (ids) => { this.reprojectGraph(ids); },
514
+ shouldCancel: () => this.stopped,
515
+ });
516
+ }
517
+ /**
518
+ * 确定性写面的公共入队钩子(graph-write-plane R2):非蒸馏写入路径
519
+ * (`memory_add`/`memory_import`/`memory_update`)落库成功后把涉及记录投进
520
+ * 投影队——与 distill 分支同源同优先级,闭合"只有蒸馏才投影"的旁路缺口
521
+ * (用户手加记录长期 projected=None 的真根因)。部署未开图谱时 no-op,
522
+ * 用户后续开启由启动/周期补投影兜底(与 distill 分支同一口径)。
523
+ */
524
+ enqueueGraphFor(recordIds) {
525
+ if (recordIds.length === 0 || !this.stores.graph || !this.cfg.graph?.enabled)
526
+ return;
527
+ this.stores.graph.queueGraphProjection([...recordIds], GRAPH_PRIORITY_NEW);
528
+ this.maybeQueueGraphTask();
529
+ }
530
+ /**
531
+ * 强制重投的写面入口(graph-write-plane R3):把上游 R10 的内部
532
+ * `forceReprojectRecord` 接到泵上——删凭证、高优入队、拉泵;幂等由
533
+ * 在途 mapping 的 PK 去重保证。图谱未启用时直接回零(不开"投了永不泵"
534
+ * 的陈旧队列口子,纠正通道的语义前提是图谱通路存在)。
535
+ */
536
+ reprojectGraph(recordIds) {
537
+ const graphs = this.stores.graph;
538
+ if (!graphs || !this.cfg.graph?.enabled || recordIds.length === 0)
539
+ return { queued: 0, jobIds: [] };
540
+ const r = graphs.forceReprojectRecord(recordIds);
541
+ if (r.queued > 0)
542
+ this.maybeQueueGraphTask();
543
+ return r;
544
+ }
457
545
  /**
458
546
  * 图谱投影泵入队判定:
459
547
  * - 双门:部署级 cfg.graph.enabled + 运行时 live.enabled/distill,任一为假不入队;
@@ -478,7 +566,10 @@ export class MemoryRunner {
478
566
  this.graphPumpQueued = false;
479
567
  if (this.stopped)
480
568
  return;
481
- const had = await runGraphProjection(this.ctx, effectiveCfg(this.cfg, this.live), graphs, this.logger);
569
+ const had = await runGraphProjection(this.ctx, effectiveCfg(this.cfg, this.live), graphs, this.logger,
570
+ // G-vec 缺陷修的嵌入缝:惰性取 L1 同一嵌入源(活切换不被钉死),
571
+ // 未就绪/失败 embedText 回 undefined = 不补写(词法路不受影响)。
572
+ (t) => this.stores.l1.embedText(t));
482
573
  // 有实际投影(说明队列还有积压)才续排;claim 落空则停泵,等下一个触发源
483
574
  if (had)
484
575
  this.maybeQueueGraphTask();
@@ -568,15 +659,13 @@ export class MemoryRunner {
568
659
  const effective = effectiveExtractThreshold(this.warmup[mode], cfg.extract.minMessages);
569
660
  const sliceLen = bucket.reduce((n, m) => (m.sessionId === sessionId ? n + 1 : n), 0);
570
661
  // 抽取失败退避:重建轮(noBufferCap,用户显式动作、有自己的失败/取消 UI)豁免
571
- const backedOff = !opts?.noBufferCap && this.inExtractBackoff(sessionId);
662
+ const backedOff = !opts?.noBufferCap && !opts?.bypassBackoff && this.inExtractBackoff(sessionId);
572
663
  if ((opts?.force || sliceLen >= effective) && !backedOff) {
573
664
  newRecords = await this.extractSessionSlice(sessionId, mode, cfg, effective, opts);
574
665
  // 新记录入投影队列(优先级恒高于存量补投影)并拉起图谱泵;入队本身廉价,
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
- }
666
+ // 部署未开图谱(cfg.graph.enabled)时不入队——用户后续开启由启动补投影兜底。
667
+ // (写面 R2:分支本体抽成 enqueueGraphFor,与工具三路径同源,不再两处漂移。)
668
+ this.enqueueGraphFor(newRecords.map((r) => r.id));
580
669
  }
581
670
  else {
582
671
  this.logger.debug?.(backedOff
@@ -723,9 +812,12 @@ export class MemoryRunner {
723
812
  (oldId, newId) => {
724
813
  this.stores.usage?.rekey(oldId, newId);
725
814
  this.stores.feedback?.rekey(oldId, newId);
726
- });
815
+ }, this.stores.routeHealth);
816
+ // R9: 本轮学到的跑通块长落盘(best-effort,失败不阻蒸馏)
817
+ await this.stores.routeHealth?.save().catch(() => { });
727
818
  if (!result.skipped) {
728
- this.pending[mode] = rest;
819
+ // R1′ 部分消费:成功块出桶、抽取失败块留桶下轮重投(rest ∪ failedMessages)
820
+ this.pending[mode] = [...rest, ...(result.failedMessages ?? [])];
729
821
  // 重建轮(force)不是有机对话,不推进爬坡
730
822
  if (!opts?.force)
731
823
  this.warmup[mode] = advanceWarmupThreshold(this.warmup[mode], cfg.extract.minMessages);
@@ -777,6 +869,12 @@ export class MemoryRunner {
777
869
  ok: false,
778
870
  errorKind: err instanceof Error ? err.name : 'error',
779
871
  });
872
+ // R4:无可用蒸馏路由=配置缺失、非模型慢——不进退避(否则把"没配"误当故障反复退避),只顺延。
873
+ if (err instanceof NoDistillRouteError) {
874
+ this.logger.warn(`[memory] 无可用蒸馏路由,本轮蒸馏顺延、不记退避(session=${sessionId},mode=${mode})`);
875
+ await this.persistPending(opts?.noBufferCap).catch(() => { });
876
+ return [];
877
+ }
780
878
  // 指数退避:压制闲置兜底/补跑在 LLM 故障期间的连环重试(成功消费时清零)
781
879
  const streak = (this.extractFailures.get(sessionId)?.streak ?? 0) + 1;
782
880
  const delayMs = extractionBackoffMs(streak);
@@ -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 {
@@ -1,4 +1,5 @@
1
1
  import { isRetired } from '../store/supersede.js';
2
+ import { stateOfRecord } from '../store/validity.js';
2
3
  export const MEMORY_RECALL_KEY = 'memoryRecall';
3
4
  /** view 最多透出条数(最近在上;超出部分只计入 count,防 wire 体量失控)。 */
4
5
  export const RECALL_VIEW_CAP = 20;
@@ -43,6 +44,7 @@ export const stateSchema = {
43
44
  },
44
45
  };
45
46
  /** view 校验(出网前调用;未知键容忍,同 slots 投影的语义边界)。 */
47
+ const VALIDITY_STATES_VIEW = ['current', 'historical', 'expired', 'expired_suspect', 'superseded', 'retired'];
46
48
  export const viewSchema = {
47
49
  parse: (value) => {
48
50
  const what = 'memoryRecall.view';
@@ -73,6 +75,12 @@ export const viewSchema = {
73
75
  invalid(at, 'retired 只能是 true 或缺席');
74
76
  if (item.superseded !== undefined && item.superseded !== true)
75
77
  invalid(at, 'superseded 只能是 true 或缺席');
78
+ // 时效标注(task_3.5):缺席=旧帧容忍;有则必须六态枚举内,validUntil 必字符串。
79
+ if (item.validity !== undefined && VALIDITY_STATES_VIEW.indexOf(item.validity) < 0) {
80
+ invalid(at, 'validity 必须是六态之一或缺席');
81
+ }
82
+ if (item.validUntil !== undefined)
83
+ requireString(item.validUntil, at, 'validUntil');
76
84
  });
77
85
  return value;
78
86
  },
@@ -150,6 +158,11 @@ function makeView(state, l1, feedback) {
150
158
  ? r.metadata?.dsh_usage_scenario
151
159
  : undefined;
152
160
  const rating = feedback?.lastOf(id);
161
+ // 时效标注(task_3.5):只对"过时两档"写键——current/historical 不必让人看见时效噪声;
162
+ // 旧帧无此键,读侧缺席即活跃。判定现算(stateOfRecord),不吃落库快照;
163
+ // 未退场行才会真判过时两档(退场行由 retired 键表达,validity 不再叠加)。
164
+ const vState = isRetired(r) ? undefined : stateOfRecord(r, Date.now());
165
+ const showValidity = vState === 'expired' || vState === 'expired_suspect';
153
166
  items.push({
154
167
  id: r.id,
155
168
  type: r.type,
@@ -162,6 +175,7 @@ function makeView(state, l1, feedback) {
162
175
  ...(isRetired(r) ? { retired: true } : {}),
163
176
  // 已被摘要取代:正文已不在上下文里。与 retired 正交,两者可同时在。
164
177
  ...(superseded.has(id) ? { superseded: true } : {}),
178
+ ...(showValidity ? { validity: vState, validUntil: r.validTo === undefined ? '' : new Date(r.validTo).toISOString() } : {}),
165
179
  });
166
180
  }
167
181
  const next = { rev: state.rev, sessionId: state.sessionId, count: state.ids.length, items };
package/dist/settings.js CHANGED
@@ -70,6 +70,7 @@ const ALWAYS_ON = {
70
70
  distillChain: [],
71
71
  distillBudgets: { extract: 0, dedup: 0, l2: 0, l3: 0, graph: 0 },
72
72
  distillMaxInputChars: 0,
73
+ distillTimeoutMs: 0,
73
74
  distillLayerChains: { l1: [], l2: [], l3: [] },
74
75
  distillMode: '',
75
76
  directBaseURL: '',
@@ -85,6 +86,8 @@ const ALWAYS_ON = {
85
86
  redistillEnabled: false,
86
87
  mdSyncEnabled: false,
87
88
  memportEnabled: false,
89
+ // 时效分级门2 灰度开关:默认关(注入与改前逐字一致)
90
+ validityTiering: false,
88
91
  // md 投射护栏运行时覆盖:0 = 跟随部署 cfg.memExport.maxRecords(面板上可改)
89
92
  mdExportMaxRecords: 0,
90
93
  // 回填并行度运行时覆盖:0 = 跟随部署 cfg.backfill.concurrency(概览页滑块可改)
@@ -307,6 +310,8 @@ function resolveSettings(value) {
307
310
  graph: num(rawBudgets.graph),
308
311
  },
309
312
  distillMaxInputChars: num(v.distillMaxInputChars),
313
+ // R10 蒸馏超时运行时覆盖:钳 0~600000(与 live schema 同上限);0 = 跟随部署 llm.timeoutMs
314
+ distillTimeoutMs: Math.min(600_000, Math.max(0, num(v.distillTimeoutMs))),
310
315
  // 蒸馏通道:mode 白名单(非法归 '' = 跟随部署);端点与密钥截断防御但保留原始内容
311
316
  distillMode: v.distillMode === 'host' || v.distillMode === 'direct' ? v.distillMode : '',
312
317
  directBaseURL: typeof v.directBaseURL === 'string' ? v.directBaseURL.slice(0, 2000) : '',
@@ -329,6 +334,7 @@ function resolveSettings(value) {
329
334
  redistillEnabled: v.redistillEnabled === true,
330
335
  mdSyncEnabled: v.mdSyncEnabled === true,
331
336
  memportEnabled: v.memportEnabled === true,
337
+ validityTiering: v.validityTiering === true,
332
338
  // md 投射护栏钳 0~10000(与 live schema 同上限);0 = 跟随部署默认
333
339
  mdExportMaxRecords: Math.min(10_000, Math.max(0, num(v.mdExportMaxRecords))),
334
340
  // 回填并行度钳 0~8(与 live schema 同上限);0 = 跟随部署 cfg.backfill.concurrency
package/dist/stats.d.ts CHANGED
@@ -183,7 +183,7 @@ export declare function hitToUiRecord(r: {
183
183
  scope?: string;
184
184
  /** 归属工作区路径(仅 scope='workspace' 时非空)。 */
185
185
  workspaceId?: string;
186
- /** 退场判据:`valid_to` 闭合即已退场(软删)。由 `listL1`/`getByIds` 透传。 */
186
+ /** 退场判据:与库侧同源(门1 解耦后 marker 单源,`isRetired`)。由 `listL1`/`getByIds` 透传。 */
187
187
  validTo?: number;
188
188
  }, extra?: {
189
189
  agingWeight?: number | null;