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
@@ -1,6 +1,7 @@
1
1
  import { resolveDataDir } from '../config.js';
2
- import { emptyPending, loadPending, PENDING_MODES, pendingPathFor, savePending } from '../store/pending.js';
2
+ import { emptyPending, freshWarmup, groupPendingBySession, loadPending, PENDING_MODES, pendingPathFor, savePending, } from '../store/pending.js';
3
3
  import { errDetail } from '../util/filelog.js';
4
+ import { advanceWarmupThreshold, effectiveExtractThreshold, idleSessionsToFlush, modeSwitchAction, pickSessionBackground, } from './trigger.js';
4
5
  import { runExtraction } from './l1.js';
5
6
  import { runSceneConsolidation } from './l2.js';
6
7
  import { runPersona } from './l3.js';
@@ -24,28 +25,37 @@ export function effectiveCfg(cfg, live) {
24
25
  const PENDING_BUCKET_CAP = 200;
25
26
  /** 启动补跑延迟:避开宿主启动期忙乱。 */
26
27
  const STARTUP_RETRY_DELAY_MS = 20_000;
28
+ /** 闲置扫描 tick 粒度(实际落袋延迟 = idleSeconds + 至多一个 tick)。 */
29
+ const IDLE_TICK_MS = 30_000;
27
30
  export class MemoryRunner {
28
31
  ctx;
29
32
  cfg;
30
33
  stores;
31
34
  logger;
32
35
  live;
36
+ modes;
33
37
  tasks = [];
34
38
  draining = false;
35
39
  /** 停止标志(dispose 序置位):不再取新任务;进行中任务自然收尾,其 DB 写入失败由各层兜底捕获。 */
36
40
  stopped = false;
37
41
  pending = emptyPending();
42
+ /** 各档位桶渐进阈值(1 起步翻倍至稳态毕业;ADR-0003,随 pending.json 持久化)。 */
43
+ warmup = freshWarmup();
44
+ /** 每会话最后活动时间(闲置兜底判定用)。 */
45
+ lastActivity = new Map();
38
46
  pendingFile;
39
- background = [];
40
47
  /** 分族 checkpoint(init 后可用;重建收尾也从这里读活引用)。 */
41
48
  states;
42
49
  afterRun;
43
- constructor(ctx, cfg, stores, logger, live) {
50
+ constructor(ctx, cfg, stores, logger, live,
51
+ /** 会话档位只读句柄(闲置扫描跳过 off 档会话;挂起语义)。 */
52
+ modes) {
44
53
  this.ctx = ctx;
45
54
  this.cfg = cfg;
46
55
  this.stores = stores;
47
56
  this.logger = logger;
48
57
  this.live = live;
58
+ this.modes = modes;
49
59
  this.pendingFile = pendingPathFor(resolveDataDir(cfg));
50
60
  }
51
61
  async init() {
@@ -60,12 +70,13 @@ export class MemoryRunner {
60
70
  }
61
71
  // 恢复未蒸馏缓冲(上次进程退出前未蒸馏的消息,含失败待重试与攒阈值中途的)
62
72
  try {
63
- const loaded = await loadPending(this.pendingFile, this.logger);
73
+ const { buckets: loaded, warmup } = await loadPending(this.pendingFile, this.logger);
64
74
  for (const key of PENDING_MODES) {
65
75
  if (loaded[key].length > PENDING_BUCKET_CAP)
66
76
  loaded[key] = loaded[key].slice(-PENDING_BUCKET_CAP);
67
77
  }
68
78
  this.pending = loaded;
79
+ this.warmup = warmup;
69
80
  if (this.pendingCount > 0) {
70
81
  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
82
  this.scheduleStartupRetry();
@@ -75,13 +86,18 @@ export class MemoryRunner {
75
86
  this.logger.warn(`[memory] 未蒸馏缓冲恢复失败(空桶起步): ${errDetail(err)}`);
76
87
  }
77
88
  }
78
- /** 启动补跑:对每个非空桶入队一次蒸馏尝试(受 live 开关与阈值约束,失败不无限重试)。 */
89
+ /** 启动补跑:对每个非空桶的每个会话切片入队一次蒸馏尝试(受 live 开关与
90
+ * 生效阈值约束;不足阈值的消息等用户继续或闲置兜底,失败不无限重试)。 */
79
91
  scheduleStartupRetry() {
80
92
  const modes = PENDING_MODES.filter((m) => this.pending[m].length > 0);
81
93
  this.ctx.effect(() => {
82
94
  const timer = setTimeout(() => {
83
- for (const mode of modes)
84
- this.enqueue('startup-retry', [], mode);
95
+ // 到点后按当前桶内容重新分组(期间可能有新轮次已消费切片)
96
+ for (const mode of modes) {
97
+ for (const g of groupPendingBySession(this.pending[mode])) {
98
+ this.enqueue(g.sessionId, [], mode);
99
+ }
100
+ }
85
101
  }, STARTUP_RETRY_DELAY_MS);
86
102
  return () => clearTimeout(timer);
87
103
  });
@@ -95,21 +111,81 @@ export class MemoryRunner {
95
111
  this.afterRun = fn;
96
112
  }
97
113
  /** 一轮对话结束后入队(L0 落盘由 capture 在 turn/end 即时完成,不排蒸馏队列)。 */
98
- enqueue(sessionId, messages, mode) {
99
- this.pushTask({ kind: 'live', run: () => this.runTurn(sessionId, messages, mode) });
114
+ enqueue(sessionId, messages, mode, opts) {
115
+ this.pushTask({ kind: 'live', run: () => this.runTurn(sessionId, messages, mode, opts) });
100
116
  }
101
117
  /** 重建任务入队(低优先级:让位于正常轮次;由 RebuildController 分块驱动)。 */
102
118
  enqueueRebuildTask(run) {
103
119
  this.pushTask({ kind: 'rebuild', run });
104
120
  }
105
- /** 重建蒸馏轮:统一 auto 档,不受缓冲 200 上限(历史会话全量入桶,由 char 预算分块)。 */
121
+ /** 重建蒸馏轮:统一 auto 档,全量强制蒸馏(不受阈值约束——历史小会话也必须出记忆)、
122
+ * 不受缓冲 200 上限(历史会话全量入桶,由 char 预算分块)。 */
106
123
  runRebuildTurn(sessionId, messages) {
107
- return this.runTurn(sessionId, messages, 'auto', { noBufferCap: true });
124
+ return this.runTurn(sessionId, messages, 'auto', { noBufferCap: true, force: true });
108
125
  }
109
126
  /** 停止取新任务(插件 dispose 序调用;进行中任务照常跑完但不 await——LLM 慢调用不拖住宿主卸载)。 */
110
127
  stop() {
111
128
  this.stopped = true;
112
129
  }
130
+ /** 启动闲置兜底定时器(index.ts 装配;idleSeconds=0 关闭)。 */
131
+ startIdleTimer() {
132
+ const idleMs = (this.cfg.extract.idleSeconds ?? 0) * 1000;
133
+ if (!(idleMs > 0))
134
+ return;
135
+ this.ctx.effect(() => {
136
+ const timer = setInterval(() => this.flushIdleSlices(Date.now(), idleMs), IDLE_TICK_MS);
137
+ return () => clearInterval(timer);
138
+ });
139
+ }
140
+ /** 闲置扫描:静默达标且有切片的会话按捕获档位落袋(off 档会话挂起跳过)。 */
141
+ flushIdleSlices(now, idleMs) {
142
+ if (this.stopped)
143
+ return;
144
+ const liveNow = this.live.get();
145
+ if (!(liveNow.enabled && liveNow.distill))
146
+ return;
147
+ // 聚合每会话跨桶切片(sessionId 全局唯一)
148
+ const infos = new Map();
149
+ for (const mode of PENDING_MODES) {
150
+ for (const m of this.pending[mode]) {
151
+ const info = infos.get(m.sessionId) ?? { sessionId: m.sessionId, count: 0, lastMessageAt: 0 };
152
+ info.count++;
153
+ info.lastMessageAt = Math.max(info.lastMessageAt, m.timestamp);
154
+ infos.set(m.sessionId, info);
155
+ }
156
+ }
157
+ if (infos.size === 0)
158
+ return;
159
+ const targets = idleSessionsToFlush([...infos.values()], this.lastActivity, now, idleMs, (sid) => this.modes?.get(sid) === 'off');
160
+ for (const sid of targets) {
161
+ this.logger.info(`[memory] 闲置兜底:会话 ${sid} 静默达标,未蒸馏切片落袋`);
162
+ this.enqueueSessionSlices(sid);
163
+ }
164
+ }
165
+ /** 把某会话在各桶中的切片按捕获档位逐个入队强制蒸馏(闲置兜底 / 档位切换共用)。 */
166
+ enqueueSessionSlices(sessionId) {
167
+ for (const mode of PENDING_MODES) {
168
+ if (this.pending[mode].some((m) => m.sessionId === sessionId)) {
169
+ this.enqueue(sessionId, [], mode, { force: true });
170
+ }
171
+ }
172
+ }
173
+ /**
174
+ * 档位切换同步(session-modes 的 set() 回调,ADR-0003):
175
+ * 非 off 间切换 → 该会话切片立即按捕获档位蒸馏(新档位从空切片起步);
176
+ * 切到 off → 挂起(切片留存,闲置扫描跳过);从 off 切回 → 挂起片按捕获档位落袋。
177
+ */
178
+ onModeChange(sessionId, oldMode, newMode) {
179
+ const action = modeSwitchAction(oldMode, newMode);
180
+ if (action === 'none')
181
+ return;
182
+ if (action === 'park') {
183
+ this.logger.info(`[memory] 档位切换 ${oldMode}→off:会话 ${sessionId} 未蒸馏切片挂起`);
184
+ return;
185
+ }
186
+ this.logger.info(`[memory] 档位切换 ${oldMode}→${newMode}(${action}):会话 ${sessionId} 切片按捕获档位落袋`);
187
+ this.enqueueSessionSlices(sessionId);
188
+ }
113
189
  pushTask(task) {
114
190
  if (this.stopped)
115
191
  return;
@@ -147,7 +223,7 @@ export class MemoryRunner {
147
223
  this.pending[key] = bucket.slice(-PENDING_BUCKET_CAP);
148
224
  }
149
225
  }
150
- await savePending(this.pendingFile, this.pending);
226
+ await savePending(this.pendingFile, this.pending, this.warmup);
151
227
  }
152
228
  catch (err) {
153
229
  this.logger.warn(`[memory] 未蒸馏缓冲落盘失败: ${errDetail(err)}`);
@@ -158,38 +234,31 @@ export class MemoryRunner {
158
234
  this.logger.info(`[memory] 蒸馏管线开始(session=${sessionId},mode=${mode},本轮 ${messages.length} 条消息,待重试 ${this.pendingCount} 条)`);
159
235
  // ── L0:原始对话已由 capture 在 turn/end 即时落盘(不排蒸馏队列,防慢 LLM 阻塞/退出丢消息) ──
160
236
  const cfg = effectiveCfg(this.cfg, this.live);
161
- // ── L1:抽取 + 去重(按档分桶,失败按桶保留待重试) ──
237
+ // ── L1:抽取 + 去重(按档分桶、桶内按会话切片;失败按切片保留待重试) ──
162
238
  let newRecords = [];
163
239
  const liveNow = this.live.get();
164
240
  const distillOn = liveNow.enabled && liveNow.distill;
165
241
  if (cfg.extract.enabled && distillOn) {
166
242
  const bucket = this.pending[mode];
167
- bucket.push(...messages);
243
+ // 入桶即携带会话标识(会话切片成员;ADR-0003:切片内永不跨会话混装)
244
+ bucket.push(...messages.map((m) => ({ ...m, sessionId })));
168
245
  if (!opts?.noBufferCap && bucket.length > PENDING_BUCKET_CAP) {
169
246
  bucket.splice(0, bucket.length - PENDING_BUCKET_CAP);
170
247
  }
171
- try {
172
- const t = Date.now();
173
- const result = await runExtraction(this.ctx, cfg, this.stores.l1, this.states, bucket, this.background, this.logger, mode);
174
- if (!result.skipped)
175
- this.pending[mode] = [];
176
- this.background = [...this.background.slice(-cfg.extract.backgroundMessages), ...messages];
177
- newRecords = result.newRecords;
178
- this.logger.info(`[memory] L1 阶段完成(${Date.now() - t}ms)`);
179
- }
180
- catch (err) {
181
- // 保留 pending 下次重试(runTurn 入口已裁到 ≤200,防无限堆积;重建轮不裁,量被会话规模约束)
182
- this.logger.warn(`[memory] L1 抽取失败(mode=${mode},pending=${this.pending[mode].length}): ${errDetail(err)}`);
183
- }
184
- // 缓冲每次尝试后立即落盘:进程中途退出不丢待重试/攒阈值状态
185
- await this.persistPending(opts?.noBufferCap);
186
- // L1 计数推进后立即落盘:L2/L3 失败或进程中途退出不得回滚阈值进度
187
- // (记录已入库但计数丢失会让该族 L2 永远差一截,state 与 DB 脱节)
188
- try {
189
- await this.stores.state.save();
248
+ if (messages.length > 0)
249
+ this.lastActivity.set(sessionId, Date.now());
250
+ // 按会话切片触发(ADR-0003):只看本会话切片是否达到生效阈值(渐进爬坡),
251
+ // 达标只抽取该切片——其余会话的切片继续攒,绝不跨会话混装。
252
+ // force:重建轮全量蒸馏(历史小会话也必须出记忆,不受阈值约束)。
253
+ const effective = effectiveExtractThreshold(this.warmup[mode], cfg.extract.minMessages);
254
+ const sliceLen = bucket.reduce((n, m) => (m.sessionId === sessionId ? n + 1 : n), 0);
255
+ if (opts?.force || sliceLen >= effective) {
256
+ newRecords = await this.extractSessionSlice(sessionId, mode, cfg, effective, opts);
190
257
  }
191
- catch (err) {
192
- this.logger.warn(`[memory] 状态保存失败: ${errDetail(err)}`);
258
+ else {
259
+ this.logger.debug?.(`[memory] 会话切片攒批中(session=${sessionId},mode=${mode},${sliceLen}/${effective})`);
260
+ // 攒阈值中途也落盘:进程退出后切片与爬坡状态不丢
261
+ await this.persistPending(opts?.noBufferCap);
193
262
  }
194
263
  }
195
264
  // ── L2/L3:按记录族各自判定与执行 ──
@@ -239,4 +308,48 @@ export class MemoryRunner {
239
308
  this.afterRun?.();
240
309
  return newRecords.length;
241
310
  }
311
+ /**
312
+ * 抽取并消费一个会话切片:成功才把切片移出桶并推进爬坡阈值;失败保留切片待重试。
313
+ * 调用方已保证切片达到生效阈值(或 force)。背景参考按会话从 L0 现查并剔除切片
314
+ * 自身(ADR-0003:会话间互不污染、重启不丢背景)。
315
+ */
316
+ async extractSessionSlice(sessionId, mode, cfg, effectiveThreshold, opts) {
317
+ const bucket = this.pending[mode];
318
+ const slice = bucket.filter((m) => m.sessionId === sessionId);
319
+ if (slice.length === 0)
320
+ return [];
321
+ const rest = bucket.filter((m) => m.sessionId !== sessionId);
322
+ try {
323
+ // 背景参考:该会话最近消息(多取切片条数补偿被剔除者),剔除切片自身
324
+ const background = this.stores.l0
325
+ ? pickSessionBackground(await this.stores.l0.recentBySession(sessionId, cfg.extract.backgroundMessages + slice.length), new Set(slice.map((m) => m.id)), cfg.extract.backgroundMessages)
326
+ : [];
327
+ const t = Date.now();
328
+ const result = await runExtraction(this.ctx, cfg, this.stores.l1, this.states, slice, background, this.logger, mode);
329
+ if (!result.skipped) {
330
+ this.pending[mode] = rest;
331
+ // 重建轮(force)不是有机对话,不推进爬坡
332
+ if (!opts?.force)
333
+ this.warmup[mode] = advanceWarmupThreshold(this.warmup[mode], cfg.extract.minMessages);
334
+ }
335
+ this.logger.info(`[memory] L1 阶段完成(session=${sessionId},mode=${mode},切片 ${slice.length} 条,背景 ${background.length} 条,阈值 ${effectiveThreshold},${Date.now() - t}ms)`);
336
+ // 缓冲与爬坡每次尝试后立即落盘:进程中途退出不丢待重试/攒阈值状态
337
+ await this.persistPending(opts?.noBufferCap);
338
+ // L1 计数推进后立即落盘:L2/L3 失败或进程中途退出不得回滚阈值进度
339
+ // (记录已入库但计数丢失会让该族 L2 永远差一截,state 与 DB 脱节)
340
+ try {
341
+ await this.stores.state.save();
342
+ }
343
+ catch (err) {
344
+ this.logger.warn(`[memory] 状态保存失败: ${errDetail(err)}`);
345
+ }
346
+ return result.newRecords;
347
+ }
348
+ catch (err) {
349
+ // 保留切片下次重试(桶入口已裁到 ≤200,防无限堆积;重建轮不裁,量被会话规模约束)
350
+ this.logger.warn(`[memory] L1 抽取失败(session=${sessionId},mode=${mode},切片 ${slice.length} 条): ${errDetail(err)}`);
351
+ await this.persistPending(opts?.noBufferCap).catch(() => { });
352
+ return [];
353
+ }
354
+ }
242
355
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * 蒸馏触发决策纯函数(ADR-0003 / 规格 B 节;S2 测试缝——决策与执行分离,
3
+ * 不依赖 dsh 宿主即可测调度语义)。
4
+ *
5
+ * 语义(CONTEXT.md「渐进阈值」「会话切片」):
6
+ * - 生效阈值从 1 起步(新用户首轮即出记忆),每次成功抽取后翻倍至稳态值毕业;
7
+ * - 阈值按会话切片计数,只抽取达到阈值的那个会话的切片;
8
+ * - warmup 值 0 = 已毕业(使用稳态值),按档位桶各记一次(全局爬坡,非按会话)。
9
+ */
10
+ /** 生效阈值:爬坡中取 min(当前爬坡值, 稳态),毕业(0)取稳态。 */
11
+ export declare function effectiveExtractThreshold(warmup: number, steady: number): number;
12
+ /** 成功抽取后推进爬坡:翻倍,达到稳态即毕业(0);已毕业保持 0。 */
13
+ export declare function advanceWarmupThreshold(current: number, steady: number): number;
14
+ export type ModeSwitchAction = 'flush' | 'park' | 'unpark' | 'none';
15
+ /**
16
+ * 档位切换动作表(ADR-0003):
17
+ * - 非 off 档间切换 → flush(该会话切片立即按捕获档位蒸馏,新档位从空切片起步)
18
+ * - 切到 off → park(切片挂起:用户刚说"停止记忆",把存量再蒸馏违背意图)
19
+ * - 从 off 切回 → unpark(挂起片按捕获档位落袋)
20
+ */
21
+ export declare function modeSwitchAction(oldMode: string, newMode: string): ModeSwitchAction;
22
+ /** 从"该会话最近消息"里剔除切片自身成员后取尾部 n 条作为抽取背景。
23
+ * 剔除防止抽取目标混入背景(L0 在 turn/end 已先落盘,现查必含切片)。 */
24
+ export declare function pickSessionBackground<T extends {
25
+ id: string;
26
+ }>(recent: readonly T[], sliceIds: ReadonlySet<string>, n: number): T[];
27
+ export interface IdleSliceInfo {
28
+ sessionId: string;
29
+ /** 该会话跨桶合计的切片条数。 */
30
+ count: number;
31
+ /** 切片内最晚消息时间(无活动记录时的兜底锚点,如重启后的残留切片)。 */
32
+ lastMessageAt: number;
33
+ }
34
+ /**
35
+ * 闲置扫描:静默达标且有切片的会话。off 档会话跳过(挂起语义);
36
+ * 活动时间优先取运行时记录,缺省回退切片内最晚消息时间。
37
+ */
38
+ export declare function idleSessionsToFlush(slices: IdleSliceInfo[], lastActivity: ReadonlyMap<string, number>, now: number, idleMs: number, isOffSession: (sessionId: string) => boolean): string[];
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 蒸馏触发决策纯函数(ADR-0003 / 规格 B 节;S2 测试缝——决策与执行分离,
3
+ * 不依赖 dsh 宿主即可测调度语义)。
4
+ *
5
+ * 语义(CONTEXT.md「渐进阈值」「会话切片」):
6
+ * - 生效阈值从 1 起步(新用户首轮即出记忆),每次成功抽取后翻倍至稳态值毕业;
7
+ * - 阈值按会话切片计数,只抽取达到阈值的那个会话的切片;
8
+ * - warmup 值 0 = 已毕业(使用稳态值),按档位桶各记一次(全局爬坡,非按会话)。
9
+ */
10
+ /** 生效阈值:爬坡中取 min(当前爬坡值, 稳态),毕业(0)取稳态。 */
11
+ export function effectiveExtractThreshold(warmup, steady) {
12
+ if (!Number.isFinite(warmup) || warmup <= 0)
13
+ return steady;
14
+ return Math.min(warmup, steady);
15
+ }
16
+ /** 成功抽取后推进爬坡:翻倍,达到稳态即毕业(0);已毕业保持 0。 */
17
+ export function advanceWarmupThreshold(current, steady) {
18
+ if (!Number.isFinite(current) || current <= 0)
19
+ return 0;
20
+ const next = current * 2;
21
+ return next >= steady ? 0 : next;
22
+ }
23
+ /**
24
+ * 档位切换动作表(ADR-0003):
25
+ * - 非 off 档间切换 → flush(该会话切片立即按捕获档位蒸馏,新档位从空切片起步)
26
+ * - 切到 off → park(切片挂起:用户刚说"停止记忆",把存量再蒸馏违背意图)
27
+ * - 从 off 切回 → unpark(挂起片按捕获档位落袋)
28
+ */
29
+ export function modeSwitchAction(oldMode, newMode) {
30
+ if (oldMode === newMode)
31
+ return 'none';
32
+ if (newMode === 'off')
33
+ return 'park';
34
+ if (oldMode === 'off')
35
+ return 'unpark';
36
+ return 'flush';
37
+ }
38
+ // ── 背景参考按会话现查(ADR-0003:抽取背景从 L0 按会话取,剔除切片自身) ──
39
+ /** 从"该会话最近消息"里剔除切片自身成员后取尾部 n 条作为抽取背景。
40
+ * 剔除防止抽取目标混入背景(L0 在 turn/end 已先落盘,现查必含切片)。 */
41
+ export function pickSessionBackground(recent, sliceIds, n) {
42
+ if (n <= 0)
43
+ return [];
44
+ return recent.filter((m) => !sliceIds.has(m.id)).slice(-n);
45
+ }
46
+ /**
47
+ * 闲置扫描:静默达标且有切片的会话。off 档会话跳过(挂起语义);
48
+ * 活动时间优先取运行时记录,缺省回退切片内最晚消息时间。
49
+ */
50
+ export function idleSessionsToFlush(slices, lastActivity, now, idleMs, isOffSession) {
51
+ if (!(idleMs > 0))
52
+ return [];
53
+ const out = [];
54
+ for (const s of slices) {
55
+ if (s.count <= 0)
56
+ continue;
57
+ if (isOffSession(s.sessionId))
58
+ continue;
59
+ const activity = lastActivity.get(s.sessionId) ?? s.lastMessageAt;
60
+ if (now - activity >= idleMs)
61
+ out.push(s.sessionId);
62
+ }
63
+ return out;
64
+ }