dsh-layered-memory 0.6.0 → 0.7.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 (52) hide show
  1. package/README.en.md +101 -81
  2. package/README.md +91 -71
  3. package/assets/img/Hero.png +0 -0
  4. package/assets/img/Layers.png +0 -0
  5. package/assets/img/Modes.png +0 -0
  6. package/assets/img/ui-dark.jpg +0 -0
  7. package/assets/img/ui-light.jpg +0 -0
  8. package/assets/readme/flow.svg +189 -0
  9. package/assets/readme/storage.svg +115 -0
  10. package/dist/client.js +1242 -325
  11. package/dist/config.d.ts +12 -0
  12. package/dist/config.js +20 -17
  13. package/dist/hooks/capture.d.ts +17 -1
  14. package/dist/hooks/capture.js +45 -11
  15. package/dist/hooks/recall.d.ts +7 -0
  16. package/dist/hooks/recall.js +20 -4
  17. package/dist/index.d.ts +8 -0
  18. package/dist/index.js +112 -44
  19. package/dist/pipeline/rebuild.d.ts +80 -0
  20. package/dist/pipeline/rebuild.js +307 -0
  21. package/dist/pipeline/runner.d.ts +42 -4
  22. package/dist/pipeline/runner.js +125 -21
  23. package/dist/settings.js +79 -20
  24. package/dist/stats.d.ts +8 -1
  25. package/dist/stats.js +156 -14
  26. package/dist/store/download-queue.d.ts +71 -0
  27. package/dist/store/download-queue.js +313 -0
  28. package/dist/store/embedding-source.d.ts +160 -0
  29. package/dist/store/embedding-source.js +421 -0
  30. package/dist/store/embedding.d.ts +3 -1
  31. package/dist/store/embedding.js +5 -0
  32. package/dist/store/l0.d.ts +13 -5
  33. package/dist/store/l0.js +33 -6
  34. package/dist/store/l1.d.ts +15 -5
  35. package/dist/store/l1.js +40 -15
  36. package/dist/store/local-embedding.d.ts +64 -0
  37. package/dist/store/local-embedding.js +120 -0
  38. package/dist/store/model-catalog.d.ts +45 -0
  39. package/dist/store/model-catalog.js +78 -0
  40. package/dist/store/pending.d.ts +15 -0
  41. package/dist/store/pending.js +56 -0
  42. package/dist/store/runtime-installer.d.ts +60 -0
  43. package/dist/store/runtime-installer.js +181 -0
  44. package/dist/store/sqlite.d.ts +68 -7
  45. package/dist/store/sqlite.js +414 -71
  46. package/dist/store/state.d.ts +6 -0
  47. package/dist/store/state.js +11 -0
  48. package/dist/tools/index.js +6 -4
  49. package/dist/util/filelog.d.ts +2 -0
  50. package/dist/util/filelog.js +20 -3
  51. package/package.json +1 -1
  52. package/assets/readme/hero.svg +0 -58
@@ -0,0 +1,307 @@
1
+ /**
2
+ * 重建控制器:从 L0 事实源重新推导 L1/L2/L3(用户主动动作,设置页按钮触发)。
3
+ *
4
+ * 语义(CONTEXT.md「重建」):
5
+ * - L0 永不改动;旧派生层归档保留(records/ scenes/ persona-*.md → *.bak.<ts>,不硬删);
6
+ * - 检索库 L1 三表清空、checkpoint 原地重置;
7
+ * - 统一按 auto 档、按会话分块重蒸馏;分块经 runner 的低优先级队列让位于正常轮次;
8
+ * - 收尾强制一轮 L2(各族残余记录)+ L3(重建后 hasPersona=false → 冷启动触发)。
9
+ *
10
+ * 失败语义:准备/归档任一步失败 → phase=failed,绝不拖垮宿主;
11
+ * 单块蒸馏失败继续下一块(消息留在未蒸馏缓冲,下轮对话/重启补跑自愈)。
12
+ */
13
+ import { promises as fs } from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import { resolveDataDir } from '../config.js';
16
+ import { errDetail } from '../util/filelog.js';
17
+ import { runSceneConsolidation } from './l2.js';
18
+ import { runPersona } from './l3.js';
19
+ import { effectiveCfg } from './runner.js';
20
+ export function groupL0Sessions(records) {
21
+ const bySession = new Map();
22
+ for (const r of records) {
23
+ if (!r || typeof r.id !== 'string' || typeof r.content !== 'string')
24
+ continue;
25
+ if (r.role !== 'user' && r.role !== 'assistant')
26
+ continue;
27
+ if (!r.content.trim())
28
+ continue;
29
+ const key = r.sessionId || 'default';
30
+ const arr = bySession.get(key) ?? [];
31
+ arr.push({ id: r.id, role: r.role, content: r.content, timestamp: r.timestamp ?? 0 });
32
+ bySession.set(key, arr);
33
+ }
34
+ const chunks = [];
35
+ for (const [sessionId, messages] of bySession) {
36
+ messages.sort((a, b) => a.timestamp - b.timestamp);
37
+ chunks.push({ sessionId, messages });
38
+ }
39
+ // 会话按首条消息时间升序:情境链按时间顺序衔接(与原始发生顺序一致)
40
+ chunks.sort((a, b) => a.messages[0].timestamp - b.messages[0].timestamp);
41
+ return chunks;
42
+ }
43
+ /** 抽取调用数下界估算(与 l1.ts 的 perChunk 同式;会话数与字符预算取大)。 */
44
+ export function estimateCalls(sessions, messages, chars, maxInputChars) {
45
+ if (messages === 0)
46
+ return 0;
47
+ const perChunk = Math.max(20_000, maxInputChars - 42_000);
48
+ return Math.max(sessions, Math.ceil((chars + 64 * messages) / perChunk));
49
+ }
50
+ function idleStatus() {
51
+ return {
52
+ running: false,
53
+ phase: 'idle',
54
+ done: 0,
55
+ total: 0,
56
+ sessionCount: 0,
57
+ messageCount: 0,
58
+ estCalls: 0,
59
+ recordsBuilt: 0,
60
+ cancelRequested: false,
61
+ startedAt: null,
62
+ finishedAt: null,
63
+ error: null,
64
+ archiveNote: null,
65
+ };
66
+ }
67
+ /** 时间戳后缀(归档命名,秒级防撞)。 */
68
+ function stamp(now = new Date()) {
69
+ const p = (n) => String(n).padStart(2, '0');
70
+ return `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
71
+ }
72
+ export class RebuildController {
73
+ ctx;
74
+ cfg;
75
+ stores;
76
+ db;
77
+ runner;
78
+ logger;
79
+ live;
80
+ status = idleStatus();
81
+ chunks = [];
82
+ cancelRequested = false;
83
+ /** 快照时刻(收尾时按它区分重建产物与重建后新对话的记录)。 */
84
+ rebuildStartMs = 0;
85
+ constructor(ctx, cfg, stores, db, runner, logger, live) {
86
+ this.ctx = ctx;
87
+ this.cfg = cfg;
88
+ this.stores = stores;
89
+ this.db = db;
90
+ this.runner = runner;
91
+ this.logger = logger;
92
+ this.live = live;
93
+ }
94
+ /** 状态快照(idle 时附带实时 L0 预估,供确认弹窗显示成本)。 */
95
+ getStatus() {
96
+ if (this.status.phase === 'idle') {
97
+ const est = this.db.l0RebuildEstimate();
98
+ return {
99
+ ...this.status,
100
+ sessionCount: est.sessions,
101
+ messageCount: est.messages,
102
+ estCalls: estimateCalls(est.sessions, est.messages, est.chars, this.cfg.llm.maxInputChars),
103
+ };
104
+ }
105
+ return { ...this.status };
106
+ }
107
+ /** 内存中尚未处理的会话快照块数(收尾后应为 0——快照即弃,诊断/冒烟用)。 */
108
+ get chunkCount() {
109
+ return this.chunks.length;
110
+ }
111
+ /** 启动重建(校验后入队准备任务;真正的清库/归档在管线队列里串行执行,避开并发竞态)。 */
112
+ start() {
113
+ if (this.status.running)
114
+ throw new Error('重建已在进行中');
115
+ const est = this.db.l0RebuildEstimate();
116
+ if (est.messages === 0)
117
+ throw new Error('L0 无任何消息,无需重建');
118
+ this.cancelRequested = false;
119
+ this.chunks = [];
120
+ this.status = {
121
+ ...idleStatus(),
122
+ running: true,
123
+ phase: 'preparing',
124
+ sessionCount: est.sessions,
125
+ messageCount: est.messages,
126
+ estCalls: estimateCalls(est.sessions, est.messages, est.chars, this.cfg.llm.maxInputChars),
127
+ startedAt: Date.now(),
128
+ };
129
+ this.runner.enqueueRebuildTask(() => this.prepare());
130
+ this.logger.info(`[memory] 重建开始:${est.sessions} 个会话 / ${est.messages} 条 L0 消息(预计 ≥${this.status.estCalls} 次抽取调用)`);
131
+ return { ...this.status };
132
+ }
133
+ /** 请求取消:当前块完成后停止,已重建部分保留并照常收尾 L2/L3。 */
134
+ requestCancel() {
135
+ if (!this.status.running)
136
+ return this.getStatus();
137
+ this.cancelRequested = true;
138
+ this.status.cancelRequested = true;
139
+ this.logger.info('[memory] 重建取消已请求(当前块完成后停止)');
140
+ return { ...this.status };
141
+ }
142
+ async prepare() {
143
+ try {
144
+ // 快照:从检索库读全量 L0(事务一致;重建期间新捕获的消息走正常轮次,天然不重不漏)
145
+ this.rebuildStartMs = Date.now();
146
+ this.chunks = groupL0Sessions(this.db.listL0All());
147
+ if (this.chunks.length === 0) {
148
+ this.finish('failed', 'L0 快照为空');
149
+ return;
150
+ }
151
+ this.status.total = this.chunks.length;
152
+ // 归档旧派生层(改名不硬删;任一失败即终止——半清半留会破坏"全量重导"语义)
153
+ const archiveNote = await this.archiveDerived();
154
+ this.status.archiveNote = archiveNote ?? null;
155
+ // 清检索库 + 重置 checkpoint;归档后重建空目录(records/ 由 appendNew 自动重建)
156
+ if (!this.db.clearL1())
157
+ throw new Error('L1 检索库清空失败');
158
+ this.stores.state.reset();
159
+ await this.stores.state.save();
160
+ await Promise.all([
161
+ this.stores.scenes.chat.init(),
162
+ this.stores.scenes.work.init(),
163
+ this.stores.persona.chat.init(),
164
+ this.stores.persona.work.init(),
165
+ ]);
166
+ this.status.phase = 'distilling';
167
+ this.logger.info(`[memory] 重建准备完成(归档:${archiveNote ?? '无旧产物'},${this.chunks.length} 个会话块)`);
168
+ this.scheduleChunk(0);
169
+ }
170
+ catch (err) {
171
+ this.finish('failed', `准备阶段失败: ${errDetail(err)}`);
172
+ }
173
+ }
174
+ /** 分块链:一次只挂一个重建块,跑完再挂下一块——正常轮次可随时插队。 */
175
+ scheduleChunk(i) {
176
+ if (this.cancelRequested || i >= this.chunks.length) {
177
+ this.runner.enqueueRebuildTask(() => this.finalize());
178
+ return;
179
+ }
180
+ const chunk = this.chunks[i];
181
+ this.runner.enqueueRebuildTask(async () => {
182
+ // 入队后开跑前可能已收到取消(等待插队的正常轮次期间),直接跳到收尾
183
+ if (this.cancelRequested) {
184
+ this.runner.enqueueRebuildTask(() => this.finalize());
185
+ return;
186
+ }
187
+ try {
188
+ const n = await this.runner.runRebuildTurn(chunk.sessionId, chunk.messages);
189
+ this.status.recordsBuilt += n;
190
+ }
191
+ catch (err) {
192
+ this.logger.warn(`[memory] 重建块失败(session=${chunk.sessionId},跳过继续): ${errDetail(err)}`);
193
+ }
194
+ this.status.done = i + 1;
195
+ this.scheduleChunk(i + 1);
196
+ });
197
+ }
198
+ async finalize() {
199
+ try {
200
+ this.status.phase = 'finalizing';
201
+ const cfg = effectiveCfg(this.cfg, this.live);
202
+ const liveNow = this.live.get();
203
+ const distillOn = liveNow.enabled && liveNow.distill;
204
+ // 强制 L2:把重建窗口内该族尚未整合的残余记录补一轮(正常轮次语义里差几条
205
+ // 不触发是常态,但"重建"应把已有记录全部落进场景)
206
+ if (cfg.l2.enabled && distillOn) {
207
+ for (const family of ['chat', 'work']) {
208
+ const fstate = this.runner.states[family];
209
+ if (fstate.newMemoriesSinceL2 <= 0)
210
+ continue;
211
+ const leftovers = this.collectRebuildRecords(family);
212
+ if (leftovers.length === 0)
213
+ continue;
214
+ try {
215
+ const t = Date.now();
216
+ const result = await runSceneConsolidation(this.ctx, cfg, this.stores.scenes[family], leftovers, this.logger, family);
217
+ fstate.lastL2At = Date.now();
218
+ fstate.newMemoriesSinceL2 = 0;
219
+ if (result.personaRequestedReason)
220
+ fstate.personaRequestedReason = result.personaRequestedReason;
221
+ this.logger.info(`[memory] 重建收尾 L2 完成(family=${family},${Date.now() - t}ms,${leftovers.length} 条残余记录)`);
222
+ }
223
+ catch (err) {
224
+ this.logger.warn(`[memory] 重建收尾 L2 失败(family=${family}): ${errDetail(err)}`);
225
+ }
226
+ }
227
+ }
228
+ // 强制 L3:checkpoint 已重置(hasPersona=false)→ 冷启动触发;无场景的族跳过
229
+ if (cfg.l3.enabled && distillOn) {
230
+ for (const family of ['chat', 'work']) {
231
+ try {
232
+ const scenes = await this.stores.scenes[family].list();
233
+ if (scenes.length === 0)
234
+ continue;
235
+ await runPersona(this.ctx, cfg, this.stores.scenes[family], this.stores.persona[family], this.runner.states[family], this.logger, family);
236
+ }
237
+ catch (err) {
238
+ this.logger.warn(`[memory] 重建收尾 L3 失败(family=${family}): ${errDetail(err)}`);
239
+ }
240
+ }
241
+ }
242
+ await this.stores.state.save();
243
+ this.finish(this.cancelRequested ? 'cancelled' : 'done', null);
244
+ }
245
+ catch (err) {
246
+ this.finish('failed', `收尾失败: ${errDetail(err)}`);
247
+ }
248
+ }
249
+ /**
250
+ * 收集重建窗口内某族的记录:重建产物全部是新插入(updated==created),
251
+ * 按 updated_time 倒序翻页、越过 rebuildStartMs 即停。
252
+ */
253
+ collectRebuildRecords(family) {
254
+ const out = [];
255
+ const PAGE = 200;
256
+ for (let offset = 0;; offset += PAGE) {
257
+ const { items } = this.stores.l1.list({ family, limit: PAGE, offset });
258
+ if (items.length === 0)
259
+ break;
260
+ let beyond = 0;
261
+ for (const r of items) {
262
+ if (r.createdAt >= this.rebuildStartMs)
263
+ out.push(r);
264
+ else
265
+ beyond++;
266
+ }
267
+ if (beyond > 0 || items.length < PAGE)
268
+ break;
269
+ }
270
+ return out;
271
+ }
272
+ finish(phase, error) {
273
+ this.status.running = false;
274
+ this.status.phase = phase;
275
+ this.status.error = error;
276
+ this.status.finishedAt = Date.now();
277
+ // 快照即弃:全量 L0 消息(可能几十 MB)在重建结束/取消/失败后必须释放,
278
+ // 不能滞留到下一次 start() 覆盖(M6——宿主长跑内存只增不减)
279
+ this.chunks = [];
280
+ const cost = this.status.finishedAt - (this.status.startedAt ?? this.status.finishedAt);
281
+ this.logger.info(`[memory] 重建结束(${phase}):${this.status.done}/${this.status.total} 会话,产出 ${this.status.recordsBuilt} 条记录,耗时 ${Math.round(cost / 1000)}s` +
282
+ (error ? `,错误:${error}` : ''));
283
+ }
284
+ /** 归档旧派生层:records/ scenes/ persona-*.md 改名 .bak.<ts>。不存在则跳过。 */
285
+ async archiveDerived() {
286
+ const dataDir = resolveDataDir(this.cfg);
287
+ const ts = stamp();
288
+ const items = [
289
+ [path.join(dataDir, 'records'), path.join(dataDir, `records.bak.${ts}`)],
290
+ [path.join(dataDir, 'scenes'), path.join(dataDir, `scenes.bak.${ts}`)],
291
+ [path.join(dataDir, 'persona-chat.md'), path.join(dataDir, `persona-chat.md.bak.${ts}`)],
292
+ [path.join(dataDir, 'persona-work.md'), path.join(dataDir, `persona-work.md.bak.${ts}`)],
293
+ ];
294
+ const archived = [];
295
+ for (const [from, to] of items) {
296
+ try {
297
+ await fs.access(from);
298
+ }
299
+ catch {
300
+ continue;
301
+ }
302
+ await fs.rename(from, to);
303
+ archived.push(path.basename(to));
304
+ }
305
+ return archived.length > 0 ? archived.join(', ') : undefined;
306
+ }
307
+ }
@@ -4,9 +4,15 @@
4
4
  *
5
5
  * 会话档位:enqueue 带 mode(off 在捕获侧已被拦截);L1 待重试缓冲按档分桶;
6
6
  * L2/L3 按记录族各自跑各自的场景/画像存储与阈值计数(分族隔离不变量)。
7
+ *
8
+ * 调度:内部是带优先级的任务列表——正常对话轮次(live)优先于重建分块(rebuild),
9
+ * 重建期间用户照常聊天,新轮次的蒸馏最多等一个重建块。任务串行,同一时刻至多一个在跑。
10
+ *
11
+ * 未蒸馏缓冲:pending 三桶持久化在 pending.json,进程重启不丢;init 恢复后延迟补跑一次
12
+ * (受 live 开关与 minMessages 阈值约束,失败维持"等下一轮同档对话"的现状语义)。
7
13
  */
8
14
  import type { Context } from '@deepseek-ai/cordis';
9
- import type { MemoryConfig } from '../config.js';
15
+ import { type MemoryConfig } from '../config.js';
10
16
  import type { LiveSettingsHandle } from '../settings.js';
11
17
  import type { L0Store } from '../store/l0.js';
12
18
  import type { L1Store } from '../store/l1.js';
@@ -14,6 +20,7 @@ import type { PersonaStore } from '../store/persona.js';
14
20
  import type { SceneStore } from '../store/scenes.js';
15
21
  import type { StateStore } from '../store/state.js';
16
22
  import type { ConversationMessage, ExtractMode, MemoryFamily, MemoryLogger } from '../types.js';
23
+ import type { FamilyStates } from './l1.js';
17
24
  export interface MemoryStores {
18
25
  l0: L0Store;
19
26
  l1: L1Store;
@@ -21,24 +28,55 @@ export interface MemoryStores {
21
28
  persona: Record<MemoryFamily, PersonaStore>;
22
29
  state: StateStore;
23
30
  }
31
+ /** 管线任务(优先级调度:live 优先于 rebuild)。 */
32
+ export interface PipelineTask {
33
+ kind: 'live' | 'rebuild';
34
+ run: () => Promise<unknown>;
35
+ }
36
+ /** 选取下一个要执行的任务下标:最早的 live 优先,否则队首(rebuild 分块让位)。 */
37
+ export declare function pickNextTaskIndex(tasks: PipelineTask[]): number;
38
+ /**
39
+ * 运行时调参视图:UI 选择器可临时覆盖蒸馏思考档位(空串回退静态 config 默认)。
40
+ * 浅拷贝只覆盖 llm 一层,其余键与原 cfg 共享只读引用;pipeline 全链继续收 cfg,无需感知。
41
+ */
42
+ export declare function effectiveCfg(cfg: MemoryConfig, live: LiveSettingsHandle): MemoryConfig;
24
43
  export declare class MemoryRunner {
25
44
  private readonly ctx;
26
45
  private readonly cfg;
27
46
  private readonly stores;
28
47
  private readonly logger;
29
48
  private readonly live;
30
- private queue;
49
+ private tasks;
50
+ private draining;
51
+ /** 停止标志(dispose 序置位):不再取新任务;进行中任务自然收尾,其 DB 写入失败由各层兜底捕获。 */
52
+ private stopped;
31
53
  private pending;
54
+ private readonly pendingFile;
32
55
  private background;
33
- private states;
56
+ /** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
57
+ states: FamilyStates;
34
58
  private afterRun;
35
59
  constructor(ctx: Context, cfg: MemoryConfig, stores: MemoryStores, logger: MemoryLogger, live: LiveSettingsHandle);
36
60
  init(): Promise<void>;
61
+ /** 启动补跑:对每个非空桶入队一次蒸馏尝试(受 live 开关与阈值约束,失败不无限重试)。 */
62
+ private scheduleStartupRetry;
37
63
  /** L1 抽取待重试的消息条数(状态面板用)。 */
38
64
  get pendingCount(): number;
39
65
  /** 管线跑完一轮后的回调(用于召回缓存失效)。 */
40
66
  setAfterRun(fn: () => void): void;
41
- /** 一轮对话结束后入队(L0 落盘 + 蒸馏触发判定)。 */
67
+ /** 一轮对话结束后入队(L0 落盘由 capture turn/end 即时完成,不排蒸馏队列)。 */
42
68
  enqueue(sessionId: string, messages: ConversationMessage[], mode: ExtractMode): void;
69
+ /** 重建任务入队(低优先级:让位于正常轮次;由 RebuildController 分块驱动)。 */
70
+ enqueueRebuildTask(run: () => Promise<unknown>): void;
71
+ /** 重建蒸馏轮:统一 auto 档,不受缓冲 200 上限(历史会话全量入桶,由 char 预算分块)。 */
72
+ runRebuildTurn(sessionId: string, messages: ConversationMessage[]): Promise<number>;
73
+ /** 停止取新任务(插件 dispose 序调用;进行中任务照常跑完但不 await——LLM 慢调用不拖住宿主卸载)。 */
74
+ stop(): void;
75
+ private pushTask;
76
+ private drain;
77
+ /** 缓冲落盘(每次蒸馏尝试后调用;失败只告警不阻断管线)。
78
+ * 非重建轮持久化前按桶截断到上限:重建取消后的大桶不至于在后续每次
79
+ * 蒸馏尝试时反复整量序列化落盘(多 MB 级 IO);重建轮豁免维持。 */
80
+ private persistPending;
43
81
  private runTurn;
44
82
  }
@@ -1,16 +1,43 @@
1
+ import { resolveDataDir } from '../config.js';
2
+ import { emptyPending, loadPending, PENDING_MODES, pendingPathFor, savePending } from '../store/pending.js';
3
+ import { errDetail } from '../util/filelog.js';
1
4
  import { runExtraction } from './l1.js';
2
5
  import { runSceneConsolidation } from './l2.js';
3
6
  import { runPersona } from './l3.js';
4
- import { errDetail } from '../util/filelog.js';
7
+ /** 选取下一个要执行的任务下标:最早的 live 优先,否则队首(rebuild 分块让位)。 */
8
+ export function pickNextTaskIndex(tasks) {
9
+ for (let i = 0; i < tasks.length; i++) {
10
+ if (tasks[i].kind === 'live')
11
+ return i;
12
+ }
13
+ return 0;
14
+ }
15
+ /**
16
+ * 运行时调参视图:UI 选择器可临时覆盖蒸馏思考档位(空串回退静态 config 默认)。
17
+ * 浅拷贝只覆盖 llm 一层,其余键与原 cfg 共享只读引用;pipeline 全链继续收 cfg,无需感知。
18
+ */
19
+ export function effectiveCfg(cfg, live) {
20
+ const eff = live.get().reasoningEffort;
21
+ return eff ? { ...cfg, llm: { ...cfg.llm, reasoningEffort: eff } } : cfg;
22
+ }
23
+ /** 单桶堆积上限(防无限堆积;重建分块不受限——历史会话需全量入桶蒸馏)。 */
24
+ const PENDING_BUCKET_CAP = 200;
25
+ /** 启动补跑延迟:避开宿主启动期忙乱。 */
26
+ const STARTUP_RETRY_DELAY_MS = 20_000;
5
27
  export class MemoryRunner {
6
28
  ctx;
7
29
  cfg;
8
30
  stores;
9
31
  logger;
10
32
  live;
11
- queue = Promise.resolve();
12
- pending = { auto: [], chat: [], work: [] };
33
+ tasks = [];
34
+ draining = false;
35
+ /** 停止标志(dispose 序置位):不再取新任务;进行中任务自然收尾,其 DB 写入失败由各层兜底捕获。 */
36
+ stopped = false;
37
+ pending = emptyPending();
38
+ pendingFile;
13
39
  background = [];
40
+ /** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
14
41
  states;
15
42
  afterRun;
16
43
  constructor(ctx, cfg, stores, logger, live) {
@@ -19,6 +46,7 @@ export class MemoryRunner {
19
46
  this.stores = stores;
20
47
  this.logger = logger;
21
48
  this.live = live;
49
+ this.pendingFile = pendingPathFor(resolveDataDir(cfg));
22
50
  }
23
51
  async init() {
24
52
  await this.stores.state.load();
@@ -30,6 +58,33 @@ export class MemoryRunner {
30
58
  this.logger.info('[memory] state.json 已迁移为 v2 分族格式(旧数据归 chat 桶)');
31
59
  await this.stores.state.save();
32
60
  }
61
+ // 恢复未蒸馏缓冲(上次进程退出前未蒸馏的消息,含失败待重试与攒阈值中途的)
62
+ try {
63
+ const loaded = await loadPending(this.pendingFile, this.logger);
64
+ for (const key of PENDING_MODES) {
65
+ if (loaded[key].length > PENDING_BUCKET_CAP)
66
+ loaded[key] = loaded[key].slice(-PENDING_BUCKET_CAP);
67
+ }
68
+ this.pending = loaded;
69
+ if (this.pendingCount > 0) {
70
+ this.logger.info(`[memory] 未蒸馏缓冲已恢复 ${this.pendingCount} 条(auto=${this.pending.auto.length}/chat=${this.pending.chat.length}/work=${this.pending.work.length}),${STARTUP_RETRY_DELAY_MS / 1000}s 后自动补跑`);
71
+ this.scheduleStartupRetry();
72
+ }
73
+ }
74
+ catch (err) {
75
+ this.logger.warn(`[memory] 未蒸馏缓冲恢复失败(空桶起步): ${errDetail(err)}`);
76
+ }
77
+ }
78
+ /** 启动补跑:对每个非空桶入队一次蒸馏尝试(受 live 开关与阈值约束,失败不无限重试)。 */
79
+ scheduleStartupRetry() {
80
+ const modes = PENDING_MODES.filter((m) => this.pending[m].length > 0);
81
+ this.ctx.effect(() => {
82
+ const timer = setTimeout(() => {
83
+ for (const mode of modes)
84
+ this.enqueue('startup-retry', [], mode);
85
+ }, STARTUP_RETRY_DELAY_MS);
86
+ return () => clearTimeout(timer);
87
+ });
33
88
  }
34
89
  /** L1 抽取待重试的消息条数(状态面板用)。 */
35
90
  get pendingCount() {
@@ -39,32 +94,80 @@ export class MemoryRunner {
39
94
  setAfterRun(fn) {
40
95
  this.afterRun = fn;
41
96
  }
42
- /** 一轮对话结束后入队(L0 落盘 + 蒸馏触发判定)。 */
97
+ /** 一轮对话结束后入队(L0 落盘由 capture turn/end 即时完成,不排蒸馏队列)。 */
43
98
  enqueue(sessionId, messages, mode) {
44
- this.queue = this.queue
45
- .then(() => this.runTurn(sessionId, messages, mode))
46
- .catch((err) => {
47
- this.logger.warn(`[memory] 管线失败(已兜底): ${errDetail(err)}`);
48
- });
99
+ this.pushTask({ kind: 'live', run: () => this.runTurn(sessionId, messages, mode) });
100
+ }
101
+ /** 重建任务入队(低优先级:让位于正常轮次;由 RebuildController 分块驱动)。 */
102
+ enqueueRebuildTask(run) {
103
+ this.pushTask({ kind: 'rebuild', run });
104
+ }
105
+ /** 重建蒸馏轮:统一 auto 档,不受缓冲 200 上限(历史会话全量入桶,由 char 预算分块)。 */
106
+ runRebuildTurn(sessionId, messages) {
107
+ return this.runTurn(sessionId, messages, 'auto', { noBufferCap: true });
108
+ }
109
+ /** 停止取新任务(插件 dispose 序调用;进行中任务照常跑完但不 await——LLM 慢调用不拖住宿主卸载)。 */
110
+ stop() {
111
+ this.stopped = true;
112
+ }
113
+ pushTask(task) {
114
+ if (this.stopped)
115
+ return;
116
+ this.tasks.push(task);
117
+ void this.drain();
118
+ }
119
+ async drain() {
120
+ if (this.draining)
121
+ return;
122
+ this.draining = true;
123
+ try {
124
+ while (!this.stopped && this.tasks.length > 0) {
125
+ const [task] = this.tasks.splice(pickNextTaskIndex(this.tasks), 1);
126
+ try {
127
+ await task.run();
128
+ }
129
+ catch (err) {
130
+ this.logger.warn(`[memory] 管线失败(已兜底): ${errDetail(err)}`);
131
+ }
132
+ }
133
+ }
134
+ finally {
135
+ this.draining = false;
136
+ }
49
137
  }
50
- async runTurn(sessionId, messages, mode) {
138
+ /** 缓冲落盘(每次蒸馏尝试后调用;失败只告警不阻断管线)。
139
+ * 非重建轮持久化前按桶截断到上限:重建取消后的大桶不至于在后续每次
140
+ * 蒸馏尝试时反复整量序列化落盘(多 MB 级 IO);重建轮豁免维持。 */
141
+ async persistPending(noBufferCap = false) {
142
+ try {
143
+ if (!noBufferCap) {
144
+ for (const key of PENDING_MODES) {
145
+ const bucket = this.pending[key];
146
+ if (bucket.length > PENDING_BUCKET_CAP)
147
+ this.pending[key] = bucket.slice(-PENDING_BUCKET_CAP);
148
+ }
149
+ }
150
+ await savePending(this.pendingFile, this.pending);
151
+ }
152
+ catch (err) {
153
+ this.logger.warn(`[memory] 未蒸馏缓冲落盘失败: ${errDetail(err)}`);
154
+ }
155
+ }
156
+ async runTurn(sessionId, messages, mode, opts) {
51
157
  const turnStart = Date.now();
52
158
  this.logger.info(`[memory] 蒸馏管线开始(session=${sessionId},mode=${mode},本轮 ${messages.length} 条消息,待重试 ${this.pendingCount} 条)`);
53
159
  // ── L0:原始对话已由 capture 在 turn/end 即时落盘(不排蒸馏队列,防慢 LLM 阻塞/退出丢消息) ──
54
- // ── 运行时调参视图:UI 选择器可临时覆盖蒸馏思考档位(空串回退静态 config 默认)。
55
- // 浅拷贝只覆盖 llm 一层,其余键与 this.cfg 共享只读引用;pipeline 全链继续收 cfg,无需感知。 ──
56
- const liveNow = this.live.get();
57
- const cfg = liveNow.reasoningEffort
58
- ? { ...this.cfg, llm: { ...this.cfg.llm, reasoningEffort: liveNow.reasoningEffort } }
59
- : this.cfg;
160
+ const cfg = effectiveCfg(this.cfg, this.live);
60
161
  // ── L1:抽取 + 去重(按档分桶,失败按桶保留待重试) ──
61
162
  let newRecords = [];
163
+ const liveNow = this.live.get();
62
164
  const distillOn = liveNow.enabled && liveNow.distill;
63
165
  if (cfg.extract.enabled && distillOn) {
64
166
  const bucket = this.pending[mode];
65
167
  bucket.push(...messages);
66
- if (bucket.length > 200)
67
- bucket.splice(0, bucket.length - 200);
168
+ if (!opts?.noBufferCap && bucket.length > PENDING_BUCKET_CAP) {
169
+ bucket.splice(0, bucket.length - PENDING_BUCKET_CAP);
170
+ }
68
171
  try {
69
172
  const t = Date.now();
70
173
  const result = await runExtraction(this.ctx, cfg, this.stores.l1, this.states, bucket, this.background, this.logger, mode);
@@ -75,11 +178,11 @@ export class MemoryRunner {
75
178
  this.logger.info(`[memory] L1 阶段完成(${Date.now() - t}ms)`);
76
179
  }
77
180
  catch (err) {
78
- // 保留 pending,下次重试;但防止无限堆积
79
- if (this.pending[mode].length > 200)
80
- this.pending[mode] = [];
181
+ // 保留 pending 下次重试(runTurn 入口已裁到 ≤200,防无限堆积;重建轮不裁,量被会话规模约束)
81
182
  this.logger.warn(`[memory] L1 抽取失败(mode=${mode},pending=${this.pending[mode].length}): ${errDetail(err)}`);
82
183
  }
184
+ // 缓冲每次尝试后立即落盘:进程中途退出不丢待重试/攒阈值状态
185
+ await this.persistPending(opts?.noBufferCap);
83
186
  // L1 计数推进后立即落盘:L2/L3 失败或进程中途退出不得回滚阈值进度
84
187
  // (记录已入库但计数丢失会让该族 L2 永远差一截,state 与 DB 脱节)
85
188
  try {
@@ -134,5 +237,6 @@ export class MemoryRunner {
134
237
  }
135
238
  this.logger.info(`[memory] 蒸馏管线结束(本轮新增 ${newRecords.length} 条,总耗时 ${Date.now() - turnStart}ms)`);
136
239
  this.afterRun?.();
240
+ return newRecords.length;
137
241
  }
138
242
  }