dsh-layered-memory 0.7.1 → 0.8.1

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 (53) hide show
  1. package/README.en.md +165 -75
  2. package/README.md +174 -104
  3. package/assets/img/EmbeddingSource.png +0 -0
  4. package/assets/img/MemoryTools.png +0 -0
  5. package/assets/img/ToolTrajectory.png +0 -0
  6. package/assets/readme/bench-dialog.svg +87 -0
  7. package/assets/readme/bench-workflow.svg +79 -0
  8. package/assets/readme/flow.svg +74 -74
  9. package/assets/readme/storage.svg +56 -56
  10. package/dist/client.js +380 -2
  11. package/dist/config.d.ts +41 -1
  12. package/dist/config.js +10 -3
  13. package/dist/hooks/recall.d.ts +11 -8
  14. package/dist/hooks/recall.js +70 -51
  15. package/dist/index.d.ts +20 -0
  16. package/dist/index.js +15 -4
  17. package/dist/llm.d.ts +28 -0
  18. package/dist/llm.js +33 -0
  19. package/dist/pipeline/l1.js +5 -4
  20. package/dist/pipeline/l2.js +7 -2
  21. package/dist/pipeline/l3.js +7 -2
  22. package/dist/pipeline/rebuild.js +2 -2
  23. package/dist/pipeline/runner.d.ts +45 -8
  24. package/dist/pipeline/runner.js +185 -39
  25. package/dist/pipeline/trigger.d.ts +38 -0
  26. package/dist/pipeline/trigger.js +64 -0
  27. package/dist/settings.d.ts +14 -0
  28. package/dist/settings.js +41 -2
  29. package/dist/stats.js +113 -1
  30. package/dist/store/bm25.js +2 -1
  31. package/dist/store/download-queue.d.ts +35 -2
  32. package/dist/store/download-queue.js +102 -5
  33. package/dist/store/embedding.d.ts +12 -6
  34. package/dist/store/embedding.js +10 -7
  35. package/dist/store/l0.d.ts +2 -0
  36. package/dist/store/l0.js +4 -0
  37. package/dist/store/l1.d.ts +2 -0
  38. package/dist/store/l1.js +2 -2
  39. package/dist/store/model-catalog.js +1 -1
  40. package/dist/store/pending.d.ts +27 -6
  41. package/dist/store/pending.js +49 -9
  42. package/dist/store/search-utils.js +3 -2
  43. package/dist/store/session-modes.d.ts +4 -0
  44. package/dist/store/session-modes.js +16 -0
  45. package/dist/store/sqlite.d.ts +12 -0
  46. package/dist/store/sqlite.js +103 -5
  47. package/dist/util/recall-budget.d.ts +32 -0
  48. package/dist/util/recall-budget.js +85 -0
  49. package/dist/util/text.d.ts +8 -3
  50. package/dist/util/text.js +41 -15
  51. package/dist/util/tokenizer.d.ts +14 -0
  52. package/dist/util/tokenizer.js +53 -0
  53. package/package.json +6 -3
@@ -1,3 +1,6 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
2
+ import { applyRecallBudget, raceRecallTimeout, RECALL_EMBED_CAP_MS } from '../util/recall-budget.js';
3
+ import { errDetail } from '../util/filelog.js';
1
4
  import { blocksToText } from '../util/text.js';
2
5
  const PROFILE_TTL = 60_000;
3
6
  /** 召回查询只取会话末尾 N 条消息(长会话每步把全史拼进 FTS MATCH 会让检索成本线性上涨)。 */
@@ -28,9 +31,13 @@ const MEMORY_TOOLS_GUIDE = `<memory-tools-guide>
28
31
  每轮对话中,memory_search 和 conversation_search **合计最多调用 3 次**。
29
32
  - 首次搜索无结果时,可换关键词或换工具重试,但总调用次数不要超过 3 次。
30
33
  - 若 3 次搜索后仍无结果,说明该信息不在记忆中,请直接根据已有信息回复用户。
34
+
35
+ 注:若当前环境限制直接调用工具(如仅允许代码执行入口),请经由该环境的工具调用机制
36
+ (如 run_code 程序内)使用以上记忆工具。
31
37
  </memory-tools-guide>`;
32
38
  export function registerRecall(ctx, cfg, stores, logger, live, modes) {
33
- const l1Cache = new Map();
39
+ /** 每 agent 最近一次注入的命中条数(工具指南门控的"本轮召回命中"信号)。 */
40
+ const recallHits = new Map();
34
41
  // 画像/场景导航按族缓存(分族隔离:注入时按会话档位选族)
35
42
  const profileCache = {
36
43
  chat: { persona: '', nav: '' },
@@ -46,7 +53,7 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes) {
46
53
  profileCache.work = work;
47
54
  }
48
55
  catch (err) {
49
- logger.warn(`[memory] 画像/场景缓存刷新失败: ${err instanceof Error ? err.message : String(err)}`);
56
+ logger.warn(`[memory] 画像/场景缓存刷新失败: ${errDetail(err)}`);
50
57
  }
51
58
  };
52
59
  // 初始刷新 + 定时刷新(TTL)
@@ -58,43 +65,72 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes) {
58
65
  const invalidateProfile = () => {
59
66
  void refreshProfile();
60
67
  };
61
- // agent 销毁时清掉召回缓存槽
68
+ // agent 销毁时清掉召回信号槽
62
69
  ctx.on('agent/disposed', (payload) => {
63
- l1Cache.delete(payload.agent.id);
70
+ recallHits.delete(payload.agent.id);
64
71
  });
65
- // ── 1. pre-step:检索并缓存(按会话档位过滤族:纯档只查本族,auto 不过滤,off 跳过) ──
72
+ // ── 1. pre-step 消息侧注入:记忆先行于每一条新的用户输入(ADR-0001) ──
73
+ // prepend 注册 + 先 next() 再改写:不劫持其他监听器(dsh-time-context 官方范式)。
66
74
  if (cfg.recall.enabled) {
67
75
  ctx.on('agent/pre-step', async (payload, next) => {
76
+ const decision = await next();
77
+ if (decision.kind === 'reject' || payload.signal.aborted)
78
+ return decision;
68
79
  try {
69
80
  const s = live.get();
70
81
  const mode = modes.get(payload.agent.id);
71
- if (!s.enabled || !s.recall || mode === 'off') {
72
- l1Cache.set(payload.agent.id, []);
73
- return next();
74
- }
82
+ if (!s.enabled || !s.recall || mode === 'off')
83
+ return decision;
84
+ // 只在有新的用户来源消息的步骤注入(轮首 claim 或 steering 插话);纯工具步透传
85
+ const hasNewUserMessage = decision.messages.some((m) => m.source?.kind === 'user');
86
+ if (!hasNewUserMessage)
87
+ return decision;
75
88
  const query = buildRecallQuery(payload.messages);
76
- if (query) {
77
- const hits = await stores.l1.search(query, cfg.recall.maxResults, {
78
- scoreThreshold: cfg.recall.scoreThreshold,
79
- family: mode === 'auto' ? undefined : mode,
80
- });
81
- l1Cache.set(payload.agent.id, hits);
82
- if (hits.length > 0) {
83
- logger.info(`[memory] 召回命中 ${hits.length} 条 L1(mode=${mode},query="${query.slice(0, 30).replace(/\n/g, ' ')}…",agent=${payload.agent.id})`);
84
- }
85
- }
86
- else {
87
- // 空查询清缓存:不把上一步的命中注入到与本步无关的上下文
88
- l1Cache.set(payload.agent.id, []);
89
+ recallHits.set(payload.agent.id, 0);
90
+ if (!query)
91
+ return decision;
92
+ const hits = await raceRecallTimeout(stores.l1.search(query, cfg.recall.maxResults, {
93
+ scoreThreshold: cfg.recall.scoreThreshold,
94
+ family: mode === 'auto' ? undefined : mode,
95
+ // 远程嵌入 fetch 内层钳制:给 FTS 降级留出总预算内的时间(本地推理不钳)
96
+ embeddingTimeoutMs: RECALL_EMBED_CAP_MS,
97
+ }), cfg.recall.timeoutMs);
98
+ if (hits === undefined) {
99
+ logger.warn('[memory] 召回超时,跳过本轮注入(不阻塞对话)');
100
+ return decision;
89
101
  }
102
+ recallHits.set(payload.agent.id, hits.length);
103
+ if (hits.length === 0)
104
+ return decision;
105
+ const lines = applyRecallBudget(hits.map((h) => `- [${h.scene_name ? `${h.type}|${h.scene_name}` : h.type}] ${h.content}`), { maxCharsPerMemory: cfg.recall.maxCharsPerMemory, maxTotalRecallChars: cfg.recall.maxTotalRecallChars });
106
+ if (lines.length === 0)
107
+ return decision;
108
+ const text = [
109
+ '<relevant-memories>',
110
+ '以下是当前对话召回的相关记忆,不代表当前任务进程,仅作为参考:',
111
+ '',
112
+ ...lines,
113
+ '',
114
+ '</relevant-memories>',
115
+ ].join('\n');
116
+ logger.info(`[memory] 召回注入 ${lines.length} 条 L1(mode=${mode},query="${query.slice(0, 30).replace(/\n/g, ' ')}…",agent=${payload.agent.id},消息侧)`);
117
+ const injection = createUserMessage({
118
+ content: [{ type: 'text', text }],
119
+ // plugin 字段是宿主 UI 的署名后缀("上下文注入 · memory")——用展示友好的
120
+ // 子系统名,不用 cordis id(dsh-memory);kind:'plugin' 的标题恒为通用
121
+ // "上下文注入"(专用"跨会话召回"标题仅留给 session-reference 来源)
122
+ source: { kind: 'plugin', plugin: 'memory', form: 'recall' },
123
+ });
124
+ // 注入消息排在用户新消息之前(原版 prepend 语义:先线索后问题)
125
+ return { kind: 'enter', messages: [injection, ...decision.messages] };
90
126
  }
91
127
  catch (err) {
92
- logger.warn(`[memory] 召回检索失败: ${err instanceof Error ? err.message : String(err)}`);
128
+ logger.warn(`[memory] 召回注入失败(跳过本轮): ${errDetail(err)}`);
129
+ return decision;
93
130
  }
94
- return next();
95
- });
131
+ }, { prepend: true });
96
132
  }
97
- // ── 2. agent 作用域上下文 provider ──
133
+ // ── 2. agent 作用域上下文 provider(系统提示稳定区:画像 + 导航 + 门控指南) ──
98
134
  // 插件可能在默认 agent 创建之后才加载(组合顺序由依赖决定),
99
135
  // 因此除了监听 agent/created,还要给已存在的 agent 补注册。
100
136
  const registered = new WeakSet();
@@ -107,16 +143,6 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes) {
107
143
  registered.add(agent);
108
144
  try {
109
145
  contextDisposers.push(agent.ctx.systemPrompt.context({
110
- name: 'memory:recall',
111
- order: 500,
112
- text: () => {
113
- const s = live.get();
114
- if (!s.enabled || !s.recall)
115
- return '';
116
- const hits = l1Cache.get(agent.id) ?? [];
117
- return formatL1Memories(hits);
118
- },
119
- }), agent.ctx.systemPrompt.context({
120
146
  name: 'memory:profile',
121
147
  order: 510,
122
148
  text: () => {
@@ -130,9 +156,14 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes) {
130
156
  const body = mode === 'auto'
131
157
  ? formatProfileAuto(profileCache.chat, profileCache.work)
132
158
  : formatProfileSingle(profileCache[mode]);
133
- if (!body)
134
- return MEMORY_TOOLS_GUIDE;
135
- return `${body}\n\n${MEMORY_TOOLS_GUIDE}`;
159
+ const hasRecallHit = (recallHits.get(agent.id) ?? 0) > 0;
160
+ // 指南三条件门控:工具已注册(cfg.tools)&&(稳定内容 ∥ 本轮召回命中)——
161
+ // 空库用户与关闭工具的用户不付这份固定 token(原版 auto-recall 同款语义)
162
+ if (!cfg.tools)
163
+ return body;
164
+ if (!body && !hasRecallHit)
165
+ return '';
166
+ return body ? `${body}\n\n${MEMORY_TOOLS_GUIDE}` : MEMORY_TOOLS_GUIDE;
136
167
  },
137
168
  }));
138
169
  }
@@ -160,18 +191,6 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes) {
160
191
  });
161
192
  return { invalidateProfile };
162
193
  }
163
- function formatL1Memories(items) {
164
- if (items.length === 0)
165
- return '';
166
- const lines = ['<relevant-memories>', ''];
167
- for (const item of items) {
168
- // [type|scene] 标签 + 内容(官方召回行格式)
169
- const tag = item.scene_name ? `${item.type}|${item.scene_name}` : item.type;
170
- lines.push(`- [${tag}] ${item.content}`);
171
- }
172
- lines.push('', '</relevant-memories>');
173
- return lines.join('\n');
174
- }
175
194
  /** auto 档 <user-persona> 内的域说明:让模型理解分块结构与两域的独立性。 */
176
195
  const DOMAIN_HINT = '以下内容按记忆域分块:chat=用户个人画像(User Narrative Profile),work=团队工作准则(Team Operating Doctrine)。' +
177
196
  '两域独立蒸馏与更新,请按当前对话语境参考对应域,不要把一域的内容当作另一域的事实。';
package/dist/index.d.ts CHANGED
@@ -23,11 +23,13 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
23
23
  extract: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
24
24
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
25
25
  minMessages: import("@deepseek-ai/schemastery").default<number, number>;
26
+ idleSeconds: import("@deepseek-ai/schemastery").default<number, number>;
26
27
  backgroundMessages: import("@deepseek-ai/schemastery").default<number, number>;
27
28
  candidatePool: import("@deepseek-ai/schemastery").default<number, number>;
28
29
  }>, Schemastery.ObjectT<{
29
30
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
30
31
  minMessages: import("@deepseek-ai/schemastery").default<number, number>;
32
+ idleSeconds: import("@deepseek-ai/schemastery").default<number, number>;
31
33
  backgroundMessages: import("@deepseek-ai/schemastery").default<number, number>;
32
34
  candidatePool: import("@deepseek-ai/schemastery").default<number, number>;
33
35
  }>>;
@@ -52,6 +54,9 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
52
54
  recall: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
53
55
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
54
56
  maxResults: import("@deepseek-ai/schemastery").default<number, number>;
57
+ maxCharsPerMemory: import("@deepseek-ai/schemastery").default<number, number>;
58
+ maxTotalRecallChars: import("@deepseek-ai/schemastery").default<number, number>;
59
+ timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
55
60
  includePersona: import("@deepseek-ai/schemastery").default<boolean, boolean>;
56
61
  includeSceneNav: import("@deepseek-ai/schemastery").default<boolean, boolean>;
57
62
  strategy: import("@deepseek-ai/schemastery").default<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
@@ -59,6 +64,9 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
59
64
  }>, Schemastery.ObjectT<{
60
65
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
61
66
  maxResults: import("@deepseek-ai/schemastery").default<number, number>;
67
+ maxCharsPerMemory: import("@deepseek-ai/schemastery").default<number, number>;
68
+ maxTotalRecallChars: import("@deepseek-ai/schemastery").default<number, number>;
69
+ timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
62
70
  includePersona: import("@deepseek-ai/schemastery").default<boolean, boolean>;
63
71
  includeSceneNav: import("@deepseek-ai/schemastery").default<boolean, boolean>;
64
72
  strategy: import("@deepseek-ai/schemastery").default<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
@@ -74,6 +82,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
74
82
  timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
75
83
  allowLocalModels: import("@deepseek-ai/schemastery").default<boolean, boolean>;
76
84
  mirror: import("@deepseek-ai/schemastery").default<string, string>;
85
+ proxy: import("@deepseek-ai/schemastery").default<string, string>;
77
86
  }>, Schemastery.ObjectT<{
78
87
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
79
88
  baseUrl: import("@deepseek-ai/schemastery").default<string, string>;
@@ -84,6 +93,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
84
93
  timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
85
94
  allowLocalModels: import("@deepseek-ai/schemastery").default<boolean, boolean>;
86
95
  mirror: import("@deepseek-ai/schemastery").default<string, string>;
96
+ proxy: import("@deepseek-ai/schemastery").default<string, string>;
87
97
  }>>;
88
98
  llm: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
89
99
  provider: import("@deepseek-ai/schemastery").default<string, string>;
@@ -118,11 +128,13 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
118
128
  extract: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
119
129
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
120
130
  minMessages: import("@deepseek-ai/schemastery").default<number, number>;
131
+ idleSeconds: import("@deepseek-ai/schemastery").default<number, number>;
121
132
  backgroundMessages: import("@deepseek-ai/schemastery").default<number, number>;
122
133
  candidatePool: import("@deepseek-ai/schemastery").default<number, number>;
123
134
  }>, Schemastery.ObjectT<{
124
135
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
125
136
  minMessages: import("@deepseek-ai/schemastery").default<number, number>;
137
+ idleSeconds: import("@deepseek-ai/schemastery").default<number, number>;
126
138
  backgroundMessages: import("@deepseek-ai/schemastery").default<number, number>;
127
139
  candidatePool: import("@deepseek-ai/schemastery").default<number, number>;
128
140
  }>>;
@@ -147,6 +159,9 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
147
159
  recall: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
148
160
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
149
161
  maxResults: import("@deepseek-ai/schemastery").default<number, number>;
162
+ maxCharsPerMemory: import("@deepseek-ai/schemastery").default<number, number>;
163
+ maxTotalRecallChars: import("@deepseek-ai/schemastery").default<number, number>;
164
+ timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
150
165
  includePersona: import("@deepseek-ai/schemastery").default<boolean, boolean>;
151
166
  includeSceneNav: import("@deepseek-ai/schemastery").default<boolean, boolean>;
152
167
  strategy: import("@deepseek-ai/schemastery").default<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
@@ -154,6 +169,9 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
154
169
  }>, Schemastery.ObjectT<{
155
170
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
156
171
  maxResults: import("@deepseek-ai/schemastery").default<number, number>;
172
+ maxCharsPerMemory: import("@deepseek-ai/schemastery").default<number, number>;
173
+ maxTotalRecallChars: import("@deepseek-ai/schemastery").default<number, number>;
174
+ timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
157
175
  includePersona: import("@deepseek-ai/schemastery").default<boolean, boolean>;
158
176
  includeSceneNav: import("@deepseek-ai/schemastery").default<boolean, boolean>;
159
177
  strategy: import("@deepseek-ai/schemastery").default<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
@@ -169,6 +187,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
169
187
  timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
170
188
  allowLocalModels: import("@deepseek-ai/schemastery").default<boolean, boolean>;
171
189
  mirror: import("@deepseek-ai/schemastery").default<string, string>;
190
+ proxy: import("@deepseek-ai/schemastery").default<string, string>;
172
191
  }>, Schemastery.ObjectT<{
173
192
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
174
193
  baseUrl: import("@deepseek-ai/schemastery").default<string, string>;
@@ -179,6 +198,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
179
198
  timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
180
199
  allowLocalModels: import("@deepseek-ai/schemastery").default<boolean, boolean>;
181
200
  mirror: import("@deepseek-ai/schemastery").default<string, string>;
201
+ proxy: import("@deepseek-ai/schemastery").default<string, string>;
182
202
  }>>;
183
203
  llm: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
184
204
  provider: import("@deepseek-ai/schemastery").default<string, string>;
package/dist/index.js CHANGED
@@ -39,6 +39,7 @@ import { StateStore } from './store/state.js';
39
39
  import { registerMemoryTools } from './tools/index.js';
40
40
  import { errDetail, withFileLog } from './util/filelog.js';
41
41
  import { resolveModelRoute } from './llm.js';
42
+ import { effectiveCfg } from './pipeline/runner.js';
42
43
  export const name = 'dsh-memory-plugin';
43
44
  /** 硬依赖:蒸馏要用 llm,工具注册要用 tools,召回注入要用 systemPrompt。 */
44
45
  export const inject = ['llm', 'tools', 'systemPrompt'];
@@ -86,7 +87,11 @@ export async function apply(ctx, config) {
86
87
  // ── 嵌入源三态(D4:远程/本地/关闭)——状态文件优先于静态配置 ──
87
88
  const sourceStore = new EmbeddingSourceStore(dataDir, logger);
88
89
  const installer = new RuntimeInstaller(dataDir, PINNED_TRANSFORMERS_VERSION, { logger });
89
- const downloader = new ModelDownloadQueue(dataDir, { mirror: config.embedding.mirror, logger });
90
+ const downloader = new ModelDownloadQueue(dataDir, {
91
+ mirror: config.embedding.mirror,
92
+ logger,
93
+ proxy: config.embedding.proxy,
94
+ });
90
95
  const makeLocalService = makeLocalServiceFactory(installer, downloader, logger);
91
96
  let initial = { svc: new NoopEmbeddingService(), dims: 0 };
92
97
  /** 管理器引用:启动重嵌链/backfill 闭包在运行期解引用(声明早于创建避免 TDZ)。 */
@@ -157,9 +162,10 @@ export async function apply(ctx, config) {
157
162
  logger.warn(`[memory] 检索策略 ${config.recall.strategy} 需要向量能力(embedding 未启用/不可用),本次运行按 keyword(FTS5 BM25)检索`);
158
163
  }
159
164
  logger.info(`[memory] 数据目录: ${dataDir}(v${PLUGIN_VERSION},默认档=${config.family},检索: FTS=${caps.ftsSearch} 向量=${caps.vectorSearch}${storageOk ? '' : ',存储不可用已停用捕获/蒸馏'})`);
160
- // 蒸馏模型路由:启动期解析一次并记录(路由错误是最难事后排查的问题之一)
165
+ // 蒸馏模型路由:启动期解析一次并记录(路由错误是最难事后排查的问题之一);
166
+ // 用运行时调参视图解析——用户已用 UI 覆盖蒸馏模型时,日志反映实际路由
161
167
  try {
162
- const route = await resolveModelRoute(ctx, config);
168
+ const route = await resolveModelRoute(ctx, effectiveCfg(config, live));
163
169
  logger.info(`[memory] 蒸馏模型路由: ${route.provider}/${route.model}`);
164
170
  }
165
171
  catch (err) {
@@ -269,8 +275,12 @@ export async function apply(ctx, config) {
269
275
  })
270
276
  : undefined;
271
277
  embedManagerRef = embedManager;
272
- const runner = new MemoryRunner(ctx, config, stores, logger, live);
278
+ const runner = new MemoryRunner(ctx, config, stores, logger, live, modes);
273
279
  await runner.init();
280
+ // 档位切换同步(ADR-0003):切走按捕获档位落袋 / 切 off 挂起 / 切回清挂起
281
+ modes.setModeChangeHandler((sessionId, oldMode, newMode) => runner.onModeChange(sessionId, oldMode, newMode));
282
+ // 闲置兜底:静默达标会话的未蒸馏切片自动落袋(idleSeconds=0 关闭)
283
+ runner.startIdleTimer();
274
284
  // 重建控制器(存储降级时不建——RPC 端点走 supported=false 分支)
275
285
  const rebuild = storageOk && !db.isDegraded()
276
286
  ? new RebuildController(ctx, config, stores, db, runner, logger, live)
@@ -294,6 +304,7 @@ export async function apply(ctx, config) {
294
304
  disposed = true;
295
305
  runner.stop();
296
306
  embedManager?.dispose();
307
+ downloader.dispose();
297
308
  return (async () => {
298
309
  await flushL0?.();
299
310
  db.close();
package/dist/llm.d.ts CHANGED
@@ -13,6 +13,34 @@ export interface LlmCallOptions {
13
13
  /** 诊断日志(缺失时静默,不影响调用) */
14
14
  logger?: MemoryLogger;
15
15
  }
16
+ /** L1 抽取(大输入块的 JSON 记忆数组输出)。 */
17
+ export declare const LAYER_MAX_TOKENS_EXTRACT = 16000;
18
+ /** L1 去重(合并决策数组,输出比抽取短)。 */
19
+ export declare const LAYER_MAX_TOKENS_DEDUP = 8000;
20
+ /** L2 场景整合(完整场景 Markdown 文件输出,输出最重的层)。 */
21
+ export declare const LAYER_MAX_TOKENS_L2 = 32000;
22
+ /** L3 画像(完整 persona 文档)。 */
23
+ export declare const LAYER_MAX_TOKENS_L3 = 16000;
24
+ /** 分层输出预算键(运行时设置 / UI 与内置默认共用同一套键名)。 */
25
+ export type DistillBudgetLayer = 'extract' | 'dedup' | 'l2' | 'l3';
26
+ /** 各层内置默认预算(设置页"0 = 跟随默认"的默认值来源)。 */
27
+ export declare const LAYER_DEFAULT_BUDGETS: Record<DistillBudgetLayer, number>;
28
+ /**
29
+ * 解析某蒸馏层的生效输出预算:运行时覆盖(cfg.llm.budgets,由 effectiveCfg 从
30
+ * 设置页 distillBudgets 注入,0/缺省 = 跟随)→ 内置默认 → 思考档放大
31
+ * (high/max ×4,reasoning 计入输出预算的历史事故防线)。
32
+ */
33
+ export declare function resolveLayerTokens(cfg: {
34
+ llm: {
35
+ reasoningEffort: string;
36
+ budgets?: Partial<Record<DistillBudgetLayer, number>>;
37
+ };
38
+ }, layer: DistillBudgetLayer): number;
39
+ /**
40
+ * 思考档预算放大:reasoning 计入输出预算(v4-flash 事故:high 思考可吃光全部
41
+ * 预算致正文 0 字符)——effort 为 high/max 时分层预算 ×4。
42
+ */
43
+ export declare function layerMaxTokens(base: number, reasoningEffort: string): number;
16
44
  /** 解析蒸馏用的 provider/model:配置优先,其次当前默认选择。 */
17
45
  export declare function resolveModelRoute(ctx: Context, cfg: MemoryConfig): Promise<{
18
46
  provider: string;
package/dist/llm.js CHANGED
@@ -1,5 +1,38 @@
1
1
  import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
2
2
  import { errDetail } from './util/filelog.js';
3
+ // ── 分层输出预算(规格 C 节):结构化蒸馏远用不到总闸级预算,逐层设护栏;
4
+ // 模型跑偏时单次损失有界。数值"先试跑"状态,按线上截断率调整。
5
+ /** L1 抽取(大输入块的 JSON 记忆数组输出)。 */
6
+ export const LAYER_MAX_TOKENS_EXTRACT = 16_000;
7
+ /** L1 去重(合并决策数组,输出比抽取短)。 */
8
+ export const LAYER_MAX_TOKENS_DEDUP = 8_000;
9
+ /** L2 场景整合(完整场景 Markdown 文件输出,输出最重的层)。 */
10
+ export const LAYER_MAX_TOKENS_L2 = 32_000;
11
+ /** L3 画像(完整 persona 文档)。 */
12
+ export const LAYER_MAX_TOKENS_L3 = 16_000;
13
+ /** 各层内置默认预算(设置页"0 = 跟随默认"的默认值来源)。 */
14
+ export const LAYER_DEFAULT_BUDGETS = {
15
+ extract: LAYER_MAX_TOKENS_EXTRACT,
16
+ dedup: LAYER_MAX_TOKENS_DEDUP,
17
+ l2: LAYER_MAX_TOKENS_L2,
18
+ l3: LAYER_MAX_TOKENS_L3,
19
+ };
20
+ /**
21
+ * 解析某蒸馏层的生效输出预算:运行时覆盖(cfg.llm.budgets,由 effectiveCfg 从
22
+ * 设置页 distillBudgets 注入,0/缺省 = 跟随)→ 内置默认 → 思考档放大
23
+ * (high/max ×4,reasoning 计入输出预算的历史事故防线)。
24
+ */
25
+ export function resolveLayerTokens(cfg, layer) {
26
+ const override = cfg.llm.budgets?.[layer];
27
+ return layerMaxTokens(override && override > 0 ? override : LAYER_DEFAULT_BUDGETS[layer], cfg.llm.reasoningEffort);
28
+ }
29
+ /**
30
+ * 思考档预算放大:reasoning 计入输出预算(v4-flash 事故:high 思考可吃光全部
31
+ * 预算致正文 0 字符)——effort 为 high/max 时分层预算 ×4。
32
+ */
33
+ export function layerMaxTokens(base, reasoningEffort) {
34
+ return reasoningEffort === 'high' || reasoningEffort === 'max' ? base * 4 : base;
35
+ }
3
36
  /** 解析蒸馏用的 provider/model:配置优先,其次当前默认选择。 */
4
37
  export async function resolveModelRoute(ctx, cfg) {
5
38
  if (cfg.llm.provider && cfg.llm.model) {
@@ -3,7 +3,7 @@
3
3
  * 移植 MemoryCore 的 l1 runner 语义(抽取 prompt → batch 冲突检测 → apply)。
4
4
  */
5
5
  import { randomBytes } from 'node:crypto';
6
- import { callLLM, parseJsonLogged } from '../llm.js';
6
+ import { callLLM, parseJsonLogged, resolveLayerTokens } from '../llm.js';
7
7
  import { formatExtractionPrompt, getExtractMemoriesSystemPrompt } from '../prompts/l1-extraction.js';
8
8
  import { formatBatchConflictPrompt, getConflictDetectionSystemPrompt } from '../prompts/l1-dedup.js';
9
9
  import { familyForType } from '../types.js';
@@ -37,9 +37,8 @@ export function chunkByCharBudget(messages, budgetChars) {
37
37
  export async function runExtraction(ctx, cfg, store, states, pending, background, logger, mode) {
38
38
  if (!cfg.extract.enabled)
39
39
  return { stored: 0, skipped: true, sceneName: chainHead(states, mode), newRecords: [] };
40
- if (pending.length < cfg.extract.minMessages) {
41
- return { stored: 0, skipped: true, sceneName: chainHead(states, mode), newRecords: [] };
42
- }
40
+ // 触发阈值(渐进爬坡 + 按会话切片计数)由 runner 判定(trigger.ts);
41
+ // 此处不再重复 gate——重建轮 force 与 warmup 早期轮次都会传入小于稳态值的切片。
43
42
  // 纯档强制族 = 档位族;auto 档抽取后的记录族按 type 前缀判定
44
43
  const forcedFamily = mode === 'auto' ? undefined : mode;
45
44
  // 情境链锚点桶:纯档用本族;auto 用最近活跃的族(chainHead 同源)
@@ -64,6 +63,7 @@ export async function runExtraction(ctx, cfg, store, states, pending, background
64
63
  const raw = await callLLM(ctx, cfg, {
65
64
  system: getExtractMemoriesSystemPrompt(mode),
66
65
  user: userPrompt,
66
+ maxTokens: resolveLayerTokens(cfg, 'extract'),
67
67
  logger,
68
68
  });
69
69
  const scenes = parseJsonLogged(raw, 'L1 抽取', logger);
@@ -101,6 +101,7 @@ export async function runExtraction(ctx, cfg, store, states, pending, background
101
101
  const dedupRaw = await callLLM(ctx, cfg, {
102
102
  system: getConflictDetectionSystemPrompt(mode),
103
103
  user: dedupPrompt,
104
+ maxTokens: resolveLayerTokens(cfg, 'dedup'),
104
105
  logger,
105
106
  });
106
107
  const decisions = parseJsonLogged(dedupRaw, 'L1 去重判定', logger);
@@ -1,4 +1,4 @@
1
- import { callLLM, parseJsonLogged } from '../llm.js';
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
4
  const REQUEST_RE = /\[PERSONA_UPDATE_REQUEST\]\s*reason:\s*([\s\S]*?)\[\/PERSONA_UPDATE_REQUEST\]/;
@@ -27,7 +27,12 @@ export async function runSceneConsolidation(ctx, cfg, scenes, newMemories, logge
27
27
  maxScenes: cfg.l2.maxScenes,
28
28
  family,
29
29
  });
30
- const raw = await callLLM(ctx, cfg, { system: systemPrompt, user: userPrompt, logger });
30
+ const raw = await callLLM(ctx, cfg, {
31
+ system: systemPrompt,
32
+ user: userPrompt,
33
+ maxTokens: resolveLayerTokens(cfg, 'l2'),
34
+ logger,
35
+ });
31
36
  // ── 解析:先取 PERSONA_UPDATE_REQUEST 标记,再解析操作数组 ──
32
37
  const reqMatch = REQUEST_RE.exec(raw);
33
38
  const personaRequestedReason = reqMatch?.[1]?.trim() || undefined;
@@ -1,4 +1,4 @@
1
- import { callLLM } from '../llm.js';
1
+ import { callLLM, resolveLayerTokens } from '../llm.js';
2
2
  import { buildPersonaPrompt } from '../prompts/persona.js';
3
3
  export async function runPersona(ctx, cfg, scenes, persona, state, logger, family) {
4
4
  if (!cfg.l3.enabled)
@@ -48,7 +48,12 @@ export async function runPersona(ctx, cfg, scenes, persona, state, logger, famil
48
48
  existingPersona,
49
49
  triggerInfo: reason,
50
50
  });
51
- const raw = await callLLM(ctx, cfg, { system: systemPrompt, user: userPrompt, logger });
51
+ const raw = await callLLM(ctx, cfg, {
52
+ system: systemPrompt,
53
+ user: userPrompt,
54
+ maxTokens: resolveLayerTokens(cfg, 'l3'),
55
+ logger,
56
+ });
52
57
  const body = unwrapFence(raw);
53
58
  if (!body) {
54
59
  logger.error(`[memory] L3 输出为空,原始输出前 400 字符: ${raw.slice(0, 400)}`);
@@ -99,7 +99,7 @@ export class RebuildController {
99
99
  ...this.status,
100
100
  sessionCount: est.sessions,
101
101
  messageCount: est.messages,
102
- estCalls: estimateCalls(est.sessions, est.messages, est.chars, this.cfg.llm.maxInputChars),
102
+ estCalls: estimateCalls(est.sessions, est.messages, est.chars, effectiveCfg(this.cfg, this.live).llm.maxInputChars),
103
103
  };
104
104
  }
105
105
  return { ...this.status };
@@ -123,7 +123,7 @@ export class RebuildController {
123
123
  phase: 'preparing',
124
124
  sessionCount: est.sessions,
125
125
  messageCount: est.messages,
126
- estCalls: estimateCalls(est.sessions, est.messages, est.chars, this.cfg.llm.maxInputChars),
126
+ estCalls: estimateCalls(est.sessions, est.messages, est.chars, effectiveCfg(this.cfg, this.live).llm.maxInputChars),
127
127
  startedAt: Date.now(),
128
128
  };
129
129
  this.runner.enqueueRebuildTask(() => this.prepare());
@@ -36,42 +36,73 @@ export interface PipelineTask {
36
36
  /** 选取下一个要执行的任务下标:最早的 live 优先,否则队首(rebuild 分块让位)。 */
37
37
  export declare function pickNextTaskIndex(tasks: PipelineTask[]): number;
38
38
  /**
39
- * 运行时调参视图:UI 选择器可临时覆盖蒸馏思考档位(空串回退静态 config 默认)。
40
- * 浅拷贝只覆盖 llm 一层,其余键与原 cfg 共享只读引用;pipeline 全链继续收 cfg,无需感知。
39
+ * 运行时调参视图:UI 选择器可临时覆盖蒸馏思考档位、蒸馏模型路由与分层输出预算
40
+ * (空串/0 回退静态 config / 内置默认)。浅拷贝只覆盖 llm 一层,其余键与原 cfg
41
+ * 共享只读引用;pipeline 全链继续收 cfg,无需感知。
42
+ *
43
+ * 蒸馏模型覆盖优先级:部署静态 pin(cfg.llm.provider+model 双字段齐)不可被
44
+ * 运行时覆盖(部署可强制蒸馏走内网路由,防用户选择把对话外送);未 pin 时
45
+ * 运行时选择(distillProvider+distillModel 成对)生效;再退 agentDefaultModel。
46
+ * 输出预算无部署上限语义:非零运行时值直接注入 cfg.llm.budgets(0/缺省不注入)。
41
47
  */
42
- export declare function effectiveCfg(cfg: MemoryConfig, live: LiveSettingsHandle): MemoryConfig;
48
+ export declare function effectiveCfg(cfg: MemoryConfig, live?: LiveSettingsHandle): MemoryConfig;
43
49
  export declare class MemoryRunner {
44
50
  private readonly ctx;
45
51
  private readonly cfg;
46
52
  private readonly stores;
47
53
  private readonly logger;
48
54
  private readonly live;
55
+ /** 会话档位只读句柄(闲置扫描跳过 off 档会话;挂起语义)。 */
56
+ private readonly modes?;
49
57
  private tasks;
50
58
  private draining;
51
59
  /** 停止标志(dispose 序置位):不再取新任务;进行中任务自然收尾,其 DB 写入失败由各层兜底捕获。 */
52
60
  private stopped;
53
61
  private pending;
62
+ /** 各档位桶渐进阈值(1 起步翻倍至稳态毕业;ADR-0003,随 pending.json 持久化)。 */
63
+ private warmup;
64
+ /** 每会话最后活动时间(闲置兜底判定用)。 */
65
+ private lastActivity;
54
66
  private readonly pendingFile;
55
- private background;
56
67
  /** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
57
68
  states: FamilyStates;
58
69
  private afterRun;
59
- constructor(ctx: Context, cfg: MemoryConfig, stores: MemoryStores, logger: MemoryLogger, live: LiveSettingsHandle);
70
+ constructor(ctx: Context, cfg: MemoryConfig, stores: MemoryStores, logger: MemoryLogger, live: LiveSettingsHandle,
71
+ /** 会话档位只读句柄(闲置扫描跳过 off 档会话;挂起语义)。 */
72
+ modes?: {
73
+ get(sessionId: string): string;
74
+ } | undefined);
60
75
  init(): Promise<void>;
61
- /** 启动补跑:对每个非空桶入队一次蒸馏尝试(受 live 开关与阈值约束,失败不无限重试)。 */
76
+ /** 启动补跑:对每个非空桶的每个会话切片入队一次蒸馏尝试(受 live 开关与
77
+ * 生效阈值约束;不足阈值的消息等用户继续或闲置兜底,失败不无限重试)。 */
62
78
  private scheduleStartupRetry;
63
79
  /** L1 抽取待重试的消息条数(状态面板用)。 */
64
80
  get pendingCount(): number;
65
81
  /** 管线跑完一轮后的回调(用于召回缓存失效)。 */
66
82
  setAfterRun(fn: () => void): void;
67
83
  /** 一轮对话结束后入队(L0 落盘由 capture 在 turn/end 即时完成,不排蒸馏队列)。 */
68
- enqueue(sessionId: string, messages: ConversationMessage[], mode: ExtractMode): void;
84
+ enqueue(sessionId: string, messages: ConversationMessage[], mode: ExtractMode, opts?: {
85
+ force?: boolean;
86
+ }): void;
69
87
  /** 重建任务入队(低优先级:让位于正常轮次;由 RebuildController 分块驱动)。 */
70
88
  enqueueRebuildTask(run: () => Promise<unknown>): void;
71
- /** 重建蒸馏轮:统一 auto 档,不受缓冲 200 上限(历史会话全量入桶,由 char 预算分块)。 */
89
+ /** 重建蒸馏轮:统一 auto 档,全量强制蒸馏(不受阈值约束——历史小会话也必须出记忆)、
90
+ * 不受缓冲 200 上限(历史会话全量入桶,由 char 预算分块)。 */
72
91
  runRebuildTurn(sessionId: string, messages: ConversationMessage[]): Promise<number>;
73
92
  /** 停止取新任务(插件 dispose 序调用;进行中任务照常跑完但不 await——LLM 慢调用不拖住宿主卸载)。 */
74
93
  stop(): void;
94
+ /** 启动闲置兜底定时器(index.ts 装配;idleSeconds=0 关闭)。 */
95
+ startIdleTimer(): void;
96
+ /** 闲置扫描:静默达标且有切片的会话按捕获档位落袋(off 档会话挂起跳过)。 */
97
+ private flushIdleSlices;
98
+ /** 把某会话在各桶中的切片按捕获档位逐个入队强制蒸馏(闲置兜底 / 档位切换共用)。 */
99
+ private enqueueSessionSlices;
100
+ /**
101
+ * 档位切换同步(session-modes 的 set() 回调,ADR-0003):
102
+ * 非 off 间切换 → 该会话切片立即按捕获档位蒸馏(新档位从空切片起步);
103
+ * 切到 off → 挂起(切片留存,闲置扫描跳过);从 off 切回 → 挂起片按捕获档位落袋。
104
+ */
105
+ onModeChange(sessionId: string, oldMode: string, newMode: string): void;
75
106
  private pushTask;
76
107
  private drain;
77
108
  /** 缓冲落盘(每次蒸馏尝试后调用;失败只告警不阻断管线)。
@@ -79,4 +110,10 @@ export declare class MemoryRunner {
79
110
  * 蒸馏尝试时反复整量序列化落盘(多 MB 级 IO);重建轮豁免维持。 */
80
111
  private persistPending;
81
112
  private runTurn;
113
+ /**
114
+ * 抽取并消费一个会话切片:成功才把切片移出桶并推进爬坡阈值;失败保留切片待重试。
115
+ * 调用方已保证切片达到生效阈值(或 force)。背景参考按会话从 L0 现查并剔除切片
116
+ * 自身(ADR-0003:会话间互不污染、重启不丢背景)。
117
+ */
118
+ private extractSessionSlice;
82
119
  }