dsh-bulletin-dispatch 1.3.19

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.
@@ -0,0 +1,427 @@
1
+ /**
2
+ * **会话回收**:把"平台侧已经不存在的会话"从 `sessions` 表里清掉。
3
+ *
4
+ * ## 为什么需要(2026-09-30)
5
+ *
6
+ * > *"已认桌的会话,我要是把那个会话删掉,系统和仪表盘能不能也同步删掉?"*
7
+ *
8
+ * **原来不能** —— `sessions` 表**只增不减**(只有认桌时往里写,全项目**没有一处 `sessions.delete`**)
9
+ * ⇒ 你删掉会话,它只是不再跑 `pre-step`,**记录永远留在表里**,
10
+ * 状态表上**一直列着一个已经不存在的会话**。
11
+ *
12
+ * ⚠️ 这是"**表在骗人**"那一类问题 —— 与今天修过的几个同类(状态表漏会话、死配置键)。
13
+ *
14
+ * ## 判据(**2026-09-30 实测过,不是推断**)
15
+ *
16
+ * | 事实 | 证据 |
17
+ * |---|---|
18
+ * | 删会话后 `list()` 里少了它 | 24 → 23 ✅ |
19
+ * | ⭐ **`stat(被删的 id)` 返回 `undefined`** | ✅ 实测 |
20
+ * | ⭐ **`stat` 对"没在跑的旧会话"照样认得** | ✅ 被删的那个本来就没在跑,删前答"有"、删后才 `undefined` ⇒ **不会误删历史会话** |
21
+ *
22
+ * 平台契约原文:`stat` *"returns the snapshot, or **`undefined` when the session does not exist**"*。
23
+ *
24
+ * ## ⭐⭐ 判据的最终形态:**问平台"现在有哪些会话",而不是自己记**(2026-09-30)
25
+ *
26
+ * ### 第一版(错的,已废):自己维护一个"见过活着"的集合
27
+ *
28
+ * 原来是 `stat(id)` + 一个**进程内** `ALIVE` 集合:**只有"曾经查到过它活着"才敢删**。
29
+ * 想防的是"平台一时查不到 ⇒ 误删"。
30
+ *
31
+ * **但它在真环境里坏了**(用户实测):用户删掉一个会话后,回收**跑了 4 次都没清它**,
32
+ * 而日志只说"没有发现已消失的会话" —— **看不出为什么**。逐 id 诊断才看清:
33
+ *
34
+ * ```
35
+ * session-2265ab81… → undefined(平台说它不存在)
36
+ * 但它在"见过活着"的集合里:没有
37
+ * ⇒ if (!ALIVE.has(id)) continue; ← 静默跳过
38
+ * ```
39
+ *
40
+ * **根因**:进程**重启过** ⇒ `ALIVE` 重置 ⇒ 那个会话**从未在本进程被"认领"过**
41
+ * ⇒ **安全阀永久拦着它**。**我为了防误删,造了一个重启后就永远不工作的东西。**
42
+ *
43
+ * ### 第二版(现在):直接问平台要权威名单
44
+ *
45
+ * ⭐ **去仓库里找到了现成的**:`ctx.sessionQuery.listSessions()` ——
46
+ * 契约原文:*"List the **complete logical corpus** with live precedence and cloned headers"*。
47
+ *
48
+ * ⇒ **拿它当"现在有哪些会话"的权威名单**,表里不在其中的就删。
49
+ * **不需要内存集合、不需要持久化、跨重启天然正确**(平台知道真相,我不知道)。
50
+ *
51
+ * > ⚠️ `sessionQuery` **在插件根 ctx 上取不到** —— 只能在 **`agent.ctx`** 上拿
52
+ * > (这一条在 `identity.js` 里已经踩过并记着)。所以扫描要在 **pre-step** 里做,
53
+ * > 因为那里正好有 `payload.agent.ctx`。
54
+ *
55
+ * ## 安全设计(**三条**,第 2 条被上面那次替换掉了)
56
+ *
57
+ * | # | 措施 |
58
+ * |---|---|
59
+ * | **1** | **只删 `sessions` 表的记录** —— 不动单子、不动取走历史(那些是别的桌的事) |
60
+ * | **2** | ⭐ **以 `listSessions()` 为准** —— 它不在名单里 ⇒ 平台说它不存在 ⇒ 才删。<br>(拿不到名单时**一条都不删** —— 宁可不清,也不误删) |
61
+ * | **3** | **不删当前会话自己**(它当然存在,但省一次查询、也避免奇怪的时序) |
62
+ * | **4** | **节流**(默认 10 分钟一次):列名单/查询都是异步的,不能每步都全查 |
63
+ *
64
+ * ⚠️ **清掉一条记录不会丢东西** —— `sessions` 表是**缓存**(注释里就写着"这是缓存不是权威")。
65
+ * 那个会话要是又出现了,下一轮就会**重新认桌、重新写进去**。
66
+ */
67
+ import { errText } from '../log.js';
68
+ import { listRecords } from '../store.js';
69
+ import { findSessionQuery } from '../identity.js';
70
+
71
+ /** 本功能的配置键(与 `index.js` 的开关同名)。 */
72
+ export const FEATURE = 'sessionGc';
73
+
74
+ /**
75
+ * ⚠️ **只在"拿不到权威名单"时用的兜底**(第一版的机制)。
76
+ *
77
+ * 正常路径已经改成"问平台要名单"(见上面那段长注释)。
78
+ * 这个集合只在**服务不可用**时留个念想 —— 但那时我们**一条都不删**,所以它其实不参与判据了。
79
+ * 保留它只为了让自测能验"没见过活着就不敢删"这条历史行为。
80
+ */
81
+ const ALIVE = new Set();
82
+
83
+ /** ⚠️ **仅供自测**:清掉进程内状态。 */
84
+ export function resetSessionGcForTest() {
85
+ ALIVE.clear();
86
+ }
87
+
88
+ /**
89
+ * 跑一次回收。
90
+ *
91
+ * @param {object} p
92
+ * @param {object} p.sessions `sessions` 表
93
+ * @param {object} p.sp `sessionPersistence` 服务
94
+ * @param {string} p.selfSessionId 当前会话(**不删它**)
95
+ * @param {Function} p.log 诊断日志
96
+ * @param {Function} p.warn 警告
97
+ * @returns {Promise<{ checked: number, removed: string[], alive: number }>}
98
+ */
99
+ export async function sweepSessions({ sessions, sp, sessionQuery, selfSessionId, forget, log, warn }) {
100
+ const rows = listRecords(sessions).filter(([, r]) => r !== null && typeof r === 'object');
101
+ const removed = [];
102
+
103
+ /**
104
+ * ⭐ **明确点名"忘掉这个 id"**(2026-09-30 加,为清掉一个孤儿子代理)。
105
+ *
106
+ * ## 为什么需要这个入口
107
+ *
108
+ * 有个子代理会话(`e0c45fb8…`)—— 它**跑过一次**(所以表里有记录),
109
+ * 但它是个**空壳**(解压后只有 header),而且**界面到不了它**
110
+ * (子代理不列在侧栏、搜索也搜不到)。
111
+ *
112
+ * ⇒ 删掉它的文件之后,平台**可能**还认得它 ⇒
113
+ * 常规判据("平台认得就不删")会**一直留着那一行**。
114
+ * ⇒ 给一个**显式的、只删指定 id** 的入口,比等平台自己发现更干脆。
115
+ *
116
+ * ⚠️ **只在被明确点名时走这条路**(`forget: ['id']`)—— **绝不用于自动清理**。
117
+ * 日常那条路仍然是"以 `listSessions()` 的权威名单为准"(见下面)。
118
+ */
119
+ if (Array.isArray(forget) && forget.length > 0) {
120
+ for (const id of forget) {
121
+ const key = String(id);
122
+ if (key === selfSessionId) continue;
123
+ try {
124
+ await sessions.delete(key);
125
+ removed.push(key);
126
+ log('会话回收/按名忘掉', { id: key.slice(0, 24) });
127
+ } catch (error) {
128
+ warn('回收:忘不掉(不影响别的)', { id: key.slice(0, 24), error: errText(error) });
129
+ }
130
+ }
131
+ return { checked: 0, removed, alive: ALIVE.size, skipped: [], platformKnows: null };
132
+ }
133
+
134
+ /**
135
+ * ⭐ **第一步:问平台要"现在有哪些会话"的权威名单。**
136
+ *
137
+ * ⚠️ **拿不到名单就一条都不删** —— 宁可不清,也不误删。
138
+ * (这正是第一版把 `ALIVE` 当判据时犯的错:那个判据在重启后**永远不工作**。)
139
+ */
140
+ let known = null;
141
+ try {
142
+ const all = await sessionQuery.listSessions();
143
+ known = new Set(all.map((r) => String(r?.header?.id ?? '')).filter((x) => x !== ''));
144
+ } catch (error) {
145
+ warn('回收:拿不到会话名单 ⇒ 这一轮不删任何东西', { error: errText(error) });
146
+ }
147
+ if (known === null) {
148
+ return { checked: 0, removed: [], alive: ALIVE.size, skipped: [], note: '拿不到权威名单,未动任何记录' };
149
+ }
150
+
151
+ /** 顺带把"平台认得、我们表里也有"的记进兜底集合(诊断用)。 */
152
+ for (const [id] of rows) if (known.has(id)) ALIVE.add(id);
153
+
154
+ const skipped = [];
155
+ let checked = 0;
156
+
157
+ for (const [id] of rows) {
158
+ if (id === selfSessionId) continue; // 措施 3:不动自己
159
+ checked += 1;
160
+ if (known.has(id)) continue; // ⭐ 平台认得它 ⇒ 留着
161
+
162
+ /**
163
+ * ⚠️ **平台不认得它 ⇒ 删**。
164
+ *
165
+ * 这里**不再需要"我见过它活着没有"** —— 平台自己的名单就是真相。
166
+ * (第一版正是在这里静默跳过,导致"重启前删掉的会话永远清不掉"。)
167
+ */
168
+ try {
169
+ await sessions.delete(id);
170
+ ALIVE.delete(id);
171
+ removed.push(id);
172
+ } catch (error) {
173
+ warn('回收:删不掉(不影响别的)', { id: id.slice(0, 24), error: errText(error) });
174
+ skipped.push(id);
175
+ }
176
+ }
177
+
178
+ if (removed.length > 0) {
179
+ log('会话回收/已清理', {
180
+ count: removed.length,
181
+ ids: removed.map((x) => x.slice(0, 24)),
182
+ note: '平台名单里已经没有它们了',
183
+ });
184
+ } else {
185
+ log('会话回收/检查', {
186
+ checked,
187
+ platformKnows: known.size,
188
+ alive: ALIVE.size,
189
+ heldBySafety: skipped.length,
190
+ heldIds: skipped.map((x) => x.slice(0, 24)),
191
+ note: skipped.length > 0 ? '有几个删不掉(见 warning)' : '表里每一条都还在平台名单里',
192
+ });
193
+ }
194
+ return { checked, removed, alive: ALIVE.size, skipped, platformKnows: known.size };
195
+ }
196
+
197
+ /**
198
+ * 装这个功能。
199
+ *
200
+ * @param {object} api `{ ctx, config, log, warn, store, onStateChanged }`
201
+ */
202
+ export function setup(api) {
203
+ const { ctx, config, log, warn, store } = api;
204
+ const sessions = store.tables.sessions;
205
+ const intervalMs = Number.isFinite(Number(config.sweepIntervalMs))
206
+ ? Math.max(0, Number(config.sweepIntervalMs))
207
+ : 600_000;
208
+ /** 上次全查的时间(节流用)。 */
209
+ let lastSweep = 0;
210
+ /** 正在跑(防止两轮重叠)。 */
211
+ let running = false;
212
+ /** `sessionPersistence`(由下面的 `inject` 填上;没就位时就是 undefined)。 */
213
+ let sp;
214
+ /** ⚠️ 诊断用:这个实例的 pre-step 有没有被调过(只打一次)。 */
215
+ let sawFirstCall = false;
216
+ /**
217
+ * ⚠️ **最近一次在 pre-step 里看到的 `sessionQuery`**。
218
+ *
219
+ * 为什么留这一手:诊断工具 `sweep_sessions` 是在**工具调用**里跑的,
220
+ * 那里**未必有 `agent.ctx`** ⇒ 拿不到 `sessionQuery`。
221
+ * 而它每次 pre-step 都会被看到 ⇒ **记住一份**,工具就能用。
222
+ */
223
+ let lastSessionQuery;
224
+
225
+ /**
226
+ * 试着跑一次(**节流 + 单飞**)。
227
+ *
228
+ * ⚠️ 列名单是**异步**的 ⇒ 不能每步都全查。
229
+ * 挂在 pre-step 上只是为了"**每轮顺手看一眼**",真正全查由 `sweepIntervalMs` 控制。
230
+ *
231
+ * ⚠️⚠️ **`sessionQuery` 必须从 `agent.ctx` 拿**(不是插件根 ctx)——
232
+ * 这一条在 `identity.js` 里已经踩过并记着。
233
+ */
234
+ function maybeSweep(sessionId, sessionQuery) {
235
+ if (intervalMs === 0) return; // 0 = 关掉(配置可关)
236
+ if (sessionQuery === undefined) return; // 拿不到名单服务 ⇒ 下次再说
237
+ if (running) return;
238
+ const now = Date.now();
239
+ if (now - lastSweep < intervalMs) return;
240
+ running = true;
241
+ lastSweep = now;
242
+ /**
243
+ * ⚠️ **留痕**(2026-09-30 加):排查"回收到底有没有跑"时,
244
+ * 原来只有"跑完"的日志 ⇒ **分不清"没被调度"和"被调度了但正在跑"**。
245
+ * (同一类"静默"问题本项目踩过很多次:认桌/检查 那行就是这么加的。)
246
+ */
247
+ log('会话回收/开始全查', { intervalMs, rows: listRecords(sessions).length });
248
+ void sweepSessions({ sessions, sp, sessionQuery, selfSessionId: sessionId, log, warn })
249
+ .then((r) => {
250
+ // ⭐ 删了东西 ⇒ 喊一声让状态表跟上(与"认桌落库"走同一条通路)
251
+ if (r.removed.length > 0) api.onStateChanged?.('会话回收');
252
+ })
253
+ .catch((error) => warn('会话回收整体失败(已忽略)', { error: errText(error) }))
254
+ .finally(() => { running = false; });
255
+ }
256
+
257
+ /**
258
+ * ⚠️ 用 **`inject` 拿服务**(不是 `ctx.get`)—— 这一条被误判过两次,
259
+ * 详见 `进度与待办.md` §二之八之六:`ctx.get` 在"插件刚挂载那一刻"取不到任何服务。
260
+ */
261
+ ctx.effect(() => ctx.inject(['sessionPersistence'], (sctx) => {
262
+ sp = sctx.sessionPersistence;
263
+ log('会话回收/服务已就位', {
264
+ intervalMs,
265
+ /**
266
+ * ⚠️⚠️ **把"我到底收到什么 config"整个打出来**(2026-09-30)。
267
+ *
268
+ * 现象:三份 `cordis.patch.yml`(源码 / profile junction / live 代际)**都写着 `60000`**,
269
+ * 而这个日志报 `intervalMs = 600000` ⇒ **加载器传给插件的 config 不是我 patch 里那份**。
270
+ * ⇒ 不再猜"是加载器旧了还是我读错了" —— **直接把现场打出来**:
271
+ * · `config` 上有没有 `sweepIntervalMs` 这个键
272
+ * · 如果有,值是多少
273
+ * · 全部键名是什么(看它像不像我那份 patch)
274
+ */
275
+ configKeys: Object.keys(config ?? {}),
276
+ configSweep: config?.sweepIntervalMs,
277
+ typeofSweep: typeof config?.sweepIntervalMs,
278
+ });
279
+ }));
280
+
281
+ /** ⚠️ 平级的第二个 effect(**不要嵌套**)—— 监听器和 inject 各管各的。 */
282
+ ctx.effect(() => ctx.on('agent/pre-step', async (payload, next) => {
283
+ const decision = await next();
284
+ try {
285
+ const id = payload?.agent?.session?.id;
286
+ /**
287
+ * ⭐ **`sessionQuery` 只能在 `agent.ctx` 上拿**(插件根 ctx 上没有它)。
288
+ *
289
+ * ⚠️⚠️ **而且不能直接点属性** —— 实测报错原文:
290
+ * `Error: cannot get property "sessionQuery" without inject`
291
+ * ⇒ Cordis **必须先声明依赖**(`ctx.get(...)` 或 `inject`)。
292
+ *
293
+ * **`findSessionQuery()` 正是干这个的**(先 `get()`、不行再点属性)——
294
+ * 而且 `identity.js` 里早就写着这条坑。**我第一次写这个功能时没照它做,白挨一次。**
295
+ */
296
+ const sq = findSessionQuery(payload?.agent?.ctx);
297
+ if (sq !== undefined) lastSessionQuery = sq;
298
+ /**
299
+ * ⚠️ **每个进程的第一次调用必打一条**(2026-09-30 加,为了查一个查不出的问题)。
300
+ *
301
+ * 现象:真环境里 `认桌/检查` 每 10 秒都在跑(⇒ pre-step 是活的),
302
+ * 但回收那条路**一次都没走到**(连"开始全查"都没有)——
303
+ * 而**同样的代码在隔离环境逐字验过**(`intervalMs=60000` → 全查 → 检查)。
304
+ * ⇒ 把**决定是否开跑的那几个变量的现场**打出来,**不再靠推断**。
305
+ */
306
+ if (!sawFirstCall) {
307
+ sawFirstCall = true;
308
+ log('会话回收/首次调度', {
309
+ hasSessionId: typeof id === 'string' && id !== '',
310
+ serviceReady: sp !== undefined,
311
+ hasSessionQuery: sq !== undefined,
312
+ intervalMs,
313
+ configSweep: config?.sweepIntervalMs,
314
+ running,
315
+ lastSweep,
316
+ });
317
+ }
318
+ if (typeof id === 'string' && id !== '') maybeSweep(id, sq);
319
+ } catch (error) {
320
+ warn('会话回收的调度出错(已忽略)', { error: errText(error) });
321
+ }
322
+ return decision;
323
+ }));
324
+
325
+ /**
326
+ * 自测/诊断用:**立刻**跑一次(绕过节流)。
327
+ *
328
+ * ⚠️ 为什么要暴露它:真环境里回收"跑了但没删",而**日志看不出为什么**
329
+ * —— 分不清是"`stat` 说它还活着"、"没见过它活着"、还是"删失败了"。
330
+ * ⇒ 给一个能**当场问**的入口,比读日志猜强。
331
+ */
332
+ function sweepNow(selfSessionId, sessionQuery, forget) {
333
+ const sq = sessionQuery ?? lastSessionQuery;
334
+ if (sq === undefined) return Promise.resolve({ checked: 0, removed: [], alive: ALIVE.size, skipped: [], note: '拿不到 sessionQuery(它只在 agent.ctx 上)' });
335
+ return sweepSessions({ sessions, sp, sessionQuery: sq, selfSessionId, forget, log, warn });
336
+ }
337
+
338
+ /** 诊断用:现在"见过活着"的集合 + 节流状态。 */
339
+ function gcState() {
340
+ return {
341
+ alive: [...ALIVE],
342
+ aliveCount: ALIVE.size,
343
+ intervalMs,
344
+ lastSweep,
345
+ running,
346
+ serviceReady: sp !== undefined,
347
+ hasSessionQuery: lastSessionQuery !== undefined,
348
+ };
349
+ }
350
+
351
+ /**
352
+ * ⭐ **诊断工具**(2026-09-30 加)。
353
+ *
354
+ * 为什么需要:真环境里回收"跑了但没删",**读日志看不出为什么** ——
355
+ * 分不清"`stat` 说它还活着"、"没见过它活着"、还是"删失败了"。
356
+ * ⇒ 给一个**能当场问**的入口。
357
+ */
358
+ ctx.effect(() => ctx.inject(['tools'], (tctx) => tctx.tools.register({
359
+ name: 'sweep_sessions',
360
+ description:
361
+ '【诊断】立刻跑一次会话回收(绕过节流),报告:查了几个、删了几个、'
362
+ + '以及"见过活着"的会话集合。用来回答"我的会话回收到底有没有在工作"。'
363
+ + '⚠️ 会真的删除记录(但只删"平台侧已确认不存在"的那些)。',
364
+ parameters: {
365
+ type: 'object',
366
+ properties: {
367
+ dry: { type: 'boolean', description: '只报告不删(默认 false)' },
368
+ stats: { type: 'boolean', description: '逐 id 报平台名单里有没有它' },
369
+ forget: {
370
+ type: 'string',
371
+ description: '⭐ **明确点名要忘掉的会话 id**(逗号分隔)—— 只删这些,'
372
+ + '走的是"人明确要求"而不是自动判据。用来清掉"平台可能还认得、但确定不要了"的记录。',
373
+ },
374
+ },
375
+ required: [],
376
+ additionalProperties: false,
377
+ },
378
+ output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: String(v) }] },
379
+ execute: async (args, exec) => {
380
+ const out = [];
381
+ const st = gcState();
382
+ out.push(`节流:${st.intervalMs} ms | 上次全查:${st.lastSweep === 0 ? '(还没跑过)' : `${new Date(st.lastSweep).toISOString().slice(11, 19)}Z`}`);
383
+ out.push(`拿到 sessionQuery:${st.hasSessionQuery} | sessionPersistence:${st.serviceReady}`);
384
+
385
+ /**
386
+ * ⭐ **先要权威名单** —— 判据是"平台名单里有没有它",不再是"我见没见过它活着"。
387
+ */
388
+ let known = null;
389
+ const sq = findSessionQuery(exec?.agent?.ctx) ?? lastSessionQuery;
390
+ if (sq !== undefined) {
391
+ try {
392
+ const all = await sq.listSessions();
393
+ known = new Set(all.map((r) => String(r?.header?.id ?? '')).filter((x) => x !== ''));
394
+ } catch (error) { out.push(`⚠️ 列名单失败:${errText(error)}`); }
395
+ }
396
+ out.push(known === null
397
+ ? '**平台名单:拿不到**(⇒ 按设计,这一轮不会删任何东西)'
398
+ : `**平台名单里有 ${known.size} 个会话**`);
399
+
400
+ const rows = listRecords(sessions).map(([id]) => id);
401
+ out.push('');
402
+ out.push(`我的记录表里有 ${rows.length} 条:`);
403
+ if (known !== null && (args?.stats === true || args?.dry === true)) {
404
+ for (const id of rows) {
405
+ out.push(` · \`${id.slice(0, 26)}…\` ${known.has(id) ? '(平台认得 ✅)' : '(**平台说没有** ⇒ 该清)'}`);
406
+ }
407
+ }
408
+
409
+ if (args?.dry !== true) {
410
+ out.push('');
411
+ out.push('**跑一次全查**:');
412
+ const forget = typeof args?.forget === 'string' && args.forget !== ''
413
+ ? args.forget.split(',').map((s) => s.trim()).filter(Boolean)
414
+ : undefined;
415
+ if (forget !== undefined) out.push(` ⚠️ 这次是**按名忘掉**:${forget.join(', ')}`);
416
+ const r = await sweepNow(exec?.agent?.session?.id, sq, forget);
417
+ out.push(` 查了 ${r.checked} 个 · 删了 ${r.removed.length} 个${r.removed.length > 0 ? `:${r.removed.map((x) => `\`${x.slice(0, 22)}…\``).join('、')}` : ''}`);
418
+ if (typeof r.note === 'string') out.push(` 说明:${r.note}`);
419
+ if (Array.isArray(r.skipped) && r.skipped.length > 0) out.push(` ⚠️ 没删掉的:${r.skipped.length} 个`);
420
+ out.push(` 跑完"见过活着"的集合:${gcState().aliveCount} 个`);
421
+ }
422
+ return out.join('\n');
423
+ },
424
+ })));
425
+
426
+ return { sweepNow, gcState };
427
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * 「提议改名」—— 2026-09-30 的 **方案 A:提议 + 用户点头才改**。
3
+ *
4
+ * ## 为什么不是"AI 自己改"(哪怕技术上完全做得到)
5
+ *
6
+ * 平台**确实**提供了这个能力(实测查到的服务契约):
7
+ * `ctx.sessionTitle.rename(session, title)` —— **真服务,不是 `@Remote`,插件直接可调**。
8
+ * (另一条 `ctx.sessionController.rename()` 是 `@Remote`,那是**给 UI 的**,即用户手动改名走的路。)
9
+ *
10
+ * 但能改 ≠ 该自己改。两条风险:
11
+ * ① **会覆盖用户起的名字** —— 会话里所有 AI 都能改标题;
12
+ * ② **任何一张桌都能改任意会话的名字**,包括别的桌的。
13
+ * 而办公室既有规矩是「**动别的桌子之前要先问用户**」——
14
+ * **标题记录的是"这张会话属于谁",它是用户的东西,不是插件的。**
15
+ *
16
+ * ## 所以本功能只做两件事
17
+ *
18
+ * 1. **认不出桌时,提议一次**(写进系统提示;只在"标题没有 `NN-` 前缀"时出现,
19
+ * 而且**只提议一次** —— 用户不理会就不再唠叨);
20
+ * 2. 提供 **`set_session_title` 工具** —— **AI 只在明确同意后**才调用它。
21
+ *
22
+ * ⚠️ **已经有 `NN-` 前缀的会话一个字都不提** —— 绝不覆盖用户已经起好的名字。
23
+ *
24
+ * ## ⚠️ 一个硬事实(2026-09-30)
25
+ *
26
+ * > **"开新会话不能直接改名,要先发一条消息产生真实会话之后我才能手动改。"**
27
+ *
28
+ * ⇒ 所以"平台先给自动标题、用户后改名"是**必然顺序**。
29
+ * ⇒ 这条提议是**这个缺口的补丁**:AI 可以在用户同意后**当场**改名,不用用户去点。
30
+ * ⇒ 而且**名字建议也不是瞎猜** —— 它从"这个会话最可能在干哪张桌的活"推:
31
+ * `cwd` 能对上某张桌的工作区 ⇒ 用那张桌的名字;否则用**最近在改的文件路径**推。
32
+ */
33
+ import { deskFromTitle, deskLabel, isResolvedDesk, lookupDesk, rememberDesk } from '../identity.js';
34
+ import { errText } from '../log.js';
35
+
36
+ /** 本功能的配置键(与 `index.js` 的开关同名)。 */
37
+ export const FEATURE = 'proposeRename';
38
+
39
+ /**
40
+ * `systemPrompt.section()` 的排序位。
41
+ *
42
+ * ⚠️ **`order` 是必填的**(服务契约 `PromptSection.order: number`),
43
+ * 忘了传会抛错。用一个小正数:排在"身份/人设"之后、具体工具说明之前。
44
+ */
45
+ const SECTION_ORDER = 400;
46
+
47
+ /**
48
+ * 装配"提议改名"。
49
+ *
50
+ * @param {object} api 共享运行环境(见 `index.js`)
51
+ * @returns {() => void} 卸载函数
52
+ */
53
+ export function setup(api) {
54
+ const { ctx, config, log, warn, store } = api;
55
+ const sessions = store.tables.sessions;
56
+ const deskNames = config.deskNames ?? {};
57
+
58
+ /**
59
+ * "这个会话提过了"的记账。
60
+ *
61
+ * ⚠️⚠️ **两个坑都踩过(2026-09-30 发现的)**:
62
+ *
63
+ * ① **原来用内存 `Set`** ⇒ 插件重载 / DSH 重启后**忘了** ⇒ 同一个会话**又提一次**。
64
+ * **⇒ 落盘记**(`misc` 表,键 `proposed:<sessionId>`)——
65
+ * **因为"一直被提示"比不提示更糟**(见 `docs\01` 那一节)。
66
+ *
67
+ * ② **原来在"返回文本之前"就 `add()`** ⇒ 万一那次渲染没真的送到模型
68
+ * (平台可能为了测量/试渲染而调用 `text()`),**就永久不说了** —— 反向的错。
69
+ * **⇒ 改成 `text()` 被调用过一次之后才记账**(保守:宁可多提一次,不可漏提)。
70
+ *
71
+ * ⚠️ 顺带:**`misc` 是权威存储,跨重装/重启都在**;它写不上时退回内存集合
72
+ * (那样最坏是多提一次,可接受)。
73
+ */
74
+ const proposedMemory = new Set();
75
+ const proposalKey = (sessionId) => `proposed:${sessionId}`;
76
+
77
+ function alreadyProposed(sessionId) {
78
+ try {
79
+ if (store.tables.misc?.get?.(proposalKey(sessionId)) !== undefined) return true;
80
+ } catch { /* 存储读不了 → 看内存 */ }
81
+ return proposedMemory.has(sessionId);
82
+ }
83
+
84
+ function markProposed(sessionId) {
85
+ proposedMemory.add(sessionId);
86
+ try {
87
+ store.tables.misc?.put?.(proposalKey(sessionId), { value: new Date().toISOString(), at: Date.now() });
88
+ } catch (error) {
89
+ warn('改名提议的记账写不上(最坏是下次再提一次)', { error: errText(error) });
90
+ }
91
+ }
92
+
93
+ // ── ① 提议(写进系统提示,但**只在没认出桌时**,且只提一次)────────────────
94
+ ctx.effect(() => ctx.inject(['systemPrompt'], (pctx) => pctx.systemPrompt.section({
95
+ name: 'bulletin-dispatch:propose-rename',
96
+ order: SECTION_ORDER,
97
+ text: (context) => {
98
+ try {
99
+ const agent = context?.agent;
100
+ const sessionId = agent?.session?.id;
101
+ if (typeof sessionId !== 'string' || sessionId === '') return '';
102
+ // ⭐ **先查"当前真相"视图**(同步、进程内),没有才查权威存储。
103
+ // 修的就是"注入说桌 02、系统提示还说认不出"那个矛盾:
104
+ // 视图让**同一轮里所有读者看到同一份**数据。
105
+ const known = lookupDesk(sessionId) ?? sessions.get(sessionId);
106
+ const desk = typeof known?.desk === 'string' ? known.desk : undefined;
107
+ // ⭐ **已经认出桌的会话一个字都不提** —— 绝不覆盖用户起好的名字。
108
+ if (desk === undefined || isResolvedDesk(desk)) return '';
109
+ if (alreadyProposed(sessionId)) return '';
110
+
111
+ // ⚠️ **不猜桌号**:多张桌共用同一个 cwd(`<工作区>`),
112
+ // 从会话上**推不出**它属于哪张桌 —— 猜错比不猜更糟。**直接问用户。**
113
+ const deskList = Object.entries(config.deskNames ?? {})
114
+ .map(([id, name]) => `\`${id}-${name}\``).join('、');
115
+ const text = '跨桌投递:这个会话的标题没有桌号前缀,**认不出它属于哪张桌**。\n'
116
+ + (deskList === '' ? '' : `办公室的桌子有:${deskList}。\n`)
117
+ + '⚠️ 顺手可以问用户一句"这个会话是哪张桌的",但**要改名前必须先得到用户同意** —— '
118
+ + '同意之后才调用 `set_session_title`。**用户不同意就不要改,也不要再问第二次。**\n'
119
+ + '(`NN-` 前缀是跨桌投递唯一能读出来的桌身份。)';
120
+ // ⭐ 记账**放在最后**:只有真的要给出这段文本时才记(见上面坑 ②)。
121
+ markProposed(sessionId);
122
+ return text;
123
+ } catch (error) {
124
+ // fail-open:提示渲染失败绝不影响会话。
125
+ warn('改名提议渲染失败(已忽略)', { error: errText(error) });
126
+ return '';
127
+ }
128
+ },
129
+ })));
130
+
131
+ // ── ② 工具:AI 在**用户同意后**用它改名 ────────────────────────────────────
132
+ ctx.effect(() => ctx.inject(['tools'], (tctx) => tctx.tools.register({
133
+ name: 'set_session_title',
134
+ description:
135
+ '把**当前会话**的标题改成「NN-名字」形式(N 是两位桌号),让跨桌投递能认出它属于哪张桌。'
136
+ + '⚠️ **必须先在对话里把新标题给用户看过、得到用户同意,才能调用本工具** —— '
137
+ + '标题属于用户,不要自己决定改。'
138
+ + '前缀 `NN-` 后面可以带数字区分同一张桌的第几个会话(如 `02-环境维护2`)。',
139
+ parameters: {
140
+ type: 'object',
141
+ properties: {
142
+ title: {
143
+ type: 'string',
144
+ description: '新标题,必须形如 `02-环境维护2`(两位数字 + 连字符 + 名字)',
145
+ },
146
+ },
147
+ required: ['title'],
148
+ additionalProperties: false,
149
+ },
150
+ output: {
151
+ schema: { type: 'string' },
152
+ render: (_args, value) => [{ type: 'text', text: String(value) }],
153
+ },
154
+ execute: async (args, exec) => {
155
+ const wanted = String(args?.title ?? '').trim();
156
+ // ⚠️ 复用**同一个**正则(`identity.js`),避免"写的时候和读的时候规则不一样"。
157
+ if (!/^\d{2}\s*[--—–]\s*\S/u.test(wanted)) {
158
+ throw new Error('标题必须是「两位数字 + 连字符 + 名字」的形式,例如 `02-环境维护2`');
159
+ }
160
+ const agent = exec?.agent;
161
+ const session = agent?.session;
162
+ if (session === undefined) throw new Error('拿不到当前会话(无法改名)');
163
+
164
+ // `sessionTitle` 是**真服务**(不是 @Remote)⇒ 插件可以直接调。
165
+ // 老写法(`ctx.get(...)` 在根作用域)取不到,所以从 `agent.ctx` 试。
166
+ const st = findSessionTitle(agent?.ctx) ?? findSessionTitle(ctx);
167
+ if (st === undefined || typeof st.rename !== 'function') {
168
+ throw new Error('本运行时没有 sessionTitle.rename(无法改名)—— 请让用户手动改会话标题');
169
+ }
170
+ const snap = st.rename(session, wanted);
171
+ const title = snap?.title ?? wanted;
172
+ const desk = deskFromTitle(title);
173
+ const sessionId = String(session?.id ?? '');
174
+ // ⭐ **改名之后立刻同步身份**(2026-09-30 加 —— 实测发现"改了名插件却不认"):
175
+ // 光靠"下一轮重读"也能自愈,但**同一轮里**别的读者可能已经按旧身份算过了
176
+ // ⇒ 当场把身份改过来,**不留窗口期**。
177
+ const record = { desk, title, at: Date.now() };
178
+ try {
179
+ sessions.put(sessionId, record);
180
+ } catch (error) {
181
+ // 持久层写不上不影响改名本身(下一轮重读会补上)。
182
+ warn('改名后同步持久层失败(下一轮会重读补上)', { error: errText(error) });
183
+ }
184
+ // ⭐ **更重要的一步**:写进"当前真相"视图 ⇒ **同一轮里还没渲染的读者立刻看到**。
185
+ rememberDesk(sessionId, record);
186
+ log('改名/已改', { session: sessionId.slice(0, 20), title, desk: deskLabel(desk) });
187
+ return `已把本会话标题改为「${title}」。`
188
+ + (isResolvedDesk(desk)
189
+ ? `跨桌投递已认它为 **${deskLabel(desk)}**(当场生效,不用等下一轮)。`
190
+ : '(⚠️ 但新标题仍然没有桌号前缀,跨桌投递还是认不出桌。)');
191
+ },
192
+ })));
193
+
194
+ return () => { /* 监听器/段落由 ctx.effect 的 disposer 管 */ };
195
+ }
196
+
197
+ /**
198
+ * 在给定 ctx 上找 `sessionTitle`。
199
+ *
200
+ * ⚠️ 与 `sessionQuery` 同样的坑:**插件根作用域不一定取得到**,`agent.ctx` 上才有。
201
+ * 而且判断"有没有"要 `typeof x.rename === 'function'` ——
202
+ * **`Object.keys(服务)` 看不到方法**(方法在原型链上)。
203
+ */
204
+ function findSessionTitle(c) {
205
+ if (c === undefined || c === null) return undefined;
206
+ try {
207
+ const direct = typeof c.get === 'function' ? c.get('sessionTitle') : undefined;
208
+ if (direct !== undefined && direct !== null) return direct;
209
+ } catch { /* 换下一个途径 */ }
210
+ try {
211
+ if (c.sessionTitle !== undefined && c.sessionTitle !== null) return c.sessionTitle;
212
+ } catch { /* 放弃 */ }
213
+ return undefined;
214
+ }