dsh-prime-memory 0.26.6 → 0.26.10

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.
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { randomBytes } from 'node:crypto';
10
10
  import { normWingEnabled } from '../config.js';
11
- import { callLLM, parseJsonLogged, resolveLayerTokens } from '../llm.js';
11
+ import { callLLM, parseJsonLogged, resolveLayerTokens, resolveModelRoute } from '../llm.js';
12
12
  import { buildReceipts, newRunId, persistReceiptsSafely } from '../store/receipts.js';
13
13
  import { buildConflictPair, conflictRejectId, DEFER_MAX, normalizeClaimKey, normalizeConflictType, occupiesConflictQuota, pendingHardTotal, validateConflictPair, } from '../store/conflicts.js';
14
14
  import { formatExtractionPrompt, getExtractMemoriesSystemPrompt } from '../prompts/l1-extraction.js';
@@ -23,13 +23,19 @@ import { routeWorkspace } from '../workspace-route.js';
23
23
  /**
24
24
  * L1 抽取单块字符硬顶(修复 #10 分批过大)
25
25
  *
26
- * `cfg.llm.maxInputChars` 的语义是「单次 LLM 调用输入闸门」(默认 700k),被复用为
26
+ * `cfg.llm.maxInputChars` 的语义是「单次 LLM 调用输入闸门」(默认 200k),被复用为
27
27
  * 分块预算时算出 658k/块——一次输出须在同一 JSON 数组写完全部抽出记忆,块越大字符串越多,
28
28
  * 任一处长中文里未转义 ASCII `"` 即整批 parse 失败、该块记忆全丢;慢模型更直接撞 120s 超时。
29
29
  * 砍到 24k 后输出 ≈2–4k tokens,仍远低于 LAYER_MAX_TOKENS_EXTRACT(16k),不会引入截断,
30
30
  * 单块失败半径从「几百条」缩到「几十条」。代价是多几次小调用,但每次 5–15s 完成。
31
31
  */
32
32
  const MAX_EXTRACT_CHUNK_CHARS = 24_000;
33
+ /**
34
+ * L2 场景整合单块字符硬顶(R1):与抽取同精神——旧版一次把整族 backlog 塞单调用,
35
+ * 库越大越必撞超时(实测 work-L2 >60s abort)。砍到 24k 后逐块整合,失败半径从
36
+ * “整族 backlog”缩到“一块”。默认对齐 MAX_EXTRACT_CHUNK_CHARS。
37
+ */
38
+ export const MAX_SCENE_CHUNK_CHARS = 24_000;
33
39
  /**
34
40
  * L1 去重单批发起上限(与抽取同精神)
35
41
  *
@@ -266,7 +272,9 @@ anchorMap,
266
272
  * 另两个 sidecar 里,而 merge 每次新铸 id——不搬就等于每次合并把这条记忆的
267
273
  * `used`/人工打分清零,它会被当成“没用过”而沉底。缺省不搬(行为与改动前一致)。
268
274
  */
269
- mergeRekey) {
275
+ mergeRekey,
276
+ /** R9 路由健康学习(可选):读已学到的跑通块长收紧 perChunk,成功块回记。未传=不启用。 */
277
+ routeHealth) {
270
278
  if (!cfg.extract.enabled)
271
279
  return { stored: 0, skipped: true, sceneName: chainHead(states, mode), newRecords: [] };
272
280
  // 触发阈值(渐进爬坡 + 按会话切片计数)由 runner 判定(trigger.ts);
@@ -314,12 +322,31 @@ mergeRekey) {
314
322
  const backgroundMsgs = background.slice(-cfg.extract.backgroundMessages);
315
323
  // 预留:背景消息(≤10 条 ×4000 字)+ prompt 脚手架
316
324
  // 硬顶 24k(见 MAX_EXTRACT_CHUNK_CHARS 注释);若配置更小则跟随配置
317
- const perChunk = Math.min(MAX_EXTRACT_CHUNK_CHARS, Math.max(20_000, cfg.llm.maxInputChars - 42_000));
325
+ let perChunk = Math.min(MAX_EXTRACT_CHUNK_CHARS, Math.max(20_000, cfg.llm.maxInputChars - 42_000));
326
+ // R5: 尊重模型真实输入上限(若声明),避免 700k 盲值撞本地模型硬限
327
+ if (cfg.llm.modelMaxInputChars && cfg.llm.modelMaxInputChars > 0) {
328
+ perChunk = Math.min(perChunk, Math.max(20_000, cfg.llm.modelMaxInputChars - 42_000));
329
+ }
330
+ // R9: 用该路由已学到的跑通块长再收紧 perChunk(best-effort;路由解析失败则跳过)
331
+ let rhKey;
332
+ if (routeHealth) {
333
+ try {
334
+ rhKey = await resolveModelRoute(ctx, cfg);
335
+ const lg = routeHealth.lastGoodChunkChars(rhKey.provider, rhKey.model);
336
+ if (lg > 0)
337
+ perChunk = Math.min(perChunk, Math.max(20_000, lg));
338
+ }
339
+ catch {
340
+ rhKey = undefined;
341
+ }
342
+ }
318
343
  const chunks = chunkByCharBudget(pending, perChunk);
319
344
  if (chunks.length > 1) {
320
345
  logger.info(`[memory] L1 输入超预算(${pending.length} 条消息),分 ${chunks.length} 块抽取(每块 ≤${perChunk} 字符)`);
321
346
  }
322
347
  const extracted = [];
348
+ // R1′ 抽取块级失败的消息(留桶下轮重投,实现部分消费)
349
+ const failedMessages = [];
323
350
  let lastScene = chainState.lastSceneName;
324
351
  let sceneCount = 0;
325
352
  // 治理归属(ADR-0015 T1.7/T1.9):repoKey=basename(归一 cwd),**进管线时一次算定**
@@ -330,30 +357,52 @@ mergeRekey) {
330
357
  const halls = mode === 'auto' && !hallCandidates.includes(WING_FALLBACK)
331
358
  ? [...hallCandidates, WING_FALLBACK]
332
359
  : hallCandidates;
360
+ const timeBudgetMs = cfg.llm.extractTimeBudgetMs ?? 0;
361
+ const extractStart = Date.now();
333
362
  for (const chunk of chunks) {
363
+ // R5 整轮墙钟预算:超预算把本块(及后续块)留 pending 下轮续,防慢模型独占串行泵
364
+ if (timeBudgetMs > 0 && chunks.length > 1 && Date.now() - extractStart > timeBudgetMs) {
365
+ failedMessages.push(...chunk);
366
+ continue;
367
+ }
334
368
  const userPrompt = formatExtractionPrompt({
335
369
  newMessages: chunk,
336
370
  backgroundMessages: backgroundMsgs,
337
371
  previousSceneName: lastScene || '无',
338
372
  halls,
339
373
  });
340
- const raw = await callLLM(ctx, cfg, {
341
- system: getExtractMemoriesSystemPrompt(mode, {
342
- usageScenario: cfg.usageScenario?.enabled === true,
343
- narrative: cfg.narrativeTemplates?.enabled === true,
344
- // 名称蒸馏(Phase 3):示例段只在总开关开着时付(它与 title 是两层成本,
345
- // 分开开关才能 A/B)。关着时这份 opts 产出的提示与改动前逐字相同。
346
- title: cfg.naming?.enabled === true,
347
- fewShot: cfg.naming?.enabled === true && cfg.naming.fewShot === true,
348
- }),
349
- user: userPrompt,
350
- maxTokens: resolveLayerTokens(cfg, 'extract'),
351
- layer: 'l1-extract',
352
- logger,
353
- });
354
- const scenes = parseJsonLogged(raw, 'L1 抽取', logger);
355
- if (!Array.isArray(scenes))
356
- throw new Error('L1 抽取输出不是 JSON 数组');
374
+ let scenes;
375
+ try {
376
+ const raw = await callLLM(ctx, cfg, {
377
+ system: getExtractMemoriesSystemPrompt(mode, {
378
+ usageScenario: cfg.usageScenario?.enabled === true,
379
+ narrative: cfg.narrativeTemplates?.enabled === true,
380
+ // 名称蒸馏(Phase 3):示例段只在总开关开着时付(它与 title 是两层成本,
381
+ // 分开开关才能 A/B)。关着时这份 opts 产出的提示与改动前逐字相同。
382
+ title: cfg.naming?.enabled === true,
383
+ fewShot: cfg.naming?.enabled === true && cfg.naming.fewShot === true,
384
+ }),
385
+ user: userPrompt,
386
+ maxTokens: resolveLayerTokens(cfg, 'extract'),
387
+ layer: 'l1-extract',
388
+ logger,
389
+ });
390
+ const parsed = parseJsonLogged(raw, 'L1 抽取', logger);
391
+ if (!Array.isArray(parsed))
392
+ throw new Error('L1 抽取输出不是 JSON 数组');
393
+ scenes = parsed;
394
+ // R9: 记录该路由跑通的实际块长(只降不升),供下轮收紧 perChunk
395
+ if (routeHealth && rhKey) {
396
+ const chunkChars = chunk.reduce((n, m) => n + m.content.length + 64, 0);
397
+ routeHealth.recordGood(rhKey.provider, rhKey.model, chunkChars);
398
+ }
399
+ }
400
+ catch (err) {
401
+ // R1′ 块级容错:单块超时/崩只丢这块、留桶下轮重投,不掀翻整批(旧行为是整抛)
402
+ failedMessages.push(...chunk);
403
+ logger.warn(`[memory] L1 抽取块失败(${chunk.length} 条消息留待重投): ${err instanceof Error ? err.message : String(err)}`);
404
+ continue;
405
+ }
357
406
  for (const scene of scenes) {
358
407
  if (!scene || typeof scene.scene_name !== 'string')
359
408
  continue;
@@ -397,10 +446,14 @@ mergeRekey) {
397
446
  }
398
447
  }
399
448
  if (extracted.length === 0) {
449
+ // 全部块失败:等价旧"整抛"语义——runner catch 会保留整片 + 记退避(不误当零产出成功)
450
+ if (failedMessages.length > 0) {
451
+ throw new Error(`L1 抽取全部 ${chunks.length} 块失败(逐块错误见上)`);
452
+ }
400
453
  logger.info(`[memory] L1 抽取完成:无可提取记忆(mode=${mode},${pending.length} 条消息,${sceneCount} 个情境)`);
401
454
  // 成功但零产出:同样推进时间戳/场景,与失败(lastExtractAt 保持 0)区分开
402
455
  markExtracted(states, mode, lastScene);
403
- return { stored: 0, skipped: false, sceneName: lastScene, newRecords: [] };
456
+ return { stored: 0, skipped: false, sceneName: lastScene, newRecords: [], failedMessages: [] };
404
457
  }
405
458
  // ── Step 2: 去重(batch 冲突检测;候选只在本族内召回,去重永不跨族) ──
406
459
  const matches = await Promise.all(extracted.map(async (m) => ({
@@ -773,7 +826,7 @@ mergeRekey) {
773
826
  }
774
827
  markExtracted(states, mode, lastScene);
775
828
  logger.info(`[memory] L1 抽取完成(mode=${mode}):消息 ${pending.length} 条,抽取 ${extracted.length} 条,去重后新增 ${added.length} 条(取代退场 ${supersededBy.size} 条,chat=${addedByFamily.chat}/work=${addedByFamily.work}),累计 chat=${states.chat.totalExtracted}/work=${states.work.totalExtracted}`);
776
- return { stored: added.length, skipped: false, sceneName: lastScene, newRecords: added, runId, byKind };
829
+ return { stored: added.length, skipped: false, sceneName: lastScene, newRecords: added, runId, byKind, failedMessages };
777
830
  }
778
831
  /** auto 档取最近活跃的族 checkpoint(情境链/计数锚点)。 */
779
832
  function activeState(states) {
@@ -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
  }
@@ -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 分块驱动)。 */
@@ -1,5 +1,6 @@
1
1
  import { resolveDataDir } from '../config.js';
2
2
  import { GRAPH_PRIORITY_NEW } from '../graph/types.js';
3
+ import { NoDistillRouteError } from '../llm.js';
3
4
  import { startValidityBackfill } from '../validity-backfill.js';
4
5
  import { drscRowToConversationMessage } from '../clients/drsc.js';
5
6
  import { trace } from '../store/trace.js';
@@ -83,6 +84,8 @@ export function effectiveCfg(cfg, live) {
83
84
  : null;
84
85
  const layerChains = layerPick && Object.keys(layerPick).length ? layerPick : null;
85
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;
86
89
  // 旧档位键的全局接管(含给静态回退条目盖章):仅非链模式保留(旧存量值兼容)
87
90
  const fallbacksTakeover = !chainMode && eff && cfg.llm?.fallbacks?.length
88
91
  ? cfg.llm.fallbacks.map((f) => ({ ...f, reasoningEffort: eff }))
@@ -169,6 +172,7 @@ export function effectiveCfg(cfg, live) {
169
172
  ...(override ?? {}),
170
173
  ...(budgets ? { budgets } : {}),
171
174
  ...(maxInput ? { maxInputChars: maxInput } : {}),
175
+ ...(timeoutOv ? { timeoutMs: timeoutOv } : {}),
172
176
  ...(fallbacksTakeover ? { fallbacks: fallbacksTakeover } : {}),
173
177
  ...(chainEffort ? { primaryEffort: chainEffort } : {}),
174
178
  ...(chainFallbacks ? { fallbacks: chainFallbacks } : {}),
@@ -212,6 +216,8 @@ export class MemoryRunner {
212
216
  extractFailures = new Map();
213
217
  /** 图谱泵在队列中的占位标志(同一时刻至多一个 graph 任务;护栏四件套之一)。 */
214
218
  graphPumpQueued = false;
219
+ /** R7:已入队、尚未消费的 force-flush 键集(`session:mode`),同键不重复入队(治积压刷屏)。 */
220
+ flushQueued = new Set();
215
221
  pendingFile;
216
222
  /** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
217
223
  states;
@@ -357,9 +363,25 @@ export class MemoryRunner {
357
363
  /** 一轮对话结束后入队(L0 落盘由 capture 在 turn/end 即时完成,不排蒸馏队列)。
358
364
  * onTurnDone 在该任务真正跑完后回调,携带本轮新增记录数(供反刍统计真实产出)。 */
359
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);
360
374
  this.pushTask({
361
375
  kind: 'live',
362
- 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
+ },
363
385
  ...(opts?.onTurnDone ? { onDone: (r) => opts.onTurnDone?.(typeof r === 'number' ? r : 0) } : {}),
364
386
  });
365
387
  }
@@ -637,7 +659,7 @@ export class MemoryRunner {
637
659
  const effective = effectiveExtractThreshold(this.warmup[mode], cfg.extract.minMessages);
638
660
  const sliceLen = bucket.reduce((n, m) => (m.sessionId === sessionId ? n + 1 : n), 0);
639
661
  // 抽取失败退避:重建轮(noBufferCap,用户显式动作、有自己的失败/取消 UI)豁免
640
- const backedOff = !opts?.noBufferCap && this.inExtractBackoff(sessionId);
662
+ const backedOff = !opts?.noBufferCap && !opts?.bypassBackoff && this.inExtractBackoff(sessionId);
641
663
  if ((opts?.force || sliceLen >= effective) && !backedOff) {
642
664
  newRecords = await this.extractSessionSlice(sessionId, mode, cfg, effective, opts);
643
665
  // 新记录入投影队列(优先级恒高于存量补投影)并拉起图谱泵;入队本身廉价,
@@ -790,9 +812,12 @@ export class MemoryRunner {
790
812
  (oldId, newId) => {
791
813
  this.stores.usage?.rekey(oldId, newId);
792
814
  this.stores.feedback?.rekey(oldId, newId);
793
- });
815
+ }, this.stores.routeHealth);
816
+ // R9: 本轮学到的跑通块长落盘(best-effort,失败不阻蒸馏)
817
+ await this.stores.routeHealth?.save().catch(() => { });
794
818
  if (!result.skipped) {
795
- this.pending[mode] = rest;
819
+ // R1′ 部分消费:成功块出桶、抽取失败块留桶下轮重投(rest ∪ failedMessages)
820
+ this.pending[mode] = [...rest, ...(result.failedMessages ?? [])];
796
821
  // 重建轮(force)不是有机对话,不推进爬坡
797
822
  if (!opts?.force)
798
823
  this.warmup[mode] = advanceWarmupThreshold(this.warmup[mode], cfg.extract.minMessages);
@@ -844,6 +869,12 @@ export class MemoryRunner {
844
869
  ok: false,
845
870
  errorKind: err instanceof Error ? err.name : 'error',
846
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
+ }
847
878
  // 指数退避:压制闲置兜底/补跑在 LLM 故障期间的连环重试(成功消费时清零)
848
879
  const streak = (this.extractFailures.get(sessionId)?.streak ?? 0) + 1;
849
880
  const delayMs = extractionBackoffMs(streak);
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: '',
@@ -309,6 +310,8 @@ function resolveSettings(value) {
309
310
  graph: num(rawBudgets.graph),
310
311
  },
311
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))),
312
315
  // 蒸馏通道:mode 白名单(非法归 '' = 跟随部署);端点与密钥截断防御但保留原始内容
313
316
  distillMode: v.distillMode === 'host' || v.distillMode === 'direct' ? v.distillMode : '',
314
317
  directBaseURL: typeof v.directBaseURL === 'string' ? v.directBaseURL.slice(0, 2000) : '',
package/dist/stats.js CHANGED
@@ -1344,6 +1344,7 @@ export async function handleEndpoint(endpoint, payload, deps) {
1344
1344
  toolRoom: true, toolGraph: true, toolRuminate: true, toolConflict: true, toolMutate: true, toolSkills: true,
1345
1345
  reasoningEffort: '', distillProvider: '', distillModel: '', distillChain: [],
1346
1346
  distillBudgets: { extract: 0, dedup: 0, l2: 0, l3: 0, graph: 0 }, distillMaxInputChars: 0,
1347
+ distillTimeoutMs: 0,
1347
1348
  distillLayerChains: { l1: [], l2: [], l3: [] },
1348
1349
  distillMode: '', directBaseURL: '', directApiKey: '',
1349
1350
  embedRemoteBaseURL: '', embedRemoteApiKey: '', embedRemoteModel: '', embedRemoteDimensions: 0,
@@ -1386,6 +1387,12 @@ export async function handleEndpoint(endpoint, payload, deps) {
1386
1387
  fallback: cfg.llm.maxInputChars,
1387
1388
  effective: s && s.distillMaxInputChars > 0 ? s.distillMaxInputChars : cfg.llm.maxInputChars,
1388
1389
  },
1390
+ // R10 蒸馏超时(ms):current 是运行时覆盖(0 = 跟随配置),fallback 是静态 llm.timeoutMs
1391
+ timeoutBudget: {
1392
+ current: s?.distillTimeoutMs ?? 0,
1393
+ fallback: cfg.llm.timeoutMs,
1394
+ effective: s && s.distillTimeoutMs > 0 ? s.distillTimeoutMs : cfg.llm.timeoutMs,
1395
+ },
1389
1396
  };
1390
1397
  return resp;
1391
1398
  }
@@ -1468,14 +1475,22 @@ export async function handleEndpoint(endpoint, payload, deps) {
1468
1475
  }
1469
1476
  clean.distillBudgets = budgets;
1470
1477
  }
1471
- // 输入预算(字符):0 = 跟随静态配置;正值须落在静态 schema 同款范围(1000~100 万)
1478
+ // 输入预算(字符):0 = 跟随静态配置;正值须落在静态 schema 同款范围(1000~256k)
1472
1479
  if (patch.distillMaxInputChars !== undefined) {
1473
1480
  const n = Number(patch.distillMaxInputChars);
1474
- if (!Number.isInteger(n) || n < 0 || n > 1_000_000 || (n > 0 && n < 1000)) {
1475
- throw new Error('distillMaxInputChars 须为 0 或 1000~1000000 的整数(0 = 跟随配置)');
1481
+ if (!Number.isInteger(n) || n < 0 || n > 256_000 || (n > 0 && n < 1000)) {
1482
+ throw new Error('distillMaxInputChars 须为 0 或 1000~256000 的整数(0 = 跟随配置)');
1476
1483
  }
1477
1484
  clean.distillMaxInputChars = n;
1478
1485
  }
1486
+ // R10 蒸馏超时(ms):0 = 跟随静态配置;正值 1000~600000(与 live schema 同上限)
1487
+ if (patch.distillTimeoutMs !== undefined) {
1488
+ const n = Number(patch.distillTimeoutMs);
1489
+ if (!Number.isInteger(n) || n < 0 || n > 600_000 || (n > 0 && n < 1000)) {
1490
+ throw new Error('distillTimeoutMs 须为 0 或 1000~600000 的整数(0 = 跟随配置)');
1491
+ }
1492
+ clean.distillTimeoutMs = n;
1493
+ }
1479
1494
  // 蒸馏通道运行时覆盖(direct 解耦):mode 白名单;endpoint 可回显;apiKey 属机密——
1480
1495
  // 写入后存 settings,但不回读到 UI、不落本条日志(下方日志先剔除后记录)
1481
1496
  if (patch.distillMode !== undefined) {
@@ -29,3 +29,5 @@ export declare const PENDING_FILE_VERSION = 1;
29
29
  export declare const RECONCILE_STATE_FILE_VERSION = 1;
30
30
  /** `mem-md-ledger.json`(md 投射指纹账本,N9;派生态,删了可重建)。*/
31
31
  export declare const MEM_MD_LEDGER_FILE_VERSION = 1;
32
+ /** `distill-route-health.json`(蒸馏路由健康学习值,R9;运行时派生,删了可重学)。*/
33
+ export declare const ROUTE_HEALTH_FILE_VERSION = 1;
@@ -29,3 +29,5 @@ export const PENDING_FILE_VERSION = 1;
29
29
  export const RECONCILE_STATE_FILE_VERSION = 1;
30
30
  /** `mem-md-ledger.json`(md 投射指纹账本,N9;派生态,删了可重建)。*/
31
31
  export const MEM_MD_LEDGER_FILE_VERSION = 1;
32
+ /** `distill-route-health.json`(蒸馏路由健康学习值,R9;运行时派生,删了可重学)。*/
33
+ export const ROUTE_HEALTH_FILE_VERSION = 1;
@@ -0,0 +1,38 @@
1
+ /** 单条路由健康记录。 */
2
+ export interface RouteHealthEntry {
3
+ /** 退避后实测跑通的最小块长(字符);0 = 未学到。 */
4
+ lastGoodChunkChars: number;
5
+ /** 该路由延迟 EMA(ms);0 = 未记。R5 自适应可据此调初值。 */
6
+ latencyEmaMs: number;
7
+ /** 最近一次记录时刻(ISO)。 */
8
+ updatedAt: string;
9
+ }
10
+ /** 学习值默认过期(ms):超此龄视为陈旧、读侧应重探(换模型/网关不至于被历史最小值锁死)。 */
11
+ export declare const ROUTE_HEALTH_STALE_MS: number;
12
+ export declare function routeHealthPathFor(dataDir: string): string;
13
+ export declare class RouteHealthStore {
14
+ private readonly file;
15
+ private routes;
16
+ private dirty;
17
+ constructor(file: string);
18
+ /** 读盘 fail-open:缺/坏/未知版本 → 空表、不抛。 */
19
+ load(): Promise<void>;
20
+ /** 该路由已学到、且未过期的跑通块长;0 = 无可用学习值(未学到或已陈旧)。 */
21
+ lastGoodChunkChars(provider: string, model: string, now?: number, staleMs?: number): number;
22
+ /** 该路由延迟 EMA(ms);0 = 未记。 */
23
+ latencyEma(provider: string, model: string): number;
24
+ /**
25
+ * 记一次「跑通」的块长:只降不升(更小块长才覆盖 lastGood),并更新延迟 EMA。
26
+ * @param chunkChars 本次成功的块长(字符)
27
+ * @param latencyMs 本次成功调用耗时(可选,用于 EMA)
28
+ */
29
+ recordGood(provider: string, model: string, chunkChars: number, latencyMs?: number): void;
30
+ /** 清除学习值:传 provider+model 清单条,否则清全部(面板「重置学习值」一键)。 */
31
+ reset(provider?: string, model?: string): void;
32
+ /** 全量快照(面板回显用)。 */
33
+ snapshot(): Array<{
34
+ key: string;
35
+ } & RouteHealthEntry>;
36
+ /** 有变更才落盘;失败由调用方兜底(不抛穿蒸馏)。@returns 是否写了盘 */
37
+ save(): Promise<boolean>;
38
+ }