dsh-layered-memory 0.5.4 → 0.6.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.
package/dist/config.d.ts CHANGED
@@ -72,6 +72,8 @@ export interface MemoryConfig {
72
72
  model: string;
73
73
  /** 单次蒸馏调用的输出 token 上限(推理模型的 reasoning 与正文共享该预算)。 */
74
74
  maxTokens: number;
75
+ /** 蒸馏调用的思考档位;空串不传(跟随模型默认)。 */
76
+ reasoningEffort: string;
75
77
  temperature: number;
76
78
  /** 单次蒸馏调用的用户 prompt 字符预算(≈token 数,按中文 1 字≈1 token 保守估算)。 */
77
79
  maxInputChars: number;
@@ -158,6 +160,7 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
158
160
  provider: Schema<string, string>;
159
161
  model: Schema<string, string>;
160
162
  maxTokens: Schema<number, number>;
163
+ reasoningEffort: Schema<"" | "off" | "high" | "max", "" | "off" | "high" | "max">;
161
164
  temperature: Schema<number, number>;
162
165
  maxInputChars: Schema<number, number>;
163
166
  timeoutMs: Schema<number, number>;
@@ -165,6 +168,7 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
165
168
  provider: Schema<string, string>;
166
169
  model: Schema<string, string>;
167
170
  maxTokens: Schema<number, number>;
171
+ reasoningEffort: Schema<"" | "off" | "high" | "max", "" | "off" | "high" | "max">;
168
172
  temperature: Schema<number, number>;
169
173
  maxInputChars: Schema<number, number>;
170
174
  timeoutMs: Schema<number, number>;
@@ -247,6 +251,7 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
247
251
  provider: Schema<string, string>;
248
252
  model: Schema<string, string>;
249
253
  maxTokens: Schema<number, number>;
254
+ reasoningEffort: Schema<"" | "off" | "high" | "max", "" | "off" | "high" | "max">;
250
255
  temperature: Schema<number, number>;
251
256
  maxInputChars: Schema<number, number>;
252
257
  timeoutMs: Schema<number, number>;
@@ -254,6 +259,7 @@ export declare const memorySchema: Schema<Schemastery.ObjectS<{
254
259
  provider: Schema<string, string>;
255
260
  model: Schema<string, string>;
256
261
  maxTokens: Schema<number, number>;
262
+ reasoningEffort: Schema<"" | "off" | "high" | "max", "" | "off" | "high" | "max">;
257
263
  temperature: Schema<number, number>;
258
264
  maxInputChars: Schema<number, number>;
259
265
  timeoutMs: Schema<number, number>;
package/dist/config.js CHANGED
@@ -50,7 +50,10 @@ export const memorySchema = Schema.object({
50
50
  provider: Schema.string().default(''),
51
51
  model: Schema.string().default(''),
52
52
  // 推理模型(如 v4-flash)的 reasoning 计入输出预算:预算不足会被思考吃光导致正文 0 字符
53
- maxTokens: Schema.number().default(20_000),
53
+ maxTokens: Schema.number().default(256_000),
54
+ // 蒸馏是结构化抽取任务,默认关思考(off):v4-flash 默认 high 档的思考可把任意 maxTokens
55
+ // 预算全部吃光导致正文 0 字符;非推理模型不认识 effort 时会报 UNSUPPORTED_REASONING_EFFORT,设空串跳过
56
+ reasoningEffort: Schema.union(['', 'off', 'high', 'max']).default('off'),
54
57
  temperature: Schema.number().default(0.3),
55
58
  // 模型上下文 1M token,日常压到 ~700k 使用(中文按 1 字≈1 token 保守折算)
56
59
  maxInputChars: Schema.number().default(700_000),
package/dist/index.d.ts CHANGED
@@ -85,6 +85,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
85
85
  provider: import("@deepseek-ai/schemastery").default<string, string>;
86
86
  model: import("@deepseek-ai/schemastery").default<string, string>;
87
87
  maxTokens: import("@deepseek-ai/schemastery").default<number, number>;
88
+ reasoningEffort: import("@deepseek-ai/schemastery").default<"" | "off" | "high" | "max", "" | "off" | "high" | "max">;
88
89
  temperature: import("@deepseek-ai/schemastery").default<number, number>;
89
90
  maxInputChars: import("@deepseek-ai/schemastery").default<number, number>;
90
91
  timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
@@ -92,6 +93,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
92
93
  provider: import("@deepseek-ai/schemastery").default<string, string>;
93
94
  model: import("@deepseek-ai/schemastery").default<string, string>;
94
95
  maxTokens: import("@deepseek-ai/schemastery").default<number, number>;
96
+ reasoningEffort: import("@deepseek-ai/schemastery").default<"" | "off" | "high" | "max", "" | "off" | "high" | "max">;
95
97
  temperature: import("@deepseek-ai/schemastery").default<number, number>;
96
98
  maxInputChars: import("@deepseek-ai/schemastery").default<number, number>;
97
99
  timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
@@ -174,6 +176,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
174
176
  provider: import("@deepseek-ai/schemastery").default<string, string>;
175
177
  model: import("@deepseek-ai/schemastery").default<string, string>;
176
178
  maxTokens: import("@deepseek-ai/schemastery").default<number, number>;
179
+ reasoningEffort: import("@deepseek-ai/schemastery").default<"" | "off" | "high" | "max", "" | "off" | "high" | "max">;
177
180
  temperature: import("@deepseek-ai/schemastery").default<number, number>;
178
181
  maxInputChars: import("@deepseek-ai/schemastery").default<number, number>;
179
182
  timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
@@ -181,6 +184,7 @@ export declare const Config: import("@deepseek-ai/schemastery").default<Schemast
181
184
  provider: import("@deepseek-ai/schemastery").default<string, string>;
182
185
  model: import("@deepseek-ai/schemastery").default<string, string>;
183
186
  maxTokens: import("@deepseek-ai/schemastery").default<number, number>;
187
+ reasoningEffort: import("@deepseek-ai/schemastery").default<"" | "off" | "high" | "max", "" | "off" | "high" | "max">;
184
188
  temperature: import("@deepseek-ai/schemastery").default<number, number>;
185
189
  maxInputChars: import("@deepseek-ai/schemastery").default<number, number>;
186
190
  timeoutMs: import("@deepseek-ai/schemastery").default<number, number>;
package/dist/index.js CHANGED
@@ -21,6 +21,7 @@ import { memorySchema, resolveDataDir } from './config.js';
21
21
  import { registerCapture } from './hooks/capture.js';
22
22
  import { registerRecall } from './hooks/recall.js';
23
23
  import { MemoryRunner } from './pipeline/runner.js';
24
+ import { RebuildController } from './pipeline/rebuild.js';
24
25
  import { registerMemoryRpc, PLUGIN_VERSION } from './stats.js';
25
26
  import { registerLiveSettings } from './settings.js';
26
27
  import { NoopEmbeddingService, RemoteEmbeddingService, } from './store/embedding.js';
@@ -220,6 +221,10 @@ export async function apply(ctx, config) {
220
221
  }
221
222
  const runner = new MemoryRunner(ctx, config, stores, logger, live);
222
223
  await runner.init();
224
+ // 重建控制器(存储降级时不建——RPC 端点走 supported=false 分支)
225
+ const rebuild = storageOk && !db.isDegraded()
226
+ ? new RebuildController(ctx, config, stores, db, runner, logger, live)
227
+ : undefined;
223
228
  if (storageOk) {
224
229
  registerCapture(ctx, config, runner, stores.l0, logger, live, modes);
225
230
  }
@@ -229,6 +234,6 @@ export async function apply(ctx, config) {
229
234
  registerMemoryRpc(ctx, config, stores, logger, {
230
235
  degraded: () => !storageOk || db.isDegraded(),
231
236
  pending: () => runner.pendingCount,
232
- }, live, modes, dataDir);
237
+ }, live, modes, dataDir, rebuild);
233
238
  logger.info(`[memory] L0~L3 分层蒸馏记忆插件就绪(L1 记忆 ${storageOk ? stores.l1.size : 0} 条 | 捕获=${storageOk && config.capture.enabled} | 蒸馏=${storageOk && config.extract.enabled} | 召回=${config.recall.enabled})`);
234
239
  }
package/dist/llm.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createUserMessage } from '@deepseek-ai/dsh-llm';
1
+ import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
2
2
  import { errDetail } from './util/filelog.js';
3
3
  /** 解析蒸馏用的 provider/model:配置优先,其次当前默认选择。 */
4
4
  export async function resolveModelRoute(ctx, cfg) {
@@ -35,6 +35,10 @@ export async function callLLM(ctx, cfg, opts) {
35
35
  messages: [createUserMessage({ content: [{ type: 'text', text: user }], source: { kind: 'user' } })],
36
36
  temperature: opts.temperature ?? cfg.llm.temperature,
37
37
  maxTokens: opts.maxTokens ?? cfg.llm.maxTokens,
38
+ // 默认 off:蒸馏是结构化抽取,high 思考可吃光全部输出预算致正文 0 字符;空串不传(非推理模型)
39
+ ...(cfg.llm.reasoningEffort
40
+ ? { reasoningEffort: ReasoningEffortId(cfg.llm.reasoningEffort) }
41
+ : {}),
38
42
  signal,
39
43
  });
40
44
  const startedAt = Date.now();
@@ -0,0 +1,78 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import { type MemoryConfig } from '../config.js';
3
+ import type { L1Store } from '../store/l1.js';
4
+ import type { PersonaStore } from '../store/persona.js';
5
+ import type { SceneStore } from '../store/scenes.js';
6
+ import type { StateStore } from '../store/state.js';
7
+ import type { MemoryDb } from '../store/sqlite.js';
8
+ import type { LiveSettingsHandle } from '../settings.js';
9
+ import type { ConversationMessage, L0MessageRecord, MemoryFamily, MemoryLogger } from '../types.js';
10
+ import { type MemoryRunner } from './runner.js';
11
+ /** 重建所需的存储子集(l0 不需要——快照直接走 db)。 */
12
+ export interface RebuildStores {
13
+ l1: L1Store;
14
+ scenes: Record<MemoryFamily, SceneStore>;
15
+ persona: Record<MemoryFamily, PersonaStore>;
16
+ state: StateStore;
17
+ }
18
+ export type RebuildPhase = 'idle' | 'preparing' | 'distilling' | 'finalizing' | 'done' | 'cancelled' | 'failed';
19
+ export interface RebuildStatus {
20
+ running: boolean;
21
+ phase: RebuildPhase;
22
+ /** 已完成的会话块数 / 总块数。 */
23
+ done: number;
24
+ total: number;
25
+ /** L0 体量(idle 时为实时预估,运行中为快照值)。 */
26
+ sessionCount: number;
27
+ messageCount: number;
28
+ /** 预计 LLM 抽取调用次数(下界估算:块数与字符预算取大)。 */
29
+ estCalls: number;
30
+ /** 重建产出的 L1 记录累计条数。 */
31
+ recordsBuilt: number;
32
+ cancelRequested: boolean;
33
+ startedAt: number | null;
34
+ finishedAt: number | null;
35
+ error: string | null;
36
+ /** 归档产物名(提示用户可手工找回)。 */
37
+ archiveNote: string | null;
38
+ }
39
+ export interface RebuildChunk {
40
+ sessionId: string;
41
+ messages: ConversationMessage[];
42
+ }
43
+ export declare function groupL0Sessions(records: L0MessageRecord[]): RebuildChunk[];
44
+ /** 抽取调用数下界估算(与 l1.ts 的 perChunk 同式;会话数与字符预算取大)。 */
45
+ export declare function estimateCalls(sessions: number, messages: number, chars: number, maxInputChars: number): number;
46
+ export declare class RebuildController {
47
+ private readonly ctx;
48
+ private readonly cfg;
49
+ private readonly stores;
50
+ private readonly db;
51
+ private readonly runner;
52
+ private readonly logger;
53
+ private readonly live;
54
+ private status;
55
+ private chunks;
56
+ private cancelRequested;
57
+ /** 快照时刻(收尾时按它区分重建产物与重建后新对话的记录)。 */
58
+ private rebuildStartMs;
59
+ constructor(ctx: Context, cfg: MemoryConfig, stores: RebuildStores, db: MemoryDb, runner: Pick<MemoryRunner, 'enqueueRebuildTask' | 'runRebuildTurn' | 'states'>, logger: MemoryLogger, live: LiveSettingsHandle);
60
+ /** 状态快照(idle 时附带实时 L0 预估,供确认弹窗显示成本)。 */
61
+ getStatus(): RebuildStatus;
62
+ /** 启动重建(校验后入队准备任务;真正的清库/归档在管线队列里串行执行,避开并发竞态)。 */
63
+ start(): RebuildStatus;
64
+ /** 请求取消:当前块完成后停止,已重建部分保留并照常收尾 L2/L3。 */
65
+ requestCancel(): RebuildStatus;
66
+ private prepare;
67
+ /** 分块链:一次只挂一个重建块,跑完再挂下一块——正常轮次可随时插队。 */
68
+ private scheduleChunk;
69
+ private finalize;
70
+ /**
71
+ * 收集重建窗口内某族的记录:重建产物全部是新插入(updated==created),
72
+ * 按 updated_time 倒序翻页、越过 rebuildStartMs 即停。
73
+ */
74
+ private collectRebuildRecords;
75
+ private finish;
76
+ /** 归档旧派生层:records/ scenes/ persona-*.md 改名 .bak.<ts>。不存在则跳过。 */
77
+ private archiveDerived;
78
+ }
@@ -0,0 +1,300 @@
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
+ /** 启动重建(校验后入队准备任务;真正的清库/归档在管线队列里串行执行,避开并发竞态)。 */
108
+ start() {
109
+ if (this.status.running)
110
+ throw new Error('重建已在进行中');
111
+ const est = this.db.l0RebuildEstimate();
112
+ if (est.messages === 0)
113
+ throw new Error('L0 无任何消息,无需重建');
114
+ this.cancelRequested = false;
115
+ this.chunks = [];
116
+ this.status = {
117
+ ...idleStatus(),
118
+ running: true,
119
+ phase: 'preparing',
120
+ sessionCount: est.sessions,
121
+ messageCount: est.messages,
122
+ estCalls: estimateCalls(est.sessions, est.messages, est.chars, this.cfg.llm.maxInputChars),
123
+ startedAt: Date.now(),
124
+ };
125
+ this.runner.enqueueRebuildTask(() => this.prepare());
126
+ this.logger.info(`[memory] 重建开始:${est.sessions} 个会话 / ${est.messages} 条 L0 消息(预计 ≥${this.status.estCalls} 次抽取调用)`);
127
+ return { ...this.status };
128
+ }
129
+ /** 请求取消:当前块完成后停止,已重建部分保留并照常收尾 L2/L3。 */
130
+ requestCancel() {
131
+ if (!this.status.running)
132
+ return this.getStatus();
133
+ this.cancelRequested = true;
134
+ this.status.cancelRequested = true;
135
+ this.logger.info('[memory] 重建取消已请求(当前块完成后停止)');
136
+ return { ...this.status };
137
+ }
138
+ async prepare() {
139
+ try {
140
+ // 快照:从检索库读全量 L0(事务一致;重建期间新捕获的消息走正常轮次,天然不重不漏)
141
+ this.rebuildStartMs = Date.now();
142
+ this.chunks = groupL0Sessions(this.db.listL0All());
143
+ if (this.chunks.length === 0) {
144
+ this.finish('failed', 'L0 快照为空');
145
+ return;
146
+ }
147
+ this.status.total = this.chunks.length;
148
+ // 归档旧派生层(改名不硬删;任一失败即终止——半清半留会破坏"全量重导"语义)
149
+ const archiveNote = await this.archiveDerived();
150
+ this.status.archiveNote = archiveNote ?? null;
151
+ // 清检索库 + 重置 checkpoint;归档后重建空目录(records/ 由 appendNew 自动重建)
152
+ if (!this.db.clearL1())
153
+ throw new Error('L1 检索库清空失败');
154
+ this.stores.state.reset();
155
+ await this.stores.state.save();
156
+ await Promise.all([
157
+ this.stores.scenes.chat.init(),
158
+ this.stores.scenes.work.init(),
159
+ this.stores.persona.chat.init(),
160
+ this.stores.persona.work.init(),
161
+ ]);
162
+ this.status.phase = 'distilling';
163
+ this.logger.info(`[memory] 重建准备完成(归档:${archiveNote ?? '无旧产物'},${this.chunks.length} 个会话块)`);
164
+ this.scheduleChunk(0);
165
+ }
166
+ catch (err) {
167
+ this.finish('failed', `准备阶段失败: ${errDetail(err)}`);
168
+ }
169
+ }
170
+ /** 分块链:一次只挂一个重建块,跑完再挂下一块——正常轮次可随时插队。 */
171
+ scheduleChunk(i) {
172
+ if (this.cancelRequested || i >= this.chunks.length) {
173
+ this.runner.enqueueRebuildTask(() => this.finalize());
174
+ return;
175
+ }
176
+ const chunk = this.chunks[i];
177
+ this.runner.enqueueRebuildTask(async () => {
178
+ // 入队后开跑前可能已收到取消(等待插队的正常轮次期间),直接跳到收尾
179
+ if (this.cancelRequested) {
180
+ this.runner.enqueueRebuildTask(() => this.finalize());
181
+ return;
182
+ }
183
+ try {
184
+ const n = await this.runner.runRebuildTurn(chunk.sessionId, chunk.messages);
185
+ this.status.recordsBuilt += n;
186
+ }
187
+ catch (err) {
188
+ this.logger.warn(`[memory] 重建块失败(session=${chunk.sessionId},跳过继续): ${errDetail(err)}`);
189
+ }
190
+ this.status.done = i + 1;
191
+ this.scheduleChunk(i + 1);
192
+ });
193
+ }
194
+ async finalize() {
195
+ try {
196
+ this.status.phase = 'finalizing';
197
+ const cfg = effectiveCfg(this.cfg, this.live);
198
+ const liveNow = this.live.get();
199
+ const distillOn = liveNow.enabled && liveNow.distill;
200
+ // 强制 L2:把重建窗口内该族尚未整合的残余记录补一轮(正常轮次语义里差几条
201
+ // 不触发是常态,但"重建"应把已有记录全部落进场景)
202
+ if (cfg.l2.enabled && distillOn) {
203
+ for (const family of ['chat', 'work']) {
204
+ const fstate = this.runner.states[family];
205
+ if (fstate.newMemoriesSinceL2 <= 0)
206
+ continue;
207
+ const leftovers = this.collectRebuildRecords(family);
208
+ if (leftovers.length === 0)
209
+ continue;
210
+ try {
211
+ const t = Date.now();
212
+ const result = await runSceneConsolidation(this.ctx, cfg, this.stores.scenes[family], leftovers, this.logger, family);
213
+ fstate.lastL2At = Date.now();
214
+ fstate.newMemoriesSinceL2 = 0;
215
+ if (result.personaRequestedReason)
216
+ fstate.personaRequestedReason = result.personaRequestedReason;
217
+ this.logger.info(`[memory] 重建收尾 L2 完成(family=${family},${Date.now() - t}ms,${leftovers.length} 条残余记录)`);
218
+ }
219
+ catch (err) {
220
+ this.logger.warn(`[memory] 重建收尾 L2 失败(family=${family}): ${errDetail(err)}`);
221
+ }
222
+ }
223
+ }
224
+ // 强制 L3:checkpoint 已重置(hasPersona=false)→ 冷启动触发;无场景的族跳过
225
+ if (cfg.l3.enabled && distillOn) {
226
+ for (const family of ['chat', 'work']) {
227
+ try {
228
+ const scenes = await this.stores.scenes[family].list();
229
+ if (scenes.length === 0)
230
+ continue;
231
+ await runPersona(this.ctx, cfg, this.stores.scenes[family], this.stores.persona[family], this.runner.states[family], this.logger, family);
232
+ }
233
+ catch (err) {
234
+ this.logger.warn(`[memory] 重建收尾 L3 失败(family=${family}): ${errDetail(err)}`);
235
+ }
236
+ }
237
+ }
238
+ await this.stores.state.save();
239
+ this.finish(this.cancelRequested ? 'cancelled' : 'done', null);
240
+ }
241
+ catch (err) {
242
+ this.finish('failed', `收尾失败: ${errDetail(err)}`);
243
+ }
244
+ }
245
+ /**
246
+ * 收集重建窗口内某族的记录:重建产物全部是新插入(updated==created),
247
+ * 按 updated_time 倒序翻页、越过 rebuildStartMs 即停。
248
+ */
249
+ collectRebuildRecords(family) {
250
+ const out = [];
251
+ const PAGE = 200;
252
+ for (let offset = 0;; offset += PAGE) {
253
+ const { items } = this.stores.l1.list({ family, limit: PAGE, offset });
254
+ if (items.length === 0)
255
+ break;
256
+ let beyond = 0;
257
+ for (const r of items) {
258
+ if (r.createdAt >= this.rebuildStartMs)
259
+ out.push(r);
260
+ else
261
+ beyond++;
262
+ }
263
+ if (beyond > 0 || items.length < PAGE)
264
+ break;
265
+ }
266
+ return out;
267
+ }
268
+ finish(phase, error) {
269
+ this.status.running = false;
270
+ this.status.phase = phase;
271
+ this.status.error = error;
272
+ this.status.finishedAt = Date.now();
273
+ const cost = this.status.finishedAt - (this.status.startedAt ?? this.status.finishedAt);
274
+ this.logger.info(`[memory] 重建结束(${phase}):${this.status.done}/${this.status.total} 会话,产出 ${this.status.recordsBuilt} 条记录,耗时 ${Math.round(cost / 1000)}s` +
275
+ (error ? `,错误:${error}` : ''));
276
+ }
277
+ /** 归档旧派生层:records/ scenes/ persona-*.md 改名 .bak.<ts>。不存在则跳过。 */
278
+ async archiveDerived() {
279
+ const dataDir = resolveDataDir(this.cfg);
280
+ const ts = stamp();
281
+ const items = [
282
+ [path.join(dataDir, 'records'), path.join(dataDir, `records.bak.${ts}`)],
283
+ [path.join(dataDir, 'scenes'), path.join(dataDir, `scenes.bak.${ts}`)],
284
+ [path.join(dataDir, 'persona-chat.md'), path.join(dataDir, `persona-chat.md.bak.${ts}`)],
285
+ [path.join(dataDir, 'persona-work.md'), path.join(dataDir, `persona-work.md.bak.${ts}`)],
286
+ ];
287
+ const archived = [];
288
+ for (const [from, to] of items) {
289
+ try {
290
+ await fs.access(from);
291
+ }
292
+ catch {
293
+ continue;
294
+ }
295
+ await fs.rename(from, to);
296
+ archived.push(path.basename(to));
297
+ }
298
+ return archived.length > 0 ? archived.join(', ') : undefined;
299
+ }
300
+ }
@@ -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,49 @@ 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;
31
51
  private pending;
52
+ private readonly pendingFile;
32
53
  private background;
33
- private states;
54
+ /** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
55
+ states: FamilyStates;
34
56
  private afterRun;
35
57
  constructor(ctx: Context, cfg: MemoryConfig, stores: MemoryStores, logger: MemoryLogger, live: LiveSettingsHandle);
36
58
  init(): Promise<void>;
59
+ /** 启动补跑:对每个非空桶入队一次蒸馏尝试(受 live 开关与阈值约束,失败不无限重试)。 */
60
+ private scheduleStartupRetry;
37
61
  /** L1 抽取待重试的消息条数(状态面板用)。 */
38
62
  get pendingCount(): number;
39
63
  /** 管线跑完一轮后的回调(用于召回缓存失效)。 */
40
64
  setAfterRun(fn: () => void): void;
41
- /** 一轮对话结束后入队(L0 落盘 + 蒸馏触发判定)。 */
65
+ /** 一轮对话结束后入队(L0 落盘由 capture turn/end 即时完成,不排蒸馏队列)。 */
42
66
  enqueue(sessionId: string, messages: ConversationMessage[], mode: ExtractMode): void;
67
+ /** 重建任务入队(低优先级:让位于正常轮次;由 RebuildController 分块驱动)。 */
68
+ enqueueRebuildTask(run: () => Promise<unknown>): void;
69
+ /** 重建蒸馏轮:统一 auto 档,不受缓冲 200 上限(历史会话全量入桶,由 char 预算分块)。 */
70
+ runRebuildTurn(sessionId: string, messages: ConversationMessage[]): Promise<number>;
71
+ private pushTask;
72
+ private drain;
73
+ /** 缓冲落盘(每次蒸馏尝试后调用;失败只告警不阻断管线)。 */
74
+ private persistPending;
43
75
  private runTurn;
44
76
  }