@aalis/plugin-session-manager 0.14.0 → 0.16.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.
package/dist/index.js CHANGED
@@ -1,123 +1,79 @@
1
+ import { basename } from 'node:path';
1
2
  import { agent } from '@aalis/api-agent';
3
+ import { authority } from '@aalis/api-authority';
4
+ import { chatScopeOf, windowKey } from '@aalis/api-gateway';
2
5
  import { hooks } from '@aalis/api-hooks';
3
- import { listLLMModels, llm, resolveLLMModel } from '@aalis/api-llm';
4
- import { memory } from '@aalis/api-memory';
5
- import { persona } from '@aalis/api-persona';
6
- import { getPlatformNames, platform } from '@aalis/api-platform';
7
- import { sessionManager, } from '@aalis/api-session-manager';
8
- import { tools } from '@aalis/api-tools';
9
- import { webuiServer } from '@aalis/api-webui';
6
+ import { ownHostConfig } from '@aalis/api-host-config';
7
+ import { i18n, outputLanguageFor } from '@aalis/api-i18n';
8
+ import { lightestThinking, llm, resolveLLMModel } from '@aalis/api-llm';
9
+ import { historyCleared, memory } from '@aalis/api-memory';
10
+ import { claimantsOf, getPlatformAdapters, platform, resolvePlatformBySession, sessionTypesOf, } from '@aalis/api-platform';
11
+ import { isUnfiled, oneToOneWith, rootSessionOf, sessionKind, sessionLabel, sessionManager, TAKE_UNKNOWN, } from '@aalis/api-session-manager';
12
+ import { sessionFields } from '@aalis/api-session-settings';
10
13
  import { config, definePlugin, events, lifecycle, logger, optional, provide } from '@aalis/core';
11
- import { defineConfig, parseConfig } from '@aalis/schema-config';
14
+ import { defineConfig } from '@aalis/schema-config';
15
+ import { asText, toSource, truncateParams, withText } from '@aalis/schema-i18n';
16
+ import { truncateChars } from '@aalis/util-text-normalize';
17
+ import { t } from './texts.js';
18
+ // ===== 配置 =====
19
+ // 取别的会话的东西(canTake)的范围与三个开关:都是按会话字段,按取的一方所在的会话生效。坏值原样交来,范围按本会话、开关按否
12
20
  const configSchema = defineConfig({
13
- defaults: {
14
- label: '全局默认配置',
15
- description: '所有平台共享的最低层默认配置(platform profile 之下的 fallback)。LLM 默认模型由各 agent 插件通过 ServicePreference 锁定,不再在此配置;本节仅保留 persona 等通用默认。',
16
- fields: {
17
- persona: {
18
- type: 'select',
19
- label: '默认人设',
20
- dynamicOptions: 'persona',
21
- allowCustom: true,
22
- description: '所有平台未单独指定时使用的默认人设',
23
- },
24
- },
21
+ takeScope: {
22
+ type: 'select',
23
+ label: t('takeScopeLabel'),
24
+ description: t('takeScopeDescription'),
25
+ default: 'platform',
26
+ onInvalid: 'error',
27
+ options: [
28
+ { label: t('takeScopeSession'), value: 'session' },
29
+ { label: t('takeScopePlatform'), value: 'platform' },
30
+ { label: t('takeScopeAll'), value: 'all' },
31
+ ],
32
+ session: { prominence: 'advanced' },
25
33
  },
26
- platformProfiles: {
27
- type: 'array',
28
- label: '平台默认配置',
29
- default: [],
30
- description: '为每个平台设置默认的会话配置模板。新会话创建时自动应用对应平台的模板。',
31
- items: {
32
- platform: {
33
- type: 'string',
34
- label: '平台标识',
35
- required: true,
36
- description: '平台名(如 onebot、webui、cli)',
37
- },
38
- persona: {
39
- type: 'select',
40
- label: '人设文件',
41
- dynamicOptions: 'persona',
42
- allowCustom: true,
43
- description: '该平台默认使用的人设文件名(不含后缀)',
44
- },
45
- llm: {
46
- type: 'llm-ref',
47
- label: '默认模型',
48
- description: '该平台新会话的默认 LLM (provider + model)。留空则沿用 ServicePreference 锁定的全局默认。',
49
- },
50
- enabledToolGroups: {
51
- type: 'multiselect',
52
- label: '工具分组',
53
- dynamicOptions: 'toolGroups',
54
- allowCustom: true,
55
- description: '该平台启用的工具分组',
56
- },
57
- disableOutputFormat: {
58
- type: 'boolean',
59
- label: '禁用结构化输出',
60
- description: '禁用 JSON 结构化输出,回复纯文本',
61
- },
62
- clientSideJsonRendering: {
63
- type: 'boolean',
64
- label: '客户端渲染 JSON',
65
- description: '保留完整 JSON 给前端渲染,不提取回复字段',
66
- },
67
- think: {
68
- type: 'select',
69
- label: 'thinking 默认',
70
- options: [
71
- { label: '继承 provider 全局配置', value: '' },
72
- { label: '强制开启', value: 'on' },
73
- { label: '强制关闭', value: 'off' },
74
- ],
75
- description: '该平台会话的深度思考默认档。留空=各 LLM provider 自行决定;会话可用 /session.set -t 进一步覆盖。',
76
- },
77
- },
34
+ takeOtherGroups: {
35
+ type: 'boolean',
36
+ label: t('takeOtherGroupsLabel'),
37
+ description: t('takeOtherGroupsDescription'),
38
+ default: true,
39
+ onInvalid: 'error',
40
+ session: { prominence: 'advanced' },
41
+ },
42
+ takeOthersPrivate: {
43
+ type: 'boolean',
44
+ label: t('takeOthersPrivateLabel'),
45
+ description: t('takeOthersPrivateDescription'),
46
+ default: false,
47
+ onInvalid: 'error',
48
+ session: { prominence: 'advanced' },
49
+ },
50
+ takeCrossBot: {
51
+ type: 'boolean',
52
+ label: t('takeCrossBotLabel'),
53
+ description: t('takeCrossBotDescription'),
54
+ default: false,
55
+ onInvalid: 'error',
56
+ session: { prominence: 'advanced' },
78
57
  },
79
58
  });
80
59
  // ===== 常量 =====
81
- const METADATA_NAMESPACE = 'sessions';
60
+ /** 会话表与场所记录在记忆后端元数据里的命名空间 */
61
+ const SESSIONS = 'sessions';
62
+ const PLACES = 'places';
63
+ /** 必填字段不对、没有载入的记录移到 `<命名空间>-rejected`:留在原处会被之后同键的建档整条覆盖 */
64
+ const REJECTED_SUFFIX = '-rejected';
65
+ /** 工作区的场所键前缀;默认工作区的 id 为 default */
66
+ const WORKSPACE_PREFIX = 'workspace:';
67
+ const DEFAULT_WORKSPACE = 'default';
68
+ const DEFAULT_PLACE = Object.freeze({
69
+ kind: 'workspace',
70
+ key: `${WORKSPACE_PREFIX}${DEFAULT_WORKSPACE}`,
71
+ });
82
72
  /** 读会话表失败后的重试间隔(毫秒);用尽仍失败按读表失败处理 */
83
73
  const LOAD_RETRY_DELAYS = [1000, 3000, 10_000];
84
- // ===== WebuiPages(声明式 UI) =====
85
- /**
86
- * WebUI 传来的会话配置补丁:JSON 带不了 undefined,前端用 null 表示「删除该键、恢复继承」。
87
- * updateSession 是合并语义,键置为 undefined 即从生效配置里消失(resolveConfig 会 strip)。
88
- */
89
- export function normalizeSessionConfigPatch(patch) {
90
- if (!patch || typeof patch !== 'object' || Array.isArray(patch))
91
- throw new Error('缺少配置');
92
- return Object.fromEntries(Object.entries(patch).map(([k, v]) => [k, v === null ? undefined : v]));
93
- }
94
- const webuiPages = [
95
- {
96
- key: 'sessions',
97
- label: '会话管理',
98
- icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M8 10h8M8 14h4"/></svg>',
99
- order: 6,
100
- renderer: 'sessions',
101
- },
102
- ];
74
+ /** 最近一轮出错原因最多留多少字 */
75
+ const ERROR_MAX = 200;
103
76
  // ===== 辅助函数 =====
104
- /**
105
- * 递归归档:先归档所有子会话再归档自己。
106
- *
107
- * 写成自由函数而非方法:页面动作是被单独取出调用的(没有 receiver),递归不能依赖 this。
108
- */
109
- async function archiveRecursively(sm, id) {
110
- const session = sm.getSession(id);
111
- if (session) {
112
- for (const childId of session.children) {
113
- const child = sm.getSession(childId);
114
- if (child && child.status !== 'archived') {
115
- await archiveRecursively(sm, childId);
116
- }
117
- }
118
- }
119
- await sm.updateSession(id, { status: 'archived' });
120
- }
121
77
  /** 等待 ms 毫秒,signal 中止时提前返回。不用 node:timers/promises:全局定时器测试里能用假时钟推进 */
122
78
  function wait(ms, signal) {
123
79
  return new Promise(resolve => {
@@ -130,27 +86,20 @@ function wait(ms, signal) {
130
86
  signal.addEventListener('abort', done, { once: true });
131
87
  });
132
88
  }
133
- function formatConfigSummary(config) {
134
- const parts = [];
135
- if (config.llm?.model)
136
- parts.push(`${config.llm.provider}/${config.llm.model}`);
137
- if (config.enabledToolGroups?.length)
138
- parts.push(config.enabledToolGroups.includes('*') ? 'tools:全部' : `tools:${config.enabledToolGroups.length}组`);
139
- if (config.persona)
140
- parts.push(`persona:${config.persona}`);
141
- return parts.join(', ') || '(默认)';
142
- }
143
89
  class SessionManager {
90
+ take;
144
91
  sessions = new Map();
92
+ /** 场所记录:键 → 记录。与会话表一同加载与落盘 */
93
+ places = new Map();
145
94
  caps;
146
95
  persistTimer = null;
147
96
  dirty = false;
148
97
  /**
149
- * 本进程显式删除、尚未落盘的会话(墓碑)。persist 写全量快照,但只删这些键,不按「后端有、内存无」清扫:
150
- * 后端里还可能有本进程没读到的会话,按差集清扫会把它们当孤儿删掉。每条墓碑带令牌,提交成功后只清掉
98
+ * 本进程显式删除、尚未落盘的记录(墓碑),按表分开。persist 写全量快照,但只删这些键,不按「后端有、内存无」清扫:
99
+ * 后端里还可能有本进程没读到的记录,按差集清扫会把它们当孤儿删掉。每条墓碑带令牌,提交成功后只清掉
151
100
  * 提交时那一版,提交期间再删的保留到下一次。换后端时墓碑随旧表一起作废。
152
101
  */
153
- deleted = new Map();
102
+ deleted = { [SESSIONS]: new Map(), [PLACES]: new Map() };
154
103
  /**
155
104
  * 会话表所在的 memory 实例:跟随 memory 胜者。换人时先把旧表的未落盘变更写回旧实例,再从新实例读表整体替换
156
105
  * (换后端即换库,不跨后端合并)。新表加载完成前仍指向旧实例,落盘不会把旧表写进新后端;加载窗口里的改动
@@ -159,11 +108,29 @@ class SessionManager {
159
108
  store;
160
109
  /** 最近一次挂接的加载;apply 等它完成再对外提供服务 */
161
110
  loading = Promise.resolve();
162
- /** 平台 → 默认 SessionConfig 模板 */
163
- platformProfiles = new Map();
164
- /** 全局默认配置(platform profile 之下的最低层 fallback) */
165
- defaults = {};
166
- constructor(caps) {
111
+ /** 已告警过的问题(坏数据、收录判不出的认领):同一处只告警一次 */
112
+ warned = new Set();
113
+ /** 正在收录或补记的会话:同一会话并发的原生入站共用这一次 */
114
+ recording = new Map();
115
+ /** 正在删除的会话(从摘掉、删子会话之前起,到清完数据),删除做完时兑现:这期间到的同一会话的入站等它(见 recordInbound) */
116
+ removing = new Map();
117
+ /**
118
+ * 各会话的代号(generationOf):开始删除的次数,在 remove 里加一,没删过的为 0。只记删过的 id,一直留着(之后同一 id 再建档
119
+ * 也不换回去,半路上删除之前的一代才认得出来),每个删过的 id 占一项;随这次激活,会话管理重新激活时清空
120
+ */
121
+ generations = new Map();
122
+ /**
123
+ * 在飞的回合:turnId → 所在会话与这一轮的结局(出错时带原因)。agent:turn:before 进出登记与注销,结局由
124
+ * agent:turn:after 记下(被拦下的一轮不发它,没有结局)。第一方 agent 同一会话一轮接一轮,契约不限别的实现在同一会话并行几轮,
125
+ * 所以按回合 id 记、会话在最后一轮结束时才收口;回合之外建档的会话按这里有没有回合定初始状态(见 ensureSession)
126
+ */
127
+ turns = new Map();
128
+ /** 正在解析所属实例的会话记录:同一条记录不并发解析,后到的调用不等它、直接返回 */
129
+ resolving = new WeakSet();
130
+ lookup = (id) => this.sessions.get(id);
131
+ /** take 读取的一方所在会话此刻的取别的会话的范围与三个开关(生效值,坏值原样) */
132
+ constructor(caps, take) {
133
+ this.take = take;
167
134
  this.caps = caps;
168
135
  }
169
136
  /**
@@ -171,18 +138,20 @@ class SessionManager {
171
138
  * 会话表另经 {@link store} 跟随,见 {@link attach}。
172
139
  */
173
140
  get memory() {
174
- const m = this.caps.memory.current;
175
- if (!m)
141
+ if (!this.caps.memory.present)
176
142
  throw new Error('session-manager 需要 memory 服务');
177
- return m;
143
+ return this.caps.memory.use;
178
144
  }
179
145
  /**
180
- * memory 胜者的 follow 挂接:从这个实例读会话表并整体替换内存里的这份。
146
+ * memory 胜者的 follow 挂接:从这个实例读会话表与场所记录,整体替换内存里的这份。
181
147
  * 返回的清理在换人或关闭时执行:中止读表重试的等待,等本次加载落定,再把未落盘的变更写回这个实例。
182
148
  */
183
149
  attach(instance) {
184
150
  const detached = new AbortController();
185
- this.loading = this.load(instance, AbortSignal.any([detached.signal, this.caps.lifecycle.signal]));
151
+ // 挂上的句柄只活到这次挂载的清理落定;之后的落盘(换人后的加载窗口、停机时的收尾)按条目 id 打到这一家
152
+ const id = this.caps.memory.list().find(p => p.selected)?.id;
153
+ const target = id === undefined ? instance : this.caps.memory.of(id);
154
+ this.loading = this.load(instance, target, AbortSignal.any([detached.signal, this.caps.lifecycle.use.signal]));
186
155
  return async () => {
187
156
  detached.abort();
188
157
  await this.loading;
@@ -190,52 +159,96 @@ class SessionManager {
190
159
  clearTimeout(this.persistTimer);
191
160
  this.persistTimer = null;
192
161
  }
193
- await this.persist().catch(err => this.caps.logger.warn('换后端前落盘会话失败:', err));
162
+ // 这一家可能已经撤回:清理期间经挂上的句柄落盘
163
+ await this.persist(instance).catch(err => this.caps.logger.use.warn('换后端前落盘会话失败:', err));
194
164
  };
195
165
  }
196
166
  /**
197
- * 从 memory 元数据加载持久化会话列表,加载完成后以它为会话表与落盘目标。读表失败按 {@link LOAD_RETRY_DELAYS}
198
- * 有限重试,其间仍用原来的会话表与落盘目标;用尽或被中止(换人、停机)时会话表为空且不落盘。
167
+ * 从 memory 元数据加载会话表与场所记录,加载完成后以它们为准、以这个实例为落盘目标。读表失败按 {@link LOAD_RETRY_DELAYS}
168
+ * 有限重试,其间仍用原来的表与落盘目标;用尽或被中止(换人、停机)时两张表为空且不落盘。默认工作区的记录不在时补上
169
+ * (随下一次落盘写进后端)。
199
170
  */
200
- async load(instance, signal) {
201
- let sessions;
171
+ async load(instance, target, signal) {
172
+ let loaded;
202
173
  for (let attempt = 0;; attempt++) {
203
174
  try {
204
- const entries = await instance.listMetadata(METADATA_NAMESPACE);
205
- sessions = new Map();
206
- for (const { key, data } of entries) {
207
- const info = data;
208
- if (info && info.id === key)
209
- sessions.set(key, info);
210
- }
175
+ loaded = {
176
+ sessions: await this.readTable(instance, SESSIONS, '会话记录', (key, data) => {
177
+ const read = storedSession(key, data);
178
+ return 'wrong' in read
179
+ ? read
180
+ : { record: { ...read.record, ...describeSession(read.record) }, dropped: read.dropped };
181
+ }),
182
+ places: await this.readTable(instance, PLACES, '场所记录', storedPlace),
183
+ };
211
184
  break;
212
185
  }
213
186
  catch (err) {
214
187
  const delay = LOAD_RETRY_DELAYS[attempt];
215
188
  if (delay !== undefined && !signal.aborted) {
216
- this.caps.logger.warn(`加载会话数据失败,${delay}ms 后重试:`, err);
189
+ this.caps.logger.use.warn(`加载会话数据失败,${delay}ms 后重试:`, err);
217
190
  await wait(delay, signal);
218
191
  if (!signal.aborted)
219
192
  continue;
220
193
  }
221
194
  if (signal.aborted)
222
- this.caps.logger.info('已中止加载会话数据(停用、停机或换后端)');
195
+ this.caps.logger.use.info('已中止加载会话数据(停用、停机或换后端)');
223
196
  else
224
- this.caps.logger.error('加载会话数据失败,在这个后端上的会话改动不落盘(以免覆盖原有记录):', err);
197
+ this.caps.logger.use.error('加载会话数据失败,在这个后端上的会话改动不落盘(以免覆盖原有记录):', err);
225
198
  break;
226
199
  }
227
200
  }
228
- // 加载窗口里旧表的改动:落盘目标此前仍是旧实例,换表前取快照,换表后写回
201
+ // 加载窗口里旧表的改动:落盘目标此前仍是旧的那一家,换表前取快照,换表后写回
229
202
  const previous = this.store;
230
203
  const pending = previous && this.dirty ? this.snapshot().ops : undefined;
231
- this.sessions = sessions ?? new Map();
232
- this.deleted.clear();
204
+ this.sessions = loaded?.sessions ?? new Map();
205
+ this.places = loaded?.places ?? new Map();
206
+ if (!this.places.has(DEFAULT_PLACE.key))
207
+ this.places.set(DEFAULT_PLACE.key, newPlace(DEFAULT_PLACE));
208
+ this.deleted = { [SESSIONS]: new Map(), [PLACES]: new Map() };
233
209
  this.dirty = false;
234
- this.store = sessions ? instance : undefined;
235
- if (sessions)
236
- this.caps.logger.info(`已加载 ${sessions.size} 个会话`);
237
- if (previous && pending)
238
- await previous.commitMetadata(pending).catch(err => this.caps.logger.warn('换后端时写回旧后端失败:', err));
210
+ this.store = loaded ? target : undefined;
211
+ if (loaded)
212
+ this.caps.logger.use.info(`已加载 ${loaded.sessions.size} 个会话、${loaded.places.size} 个场所`);
213
+ if (previous && pending) {
214
+ try {
215
+ await previous.commitMetadata(pending);
216
+ }
217
+ catch (err) {
218
+ this.caps.logger.use.warn('换后端时写回旧后端失败:', err);
219
+ }
220
+ }
221
+ }
222
+ /**
223
+ * 读一张表:必填字段不对的记录不载入,移到 `<命名空间>-rejected`(先写副本再删原处:后端不保证整批原子时,停在半途也
224
+ * 不丢记录);可选字段类型不对的去掉这些字段照常载入。两种都告警
225
+ */
226
+ async readTable(instance, namespace, label, parse) {
227
+ const rows = new Map();
228
+ const dropped = [];
229
+ const rejected = [];
230
+ for (const { key, data } of await instance.listMetadata(namespace)) {
231
+ const read = parse(key, data);
232
+ if ('wrong' in read) {
233
+ rejected.push({ key, data, field: read.wrong });
234
+ continue;
235
+ }
236
+ if (read.dropped.length > 0)
237
+ dropped.push(`${key}(${read.dropped.join('、')})`);
238
+ rows.set(key, read.record);
239
+ }
240
+ const aside = `${namespace}${REJECTED_SUFFIX}`;
241
+ if (rejected.length > 0) {
242
+ await instance.commitMetadata(rejected.flatMap(({ key, data }) => [
243
+ { op: 'put', namespace: aside, key, data },
244
+ { op: 'del', namespace, key },
245
+ ]));
246
+ this.caps.logger.use.warn(`${label}的必填字段不对,没有载入,原记录已移到 metadata 命名空间 ${aside}:` +
247
+ rejected.map(({ key, field }) => `${key}(${field})`).join('、'));
248
+ }
249
+ if (dropped.length > 0)
250
+ this.caps.logger.use.warn(`${label}有字段类型不对,去掉这些字段后照常载入:${dropped.join('、')}`);
251
+ return rows;
239
252
  }
240
253
  /** 标记需要持久化并延迟刷盘 */
241
254
  markDirty() {
@@ -243,7 +256,7 @@ class SessionManager {
243
256
  if (!this.persistTimer) {
244
257
  this.persistTimer = setTimeout(() => {
245
258
  this.persistTimer = null;
246
- this.persist().catch(err => this.caps.logger.warn('持久化会话失败:', err));
259
+ this.persist().catch(err => this.caps.logger.use.warn('持久化会话失败:', err));
247
260
  }, 1000);
248
261
  }
249
262
  }
@@ -258,107 +271,363 @@ class SessionManager {
258
271
  * 而是**幂等 + 可重试**:每次写的是全量快照(不是增量),删除只针对墓碑;失败时 dirty 复位、
259
272
  * 墓碑保留,下一次 markDirty 会把完整状态重写一遍,前一次的半成品被整体覆盖。
260
273
  */
261
- async persist() {
262
- const target = this.store;
263
- if (!this.dirty || !target)
274
+ async persist(target = this.store) {
275
+ if (!this.dirty || !this.store || !target)
264
276
  return;
265
277
  this.dirty = false;
266
278
  const { ops, tombstones } = this.snapshot();
267
279
  try {
268
280
  // 写入目标取落盘开始时的会话表所在实例:换人窗口里当前胜者可能已是新后端,旧表不能写过去
269
281
  await target.commitMetadata(ops);
270
- for (const [key, token] of tombstones)
271
- if (this.deleted.get(key) === token)
272
- this.deleted.delete(key);
282
+ for (const [table, key, token] of tombstones) {
283
+ if (this.deleted[table].get(key) === token)
284
+ this.deleted[table].delete(key);
285
+ }
273
286
  }
274
287
  catch (err) {
275
288
  this.dirty = true; // 失败要能重试,否则这批变更永远落不了盘
276
289
  throw err;
277
290
  }
278
291
  }
279
- /** 会话表的全量快照:每个会话一条 put,墓碑各一条 del */
292
+ /** 两张表的全量快照:每条记录一条 put,墓碑各一条 del */
280
293
  snapshot() {
281
- const ops = [...this.sessions].map(([id, info]) => ({
282
- op: 'put',
283
- namespace: METADATA_NAMESPACE,
284
- key: id,
285
- data: info,
286
- }));
287
- const tombstones = [...this.deleted];
288
- for (const [key] of tombstones) {
289
- if (!this.sessions.has(key))
290
- ops.push({ op: 'del', namespace: METADATA_NAMESPACE, key });
294
+ const ops = [];
295
+ const tombstones = [];
296
+ for (const [namespace, rows] of [
297
+ [SESSIONS, this.sessions],
298
+ [PLACES, this.places],
299
+ ]) {
300
+ for (const [key, data] of rows) {
301
+ ops.push({ op: 'put', namespace, key, data: data });
302
+ }
303
+ for (const [key, token] of this.deleted[namespace]) {
304
+ tombstones.push([namespace, key, token]);
305
+ if (!rows.has(key))
306
+ ops.push({ op: 'del', namespace, key });
307
+ }
291
308
  }
292
309
  return { ops, tombstones };
293
310
  }
294
311
  /**
295
- * 按精确 id 幂等 upsert:命中走 updateSession(合并 config + emit `session:updated`),
296
- * 未命中以传入 id 建 active 记录(emit `session:created`)。
297
- * 平台派生 sessionId(onebot 等)首次落配置覆盖时用——那些 id 不经 createSession 预建。
312
+ * 按精确 id 幂等 upsert:命中走 updateSession(emit `session:updated`),未命中以传入 id 建档。
313
+ * 创建来源在建档时写;命中按 id 兜底建档留下的空记录({@link isUnfiled})时补写,随这次更新落盘。
314
+ * 终端配置的会话 id、在收录不了的会话上写设置时用
315
+ */
316
+ async ensureSession(id, patch = {}, creation) {
317
+ const session = this.sessions.get(id);
318
+ if (!session)
319
+ return this.insert(id, patch, creation ? { createdFrom: creation.createdFrom } : {});
320
+ if (creation && isUnfiled(session))
321
+ session.createdFrom = creation.createdFrom;
322
+ return this.updateSession(id, patch);
323
+ }
324
+ /**
325
+ * IM 房间收录:按原生入站记下的出生平台与聊天窗口建档,名字取出生平台给的群名或昵称(没有时不取名)。只给会话表里
326
+ * 还没有的 id 同步调用。场所是窗口的同时确保窗口的场所记录:insert 在第一次等待之前已同步占住 id、记下来源字段
327
+ */
328
+ async recordRoom(id, nativeName, origin) {
329
+ const [created] = await Promise.all([
330
+ this.insert(id, { name: nativeName, status: 'waiting' }, origin),
331
+ this.noteWindow(id, nativeName),
332
+ ]);
333
+ return created;
334
+ }
335
+ /**
336
+ * IM 房间收录的补记:按 id 兜底建档的根会话(没有来源字段、创建来源与绑定)与升级前只记了出生平台的记录(没有聊天窗口,
337
+ * 出生平台与消息平台相同)收到原生入站时,记下与按这条消息建档时一样的来源字段,还没有名字的一并补上,场所是窗口的
338
+ * 确保窗口的场所记录。只给表里这条记录同步调用,由收录监听判定这个 id 归消息平台的适配器管
298
339
  */
299
- async ensureSession(id, patch = {}) {
300
- if (this.sessions.has(id)) {
301
- // updateSession 不接受 createdBy(建档专用),命中路径剥离后转发
302
- const { createdBy: _ignore, ...updates } = patch;
303
- return this.updateSession(id, updates);
340
+ async backfillRoom(session, nativeName, origin) {
341
+ Object.assign(session, origin, session.name === undefined && nativeName ? { name: nativeName } : {});
342
+ Object.assign(session, describeSession(session));
343
+ await this.noteWindow(session.id, nativeName);
344
+ this.markDirty();
345
+ await this.caps.events.use.emit('session:updated', session);
346
+ }
347
+ /**
348
+ * 原生入站之后的窗口场所记录:会话的场所是窗口时,没有记录就建(显示名取出生平台给的名字,没有时为场所键),有记录而
349
+ * 名字变了就跟着改。场所是工作区的(owner 的 IM 私聊)不建。建了或改了名,给场所是这个窗口的根会话各发一次
350
+ * session:updated:会话的摘要在生成时取场所的显示名,不说话的群改了名也要刷新
351
+ */
352
+ async noteWindow(sessionId, nativeName) {
353
+ const place = this.placeOf(sessionId);
354
+ if (place.kind !== 'window')
355
+ return;
356
+ const record = this.places.get(place.key);
357
+ if (!record) {
358
+ this.places.set(place.key, newPlace(place, nativeName || place.key));
359
+ }
360
+ else if (nativeName && record.name !== nativeName) {
361
+ record.name = nativeName;
362
+ record.updatedAt = Date.now();
304
363
  }
364
+ else
365
+ return;
366
+ this.markDirty();
367
+ for (const session of [...this.sessions.values()]) {
368
+ if (!session.parentId && this.placeOf(session.id).key === place.key) {
369
+ await this.caps.events.use.emit('session:updated', session);
370
+ }
371
+ }
372
+ }
373
+ /**
374
+ * 以传入 id 建档(emit `session:created`),fields 是只在建档时写的字段。状态缺省按有没有回合在跑:有为 active
375
+ * (回合结束时收口),没有为 waiting(指令回复等回合之外的出站不改状态,建成 active 就会一直停在进行中)
376
+ */
377
+ async insert(id, patch, fields) {
305
378
  const now = Date.now();
306
379
  const session = {
307
380
  id,
308
- name: patch.name ||
309
- `会话 ${new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`,
310
- title: patch.metadata?.title,
381
+ name: patch.name || undefined,
311
382
  parentId: undefined,
312
383
  children: [],
313
- status: patch.status || 'active',
314
- config: patch.config || {},
384
+ status: patch.status || (this.hasTurnInFlight(id) ? 'active' : 'waiting'),
315
385
  createdAt: now,
316
386
  updatedAt: now,
317
- createdBy: patch.createdBy || 'user',
318
- inputContext: patch.metadata?.inputContext,
319
387
  metadata: patch.metadata,
388
+ ...fields,
389
+ ...describeSession(fields),
320
390
  };
321
391
  this.sessions.set(id, session);
322
392
  this.markDirty();
323
- await this.caps.events.emit('session:created', session);
324
- this.caps.logger.info(`会话建档(ensure): ${session.name} (${id})`);
393
+ await this.caps.events.use.emit('session:created', session);
394
+ this.caps.logger.use.info(`会话建档(ensure): ${toSource(sessionLabel(session))} (${id})`);
325
395
  return session;
326
396
  }
397
+ /**
398
+ * 原生入站的收录与补记。会话表里还没有记录的真人入站(不带 source)按这条消息建档:出生平台取消息的平台,聊天窗口按这个
399
+ * 平台的类型声明经 chatScopeOf 记下,之后不变。WebUI 与终端的会话在入站之前已建档、带创建来源,走快路径。带 source 的是
400
+ * 内部注入(工作流、定时任务、空闲开话题、宿主通知、好友申请与入群邀请的合成通知),不登记;子会话按 parentId 不登记(recorded)。
401
+ * 消息带的类型名不在平台的声明里时按多方记下,每个平台与类型名告警一次;消息没带类型名、按平台声明的第一个多方类型记下时,
402
+ * 每个平台告警一次。按 id 兜底建档的根会话(注入回合在未登记的房间开子任务)没有来源字段,升级前的记录只记了出生平台、没有
403
+ * 聊天窗口,两者都由第一条归它的适配器管的原生入站补记(backfillRoom;后者只认出生平台的入站)。
404
+ * 收录与补记之前问遍认领这个 id 的适配器(claimantsOf),第一个认领者随来源字段记作所属实例。认领它的平台不止一个
405
+ * (如终端配置的会话 id 配成了群的 id)时判不出它是谁的窗口,不收录、不补记,每个 id 告警一次:结果不能取决于
406
+ * 适配器的登记先后。问的期间别的入站可能已收录,问完按会话表此刻的记录判,再建档或补记。同一会话并发的调用共用这一次。
407
+ * 名字只采信出生平台自己的入站:多方取群名、一对一取对方昵称,缺省不取名(群的首条入站可能是不带群名的戳一戳,
408
+ * 不回退到昵称)。没有名字时由后到的原生入站补上,起过的名字不动;补记不成、仍没有聊天窗口的记录判不出该取哪个,不补。
409
+ * 场所是窗口的同时建窗口的场所记录,它的显示名随出生平台的入站更新(owner 的 IM 私聊在默认工作区,不建)。
410
+ * 已登记、还没记下所属实例的房间,每条真人入站都再解析一次,认领到就补记。不会拒绝:收录失败只告警。
411
+ * 这个会话正在删除时(带 source 的也一样)先等删除做完,问认领者的期间开始删除的同样等删完再从头问:删除期间到的是新的
412
+ * 一代,不被这次删除停下、清掉
413
+ */
414
+ recordInbound(msg) {
415
+ const { sessionId } = msg;
416
+ const removing = this.removing.get(sessionId);
417
+ if (removing)
418
+ return removing.then(() => this.recordInbound(msg));
419
+ if (!sessionId || msg.source)
420
+ return Promise.resolve();
421
+ const known = this.sessions.get(sessionId);
422
+ if (this.recorded(known, msg)) {
423
+ this.noteRecorded(known, msg);
424
+ return Promise.resolve();
425
+ }
426
+ const pending = this.recording.get(sessionId);
427
+ if (pending)
428
+ return pending;
429
+ const recording = this.record(msg)
430
+ .catch(err => this.caps.logger.use.warn(`IM 房间登记失败 (${sessionId}):`, err))
431
+ .finally(() => this.recording.delete(sessionId));
432
+ this.recording.set(sessionId, recording);
433
+ return recording;
434
+ }
435
+ generationOf(sessionId) {
436
+ return this.generations.get(sessionId) ?? 0;
437
+ }
438
+ /** 不经这条入站收录或补记的会话:子会话(没有窗口),有聊天窗口、创建来源或绑定的,与出生平台不是消息平台的 */
439
+ recorded(session, msg) {
440
+ return (session !== undefined &&
441
+ (Boolean(session.parentId || session.originChat || session.createdFrom || session.work) ||
442
+ (session.originPlatform ?? msg.platform) !== msg.platform));
443
+ }
444
+ /** 已收录的会话:出生平台的入站补名、更新窗口的显示名,还没记下所属实例的再解析一次(都不等) */
445
+ noteRecorded(session, msg) {
446
+ const chat = session.originChat;
447
+ if (chat && session.originPlatform === msg.platform) {
448
+ const nativeName = chat.multiParty ? msg.groupName : msg.nickname;
449
+ if (nativeName && session.name === undefined)
450
+ this.updateSession(session.id, { name: nativeName }).catch(() => { });
451
+ void this.noteWindow(session.id, nativeName);
452
+ }
453
+ void this.recordOriginInstance(session);
454
+ }
455
+ /** 问完认领者再收录或补记,见 {@link recordInbound} */
456
+ async record(msg) {
457
+ const { sessionId } = msg;
458
+ const claimants = await claimantsOf(this.caps.platform, sessionId, this.caps.logger.use);
459
+ // 问的期间开始删除这个会话的,与入口同一条规则:等删除做完再从头问
460
+ const removing = this.removing.get(sessionId);
461
+ if (removing)
462
+ return removing.then(() => this.record(msg));
463
+ const session = this.sessions.get(sessionId);
464
+ if (this.recorded(session, msg)) {
465
+ this.noteRecorded(session, msg);
466
+ return;
467
+ }
468
+ const platforms = [...new Set(claimants.map(({ use: instance }) => instance.platform))];
469
+ if (platforms.length > 1) {
470
+ this.warnOnce(`会话 ${sessionId} 同时由平台「${platforms.join('」「')}」认领:判不出它是哪个平台的聊天窗口,不收录。` +
471
+ '改配置,一个会话 id 只归一个平台', `claim:${sessionId}`);
472
+ return;
473
+ }
474
+ // 补记只认这个 id 归消息平台的适配器管的入站:WebUI 与终端往房间插话不算
475
+ if (session && platforms[0] !== msg.platform)
476
+ return;
477
+ const adapter = getPlatformAdapters(this.caps.platform).find(a => a.platform === msg.platform);
478
+ const scope = chatScopeOf(msg, sessionTypesOf(adapter));
479
+ if (scope.undeclared) {
480
+ this.warnOnce(`平台「${scope.platform}」没有声明类型「${scope.type}」(${sessionId}):这个聊天窗口按多方记下。` +
481
+ '适配器要在 sessionTypes 里声明它发出的类型名', `type:${scope.platform}:${scope.type}`);
482
+ }
483
+ else if (msg.sessionType === undefined && scope.multiParty) {
484
+ this.warnOnce(`平台「${scope.platform}」的消息没带类型名(${sessionId}):这个聊天窗口按类型「${scope.type}」、多方记下。` +
485
+ '适配器要在消息上带类型名', `untyped:${scope.platform}`);
486
+ }
487
+ const originChat = {
488
+ type: scope.type,
489
+ multiParty: scope.multiParty,
490
+ ...(msg.selfId === undefined ? {} : { selfId: msg.selfId }),
491
+ targetId: scope.targetId,
492
+ };
493
+ const nativeName = scope.multiParty ? msg.groupName : msg.nickname;
494
+ const origin = {
495
+ originPlatform: msg.platform,
496
+ originChat,
497
+ ...(claimants[0] ? { originInstance: claimants[0].id } : {}),
498
+ };
499
+ if (session)
500
+ await this.backfillRoom(session, nativeName, origin);
501
+ else
502
+ await this.recordRoom(sessionId, nativeName, origin);
503
+ }
504
+ /**
505
+ * 有出生平台的房间还没记下接管它的适配器实例时,解析并记下,会话列表按它分组;已记下的不改,子任务与 owner 面
506
+ * 会话不记。收录与补记时实例随来源字段一起记下(收录之前已解析);收录时没有适配器认领(适配器没装好、连接还没上来、
507
+ * 账号暂时不属于任何实例)的房间与读表读进来的旧房间,由之后的真人入站调它补记。解析期间会话被删除或换表的不再写回,
508
+ * 同一条记录的解析不并发。不会拒绝。
509
+ */
510
+ async recordOriginInstance(session) {
511
+ if (session.kind !== 'room' || session.originInstance || this.resolving.has(session))
512
+ return;
513
+ this.resolving.add(session);
514
+ try {
515
+ const entry = await resolvePlatformBySession(this.caps.platform, session.id, this.caps.logger.use);
516
+ if (!entry || this.sessions.get(session.id) !== session)
517
+ return;
518
+ session.originInstance = entry.id;
519
+ this.markDirty();
520
+ await this.caps.events.use.emit('session:updated', session);
521
+ }
522
+ finally {
523
+ this.resolving.delete(session);
524
+ }
525
+ }
526
+ // ---- 取别的会话的东西 ----
527
+ canTake(currentId, targetId, principal) {
528
+ if (currentId === targetId)
529
+ return { ok: true };
530
+ const current = rootSessionOf(this.sessions.get(currentId), this.lookup);
531
+ const target = rootSessionOf(this.sessions.get(targetId), this.lookup);
532
+ // 同一棵会话树:两个判不出的根会话(都是 undefined)不算相同
533
+ if (current && current === target)
534
+ return { ok: true };
535
+ const from = platformOf(current);
536
+ const to = platformOf(target);
537
+ if (!current || !target || from === undefined || to === undefined)
538
+ return { ok: false, reason: TAKE_UNKNOWN };
539
+ const values = this.take.get(currentId, { platform: from });
540
+ // 范围写坏了的按本会话
541
+ if (values.takeScope !== 'platform' && values.takeScope !== 'all')
542
+ return { ok: false, reason: t('scopeSession') };
543
+ if (values.takeScope === 'platform' && from !== to)
544
+ return { ok: false, reason: t('scopePlatform') };
545
+ // 机器人账号只在同一平台里有意义:跨平台由范围管
546
+ const ownBot = current.originChat?.selfId;
547
+ const chat = target.originChat;
548
+ const otherBot = from === to && ownBot !== undefined && chat?.selfId !== undefined && chat.selfId !== ownBot;
549
+ if (otherBot && values.takeCrossBot !== true)
550
+ return { ok: false, reason: t('crossBot') };
551
+ if (chat?.multiParty) {
552
+ return values.takeOtherGroups === true ? { ok: true } : { ok: false, reason: t('otherGroups') };
553
+ }
554
+ if (this.ownChat(target, principal))
555
+ return { ok: true };
556
+ return values.takeOthersPrivate === true ? { ok: true } : { ok: false, reason: t('othersPrivate') };
557
+ }
558
+ /**
559
+ * 一对一的目标(根会话)是不是授权身份自己与她的私聊。owner 的面按与 owner 的一对一,看授权身份是不是 owner;IM 窗口按
560
+ * oneToOneWith,出生平台的适配器(与收录时判一对一用的同一个)明确声明了它的类型为一对一才算。权限服务不在时没有人是 owner
561
+ */
562
+ ownChat(root, principal) {
563
+ const authority = this.caps.authority.present ? this.caps.authority.use : undefined;
564
+ const isOwner = authority && ((platform, userId) => authority.isOwner(platform, userId));
565
+ if (root.originPlatform === undefined) {
566
+ return Boolean(principal?.userId && isOwner?.(principal.platform, principal.userId));
567
+ }
568
+ const adapter = getPlatformAdapters(this.caps.platform).find(a => a.platform === root.originPlatform);
569
+ const declared = adapter?.sessionTypes?.find(type => type.name === root.originChat?.type)?.multiParty === false;
570
+ return oneToOneWith(root, principal, declared, isOwner);
571
+ }
327
572
  // ---- CRUD ----
328
- async createSession(opts) {
329
- const id = opts?.parentId
330
- ? `${opts.parentId}::${crypto.randomUUID().slice(0, 8)}`
331
- : `session-${crypto.randomUUID().slice(0, 8)}`;
573
+ async createSession(opts, creation) {
574
+ if (creation && opts?.parentId)
575
+ throw new Error('建档专用参数只给根会话:子会话按根会话取创建来源、所属工作区与绑定');
576
+ const created = creation ? this.creationFields(creation) : {};
577
+ // 子会话的 id 同样不带父会话的 id:平台按会话 id 认领窗口,子会话没有窗口。撞上已有的会话就重抽
578
+ let id;
579
+ do
580
+ id = `session-${crypto.randomUUID().slice(0, 8)}`;
581
+ while (this.sessions.has(id));
582
+ const parent = opts?.parentId ? this.sessions.get(opts.parentId) : undefined;
332
583
  const now = Date.now();
333
584
  const session = {
334
585
  id,
335
- name: opts?.name ||
336
- `会话 ${new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`,
337
- title: opts?.metadata?.title,
586
+ name: opts?.name || undefined,
587
+ title: opts?.title,
338
588
  parentId: opts?.parentId,
339
589
  children: [],
340
590
  status: opts?.status || 'active',
341
- config: opts?.config || {},
342
591
  createdAt: now,
343
592
  updatedAt: now,
344
- createdBy: opts?.createdBy || 'user',
345
- inputContext: opts?.inputContext ?? opts?.metadata?.inputContext,
593
+ inputContext: opts?.inputContext,
346
594
  metadata: opts?.metadata,
595
+ ...created,
596
+ // 子会话的当前目录取父会话此刻的值,之后各改各的
597
+ ...(parent?.currentDir === undefined ? {} : { currentDir: parent.currentDir }),
598
+ ...describeSession({ parentId: opts?.parentId }),
347
599
  };
348
600
  this.sessions.set(id, session);
349
- // 如果有父会话,更新父会话的 children
350
- if (session.parentId) {
351
- const parent = this.sessions.get(session.parentId);
352
- if (parent) {
353
- parent.children.push(id);
354
- parent.updatedAt = now;
355
- }
601
+ // 挂到父会话的 children 下:父会话的子会话列表变了,建档之后再给它发一次 session:updated
602
+ if (parent) {
603
+ parent.children.push(id);
604
+ parent.updatedAt = now;
356
605
  }
357
606
  this.markDirty();
358
- await this.caps.events.emit('session:created', session);
359
- this.caps.logger.info(`会话创建: ${session.name} (${id})`);
607
+ await this.caps.events.use.emit('session:created', session);
608
+ if (parent)
609
+ await this.caps.events.use.emit('session:updated', parent);
610
+ this.caps.logger.use.info(`会话创建: ${toSource(sessionLabel(session))} (${id})`);
360
611
  return session;
361
612
  }
613
+ /**
614
+ * 建档专用参数落成会话字段:工作区要有记录,项目工作区(有目录的)要带绑定,没有目录的不收绑定;默认工作区不记
615
+ * (缺省即默认工作区)。建档不写当前目录:没有值即场所的主目录
616
+ */
617
+ creationFields({ createdFrom, workspace = DEFAULT_WORKSPACE, work, }) {
618
+ const record = this.places.get(`${WORKSPACE_PREFIX}${workspace}`);
619
+ if (!record)
620
+ throw new Error(`工作区不存在:${workspace}`);
621
+ if (record.root !== undefined && !work)
622
+ throw new Error(`在项目工作区里建会话要带绑定目录:${workspace}`);
623
+ if (record.root === undefined && work)
624
+ throw new Error(`绑定目录只给项目工作区里的会话:${workspace}`);
625
+ return {
626
+ createdFrom,
627
+ ...(workspace === DEFAULT_WORKSPACE ? {} : { workspace }),
628
+ ...(work ? { work: { root: work.root, extraRoots: [], worktrees: [] } } : {}),
629
+ };
630
+ }
362
631
  getSession(id) {
363
632
  return this.sessions.get(id);
364
633
  }
@@ -389,37 +658,76 @@ class SessionManager {
389
658
  session.status = updates.status;
390
659
  if (updates.metadata !== undefined)
391
660
  session.metadata = { ...session.metadata, ...updates.metadata };
392
- if (updates.config !== undefined) {
393
- // 合并配置而不是替换
394
- session.config = { ...session.config, ...updates.config };
395
- }
396
661
  session.updatedAt = Date.now();
397
662
  this.markDirty();
398
- await this.caps.events.emit('session:updated', session);
663
+ await this.caps.events.use.emit('session:updated', session);
399
664
  return session;
400
665
  }
401
666
  async deleteSession(id) {
667
+ await this.removeAll([id]);
668
+ }
669
+ /**
670
+ * 逐个删掉这些会话(各连同下面各级子会话),返回删掉的会话数。哪一处抛错都不拦住其余的,全部做完再交出:一处原样交出,几处
671
+ * 合成一个挂着给人看的文字的 AggregateError(各级的摊平成一层)
672
+ */
673
+ async removeAll(ids) {
674
+ const failures = [];
675
+ let removed = 0;
676
+ for (const id of ids)
677
+ removed += await this.remove(id, failures);
678
+ if (failures.length === 1)
679
+ throw failures[0];
680
+ if (failures.length > 1) {
681
+ const text = t('deleteFailed', { count: failures.length });
682
+ throw withText(new AggregateError(failures, text.source), text);
683
+ }
684
+ return removed;
685
+ }
686
+ /** 删掉一个会话连同下面各级子会话,返回删掉的会话数(不在表里的算 0);抛错带会话 id 告警、收进 failures,自己不抛 */
687
+ async remove(id, failures) {
402
688
  const session = this.sessions.get(id);
403
689
  if (!session)
404
- return;
405
- // 递归删除所有子会话(含活跃子会话)
406
- for (const childId of [...session.children]) {
407
- await this.deleteSession(childId);
408
- }
409
- // 从父会话的 children 中移除
410
- if (session.parentId) {
411
- const parent = this.sessions.get(session.parentId);
412
- if (parent) {
413
- parent.children = parent.children.filter(c => c !== id);
414
- parent.updatedAt = Date.now();
415
- }
690
+ return 0;
691
+ // 开始删除,同一步:从表里摘掉(父会话的子会话列表变了,删除之后再给它发一次 session:updated)、换代号。这之后到的同一会话的
692
+ // 入站等删除做完(见 recordInbound),是新的一代;这之前已收录、还在半路上的是旧的一代,网关交给 agent 之前、agent 交给通道
693
+ // 之前比代号放掉、不归档
694
+ const parent = session.parentId ? this.sessions.get(session.parentId) : undefined;
695
+ if (parent) {
696
+ parent.children = parent.children.filter(c => c !== id);
697
+ parent.updatedAt = Date.now();
416
698
  }
417
699
  this.sessions.delete(id);
418
- this.deleted.set(id, Symbol(id));
419
- await this.clearDeletedSessionData(id);
700
+ this.deleted[SESSIONS].set(id, Symbol(id));
420
701
  this.markDirty();
421
- await this.caps.events.emit('session:deleted', id);
422
- this.caps.logger.info(`会话删除: ${session.name} (${id})`);
702
+ let done;
703
+ this.removing.set(id, new Promise(resolve => (done = resolve)));
704
+ this.generations.set(id, this.generationOf(id) + 1);
705
+ // 整个删除交给 agent 的 clearing:它先按住这个会话、停下在跑的一轮并等它收尾,按住到清完,删子会话期间这个会话也不开轮。先删
706
+ // 各级子会话(含活跃子会话),再发事件、清数据:监听方在事件里停掉这个会话在跑的东西,清数据时就不会有中途写回来的。子会话的
707
+ // 抛错收进 failures,不拦住余下的子会话与这个会话自己的发事件、清数据、给上一级发 session:updated:这个会话已摘掉,停在半路
708
+ // 的话它的数据不清,余下子会话的 parentId 悬空,也没法再删一次
709
+ let removed = 1;
710
+ const finish = async () => {
711
+ for (const childId of [...session.children])
712
+ removed += await this.remove(childId, failures);
713
+ await this.caps.events.use.emit('session:deleted', id);
714
+ await this.clearDeletedSessionData(id);
715
+ };
716
+ try {
717
+ await (this.caps.agent.present ? this.caps.agent.use.clearing([id], finish, '会话删除') : finish());
718
+ }
719
+ catch (err) {
720
+ this.caps.logger.use.warn(`会话删除出错 [${id}]:`, err);
721
+ failures.push(err);
722
+ }
723
+ finally {
724
+ this.removing.delete(id);
725
+ done();
726
+ }
727
+ if (parent)
728
+ await this.caps.events.use.emit('session:updated', parent);
729
+ this.caps.logger.use.info(`会话删除: ${toSource(sessionLabel(session))} (${id})`);
730
+ return removed;
423
731
  }
424
732
  async clearDeletedSessionData(id) {
425
733
  const clearData = {
@@ -427,7 +735,7 @@ class SessionManager {
427
735
  sessionId: id,
428
736
  results: [],
429
737
  };
430
- await this.caps.hooks.run('memory:clear', clearData, async () => {
738
+ await this.caps.hooks.use.run('memory:clear', clearData, async () => {
431
739
  try {
432
740
  await this.memory.clearSession(id);
433
741
  clearData.results.push({ source: 'memory', success: true, message: '会话消息历史已清空' });
@@ -439,40 +747,148 @@ class SessionManager {
439
747
  });
440
748
  const failed = clearData.results.filter(r => !r.success);
441
749
  if (failed.length > 0) {
442
- this.caps.logger.warn(`会话数据清理存在失败项 [${id}]: ${failed.map(r => `${r.source}: ${r.message}`).join('; ')}`);
750
+ this.caps.logger.use.warn(`会话数据清理存在失败项 [${id}]: ${failed.map(r => `${r.source}: ${toSource(r.message)}`).join('; ')}`);
443
751
  }
444
752
  }
753
+ /**
754
+ * 删掉一个会话的全部子会话(连同下面各级),不给 parentId 即删掉全部子会话;会话不在表里时什么都不删。返回这次删掉的会话数
755
+ * (途中别处建的、删的会话不算)
756
+ */
757
+ async deleteChildren(parentId) {
758
+ if (parentId !== undefined && !this.sessions.has(parentId))
759
+ return 0;
760
+ const targets = [...this.sessions.values()].filter(s => parentId === undefined ? s.parentId !== undefined : s.parentId === parentId);
761
+ return this.removeAll(targets.map(s => s.id));
762
+ }
445
763
  // ---- 树形操作 ----
446
764
  async createChildSession(parentId, opts) {
447
- // 平台派生会话(cli-default、OneBot 会话 id)从不经 createSession 预建,父档缺失是常态;
448
- // 先兜底建档再挂子会话,否则 create_subtask 在这些平台必败。
765
+ // 带 source 的注入回合(定时任务、编排)可能落在还没建档的会话上:先按 id 兜底建档再挂子会话,否则 create_subtask
766
+ // 在这些会话里必败。兜底建档的会话没有来源字段、创建来源与名字:是房间的由之后的原生入站补记来源字段与名字
449
767
  if (!this.sessions.has(parentId))
450
768
  await this.ensureSession(parentId);
451
- return this.createSession({
452
- ...opts,
453
- parentId,
454
- createdBy: opts?.createdBy || 'agent',
455
- });
769
+ return this.createSession({ ...opts, parentId });
456
770
  }
457
771
  getChildren(parentId) {
458
772
  return this.listSessions({ parentId });
459
773
  }
460
- getTree(rootId) {
461
- const buildNode = (session) => ({
462
- session,
463
- children: session.children
464
- .map(cid => this.sessions.get(cid))
465
- .filter((s) => !!s)
466
- .map(buildNode),
467
- });
468
- if (rootId) {
469
- const root = this.sessions.get(rootId);
470
- if (!root)
471
- return [];
472
- return [buildNode(root)];
774
+ // ---- 当前目录与项目会话的绑定 ----
775
+ async setCurrentDir(sessionId, dir) {
776
+ const session = this.sessions.get(sessionId);
777
+ if (!session)
778
+ throw new Error(`会话不存在: ${sessionId}`);
779
+ session.currentDir = dir;
780
+ this.markDirty();
781
+ await this.caps.events.use.emit('session:updated', session);
782
+ }
783
+ async setExtraRoots(sessionId, extraRoots) {
784
+ const { root, work } = this.inWorkTree(sessionId);
785
+ work.extraRoots = [...extraRoots];
786
+ this.markDirty();
787
+ await this.caps.events.use.emit('session:updated', root);
788
+ }
789
+ async addWorktree(sessionId, path) {
790
+ const { root, work } = this.inWorkTree(sessionId);
791
+ if (work.worktrees.includes(path))
792
+ return;
793
+ work.worktrees.push(path);
794
+ this.markDirty();
795
+ await this.caps.events.use.emit('session:updated', root);
796
+ }
797
+ /** 项目会话树的根会话与它的绑定;会话不存在或不在项目会话树里时抛错 */
798
+ inWorkTree(sessionId) {
799
+ const session = this.sessions.get(sessionId);
800
+ if (!session)
801
+ throw new Error(`会话不存在: ${sessionId}`);
802
+ const root = rootSessionOf(session, this.lookup);
803
+ if (!root?.work)
804
+ throw new Error(`会话 ${sessionId} 不在项目会话里:根会话没有绑定目录`);
805
+ return { root, work: root.work };
806
+ }
807
+ // ---- 场所与工作区 ----
808
+ placeOf(sessionId) {
809
+ const root = rootSessionOf(this.sessions.get(sessionId), this.lookup);
810
+ if (root?.originPlatform !== undefined) {
811
+ const chat = root.originChat;
812
+ const targetId = chat?.targetId ?? '';
813
+ // owner 的 IM 私聊是默认工作区的入口;没有 authority 时认不出 owner,按窗口算(与房间类别的失败关闭同向)
814
+ const owner = targetId !== '' &&
815
+ !chat?.multiParty &&
816
+ (this.caps.authority.present ? this.caps.authority.use.isOwner(root.originPlatform, targetId) : undefined);
817
+ if (owner)
818
+ return DEFAULT_PLACE;
819
+ const key = windowKey({ platform: root.originPlatform, type: chat?.type ?? '', targetId });
820
+ // 聊天窗口缺失或没有目标的坏数据同样是窗口:落进默认工作区会把 owner 给工作区设的值套到房间上
821
+ if (targetId === '')
822
+ this.warnOnce(`会话 ${root.id} 有出生平台却没有聊天窗口或目标,按窗口 ${key} 算`);
823
+ return { kind: 'window', key };
824
+ }
825
+ if (root?.workspace === undefined)
826
+ return DEFAULT_PLACE;
827
+ const key = `${WORKSPACE_PREFIX}${root.workspace}`;
828
+ if (this.places.has(key))
829
+ return { kind: 'workspace', key };
830
+ this.warnOnce(`会话 ${root.id} 记下的工作区 ${root.workspace} 没有记录,按默认工作区`);
831
+ return DEFAULT_PLACE;
832
+ }
833
+ getPlace(key) {
834
+ const record = this.places.get(key);
835
+ return record && placeInfo(record);
836
+ }
837
+ listPlaces() {
838
+ return [...this.places.keys()]
839
+ .sort()
840
+ .sort(defaultFirst)
841
+ .map(key => placeInfo(this.places.get(key)));
842
+ }
843
+ async updatePlace(key, updates) {
844
+ const record = this.places.get(key);
845
+ if (!record)
846
+ throw new Error(`场所不存在:${key}`);
847
+ if (updates.metadata !== undefined)
848
+ record.metadata = { ...record.metadata, ...updates.metadata };
849
+ record.updatedAt = Date.now();
850
+ this.markDirty();
851
+ return placeInfo(record);
852
+ }
853
+ addWorkspace(root) {
854
+ const existing = [...this.places.values()].find(record => record.root === root);
855
+ if (existing)
856
+ return { workspace: workspaceInfo(existing), existed: true };
857
+ const record = newPlace({ kind: 'workspace', key: `${WORKSPACE_PREFIX}${crypto.randomUUID()}` }, basename(root), root);
858
+ this.places.set(record.key, record);
859
+ this.markDirty();
860
+ this.caps.logger.use.info(`登记工作区: ${record.name} (${record.key}) ${root}`);
861
+ return { workspace: workspaceInfo(record), existed: false };
862
+ }
863
+ listWorkspaces() {
864
+ return [...this.places.values()]
865
+ .filter(record => record.kind === 'workspace')
866
+ .sort((a, b) => defaultFirst(a.key, b.key) || a.createdAt - b.createdAt)
867
+ .map(workspaceInfo);
868
+ }
869
+ async removeWorkspace(id) {
870
+ if (id === DEFAULT_WORKSPACE)
871
+ throw new Error('默认工作区不能取消登记');
872
+ const key = `${WORKSPACE_PREFIX}${id}`;
873
+ const record = this.places.get(key);
874
+ if (!record)
875
+ throw new Error(`工作区不存在:${id}`);
876
+ // 先认出原属的会话树(按根会话记下的工作区),再一并清:根会话的所属工作区与绑定,整棵树的当前目录
877
+ const affected = [...this.sessions.values()].filter(s => rootSessionOf(s, this.lookup)?.workspace === id);
878
+ const changed = affected.filter(s => !s.parentId || s.currentDir !== undefined);
879
+ for (const session of affected) {
880
+ delete session.currentDir;
881
+ if (!session.parentId) {
882
+ delete session.workspace;
883
+ delete session.work;
884
+ }
473
885
  }
474
- // 返回所有根会话的树
475
- return this.listSessions({ parentId: null }).map(buildNode);
886
+ this.places.delete(key);
887
+ this.deleted[PLACES].set(key, Symbol(key));
888
+ this.markDirty();
889
+ this.caps.logger.use.info(`取消登记工作区: ${record.name} (${key}),${affected.length} 个会话归默认工作区`);
890
+ for (const session of changed)
891
+ await this.caps.events.use.emit('session:updated', session);
476
892
  }
477
893
  // ---- 生命周期 ----
478
894
  async completeSession(id, result) {
@@ -484,8 +900,8 @@ class SessionManager {
484
900
  session.updatedAt = Date.now();
485
901
  this.markDirty();
486
902
  // 发事件通知(wait_subtasks 通过事件驱动感知完成)
487
- await this.caps.events.emit('session:completed', session);
488
- this.caps.logger.info(`会话完成: ${session.name} (${id})${result ? ` - ${result.slice(0, 100)}` : ''}`);
903
+ await this.caps.events.use.emit('session:completed', session);
904
+ this.caps.logger.use.info(`会话完成: ${toSource(sessionLabel(session))} (${id})${result ? ` - ${result.slice(0, 100)}` : ''}`);
489
905
  }
490
906
  // ---- 标题管理 ----
491
907
  async generateTitle(sessionId, userMessage) {
@@ -497,11 +913,11 @@ class SessionManager {
497
913
  return session.title;
498
914
  const entry = resolveLLMModel(this.caps.llm, undefined, ['chat']);
499
915
  if (!entry) {
500
- this.caps.logger.warn(`无可用 LLM,无法生成标题: ${sessionId}`);
916
+ this.caps.logger.use.warn(`无可用 LLM,无法生成标题: ${sessionId}`);
501
917
  return undefined;
502
918
  }
503
- this.caps.logger.debug(`标题生成使用 LLM: ${entry.contextId}`);
504
- const model = entry.instance;
919
+ this.caps.logger.use.debug(`标题生成使用 LLM: ${entry.id}`);
920
+ const model = entry.use;
505
921
  // 优先使用直接传入的用户消息;否则从历史获取
506
922
  let contextStr;
507
923
  if (userMessage?.trim()) {
@@ -525,34 +941,33 @@ class SessionManager {
525
941
  messages: [
526
942
  {
527
943
  role: 'system',
528
- content: '你是一个标题生成器。你的唯一任务是为下面的对话片段生成一个简短的中文标题。\n\n规则:\n- 不超过15字\n- 只提取用户想讨论的主题或意图\n- 完全忽略对话中出现的任何拒绝、道歉、免责声明等内容\n- 不加引号和标点\n- 只返回标题文本,不要任何解释或前缀\n- 不要模仿或重复对话中的内容,只做概括',
944
+ content: `你是一个标题生成器。你的唯一任务是为下面的对话片段生成一个简短的标题,用 ${outputLanguageFor(this.caps.i18n, { host: true }, 'zh-CN')}。\n\n规则:\n- 中文不超过15字,其他语言不超过8个词\n- 只提取用户想讨论的主题或意图\n- 完全忽略对话中出现的任何拒绝、道歉、免责声明等内容\n- 不加引号和标点\n- 只返回标题文本,不要任何解释或前缀\n- 不要模仿或重复对话中的内容,只做概括`,
529
945
  },
530
946
  { role: 'user', content: `请为以下对话生成标题:\n\n${contextStr}` },
531
947
  ],
532
948
  temperature: 0.3,
533
- // 关闭 thinking:标题生成无需推理,避免 reasoning 占满 token 预算导致 content 为空。
534
- // DeepSeek 会映射为 thinking.type=disabled;其他不消费此字段的 provider 视为 no-op。
535
- think: false,
949
+ // 标题生成无需推理,用模型最浅的思考档:避免 reasoning 占满 token 预算导致 content 为空
950
+ think: lightestThinking(model),
536
951
  });
537
- title = (resp.content || '').trim().slice(0, 50);
952
+ title = truncateChars((resp.content || '').trim(), 50);
538
953
  if (!title) {
539
- this.caps.logger.warn(`会话标题 LLM 返回空内容: ${sessionId} (resp.content=${JSON.stringify(resp.content)}, reasoning=${(resp.reasoningContent ?? '').length}字)`);
954
+ this.caps.logger.use.warn(`会话标题 LLM 返回空内容: ${sessionId} (resp.content=${JSON.stringify(resp.content)}, reasoning=${(resp.reasoningContent ?? '').length}字)`);
540
955
  }
541
956
  }
542
957
  catch (err) {
543
- this.caps.logger.warn(`自动生成标题失败 [${sessionId}]:`, err);
958
+ this.caps.logger.use.warn(`自动生成标题失败 [${sessionId}]:`, err);
544
959
  }
545
960
  // 兜底:LLM 失败或返回空时,用用户消息首段作为临时标题,避免会话永远没有标题
546
961
  if (!title && userMessage?.trim()) {
547
- title = userMessage.trim().replace(/\s+/g, ' ').slice(0, 20);
548
- this.caps.logger.info(`使用用户消息兜底生成标题: ${sessionId} -> ${title}`);
962
+ title = truncateChars(userMessage.trim().replace(/\s+/g, ' '), 20);
963
+ this.caps.logger.use.info(`使用用户消息兜底生成标题: ${sessionId} -> ${title}`);
549
964
  }
550
965
  if (title) {
551
966
  session.title = title;
552
967
  session.updatedAt = Date.now();
553
968
  this.markDirty();
554
- await this.caps.events.emit('session:updated', session);
555
- this.caps.logger.info(`会话标题已生成: [${sessionId}] ${title}`);
969
+ await this.caps.events.use.emit('session:updated', session);
970
+ this.caps.logger.use.info(`会话标题已生成: [${sessionId}] ${title}`);
556
971
  return title;
557
972
  }
558
973
  return undefined;
@@ -564,101 +979,74 @@ class SessionManager {
564
979
  session.title = title;
565
980
  session.updatedAt = Date.now();
566
981
  this.markDirty();
567
- await this.caps.events.emit('session:updated', session);
982
+ await this.caps.events.use.emit('session:updated', session);
983
+ }
984
+ /** 同一处只告警一次:key 缺省为这句话本身 */
985
+ warnOnce(message, key = message) {
986
+ if (this.warned.has(key))
987
+ return;
988
+ this.warned.add(key);
989
+ this.caps.logger.use.warn(message);
990
+ }
991
+ hasTurnInFlight(sessionId) {
992
+ for (const turn of this.turns.values())
993
+ if (turn.sessionId === sessionId)
994
+ return true;
995
+ return false;
568
996
  }
569
- // ---- 配置解析 ----
570
997
  /**
571
- * 解析指定会话的最终生效配置
572
- *
573
- * 合并优先级(从高到低):
574
- * 1. 会话自身 config
575
- * 2. 父会话的 sessionDefaults
576
- * 3. 平台 profile
577
- * 4. 全局 defaults(最低)
998
+ * 回合开始:登记这一轮;已有记录且不是 active 的翻 active。归档的不翻:归档是 owner 不想在列表里看到它,
999
+ * 她在那里跑一轮不该让它回到列表,只有手动恢复才回来
578
1000
  */
579
- resolveConfig(sessionId, platform) {
580
- // 2-4 层即「继承默认」
581
- const result = this.resolveInheritedDefaults(sessionId, platform);
582
- const session = this.sessions.get(sessionId);
583
- if (!session)
584
- return result;
585
- // 1. 会话自身 config(最高优先级)
586
- Object.assign(result, stripUndefined(session.config));
587
- // 移除 sessionDefaults(不传递到消费方)
588
- delete result.sessionDefaults;
589
- return result;
1001
+ beginTurn(id, turnId) {
1002
+ this.turns.set(turnId, { sessionId: id });
1003
+ const session = this.sessions.get(id);
1004
+ if (session && session.status !== 'active' && session.status !== 'archived') {
1005
+ this.updateSession(id, { status: 'active' }).catch(() => { });
1006
+ }
1007
+ }
1008
+ /** 记下这一轮的结局与出错原因(只有结局为 error 时留原因),回合结束时用 */
1009
+ recordOutcome(turnId, outcome, error) {
1010
+ const turn = this.turns.get(turnId);
1011
+ if (!turn)
1012
+ return;
1013
+ turn.outcome = outcome;
1014
+ // 一句原因:agent 给的可能带上游的整段响应(字符串本身,或文字对象的参数),会随会话落盘、进子任务提醒,截短;空串当作没给
1015
+ if (outcome !== 'error' || !error)
1016
+ turn.error = undefined;
1017
+ else
1018
+ turn.error = typeof error === 'string' ? truncateChars(error, ERROR_MAX, '…') : truncateParams(error, ERROR_MAX);
590
1019
  }
591
1020
  /**
592
- * 解析「继承默认」:不含 session 自身 config,只算 defaults + platform profile + 父 sessionDefaults。
593
- *
594
- * WebUI 「继承 (xxx)」提示应该用这个值,否则会显示用户自己的覆盖值。
1021
+ * 回合结束:注销这一轮,记下 lastTurn(被拦下、没有结局的一轮记为 silent),结局为 replied 的另记下她开口的时间。
1022
+ * 会话里的最后一轮结束时,仍为 active 的会话(根会话与子会话一样)按这一轮的结局收口:出错为 error,其余为 completed
595
1023
  */
596
- resolveInheritedDefaults(sessionId, platform) {
597
- const result = {};
598
- // 3. 全局 defaults(最低)
599
- Object.assign(result, stripUndefined(this.defaults));
600
- // 2. 平台 profile
601
- if (platform) {
602
- const profile = this.platformProfiles.get(platform);
603
- if (profile)
604
- Object.assign(result, stripUndefined(profile));
605
- }
606
- // 1. 父会话 sessionDefaults(最高,覆盖 profile/defaults)
607
- const session = this.sessions.get(sessionId);
608
- if (session?.parentId) {
609
- const parent = this.sessions.get(session.parentId);
610
- if (parent?.config?.sessionDefaults) {
611
- Object.assign(result, stripUndefined(parent.config.sessionDefaults));
612
- }
613
- }
614
- delete result.sessionDefaults;
615
- return result;
616
- }
617
- getDefaults() {
618
- return { ...this.defaults };
619
- }
620
- /** 从配置加载全局 defaults */
621
- loadDefaults(cfg) {
622
- const next = {};
623
- if (cfg.persona)
624
- next.persona = cfg.persona;
625
- this.defaults = next;
626
- if (Object.keys(next).length > 0) {
627
- this.caps.logger.info(`已加载全局 defaults: ${Object.keys(next).join(', ')}`);
628
- }
629
- }
630
- getPlatformProfiles() {
631
- const result = {};
632
- for (const [platform, profile] of this.platformProfiles) {
633
- result[platform] = { ...profile };
634
- }
635
- return result;
636
- }
637
- /** 从配置加载平台 profiles(唯一入口:平台档属插件配置,无运行时写接口——写了也不落盘) */
638
- loadPlatformProfiles(profiles) {
639
- for (const entry of profiles) {
640
- const profile = {};
641
- if (entry.persona)
642
- profile.persona = entry.persona;
643
- if (entry.llm && typeof entry.llm === 'object' && entry.llm.provider && entry.llm.model) {
644
- profile.llm = { provider: String(entry.llm.provider), model: String(entry.llm.model) };
645
- }
646
- if (entry.enabledToolGroups !== undefined)
647
- profile.enabledToolGroups = entry.enabledToolGroups;
648
- if (entry.disableOutputFormat !== undefined)
649
- profile.disableOutputFormat = entry.disableOutputFormat;
650
- if (entry.clientSideJsonRendering !== undefined)
651
- profile.clientSideJsonRendering = entry.clientSideJsonRendering;
652
- // 空串表示继承 provider 全局配置。
653
- if (entry.think === 'on')
654
- profile.think = true;
655
- else if (entry.think === 'off')
656
- profile.think = false;
657
- this.platformProfiles.set(entry.platform, profile);
658
- }
659
- if (this.platformProfiles.size > 0) {
660
- this.caps.logger.info(`已加载 ${this.platformProfiles.size} 个平台配置模板: ${[...this.platformProfiles.keys()].join(', ')}`);
661
- }
1024
+ endTurn(turnId) {
1025
+ const turn = this.turns.get(turnId);
1026
+ if (!turn)
1027
+ return;
1028
+ this.turns.delete(turnId);
1029
+ const session = this.sessions.get(turn.sessionId);
1030
+ if (!session)
1031
+ return;
1032
+ const { outcome = 'silent', error } = turn;
1033
+ const endedAt = Date.now();
1034
+ session.lastTurn = error === undefined ? { outcome, endedAt } : { outcome, endedAt, error };
1035
+ if (outcome === 'replied')
1036
+ session.lastReplyAt = endedAt;
1037
+ const settle = session.status === 'active' && !this.hasTurnInFlight(session.id);
1038
+ const status = outcome === 'error' ? 'error' : 'completed';
1039
+ this.updateSession(session.id, settle ? { status } : {}).catch(() => { });
1040
+ }
1041
+ /** 按会话累加一次模型调用的 token:只记已有的会话,不改 updatedAt;记了就发 session:updated */
1042
+ recordUsage({ sessionId, inputTokens, outputTokens }) {
1043
+ const session = sessionId === undefined ? undefined : this.sessions.get(sessionId);
1044
+ if (!session)
1045
+ return;
1046
+ const { input, output } = session.tokens ?? { input: 0, output: 0 };
1047
+ session.tokens = { input: input + inputTokens, output: output + outputTokens };
1048
+ this.markDirty();
1049
+ void this.caps.events.use.emit('session:updated', session);
662
1050
  }
663
1051
  /**
664
1052
  * 关停收尾:仍 `active` 的会话立即收口并落盘。
@@ -667,7 +1055,7 @@ class SessionManager {
667
1055
  * 关停打断在飞回合与那条路径同义,沿用同一状态,不新造值。
668
1056
  * waiting / completed / error / archived 不是在飞,原样保留。
669
1057
  *
670
- * 第一方栈由 core 关停编排(optional 环成员先全部 drain)与 `agent:turn:after` 收口;
1058
+ * 第一方栈由 core 关停编排(optional 环成员先全部 drain)与回合结束时的收口({@link endTurn})处理;
671
1059
  * 此处兜的是未装 agent 或钩子未挂上的路径。
672
1060
  * persist 也在这里立刻刷,不走 1s debounce——onDispose 的 shutdown 仍会再刷一次。
673
1061
  */
@@ -700,220 +1088,165 @@ class SessionManager {
700
1088
  }
701
1089
  // ===== 工具函数 =====
702
1090
  /**
703
- * 移除值为 undefined / null 的键。
704
- *
705
- * null 必须一起剥:清除会话覆盖(`/model 复位`)写入的是 undefined,
706
- * 而它经 BSON 持久化后读回来是 **null**。不剥的话这个 null 会在 Object.assign 里
707
- * 把平台 profile 的同名字段盖成空——agent 侧判 `llm?.provider && llm?.model` 不成立、
708
- * 退回 `resolveLLMModel(undefined)`,静默落到首个注册的 entry。表现就是
709
- * 「配置里写着 A,实际跑的是 B」,且没有任何告警。persona 同理。
710
- * 本模型里「没有值」一律等于「继承上层」,null 与 undefined 同义。
1091
+ * 会话的种类,只由父会话与出生平台推出。新建、建档、补记与加载入表时覆盖写入,调用方传的值与存储里的旧值都不采信
711
1092
  */
712
- function stripUndefined(obj) {
713
- if (!obj)
714
- return {};
715
- const result = {};
716
- for (const [key, value] of Object.entries(obj)) {
717
- if (value !== undefined && value !== null)
718
- result[key] = value;
719
- }
720
- return result;
1093
+ function describeSession(session) {
1094
+ return { kind: sessionKind(session) };
721
1095
  }
722
1096
  /**
723
- * 页面动作全是 apply 里的闭包:直接用这次激活的 manager 与能力,
724
- * 登记随激活存亡(插件不在,WebUI 就调不到这些方法)。
1097
+ * 判得出的根会话的平台:IM 房间(出生平台加聊天窗口)取出生平台,owner 的面(有创建来源)取创建来源。不在会话表里的、按 id
1098
+ * 兜底建档的空记录与只记了出生平台的旧房间记录(等下一条原生入站补记)判不出
725
1099
  */
726
- function registerSessionActions(caps, manager) {
727
- const { webui, memory, persona, llm, tools, platform } = caps;
728
- webui.registerAction('listSessions', async () => manager.listSessions().map(s => ({
729
- ...s,
730
- displayTitle: s.title || s.name,
731
- configSummary: formatConfigSummary(s.config),
732
- childCount: s.children.length,
733
- })));
734
- webui.registerAction('createSession', async (args) => {
735
- const parentId = args.parentId || undefined;
736
- // 新建会话时复制当前生效配置,而非留空继承
737
- let config = args.config || {};
738
- if (Object.keys(config).length === 0) {
739
- if (parentId) {
740
- // 子会话:复制父会话的 resolved config
741
- config = { ...manager.resolveConfig(parentId, 'webui') };
742
- }
743
- else {
744
- // 根会话:复制 webui 平台 profile 作为初始配置
745
- const profiles = manager.getPlatformProfiles();
746
- if (profiles.webui)
747
- config = { ...profiles.webui };
748
- }
749
- }
750
- return manager.createSession({
751
- name: args.name ||
752
- `会话 ${new Date().toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}`,
753
- parentId,
754
- config,
755
- createdBy: 'user',
756
- // 新建的空会话尚未发生任何对话,初始为 'waiting'(等待中)而非 'active'(进行中)。
757
- // 否则侧栏新建的会话会一直显示"进行中"——直到首条消息触发 inbound→active→turn:after→completed。
758
- status: 'waiting',
759
- });
760
- });
761
- webui.registerAction('deleteSession', async (args) => {
762
- const id = args.id;
763
- if (!id)
764
- throw new Error('缺少会话 ID');
765
- await manager.deleteSession(id);
766
- return { success: true };
767
- });
768
- webui.registerAction('updateSessionConfig', async (args) => {
769
- const id = args.id;
770
- if (!id)
771
- throw new Error('缺少会话 ID');
772
- return manager.updateSession(id, { config: normalizeSessionConfigPatch(args.config) });
773
- });
774
- webui.registerAction('getSessionHistory', async (args) => {
775
- const store = memory.current;
776
- if (!store)
777
- throw new Error('memory 服务不可用');
778
- const sessionId = args.sessionId;
779
- if (!sessionId)
780
- throw new Error('缺少 sessionId');
781
- const limit = args.limit || 100;
782
- const history = await store.getHistory(sessionId, limit);
783
- return { sessionId, messages: history };
784
- });
785
- /** 批量归档会话 */
786
- webui.registerAction('batchArchive', async (args) => {
787
- const ids = args.ids;
788
- if (!Array.isArray(ids) || ids.length === 0)
789
- throw new Error('缺少会话 ID 列表');
790
- let count = 0;
791
- for (const id of ids) {
792
- try {
793
- await manager.updateSession(id, { status: 'archived' });
794
- count++;
795
- }
796
- catch {
797
- /* skip */
798
- }
799
- }
800
- return { success: true, count };
801
- });
802
- /** 批量删除会话 */
803
- webui.registerAction('batchDelete', async (args) => {
804
- const ids = args.ids;
805
- if (!Array.isArray(ids) || ids.length === 0)
806
- throw new Error('缺少会话 ID 列表');
807
- let count = 0;
808
- for (const id of ids) {
809
- try {
810
- await manager.deleteSession(id);
811
- count++;
812
- }
813
- catch {
814
- /* skip */
815
- }
816
- }
817
- return { success: true, count };
818
- });
819
- webui.registerAction('getSessionTree', async () => manager.getTree());
820
- /** 获取可选项列表(供前端下拉框使用) */
821
- webui.registerAction('getConfigOptions', async () => {
822
- // 可用人设列表
823
- const personaService = persona.current;
824
- const personas = personaService?.listModels ? await personaService.listModels() : [];
825
- // 可用 LLM 模型列表(枚举所有 chat-capable entry)
826
- const models = listLLMModels(llm, { caps: ['chat'] }).map(e => ({
827
- id: e.instance.id,
828
- capabilities: [...e.instance.capabilities],
829
- provider: e.instance.providerId,
830
- contextId: e.contextId,
831
- }));
832
- // 工具分组列表
833
- const toolGroups = tools.current?.getGroups().map(g => ({ name: g.name, label: g.label })) ?? [];
834
- // 已注册平台列表
835
- const platforms = getPlatformNames(platform);
836
- return { personas, models, toolGroups, platforms, profiles: manager.getPlatformProfiles() };
837
- });
838
- /** 获取指定会话的最终生效配置(合并所有层级后的结果) */
839
- webui.registerAction('getResolvedConfig', async (args) => {
840
- const sessionId = args.sessionId;
841
- if (!sessionId)
842
- throw new Error('缺少 sessionId');
843
- return manager.resolveConfig(sessionId, args.platform);
844
- });
845
- /**
846
- * 获取「继承默认」——不含 session 自身 config,只算 platform profile + 父 sessionDefaults。
847
- * WebUI 「继承 (xxx)」提示用这个,避免显示用户自己的覆盖值。
848
- */
849
- webui.registerAction('getInheritedDefaults', async (args) => {
850
- const sessionId = args.sessionId;
851
- if (!sessionId)
852
- throw new Error('缺少 sessionId');
853
- return manager.resolveInheritedDefaults(sessionId, args.platform);
854
- });
855
- /** 获取会话详情(含完整消息历史,包括已归档消息) */
856
- webui.registerAction('getSessionDetail', async (args) => {
857
- const store = memory.current;
858
- if (!store)
859
- throw new Error('memory 服务不可用');
860
- const id = args.id;
861
- if (!id)
862
- throw new Error('缺少会话 ID');
863
- const session = manager.getSession(id);
864
- if (!session)
865
- throw new Error(`会话不存在: ${id}`);
866
- const limit = args.limit || 200;
867
- // 优先使用 getFullHistory(含已归档消息),确保 UI 能看到完整对话
868
- const messages = store.getFullHistory ? await store.getFullHistory(id, limit) : await store.getHistory(id, limit);
869
- return { session, messages };
870
- });
871
- /** 手动重命名会话标题 */
872
- webui.registerAction('renameSession', async (args) => {
873
- const id = args.id;
874
- const title = args.title;
875
- if (!id || !title)
876
- throw new Error('缺少 id 或 title');
877
- await manager.updateSessionTitle(id, title);
878
- return { success: true };
879
- });
880
- /** 归档子会话(父已不再需要) */
881
- webui.registerAction('archiveSession', async (args) => {
882
- const id = args.id;
883
- if (!id)
884
- throw new Error('缺少会话 ID');
885
- await archiveRecursively(manager, id);
886
- return { success: true };
887
- });
1100
+ function platformOf(root) {
1101
+ if (root?.originPlatform !== undefined)
1102
+ return root.originChat ? root.originPlatform : undefined;
1103
+ return root?.createdFrom;
1104
+ }
1105
+ /** 新建的场所记录:默认工作区没有名字 */
1106
+ function newPlace(place, name, root) {
1107
+ const now = Date.now();
1108
+ return { ...place, name, ...(root === undefined ? {} : { root }), createdAt: now, updatedAt: now };
1109
+ }
1110
+ function placeInfo({ kind, key, name, metadata }) {
1111
+ return { kind, key, name, ...(metadata === undefined ? {} : { metadata }) };
1112
+ }
1113
+ function workspaceInfo({ key, name, root }) {
1114
+ return { id: key.slice(WORKSPACE_PREFIX.length), name, ...(root === undefined ? {} : { root }) };
1115
+ }
1116
+ /** 排序时默认工作区在前,其余不动 */
1117
+ function defaultFirst(a, b) {
1118
+ return Number(b === DEFAULT_PLACE.key) - Number(a === DEFAULT_PLACE.key);
1119
+ }
1120
+ const SESSION_STATUSES = new Set([
1121
+ 'active',
1122
+ 'waiting',
1123
+ 'completed',
1124
+ 'error',
1125
+ 'archived',
1126
+ ]);
1127
+ const TURN_OUTCOMES = new Set([
1128
+ 'replied',
1129
+ 'silent',
1130
+ 'aborted',
1131
+ 'error',
1132
+ ]);
1133
+ const isText = (value) => typeof value === 'string';
1134
+ const isTextList = (value) => Array.isArray(value) && value.every(isText);
1135
+ const isTime = (value) => typeof value === 'number' && Number.isFinite(value);
1136
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
1137
+ /**
1138
+ * 会话记录里会进会话列表与协议、类型不对会让读者出错的可选字段,各自的类型。种类载入时重算,不在这里
1139
+ */
1140
+ const OPTIONAL_FIELDS = {
1141
+ name: isText,
1142
+ title: isText,
1143
+ parentId: isText,
1144
+ createdFrom: isText,
1145
+ originPlatform: isText,
1146
+ originChat: value => isRecord(value) &&
1147
+ isText(value.type) &&
1148
+ typeof value.multiParty === 'boolean' &&
1149
+ isText(value.targetId) &&
1150
+ (value.selfId === undefined || isText(value.selfId)),
1151
+ workspace: isText,
1152
+ work: value => isRecord(value) && isText(value.root) && isTextList(value.extraRoots) && isTextList(value.worktrees),
1153
+ currentDir: isText,
1154
+ originInstance: isText,
1155
+ lastReplyAt: isTime,
1156
+ lastTurn: value => isRecord(value) &&
1157
+ TURN_OUTCOMES.has(value.outcome) &&
1158
+ isTime(value.endedAt) &&
1159
+ (value.error === undefined || asText(value.error) !== undefined),
1160
+ tokens: value => isRecord(value) && isTime(value.input) && isTime(value.output),
1161
+ };
1162
+ /**
1163
+ * 读回的会话记录:存储把没有的可选字段存成 null 的(MongoDB 就是这样)去掉这个键。字段不对的(旧数据、手改或别的插件写坏的):
1164
+ * 可选字段去掉,记在 dropped 里;必填字段不对时回这个字段名,整条不载入
1165
+ */
1166
+ function storedSession(key, data) {
1167
+ const record = Object.fromEntries(Object.entries(data).filter(([, value]) => value !== null));
1168
+ const required = {
1169
+ id: record.id === key,
1170
+ children: isTextList(record.children),
1171
+ status: SESSION_STATUSES.has(record.status),
1172
+ createdAt: isTime(record.createdAt),
1173
+ updatedAt: isTime(record.updatedAt),
1174
+ };
1175
+ const wrong = Object.keys(required).find(field => !required[field]);
1176
+ if (wrong)
1177
+ return { wrong };
1178
+ const dropped = Object.entries(OPTIONAL_FIELDS)
1179
+ .filter(([field, valid]) => record[field] !== undefined && !valid(record[field]))
1180
+ .map(([field]) => field);
1181
+ for (const field of dropped)
1182
+ delete record[field];
1183
+ return { record: record, dropped };
1184
+ }
1185
+ /**
1186
+ * 读回的场所记录:必填字段不对(含类别与键对不上:工作区的键以 `workspace:` 开头,窗口的不是)时回这个字段名,整条不载入;
1187
+ * 可选的名字(默认工作区没有)、目录与 metadata 类型不对时去掉它
1188
+ */
1189
+ function storedPlace(key, data) {
1190
+ const record = Object.fromEntries(Object.entries(data).filter(([, value]) => value !== null));
1191
+ const required = {
1192
+ key: record.key === key,
1193
+ kind: (record.kind === 'workspace' || record.kind === 'window') &&
1194
+ (record.kind === 'workspace') === key.startsWith(WORKSPACE_PREFIX),
1195
+ createdAt: isTime(record.createdAt),
1196
+ updatedAt: isTime(record.updatedAt),
1197
+ };
1198
+ const wrong = Object.keys(required).find(field => !required[field]);
1199
+ if (wrong)
1200
+ return { wrong };
1201
+ const dropped = [
1202
+ ...['name', 'root'].filter(field => record[field] !== undefined && !isText(record[field])),
1203
+ ...(record.metadata !== undefined && !isRecord(record.metadata) ? ['metadata'] : []),
1204
+ ];
1205
+ for (const field of dropped)
1206
+ delete record[field];
1207
+ return { record: record, dropped };
888
1208
  }
889
1209
  // ===== 插件入口 =====
890
1210
  const uses = {
891
1211
  /** 会话档案与消息历史的唯一落点:没有 memory 就没有会话管理 */
892
1212
  memory,
893
1213
  /**
894
- * 本插件不调用 agent 的方法;声明它是为了 agent:* 钩子的键类型。
895
- * 第一方栈由 core 关停编排(optional 环成员先全部 drain)与 `agent:turn:after` 收口;
1214
+ * 删除会话清数据时经它的 clearing 按住这个会话;另为 agent:* 钩子的键类型。
1215
+ * 第一方栈由 core 关停编排(optional 环成员先全部 drain)与回合结束时的收口处理;
896
1216
  * 此处 onDrain 兜的是未装 agent 或钩子未挂上的路径。
897
1217
  */
898
1218
  agent: optional(agent),
899
- /** 自动标题的模型来源;没有 LLM 时退回用户消息首段 */
1219
+ /** 自动标题的模型来源(没有 LLM 时退回用户消息首段) */
900
1220
  llm: optional(llm),
901
- /** 下列三项只供 WebUI 下拉框枚举选项,缺席即空列表 */
902
- persona: optional(persona),
1221
+ /**
1222
+ * 自动标题的输出语言:取全局语言(读者是 owner),缺席时按中文。plugin-i18n 也以 optional 用会话管理,
1223
+ * 两边成可选环
1224
+ */
1225
+ i18n: optional(i18n),
1226
+ /** 房间建档时问认领者、取平台的类型声明记下聊天窗口;解析接管房间的适配器实例(originInstance) */
903
1227
  platform: optional(platform),
904
- tools: optional(tools),
905
- /** 页面与页面动作的登记口;无 WebUI 时会话管理照常在后台运行 */
906
- webui: optional(webuiServer),
1228
+ /**
1229
+ * owner 的 IM 私聊在默认工作区:placeOf 按它认对端是不是 owner,与房间类别同一判据。plugin-authority 也以 optional
1230
+ * 用会话管理,两边成可选环;缺席时认不出 owner,一对一窗口按窗口算
1231
+ */
1232
+ authority: optional(authority),
1233
+ /**
1234
+ * 按会话读取别的会话的范围与三个开关;缺席时取插件配置里的值。plugin-session-settings 以 required 用会话管理:这边可选,
1235
+ * 读取器的登记随它上线补上
1236
+ */
1237
+ sessionFields: optional(sessionFields),
1238
+ /** 按会话字段的全局层:宿主配置文档里本实例的那一段 */
1239
+ document: optional(ownHostConfig),
1240
+ config,
907
1241
  events,
908
1242
  hooks,
909
1243
  lifecycle,
910
1244
  logger,
911
- config,
912
1245
  provide,
913
1246
  };
914
1247
  export default definePlugin({
915
1248
  name: '@aalis/plugin-session-manager',
916
- displayName: '会话管理',
1249
+ displayName: t('displayName'),
917
1250
  subsystem: 'session',
918
1251
  configSchema,
919
1252
  provides: [sessionManager],
@@ -921,101 +1254,81 @@ export default definePlugin({
921
1254
  apply: run,
922
1255
  });
923
1256
  async function run(caps) {
924
- const cfg = parseConfig(configSchema, caps.config, caps.logger);
925
- const { memory, webui, events, hooks, lifecycle, logger, provide } = caps;
926
- // 注册 WebUI 页面
927
- for (const page of webuiPages)
928
- webui.registerPage(page);
929
- if (memory.current === undefined) {
930
- logger.error('memory 服务不可用,会话管理无法启动');
931
- return;
932
- }
933
- const manager = new SessionManager(caps);
1257
+ const { memory, events, hooks, lifecycle, logger, provide } = caps;
1258
+ const manager = new SessionManager(caps, caps.sessionFields.use.fields(configSchema, caps));
934
1259
  // 会话表跟随 memory 胜者:首次挂接即加载;运行中胜者换人时先写回旧后端再读新后端
935
1260
  memory.follow(instance => manager.attach(instance));
936
1261
  await manager.loading;
937
1262
  // 读表(含重试等待)期间停用或停机的,不再发布服务与登记
938
- lifecycle.signal.throwIfAborted();
939
- // 加载平台 profiles
940
- manager.loadPlatformProfiles(cfg.platformProfiles);
941
- // 加载全局 defaults
942
- manager.loadDefaults(cfg.defaults);
1263
+ lifecycle.use.signal.throwIfAborted();
943
1264
  // 注册服务
944
- provide(sessionManager, manager, { label: '会话管理' });
945
- registerSessionActions(caps, manager);
946
- // ===== 会话状态自治管理 =====
947
- // 监听消息事件,自动维护会话状态(从 Agent 职责中迁出)
948
- events.on('inbound:message', (msg) => {
949
- if (!msg.sessionId)
1265
+ provide.use(sessionManager, manager, { label: t('displayName') });
1266
+ // ===== IM 房间收录 =====
1267
+ // 原生入站的收录与补记见 recordInbound;网关在入站相位之前也等它,同一会话共用同一次
1268
+ events.use.on('inbound:message', msg => manager.recordInbound(msg));
1269
+ // ===== 清空连带删除子会话 =====
1270
+ // 会话的消息历史清成功(结果里 type 为 context 的行都成功了)之后,它的子会话一并删除:范围为 session 删这个会话的,为 all
1271
+ // 删全部子会话,删了的在结果里加一行。只清别的类型、消息历史没清成时不删;会话不在表里(删除会话自己触发的那次清理,会话已
1272
+ // 先从表里摘掉)时什么都不做
1273
+ hooks.use.middleware('memory:clear', async (data, next) => {
1274
+ await next();
1275
+ if (!historyCleared(data))
950
1276
  return;
951
- const session = manager.getSession(msg.sessionId);
952
- if (session && session.status !== 'active') {
953
- manager.updateSession(msg.sessionId, { status: 'active' }).catch(() => { });
1277
+ const deleted = await manager.deleteChildren(data.scope === 'all' ? undefined : data.sessionId);
1278
+ if (deleted > 0) {
1279
+ data.results.push({
1280
+ source: 'session-manager',
1281
+ type: 'context',
1282
+ success: true,
1283
+ message: t('childrenDeleted', { count: deleted }),
1284
+ });
954
1285
  }
955
1286
  });
956
- events.on('outbound:message', (msg) => {
957
- if (!msg.sessionId)
958
- return;
959
- const session = manager.getSession(msg.sessionId);
960
- // 子会话(有 parentId)由 plugin-subtask 的 agent:turn:after 中间件负责完成并提取 result
961
- if (session && session.status === 'active' && !session.parentId) {
962
- manager.updateSession(msg.sessionId, { status: 'completed' }).catch(() => { });
1287
+ // ===== 会话状态 =====
1288
+ // 回合真正开始时(agent:turn:before)才翻 active,归档的不翻:群里只有消息、她没开口的房间不显示「进行中」
1289
+ //(触发判定在入站相位,挡下的消息到不了这里)。同一个中间件在 finally 里记下这一轮并收口,根会话与子会话一样,这是
1290
+ // 唯一的收口:next() 返回时整轮已跑完、agent:turn:after 也已执行(agent 只在这个钩子的默认动作里发它),结局已经记下;
1291
+ // 后面的中间件不调 next() 或抛错时 agent 不发回合收尾,同样由这里兜住。按回合 id 登记在飞的回合,会话里没有别的在飞回合
1292
+ // 时才收口;回合中途的出站(send_attachment、paper_send 在工具执行中直接发)不收口。
1293
+ hooks.use.middleware('agent:turn:before', async (data, next) => {
1294
+ manager.beginTurn(data.sessionId, data.turnId);
1295
+ try {
1296
+ await next();
1297
+ }
1298
+ finally {
1299
+ manager.endTurn(data.turnId);
963
1300
  }
964
1301
  });
965
- // 回合终态收口:agent 在 replied/silent/aborted/error 四条路径都会发 agent:turn:after。
966
- // 上面的 outbound:message 只覆盖"产生了回复"的情形——用户中途停止生成(aborted)或
967
- // 空回复(silent)时不发 outbound:message,会话会永远停在 'active'(即"进行中")。
968
- // 这里订阅生命周期钩子作幂等互补,确保任何回合结束都把根会话收口为 'completed'。
969
- hooks.middleware('agent:turn:after', async (data, next) => {
1302
+ hooks.use.middleware('agent:turn:after', async (data, next) => {
1303
+ manager.recordOutcome(data.turnId, data.outcome, data.error);
970
1304
  await next();
971
- if (!data.sessionId)
972
- return;
973
- const session = manager.getSession(data.sessionId);
974
- // 子会话由 plugin-subtask 负责完成并回传 result,这里只收口根会话。
975
- if (session && session.status === 'active' && !session.parentId) {
976
- manager.updateSession(data.sessionId, { status: 'completed' }).catch(() => { });
977
- }
978
1305
  });
979
- // 监听用户消息事件 → 自动生成会话标题
980
- // 在用户首次发消息时即生成标题,无需等待 AI 回复
981
- // 仅对 webui / cli 等用户交互平台生效,onebot 等外部平台不生成标题
982
- const TITLE_PLATFORMS = new Set(['webui', 'cli']);
1306
+ // 模型调用的 token 按会话累加:对话、摘要、识图、转写的调用都带会话 id
1307
+ events.use.on('llm:usage', usage => manager.recordUsage(usage));
1308
+ // 自动标题:只给带创建来源的根会话(WebUI 新建的、终端建的)生成,在首条消息时生成、不等回复;IM 房间不生成,
1309
+ // owner 从 WebUI 往房间插话也不拿来给房间起名。异步生成,不阻塞消息处理;直接传入这条消息,不依赖历史
983
1310
  const titleGenerating = new Set();
984
- events.on('inbound:message', (msg) => {
985
- const { sessionId, platform } = msg;
986
- if (!sessionId) {
987
- logger.debug('标题生成跳过: 缺少 sessionId');
988
- return;
989
- }
990
- if (titleGenerating.has(sessionId))
991
- return;
992
- // 仅对指定平台生成标题;非 webui/cli 平台(如 onebot)静默跳过,避免日志污染。
993
- // platform 缺省同样跳过:白名单是正向门,来路不明的消息不该顺带建档 + 烧一次 LLM 生成标题。
994
- if (!platform || !TITLE_PLATFORMS.has(platform))
995
- return;
1311
+ events.use.on('inbound:message', (msg) => {
1312
+ const { sessionId } = msg;
996
1313
  const session = manager.getSession(sessionId);
997
- // 已有标题或子任务会话跳过(静默)
998
- if (session && (session.title || session.parentId))
1314
+ if (!session?.createdFrom || session.title || titleGenerating.has(sessionId))
999
1315
  return;
1000
1316
  titleGenerating.add(sessionId);
1001
- logger.info(`开始生成会话标题: ${sessionId} (platform=${platform})`);
1002
- // 平台派生会话(cli-default 等)从不经 createSession 预建,缺档是常态:先兜底建档再生成
1003
- // 标题(与 createChildSession 同路),否则这些平台永远没有标题、且每条消息告警一次。
1004
- // 异步生成,不阻塞消息处理;直接传入用户消息避免依赖历史
1005
- (session ? Promise.resolve() : manager.ensureSession(sessionId).then(() => undefined))
1006
- .then(() => manager.generateTitle(sessionId, msg.content))
1007
- .catch(err => logger.warn('标题生成失败:', err))
1317
+ logger.use.info(`开始生成会话标题: ${sessionId}`);
1318
+ manager
1319
+ .generateTitle(sessionId, msg.content)
1320
+ .catch(err => logger.use.warn('标题生成失败:', err))
1008
1321
  .finally(() => titleGenerating.delete(sessionId));
1009
1322
  });
1010
1323
  // 关停收尾:仍 active 的会话在 onDrain 收口并落盘。
1011
- // 第一方栈由 core 关停编排(optional 环成员先全部 drain)与 agent:turn:after 收口;
1324
+ // 第一方栈由 core 关停编排(optional 环成员先全部 drain)与回合结束时的收口处理;
1012
1325
  // 此处兜的是未装 agent 或钩子未挂上的路径。
1013
1326
  // 持久化仍走 onDispose:覆盖 bounce / unload / updateConfig 等全部拆卸路径
1014
1327
  // (只在全局停机触发的话,热重载即丢会话元数据)。
1015
1328
  // 异步收尾由编排层的 disposeAsync 等待完成;app.stop() 的拓扑逆序保证此时 memory 提供者
1016
1329
  // 尚未关闭,单独热重载/禁用 memory 提供者时无此保证。shutdown() 幂等:清 timer + 置 dirty + 落盘。
1017
- lifecycle.onDrain(() => manager.settleActiveOnDrain(), '收口在飞会话');
1018
- lifecycle.onDispose(() => manager.shutdown());
1019
- logger.info('会话管理服务已启用');
1330
+ lifecycle.use.onDrain(() => manager.settleActiveOnDrain(), '收口在飞会话');
1331
+ lifecycle.use.onDispose(() => manager.shutdown());
1332
+ logger.use.info('会话管理服务已启用');
1020
1333
  }
1021
1334
  //# sourceMappingURL=index.js.map