@nightpainters/dsh-ling 1.5.1 → 1.5.3

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.
@@ -8,6 +8,12 @@ import { selectL1, formatL1Section } from './l1.js';
8
8
  import { currentMode } from './mode.js';
9
9
  import { neutralizeMustache } from './util.js';
10
10
 
11
+ // §4「超量折叠成一行」的那行计数(由 `memory.js` 的 branchLogDigest 在明细之后 push 进 `lines`)
12
+ // —— 它是**汇总**,不是明细。零明细时它撑不起整段(见下方渲染判据);有明细时照旧显示。
13
+ // 判据只用开头:明细行一律以主语(主人/器灵)或动作词开头,不会长成 `…另有 N 笔改动`。
14
+ // 若哪天 memory.js 改了措辞,这里退化成"照旧渲染"(fail-open)—— 只影响占不占地方,不吞明细。
15
+ const BRANCH_LOG_FOLD_LINE = /^…另有 \d+ 笔改动/;
16
+
11
17
  export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords = [], now = Date.now(), bumpHits = false } = {}) {
12
18
  const s = settings.get();
13
19
  const personaEnabled = s.persona?.enabled !== false;
@@ -44,23 +50,52 @@ export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords =
44
50
  // "树变了" —— 这是复盘时"互相纠正"能成立的前提(不需要推送:复盘对话密集,
45
51
  // 每说一句就注入一次 ⇒ 感知延迟只有一轮,体感即实时)。
46
52
  // 只报**没见过的**改动(水位 = branch_log 自增 id),否则每轮重复注入同一批。
53
+ // ── B3(1.5.2):水位从"一条按会话"变成**三条并存**,判定都在 `branchLogDigest` 里 ──
54
+ // · `branch_log_wm.<sessionId>` 本会话已看到哪(照旧,B#9);
55
+ // · `branch_log_wm.announced` **全局**"已示人"边界 ⇒ 新会话不再重播已播过的(B3-1);
56
+ // · `branch_log_wm.retired` **全局**超窗"计数式处置"边界(§2 老改动只计数不展开)。
57
+ // 取数改成 FIFO(水位之上最早的一批),水位才可能停在未示人的内容之前(B3-2)。
47
58
  // B#9(2026-09-22 审计):**渲染不消费水位** —— 这里只读、不写。
48
59
  // buildSnapshotText 也会被"只重建、未开口"的路径调用(invalidateSession / refreshIdleSnapshot),
49
60
  // 在那里推进水位会把主人刚改的东西标记成已读,等到真正注入时反而看不到。
50
- // 水位按**会话**存(`branch_log_wm.<sessionId>`),推进由 inject.js 在真正要开口时做。
61
+ // 推进由 inject.js 在真正要开口时做(它同时管 `.last` 与 §3 的开关)。
51
62
  let logMaxId = 0;
63
+ let logShownMaxId = 0;
64
+ let logRetiredMaxId = 0;
52
65
  try {
53
66
  if (typeof memory.branchLogDigest === 'function') {
54
67
  const wmk = 'branch_log_wm.' + String(sessionId || '');
55
68
  const prev = Number(memory.kvGet(wmk) || 0);
56
- const d = memory.branchLogDigest({ limit: 5, afterId: prev });
57
- if (d && Array.isArray(d.lines) && d.lines.length) {
69
+ // `now` 必须透传(B3):时间窗(§2)与水位推进要用**同一个时钟**,否则测试不可复现,
70
+ // 生产上也会出现"按渲染时刻算过期、按投递时刻推进"的错位。
71
+ const d = memory.branchLogDigest({ limit: 5, afterId: prev, now });
72
+ // 全表最大 id:与开关、与是否成文**都无关**。它是 `branch_log_wm.last`(复盘退出判据)
73
+ // 的输入 —— 关掉播报也不能让"欠交代"这笔账停摆(见 inject.advanceLogWatermark 注解)。
74
+ logMaxId = Number(d?.maxId) || 0;
75
+ // 真正示人的边界:只有真渲染了明细行时才非零。旧实现/打桩没有该字段 ⇒ 退回 maxId
76
+ // (兼容 mustache 套里 `{lines, maxId:9}` 那种形状)。
77
+ const shown = Number(d?.shownMaxId);
78
+ logShownMaxId = Number.isFinite(shown) ? Math.max(0, shown) : logMaxId;
79
+ logRetiredMaxId = Number(d?.retiredMaxId) || 0;
80
+ // ── 明细 0 条 ⇒ **整段不渲染**(主人拍板,2026-09-27)──────────────────────────
81
+ // 判据是"有没有**明细**行",不是"`lines` 非空":那行「…另有 N 笔改动」**也装在 `lines` 里**
82
+ // (memory.js:1190 附近在明细之后 push),所以只判 `lines.length` 时"零明细"照样成文 ——
83
+ // 注入面就只剩一行不放内容的计数(主人截图:395 笔改动、一条明细也没有)。
84
+ // 只改**渲染**:三个水位字段照旧原样返回(下方 return),`.last` / `announced` / `retired`
85
+ // 的推进与改前逐值相同 —— 不渲染 ≠ 把那批改动标成已读(它们仍在有效下界 `wm` 之上,
86
+ // 下一轮照样进候选;与 §3 开关"只管渲染、不管交代记账"同一取向)。
87
+ const logLines = Array.isArray(d?.lines) ? d.lines : [];
88
+ const logDetailLines = logLines.filter((x) => !BRANCH_LOG_FOLD_LINE.test(String(x)));
89
+ // §3 独立开关:只管**树改动播报**(总闸仍是 cordis 层的 cfg.injectEnabled)。
90
+ // 关掉时:不成文、也不推进"已示人/已处置"水位(否则关着关着就把一段内容标成已读 ⇒
91
+ // 再打开时那段内容永远看不到)。`.last` 照走 —— 它是复盘退出判据,不是"已示人"判据:
92
+ // **开关只管"渲染",不管"交代记账"**(停推进会卡死复盘模式的退出,回归见 tree 套 9-5)。
93
+ if (s.memory?.branchLogEnabled !== false && logDetailLines.length) {
58
94
  // A-03 / D-S03(1.5.1 红蓝对抗):段首此前**写死**「主人刚改动了记忆树」,而 schema v12 起
59
95
  // 每一行都已带主语(`branchLogDigest` 按 actor 分流成「主人」/「器灵」)⇒ 器灵经工具
60
96
  // 改的树,在它**下一轮的系统提示**里被读成"主人刚改动",与 tools.js 的设计意图正相反。
61
97
  // 段首改中性措辞,**主语交给每一行** —— 谁改的由行内说,不由段头替它说。
62
- parts.push(['【记忆树有改动】', ...d.lines.map((x) => '- ' + x)].join('\n'));
63
- logMaxId = Number(d.maxId) || 0;
98
+ parts.push(['【记忆树有改动】', ...logLines.map((x) => '- ' + x)].join('\n'));
64
99
  }
65
100
  }
66
101
  } catch { /* 感知失败不影响注入 */ }
@@ -71,8 +106,14 @@ export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords =
71
106
  return {
72
107
  text,
73
108
  mode,
74
- // B#9:本段文本渲染到了哪条 branch_log(inject.js 在真正注入时据此推进水位)
109
+ // B#9:本段文本渲染到了哪条 branch_log(inject.js 在真正注入时据此推进水位)。
110
+ // B3(1.5.2)拆成三个,别混用:
111
+ // logMaxId 全表最大 id ⇒ `.last`(复盘退出判据)
112
+ // logShownMaxId 真正示人的边界 ⇒ 会话水位 + `announced`
113
+ // logRetiredMaxId 超窗只计数的边界 ⇒ `retired`
75
114
  logMaxId,
115
+ logShownMaxId,
116
+ logRetiredMaxId,
76
117
  builtAt: new Date(now).toISOString(),
77
118
  personaVersion: snapshotVersion(s),
78
119
  memoryVersion: memory.kvGet('memory_version') || '0',
@@ -81,7 +122,7 @@ export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords =
81
122
 
82
123
  export function snapshotVersion(s) {
83
124
  const p = s?.persona || {};
84
- return [p.userTitle, p.aiName, p.aiTitle, p.tone, p.toneWork, p.toneLife, p.language, p.extraLore, p.enabled, JSON.stringify(p.hardRules), JSON.stringify(p.bottomLines), p.sealed, s?.mode?.lastMode]
125
+ return [p.userTitle, p.aiName, p.aiTitle, p.tone, p.toneWork, p.toneLife, p.language, p.extraLore, p.duty, p.enabled, JSON.stringify(p.hardRules), JSON.stringify(p.bottomLines), p.sealed, s?.mode?.lastMode]
85
126
  .join('|');
86
127
  }
87
128
 
@@ -1,23 +1,179 @@
1
1
  // dsh-ling host — DSH 会话概述器 v1(轻量)。
2
- // 把 dsh_turns_raw 中每个顶层会话聚合成 source='dsh' 的 conv_overview:
3
- // title = 首条真人消息单行截断;summary = 首问摘要 + 轮次;类别/关键词启发式。
2
+ // 把 dsh_turns_raw 中**属于 DSH 命名空间**的每个顶层会话聚合成 source='dsh' 的 conv_overview
3
+ // (范围判据是正向白名单,唯一一处定义见下方 E2 段;未知来源一律排除并留 kv 告警):
4
+ // title = 首条真人消息经启发式清洗后的要点句(剥问候/称呼前缀 → 无实义则往后取前 6 条真人发言里第一条有实义的 → 句读处收尾、不切半句、上限 58 字;全失败才硬截首句);summary = 首问(≤140 字)+ 轮次;类别/关键词启发式。
4
5
  // 增量:每会话水位 kv('sumdsh:<sid>') = 已概述的最大 seq;有新增才重建。
5
6
  // 无 LLM 深摘要(v1);原文保留在 dsh_turns_raw,供 M2 概述升级。
6
7
  import { categorizeTitle } from './classify.js';
7
8
  import { heuristicTitle, TITLE_MAX_CHARS } from './retitle.js';
8
9
  import { keywordsFrom } from './inject-common.js';
9
10
  import { toIso } from './util.js';
11
+ import { RAW_IMPORT_PREFIX, RAW_NS_SEP, RAW_SOURCE_DSH, normalizeRawSource } from './memory.js';
10
12
 
11
13
  const MIN_MESSAGES = 2;
12
14
  const MIN_CHARS = 40;
13
15
  const TITLE_MAX = TITLE_MAX_CHARS; // D2(2026-09-17):46 → 58。旧值把一句话切成半句;此值仅作启发式失败时的兜底
14
16
  const SUMMARY_FIRST_MAX = 140;
15
17
 
18
+ // ── E2(2026-10-01)「静默失效」修复:原文范围判据 = 正向白名单(全包唯一一处定义)────────
19
+ // 旧判据是黑名单式:把「已知的那一个外来命名空间(`import:` 前缀)」排除掉,其余**全部**当真
20
+ // DSH 会话。它的致命处不在漏了谁,而在**默认信任**:任何没见过的来源(别的导入器、别的域的
21
+ // 同步、将来新增的命名空间)都会被概述器当成主人的会话重建、被习惯生成当成主人的纠正信号。
22
+ // 审计原话:「最该被质疑的内容带着最真诚的标记」。
23
+ // 这里反过来 —— 只有**明确落在 DSH 宿主命名空间**里的 id 才入选,其余一律排除;而且未知前缀
24
+ // 不是"悄悄丢掉",而是写一条 kv 告警(见 noteUnknownRawNamespaces),让人能发现
25
+ // "有东西进来了,而我不知道它是什么"。
26
+ //
27
+ // 事实依据(写入侧,2026-10-01 核对):写入者只有三处 ——
28
+ // · 裸 id :DSH 宿主会话轮次(lifecycle.js 的 appendRawTurn,source 显式记 'dsh')= 唯一的"自己人"命名空间;
29
+ // · `import:<id>` :本机导入域原文(memory.js RAW_IMPORT_PREFIX,source 'import')= 别人的对话,不属于主人;
30
+ // · import-file.js 的 dsweb 折叠分支(2026-09-30 修 A):原文写进**裸 id**(与 dsweb 抓取同源)。
31
+ // 故:DSH 自己的来源 = { RAW_SOURCE_DSH('dsh') };已知外来 = KNOWN_FOREIGN_RAW_NAMESPACES / …_SOURCES。
32
+ //
33
+ // ── v14(2026-10-01,主人拍板「来源也加个列吧」):判据从"只看 id 长相"升级为 source 优先 ─────
34
+ // 加列前:命名空间信息**只存在于 id 文本里**(表结构所限),判据只能写成"id 不带前缀",于是将来
35
+ // 出现一个**不带冒号**的新来源名时,物理上分不出来(会被当主人的会话认领)。
36
+ // 加列后:`dsh_turns_raw.source` 记下**真实来源**,判据改成 ——
37
+ // · source 非空 ⇒ 只认 `source='dsh'`(记录优先;不带冒号的新来源名照样能被认出并排除 + 告警);
38
+ // · source 为空 ⇒ **回落**到旧判据"裸 id = DSH 会话"(半迁移:老行还没回填,两种行为不许并存)。
39
+ // ⚠️ 口径不变的保证(负对照):真 DSH 会话(lifecycle 写的 'dsh' + 老行的裸 id 回落)在迁移前后
40
+ // 的处理结果**逐字一致** —— 回归见 tests/summarize.test.mjs 的 E2 golden 与 E3 段。
41
+ export const DSH_RAW_NAMESPACE = ''; // 空串 = 裸 id(DSH 宿主会话)
42
+ export { RAW_NS_SEP }; // 分隔符的唯一定义处是 memory.js;这里再导出以保持既有导入面
43
+ /** 已知的**外来**(非 DSH)原文命名空间。只用于区分"已知外来"与"未知来源",不用于放行任何东西。 */
44
+ export const KNOWN_FOREIGN_RAW_NAMESPACES = Object.freeze([RAW_IMPORT_PREFIX]);
45
+ /** 同一批来源的**名字**(去尾冒号:`import:` ⇒ `import`)。v14 之后 source 列记的就是这个形态。 */
46
+ export const KNOWN_FOREIGN_RAW_SOURCES = Object.freeze(
47
+ KNOWN_FOREIGN_RAW_NAMESPACES.map((ns) => String(ns).replace(/:+$/, '')),
48
+ );
49
+
50
+ /** 正向判据的 **id 文本形态** —— 也是**半迁移的回落分支**:该 session_id 落在 DSH 命名空间吗?
51
+ * ⚠️ v14 起它**不再单独决定**某一行是否入选(整行判据是 isDshRawRow / dshSessionSql())。
52
+ * 语义一个字未改(既有断言与本模块 4 条查询的回落口径都钉在它上面)。 */
53
+ export function isDshSessionId(sessionId) {
54
+ const id = String(sessionId ?? '');
55
+ return id !== '' && !id.includes(RAW_NS_SEP);
56
+ }
57
+
58
+ /** 整行判据(JS 形态,id + source):**source 优先,空 source 回落旧判据**。
59
+ * 与 SQL 形态(dshSessionSql() 零参)必须同解 —— 两处任一改动都要在 tests/summarize.test.mjs
60
+ * 的"两形态同解"断言上过一遍。 */
61
+ export function isDshRawRow(sessionId, source) {
62
+ const src = normalizeRawSource(source);
63
+ return src ? src === RAW_SOURCE_DSH : isDshSessionId(sessionId);
64
+ }
65
+
66
+ /** 同一判据的 SQL 形态(4 条原文查询共用这一份;列名可换,判据不许再分叉)。
67
+ * · **零参**(4 条查询用的形态)= 整行判据:`source` 优先,空 source 回落旧判据;
68
+ * · **带参**(既有调用点 `dshSessionSql('?')`)= id 纯函数形态 `instr(?, ':') = 0`,**逐字未变** ——
69
+ * 那个调用点跑在不 FROM 任何表的 SELECT 里(tests/summarize.test.mjs 的两形态同解探针),
70
+ * 那里**没有 source 列可读**,若把 source 判据也塞进去只会换来 "no such column: source"。
71
+ * 故按**实参个数**分派:带参形态与整行判据的**回落分支**是同一个表达式,不是第二套口径。
72
+ * `instr(id, sep) = 0` 即"该 id 没有命名空间前缀 ⇒ 落在 DSH_RAW_NAMESPACE"。 */
73
+ export function dshSessionSql(col = null) {
74
+ const idOnly = `instr(${col ?? 'session_id'}, '${RAW_NS_SEP}') = 0`; // RAW_NS_SEP 是模块常量(非外部输入),无引号注入面
75
+ if (col !== null) return idOnly;
76
+ return `(CASE WHEN NULLIF(TRIM(source), '') IS NOT NULL THEN TRIM(source) = '${RAW_SOURCE_DSH}' ELSE ${idOnly} END)`;
77
+ }
78
+
79
+ /** 取 id 的命名空间前缀;裸 id 返回 DSH_RAW_NAMESPACE。 */
80
+ export function rawNamespaceOf(sessionId) {
81
+ const id = String(sessionId ?? '');
82
+ const i = id.indexOf(RAW_NS_SEP);
83
+ return i < 0 ? DSH_RAW_NAMESPACE : id.slice(0, i + RAW_NS_SEP.length);
84
+ }
85
+
86
+ /** 未知来源告警的 kv 键(沿用 lifecycle.js 的 `dbg.*` 诊断约定,同一族键同一读法)。 */
87
+ export const RAW_UNKNOWN_NS_KEY = 'dbg.raw_unknown_namespace';
88
+
89
+ /**
90
+ * 「有东西进来了,而我不知道它是什么」—— 扫一遍原文表里出现过的 (session_id, source),凡**不属于**
91
+ * DSH 来源、又**不在**已知外来来源里的,写一条 kv 告警。
92
+ *
93
+ * 为什么必须有这一步:黑名单→白名单的真正代价是**未知来源从"被误信"变成"被静默丢弃"**。
94
+ * 少了告警,新来源进来只会表现为"记忆莫名其妙变少",没人查得出来 —— 那只是把一种静默失效
95
+ * 换成另一种。这条 kv 就是那个看得见的响动:面板/诊断里能读到"见过来源 X(样本 id、
96
+ * 条数、首次/最近时间)"。同一来源**首次**出现时额外打一行 console.warn,便于当场发现。
97
+ *
98
+ * ── v14(2026-10-01)口径变化:告警报的是**来源名**,不再是"从前缀猜出来的字符串" ──────────
99
+ * · 带冒号的 id:键沿用**前缀串**(`weird:`),键形不因本次升级而变 ⇒ 老告警记录能直接续上;
100
+ * · 不带冒号的 id:旧判据**只能**把它当 DSH 会话(物理上分不出来);现在只要它带着别的来源名
101
+ * 落库,键就是**那个来源名**(`weird-x`)—— 这类来源正是加这一列要照出来的东西;
102
+ * · 每条多记两个字段:`source`(该行记录的来源名;null = 这行还没回填)与
103
+ * `by`('source' = 靠记录认出来的 / 'id-prefix' = 靠 id 前缀认出来的),读的人能分清
104
+ * "这是它自己说的"还是"这是我猜的"。
105
+ * 只在确有未知来源时才写(库干净时一个字节都不写 ⇒ 正常路径零副作用);读库失败一律静默
106
+ * (告警不能拖累概述与习惯生成)。
107
+ * @returns {string[]} 本次发现的未知来源(按条数降序)
108
+ */
109
+ export function noteUnknownRawNamespaces(memory, where = '') {
110
+ let rows = [];
111
+ try {
112
+ rows = memory?.db?.prepare?.(
113
+ 'SELECT session_id, source, COUNT(*) AS n FROM dsh_turns_raw GROUP BY session_id, source',
114
+ )?.all?.() || [];
115
+ } catch { return []; }
116
+ const found = new Map();
117
+ for (const r of rows) {
118
+ const sid = String(r?.session_id ?? '');
119
+ const src = normalizeRawSource(r?.source); // '' = 该行还没回填(半迁移)
120
+ if (isDshRawRow(sid, src)) continue; // 整行判据,与 4 条查询同解
121
+ const ns = rawNamespaceOf(sid); // id 前缀('' = 裸 id)
122
+ const key = ns || src; // 键:优先 id 前缀(键形稳定);裸 id 只能报记录下来的来源名
123
+ if (KNOWN_FOREIGN_RAW_NAMESPACES.includes(key) || KNOWN_FOREIGN_RAW_SOURCES.includes(src)) continue;
124
+ const cur = found.get(key) || { ns: key, source: src || null, by: src ? 'source' : 'id-prefix', turns: 0, samples: [] };
125
+ cur.turns += Number(r?.n) || 0;
126
+ if (cur.samples.length < 3) cur.samples.push(sid.slice(0, 64));
127
+ found.set(key, cur);
128
+ }
129
+ const list = [...found.values()].sort((a, b) => (b.turns - a.turns) || a.ns.localeCompare(b.ns));
130
+ if (!list.length) return [];
131
+ try {
132
+ let prev = null;
133
+ try { const raw = memory?.kvGet?.(RAW_UNKNOWN_NS_KEY); prev = raw ? JSON.parse(String(raw)) : null; } catch { prev = null; }
134
+ const prevNs = (prev && typeof prev === 'object' && prev.namespaces && typeof prev.namespaces === 'object') ? prev.namespaces : {};
135
+ const namespaces = { ...prevNs };
136
+ const now = new Date().toISOString();
137
+ const fresh = [];
138
+ for (const u of list) {
139
+ const old = (namespaces[u.ns] && typeof namespaces[u.ns] === 'object') ? namespaces[u.ns] : null;
140
+ if (!old) fresh.push(u.ns);
141
+ namespaces[u.ns] = {
142
+ turns: Number(old?.turns || 0) + u.turns,
143
+ samples: [...new Set([...(Array.isArray(old?.samples) ? old.samples : []), ...u.samples])].slice(0, 3),
144
+ // v14:来源名 + 认定方式(旧记录没有这两个字段 ⇒ 保留旧值 / 置 null,不伪造)
145
+ source: u.source ?? (old && 'source' in old ? old.source : null),
146
+ by: u.by || old?.by || null,
147
+ firstSeenAt: old?.firstSeenAt || now,
148
+ lastSeenAt: now,
149
+ };
150
+ }
151
+ // 只保留最近 20 个命名空间(整段 JSON 不截断,否则读者解析不出来)
152
+ const kept = Object.fromEntries(Object.entries(namespaces).slice(-20));
153
+ memory?.kvSet?.(RAW_UNKNOWN_NS_KEY, JSON.stringify({
154
+ at: now,
155
+ where: String(where || '').slice(0, 60),
156
+ note: 'dsh_turns_raw 里出现了既不属于 DSH 来源、也不在已知外来来源列表里的原文;'
157
+ + '已一律排除(判据见 summarizer.js isDshRawRow/dshSessionSql:source 优先,空 source 回落"裸 id");'
158
+ + '键 = id 的命名空间前缀,裸 id 时 = 该行记录下来的来源名(source 为空的行不会出现在这里)。留痕供人判断它是什么。',
159
+ namespaces: kept,
160
+ }));
161
+ if (fresh.length) {
162
+ console.warn('[dsh-ling] 原文表出现未知来源,已排除:', fresh.join(', '),
163
+ JSON.stringify(list.filter((u) => fresh.includes(u.ns))
164
+ .map((u) => ({ ns: u.ns, source: u.source, by: u.by, turns: u.turns, sample: u.samples[0] }))));
165
+ }
166
+ } catch { /* 告警写不进去不影响主流程 */ }
167
+ return list.map((u) => u.ns);
168
+ }
169
+
16
170
  export function summarizeDsh(memory, { force = false } = {}) {
171
+ // E2:先看一眼有没有"不认识的东西"进来了(未知来源命名空间 → kv 告警)。库干净时零写入。
172
+ noteUnknownRawNamespaces(memory, 'summarizer.summarizeDsh');
17
173
  const groups = memory.db.prepare(
18
174
  `SELECT session_id, COUNT(*) AS n, COALESCE(SUM(LENGTH(text)),0) AS chars,
19
175
  MIN(ts) AS t0, MAX(ts) AS t1, MAX(seq) AS maxSeq
20
- FROM dsh_turns_raw WHERE session_id NOT LIKE 'import:%' GROUP BY session_id`,
176
+ FROM dsh_turns_raw WHERE ${dshSessionSql()} GROUP BY session_id`,
21
177
  ).all();
22
178
  const stats = { sessions: groups.length, created: 0, updated: 0, skipped: 0, watermarkSet: 0, archivedSkipped: 0 };
23
179
  // 归档即清理(2026-09-16 用户定:清掉归档会话,未归档的测试会话不动):