dsh-layered-memory 0.7.0 → 0.8.0

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 (46) hide show
  1. package/README.en.md +118 -66
  2. package/README.md +131 -98
  3. package/assets/img/EmbeddingSource.png +0 -0
  4. package/assets/img/MemoryTools.png +0 -0
  5. package/assets/img/Modes.png +0 -0
  6. package/assets/img/ToolTrajectory.png +0 -0
  7. package/dist/client.js +11 -2
  8. package/dist/config.d.ts +26 -1
  9. package/dist/config.js +9 -3
  10. package/dist/hooks/recall.d.ts +11 -8
  11. package/dist/hooks/recall.js +70 -51
  12. package/dist/index.d.ts +16 -0
  13. package/dist/index.js +5 -1
  14. package/dist/llm.d.ts +13 -0
  15. package/dist/llm.js +17 -0
  16. package/dist/pipeline/l1.js +5 -4
  17. package/dist/pipeline/l2.js +7 -2
  18. package/dist/pipeline/l3.js +7 -2
  19. package/dist/pipeline/runner.d.ts +36 -5
  20. package/dist/pipeline/runner.js +148 -35
  21. package/dist/pipeline/trigger.d.ts +38 -0
  22. package/dist/pipeline/trigger.js +64 -0
  23. package/dist/runtime-package-lock.json +982 -0
  24. package/dist/store/bm25.js +2 -1
  25. package/dist/store/embedding.d.ts +12 -6
  26. package/dist/store/embedding.js +10 -7
  27. package/dist/store/l0.d.ts +2 -0
  28. package/dist/store/l0.js +11 -4
  29. package/dist/store/l1.d.ts +2 -0
  30. package/dist/store/l1.js +9 -6
  31. package/dist/store/pending.d.ts +27 -6
  32. package/dist/store/pending.js +49 -9
  33. package/dist/store/runtime-installer.d.ts +6 -1
  34. package/dist/store/runtime-installer.js +65 -26
  35. package/dist/store/search-utils.js +3 -2
  36. package/dist/store/session-modes.d.ts +4 -0
  37. package/dist/store/session-modes.js +16 -0
  38. package/dist/store/sqlite.d.ts +27 -0
  39. package/dist/store/sqlite.js +186 -6
  40. package/dist/util/recall-budget.d.ts +32 -0
  41. package/dist/util/recall-budget.js +85 -0
  42. package/dist/util/text.d.ts +8 -3
  43. package/dist/util/text.js +41 -15
  44. package/dist/util/tokenizer.d.ts +14 -0
  45. package/dist/util/tokenizer.js +53 -0
  46. package/package.json +2 -1
package/dist/config.d.ts CHANGED
@@ -19,8 +19,11 @@ export interface MemoryConfig {
19
19
  };
20
20
  extract: {
21
21
  enabled: boolean;
22
- /** 至少攒够多少条新消息才跑一次 L1 抽取(省 token)。 */
22
+ /** 稳态触发阈值:单会话攒够多少条新消息才跑一次 L1 抽取(省 token)。
23
+ * 起步阶段生效阈值从 1 翻倍爬坡到此值(渐进阈值,ADR-0003)。 */
23
24
  minMessages: number;
25
+ /** 闲置兜底:会话静默多少秒后把未蒸馏切片落袋(接住"没攒够阈值用户就离开");0 = 关闭。 */
26
+ idleSeconds: number;
24
27
  /** L1 抽取时的背景消息条数(供上下文推断,不参与提取)。 */
25
28
  backgroundMessages: number;
26
29
  /** 去重候选池大小(每条新记忆的相似候选数)。 */
@@ -44,6 +47,12 @@ export interface MemoryConfig {
44
47
  enabled: boolean;
45
48
  /** 每步自动召回注入的 L1 条数。 */
46
49
  maxResults: number;
50
+ /** 单条注入记忆的字符上限(超限截断并提示用工具查全文);0 = 不限。 */
51
+ maxCharsPerMemory: number;
52
+ /** 整轮注入总字符上限(超限按相关性丢尾部);0 = 不限。 */
53
+ maxTotalRecallChars: number;
54
+ /** 召回总预算(ms):超时跳过本轮注入、不阻塞对话;0 = 不限时。 */
55
+ timeoutMs: number;
47
56
  includePersona: boolean;
48
57
  includeSceneNav: boolean;
49
58
  /** 检索策略:keyword(FTS5 BM25)| embedding(向量)| hybrid(双路 + RRF 融合)。 */
@@ -102,11 +111,13 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
102
111
  extract: Schema<Schemastery.ObjectS<{
103
112
  enabled: Schema<boolean, boolean>;
104
113
  minMessages: Schema<number, number>;
114
+ idleSeconds: Schema<number, number>;
105
115
  backgroundMessages: Schema<number, number>;
106
116
  candidatePool: Schema<number, number>;
107
117
  }>, Schemastery.ObjectT<{
108
118
  enabled: Schema<boolean, boolean>;
109
119
  minMessages: Schema<number, number>;
120
+ idleSeconds: Schema<number, number>;
110
121
  backgroundMessages: Schema<number, number>;
111
122
  candidatePool: Schema<number, number>;
112
123
  }>>;
@@ -131,6 +142,9 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
131
142
  recall: Schema<Schemastery.ObjectS<{
132
143
  enabled: Schema<boolean, boolean>;
133
144
  maxResults: Schema<number, number>;
145
+ maxCharsPerMemory: Schema<number, number>;
146
+ maxTotalRecallChars: Schema<number, number>;
147
+ timeoutMs: Schema<number, number>;
134
148
  includePersona: Schema<boolean, boolean>;
135
149
  includeSceneNav: Schema<boolean, boolean>;
136
150
  strategy: Schema<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
@@ -138,6 +152,9 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
138
152
  }>, Schemastery.ObjectT<{
139
153
  enabled: Schema<boolean, boolean>;
140
154
  maxResults: Schema<number, number>;
155
+ maxCharsPerMemory: Schema<number, number>;
156
+ maxTotalRecallChars: Schema<number, number>;
157
+ timeoutMs: Schema<number, number>;
141
158
  includePersona: Schema<boolean, boolean>;
142
159
  includeSceneNav: Schema<boolean, boolean>;
143
160
  strategy: Schema<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
@@ -197,11 +214,13 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
197
214
  extract: Schema<Schemastery.ObjectS<{
198
215
  enabled: Schema<boolean, boolean>;
199
216
  minMessages: Schema<number, number>;
217
+ idleSeconds: Schema<number, number>;
200
218
  backgroundMessages: Schema<number, number>;
201
219
  candidatePool: Schema<number, number>;
202
220
  }>, Schemastery.ObjectT<{
203
221
  enabled: Schema<boolean, boolean>;
204
222
  minMessages: Schema<number, number>;
223
+ idleSeconds: Schema<number, number>;
205
224
  backgroundMessages: Schema<number, number>;
206
225
  candidatePool: Schema<number, number>;
207
226
  }>>;
@@ -226,6 +245,9 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
226
245
  recall: Schema<Schemastery.ObjectS<{
227
246
  enabled: Schema<boolean, boolean>;
228
247
  maxResults: Schema<number, number>;
248
+ maxCharsPerMemory: Schema<number, number>;
249
+ maxTotalRecallChars: Schema<number, number>;
250
+ timeoutMs: Schema<number, number>;
229
251
  includePersona: Schema<boolean, boolean>;
230
252
  includeSceneNav: Schema<boolean, boolean>;
231
253
  strategy: Schema<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
@@ -233,6 +255,9 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
233
255
  }>, Schemastery.ObjectT<{
234
256
  enabled: Schema<boolean, boolean>;
235
257
  maxResults: Schema<number, number>;
258
+ maxCharsPerMemory: Schema<number, number>;
259
+ maxTotalRecallChars: Schema<number, number>;
260
+ timeoutMs: Schema<number, number>;
236
261
  includePersona: Schema<boolean, boolean>;
237
262
  includeSceneNav: Schema<boolean, boolean>;
238
263
  strategy: Schema<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
package/dist/config.js CHANGED
@@ -15,7 +15,8 @@ export const memorySchema = Schema.object({
15
15
  }),
16
16
  extract: Schema.object({
17
17
  enabled: Schema.boolean().default(true),
18
- minMessages: Schema.number().min(1).max(100).default(1),
18
+ minMessages: Schema.number().min(1).max(100).default(6),
19
+ idleSeconds: Schema.number().min(0).max(86_400).default(300),
19
20
  backgroundMessages: Schema.number().min(0).max(50).default(10),
20
21
  candidatePool: Schema.number().min(1).max(20).default(5),
21
22
  }),
@@ -32,6 +33,10 @@ export const memorySchema = Schema.object({
32
33
  recall: Schema.object({
33
34
  enabled: Schema.boolean().default(true),
34
35
  maxResults: Schema.number().min(1).max(20).default(5),
36
+ // 预算与超时(ADR-0001 / 规格 A 节):截断是引流——工具路径返回全文
37
+ maxCharsPerMemory: Schema.number().min(0).max(100_000).default(500),
38
+ maxTotalRecallChars: Schema.number().min(0).max(100_000).default(2000),
39
+ timeoutMs: Schema.number().min(0).max(60_000).default(5000),
35
40
  includePersona: Schema.boolean().default(true),
36
41
  includeSceneNav: Schema.boolean().default(true),
37
42
  strategy: Schema.union(['keyword', 'embedding', 'hybrid']).default('hybrid'),
@@ -52,8 +57,9 @@ export const memorySchema = Schema.object({
52
57
  llm: Schema.object({
53
58
  provider: Schema.string().default(''),
54
59
  model: Schema.string().default(''),
55
- // 推理模型(如 v4-flash)的 reasoning 计入输出预算:预算不足会被思考吃光导致正文 0 字符
56
- maxTokens: Schema.number().min(1024).max(1_000_000).default(256_000),
60
+ // 推理模型(如 v4-flash)的 reasoning 计入输出预算:预算不足会被思考吃光导致正文 0 字符。
61
+ // 0.8.0 起各蒸馏层显式传分层预算(见 llm.ts LAYER_MAX_TOKENS_*),本值为未分层调用的兜底总闸
62
+ maxTokens: Schema.number().min(1024).max(1_000_000).default(65_536),
57
63
  // 蒸馏是结构化抽取任务,默认关思考(off):v4-flash 默认 high 档的思考可把任意 maxTokens
58
64
  // 预算全部吃光导致正文 0 字符;非推理模型不认识 effort 时会报 UNSUPPORTED_REASONING_EFFORT,设空串跳过
59
65
  reasoningEffort: Schema.union(['', 'off', 'high', 'max']).default('off'),
@@ -1,13 +1,16 @@
1
1
  /**
2
- * 召回 Hook
3
- * 1. agent/pre-step(waterfall):用进入步骤的用户消息做 L1 BM25 检索,缓存到该 agent 的槽位;
4
- * 必须调用并返回 next()(只观察,不改写步骤)。
5
- * 2. agent/created:在 agent 作用域注册动态上下文 provider(L1 召回 + L3 画像 + L2 场景导航 + 工具指南)。
2
+ * 召回 Hook(消息侧注入版,ADR-0001):
3
+ * 1. agent/pre-step(waterfall,prepend 注册):有新的用户来源消息到达的步骤
4
+ * (轮首 claim 或 steering 插话)→ 检索 L1 → 以合成消息形式注入到用户消息之前。
5
+ * 注入消息携带插件来源(form: 'recall'),对用户可见、随会话历史持久累积——
6
+ * 这就是"记忆生效"的显式提示(dsh-time-context 同款官方范式)。
7
+ * 纯工具步透传;召回超时(recall.timeoutMs 总预算)跳过本轮,绝不阻塞对话。
8
+ * 2. agent/created:在 agent 作用域注册动态上下文 provider(L3 画像 + L2 场景导航 +
9
+ * 工具指南——系统提示只保留稳定内容;指南三条件门控:工具开启 && 有内容)。
6
10
  *
7
- * 注入内容使用 <relevant-memories> / <user-persona> / <scene-navigation> / <memory-tools-guide>
8
- * 标签包裹——capture 侧 sanitize 会剥离这些标签,防止反馈循环。
9
- * auto 档两族合并时在 <user-persona>/<scene-navigation> 内部用 <domain family="..."> 子块
10
- * 分域(子块只出现在外层标签内部,随外层一起被 sanitize 剥离,无泄漏风险)。
11
+ * 注入内容使用 <relevant-memories> 标签包裹(模型侧语义边界 + 助手回显剥离锚点),
12
+ * 召回预算(单条/整轮)超限截断并引导模型用记忆工具查全文。
13
+ * L0 防污染零成本:capture 侧只收 source.kind === 'user' 的消息,注入消息天然被排除。
11
14
  *
12
15
  * 注意:PromptContext.text 是**同步**函数,画像/场景导航这类异步文件读取必须走内存缓存,
13
16
  * 由定时刷新 + 管线更新后的主动失效来更新。
@@ -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">;
@@ -118,11 +126,13 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
118
126
  extract: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
119
127
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
120
128
  minMessages: import("@deepseek-ai/schemastery").default<number, number>;
129
+ idleSeconds: import("@deepseek-ai/schemastery").default<number, number>;
121
130
  backgroundMessages: import("@deepseek-ai/schemastery").default<number, number>;
122
131
  candidatePool: import("@deepseek-ai/schemastery").default<number, number>;
123
132
  }>, Schemastery.ObjectT<{
124
133
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
125
134
  minMessages: import("@deepseek-ai/schemastery").default<number, number>;
135
+ idleSeconds: import("@deepseek-ai/schemastery").default<number, number>;
126
136
  backgroundMessages: import("@deepseek-ai/schemastery").default<number, number>;
127
137
  candidatePool: import("@deepseek-ai/schemastery").default<number, number>;
128
138
  }>>;
@@ -147,6 +157,9 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
147
157
  recall: import("@deepseek-ai/schemastery").default<Schemastery.ObjectS<{
148
158
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
149
159
  maxResults: import("@deepseek-ai/schemastery").default<number, number>;
160
+ maxCharsPerMemory: import("@deepseek-ai/schemastery").default<number, number>;
161
+ maxTotalRecallChars: import("@deepseek-ai/schemastery").default<number, number>;
162
+ timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
150
163
  includePersona: import("@deepseek-ai/schemastery").default<boolean, boolean>;
151
164
  includeSceneNav: import("@deepseek-ai/schemastery").default<boolean, boolean>;
152
165
  strategy: import("@deepseek-ai/schemastery").default<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
@@ -154,6 +167,9 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
154
167
  }>, Schemastery.ObjectT<{
155
168
  enabled: import("@deepseek-ai/schemastery").default<boolean, boolean>;
156
169
  maxResults: import("@deepseek-ai/schemastery").default<number, number>;
170
+ maxCharsPerMemory: import("@deepseek-ai/schemastery").default<number, number>;
171
+ maxTotalRecallChars: import("@deepseek-ai/schemastery").default<number, number>;
172
+ timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
157
173
  includePersona: import("@deepseek-ai/schemastery").default<boolean, boolean>;
158
174
  includeSceneNav: import("@deepseek-ai/schemastery").default<boolean, boolean>;
159
175
  strategy: import("@deepseek-ai/schemastery").default<"keyword" | "embedding" | "hybrid", "keyword" | "embedding" | "hybrid">;
package/dist/index.js CHANGED
@@ -269,8 +269,12 @@ export async function apply(ctx, config) {
269
269
  })
270
270
  : undefined;
271
271
  embedManagerRef = embedManager;
272
- const runner = new MemoryRunner(ctx, config, stores, logger, live);
272
+ const runner = new MemoryRunner(ctx, config, stores, logger, live, modes);
273
273
  await runner.init();
274
+ // 档位切换同步(ADR-0003):切走按捕获档位落袋 / 切 off 挂起 / 切回清挂起
275
+ modes.setModeChangeHandler((sessionId, oldMode, newMode) => runner.onModeChange(sessionId, oldMode, newMode));
276
+ // 闲置兜底:静默达标会话的未蒸馏切片自动落袋(idleSeconds=0 关闭)
277
+ runner.startIdleTimer();
274
278
  // 重建控制器(存储降级时不建——RPC 端点走 supported=false 分支)
275
279
  const rebuild = storageOk && !db.isDegraded()
276
280
  ? new RebuildController(ctx, config, stores, db, runner, logger, live)
package/dist/llm.d.ts CHANGED
@@ -13,6 +13,19 @@ 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
+ /**
25
+ * 思考档预算放大:reasoning 计入输出预算(v4-flash 事故:high 思考可吃光全部
26
+ * 预算致正文 0 字符)——effort 为 high/max 时分层预算 ×4。
27
+ */
28
+ export declare function layerMaxTokens(base: number, reasoningEffort: string): number;
16
29
  /** 解析蒸馏用的 provider/model:配置优先,其次当前默认选择。 */
17
30
  export declare function resolveModelRoute(ctx: Context, cfg: MemoryConfig): Promise<{
18
31
  provider: string;
package/dist/llm.js CHANGED
@@ -1,5 +1,22 @@
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
+ /**
14
+ * 思考档预算放大:reasoning 计入输出预算(v4-flash 事故:high 思考可吃光全部
15
+ * 预算致正文 0 字符)——effort 为 high/max 时分层预算 ×4。
16
+ */
17
+ export function layerMaxTokens(base, reasoningEffort) {
18
+ return reasoningEffort === 'high' || reasoningEffort === 'max' ? base * 4 : base;
19
+ }
3
20
  /** 解析蒸馏用的 provider/model:配置优先,其次当前默认选择。 */
4
21
  export async function resolveModelRoute(ctx, cfg) {
5
22
  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, layerMaxTokens, LAYER_MAX_TOKENS_DEDUP, LAYER_MAX_TOKENS_EXTRACT, parseJsonLogged } 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: layerMaxTokens(LAYER_MAX_TOKENS_EXTRACT, cfg.llm.reasoningEffort),
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: layerMaxTokens(LAYER_MAX_TOKENS_DEDUP, cfg.llm.reasoningEffort),
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, layerMaxTokens, LAYER_MAX_TOKENS_L2, parseJsonLogged } 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: layerMaxTokens(LAYER_MAX_TOKENS_L2, cfg.llm.reasoningEffort),
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, layerMaxTokens, LAYER_MAX_TOKENS_L3 } 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: layerMaxTokens(LAYER_MAX_TOKENS_L3, cfg.llm.reasoningEffort),
55
+ logger,
56
+ });
52
57
  const body = unwrapFence(raw);
53
58
  if (!body) {
54
59
  logger.error(`[memory] L3 输出为空,原始输出前 400 字符: ${raw.slice(0, 400)}`);
@@ -46,32 +46,57 @@ export declare class MemoryRunner {
46
46
  private readonly stores;
47
47
  private readonly logger;
48
48
  private readonly live;
49
+ /** 会话档位只读句柄(闲置扫描跳过 off 档会话;挂起语义)。 */
50
+ private readonly modes?;
49
51
  private tasks;
50
52
  private draining;
51
53
  /** 停止标志(dispose 序置位):不再取新任务;进行中任务自然收尾,其 DB 写入失败由各层兜底捕获。 */
52
54
  private stopped;
53
55
  private pending;
56
+ /** 各档位桶渐进阈值(1 起步翻倍至稳态毕业;ADR-0003,随 pending.json 持久化)。 */
57
+ private warmup;
58
+ /** 每会话最后活动时间(闲置兜底判定用)。 */
59
+ private lastActivity;
54
60
  private readonly pendingFile;
55
- private background;
56
61
  /** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
57
62
  states: FamilyStates;
58
63
  private afterRun;
59
- constructor(ctx: Context, cfg: MemoryConfig, stores: MemoryStores, logger: MemoryLogger, live: LiveSettingsHandle);
64
+ constructor(ctx: Context, cfg: MemoryConfig, stores: MemoryStores, logger: MemoryLogger, live: LiveSettingsHandle,
65
+ /** 会话档位只读句柄(闲置扫描跳过 off 档会话;挂起语义)。 */
66
+ modes?: {
67
+ get(sessionId: string): string;
68
+ } | undefined);
60
69
  init(): Promise<void>;
61
- /** 启动补跑:对每个非空桶入队一次蒸馏尝试(受 live 开关与阈值约束,失败不无限重试)。 */
70
+ /** 启动补跑:对每个非空桶的每个会话切片入队一次蒸馏尝试(受 live 开关与
71
+ * 生效阈值约束;不足阈值的消息等用户继续或闲置兜底,失败不无限重试)。 */
62
72
  private scheduleStartupRetry;
63
73
  /** L1 抽取待重试的消息条数(状态面板用)。 */
64
74
  get pendingCount(): number;
65
75
  /** 管线跑完一轮后的回调(用于召回缓存失效)。 */
66
76
  setAfterRun(fn: () => void): void;
67
77
  /** 一轮对话结束后入队(L0 落盘由 capture 在 turn/end 即时完成,不排蒸馏队列)。 */
68
- enqueue(sessionId: string, messages: ConversationMessage[], mode: ExtractMode): void;
78
+ enqueue(sessionId: string, messages: ConversationMessage[], mode: ExtractMode, opts?: {
79
+ force?: boolean;
80
+ }): void;
69
81
  /** 重建任务入队(低优先级:让位于正常轮次;由 RebuildController 分块驱动)。 */
70
82
  enqueueRebuildTask(run: () => Promise<unknown>): void;
71
- /** 重建蒸馏轮:统一 auto 档,不受缓冲 200 上限(历史会话全量入桶,由 char 预算分块)。 */
83
+ /** 重建蒸馏轮:统一 auto 档,全量强制蒸馏(不受阈值约束——历史小会话也必须出记忆)、
84
+ * 不受缓冲 200 上限(历史会话全量入桶,由 char 预算分块)。 */
72
85
  runRebuildTurn(sessionId: string, messages: ConversationMessage[]): Promise<number>;
73
86
  /** 停止取新任务(插件 dispose 序调用;进行中任务照常跑完但不 await——LLM 慢调用不拖住宿主卸载)。 */
74
87
  stop(): void;
88
+ /** 启动闲置兜底定时器(index.ts 装配;idleSeconds=0 关闭)。 */
89
+ startIdleTimer(): void;
90
+ /** 闲置扫描:静默达标且有切片的会话按捕获档位落袋(off 档会话挂起跳过)。 */
91
+ private flushIdleSlices;
92
+ /** 把某会话在各桶中的切片按捕获档位逐个入队强制蒸馏(闲置兜底 / 档位切换共用)。 */
93
+ private enqueueSessionSlices;
94
+ /**
95
+ * 档位切换同步(session-modes 的 set() 回调,ADR-0003):
96
+ * 非 off 间切换 → 该会话切片立即按捕获档位蒸馏(新档位从空切片起步);
97
+ * 切到 off → 挂起(切片留存,闲置扫描跳过);从 off 切回 → 挂起片按捕获档位落袋。
98
+ */
99
+ onModeChange(sessionId: string, oldMode: string, newMode: string): void;
75
100
  private pushTask;
76
101
  private drain;
77
102
  /** 缓冲落盘(每次蒸馏尝试后调用;失败只告警不阻断管线)。
@@ -79,4 +104,10 @@ export declare class MemoryRunner {
79
104
  * 蒸馏尝试时反复整量序列化落盘(多 MB 级 IO);重建轮豁免维持。 */
80
105
  private persistPending;
81
106
  private runTurn;
107
+ /**
108
+ * 抽取并消费一个会话切片:成功才把切片移出桶并推进爬坡阈值;失败保留切片待重试。
109
+ * 调用方已保证切片达到生效阈值(或 force)。背景参考按会话从 L0 现查并剔除切片
110
+ * 自身(ADR-0003:会话间互不污染、重启不丢背景)。
111
+ */
112
+ private extractSessionSlice;
82
113
  }