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.
- package/cordis.patch.yml +53 -0
- package/index.js +464 -0
- package/package.json +39 -0
- package/src/features/f0-identity.js +342 -0
- package/src/features/f0b-session-gc.js +427 -0
- package/src/features/f1-propose-rename.js +214 -0
- package/src/features/f2-dispatch.js +539 -0
- package/src/features/f2b-dispatch-tools.js +303 -0
- package/src/features/f3-status.js +773 -0
- package/src/identity.js +220 -0
- package/src/log.js +81 -0
- package/src/store.js +760 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 功能 0(骨架):**认桌 + 开桌自报身份**。
|
|
3
|
+
*
|
|
4
|
+
* 这是整包的地基 —— 功能 2–6 全建在"这张会话属于哪张桌"之上。
|
|
5
|
+
* 所以第一步只做它,做到**能在真实会话里看见**为止。
|
|
6
|
+
*
|
|
7
|
+
* 实现要点(全部来自 2026-09-30 四轮实测,不再自己试):
|
|
8
|
+
* · 身份 = **会话标题开头的两位数字**(见 `identity.js` 的注释)
|
|
9
|
+
* · 标题**不在 header 里**,必须 `agent.ctx.sessionQuery.readTitle()`
|
|
10
|
+
* · `readTitle` 失败时**不要缓存**,下一步再试(标题是平台异步生成的,第一次常常还没有)
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ **fail-open**:本功能任何一步失败都只记日志,**绝不影响会话**。
|
|
13
|
+
*/
|
|
14
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
15
|
+
import {
|
|
16
|
+
/**
|
|
17
|
+
* ⚠️ **`OFFICE_DESK` 2026-10-02 从这个 import 里去掉了** ——
|
|
18
|
+
* 它原来只用在"认不出桌才重读"那个条件里,而**那条条件改掉了**(见下面 `needRead` 那段)。
|
|
19
|
+
* **⇒ 留着一个不再使用的 import,下一个读代码的人会以为这里还在用"办公室"那档。**
|
|
20
|
+
*/
|
|
21
|
+
deskFromTitle, deskLabel, isResolvedDesk, isSubagentSession, lookupDesk,
|
|
22
|
+
readSessionTitle, rememberDesk,
|
|
23
|
+
} from '../identity.js';
|
|
24
|
+
import { errText } from '../log.js';
|
|
25
|
+
|
|
26
|
+
/** 本功能的配置键(与 `index.js` 的开关同名)。 */
|
|
27
|
+
export const FEATURE = 'identity';
|
|
28
|
+
|
|
29
|
+
/** 重读标题的冷却(毫秒)。设置项在 `index.js`,这里只是兜底默认值。 */
|
|
30
|
+
const DEFAULT_RECHECK_MS = 10_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 装配"认桌"。
|
|
34
|
+
*
|
|
35
|
+
* @param {object} api 共享运行环境
|
|
36
|
+
* @param {object} api.ctx 插件根 ctx
|
|
37
|
+
* @param {object} api.config 已校验的配置
|
|
38
|
+
* @param {(kind: string, payload?: object) => void} api.log
|
|
39
|
+
* @param {(message: string, payload?: object) => void} api.warn
|
|
40
|
+
* @param {{ tables: Record<string, object>, where: string }} api.store
|
|
41
|
+
* @returns {() => void} 卸载函数
|
|
42
|
+
*/
|
|
43
|
+
export function setup(api) {
|
|
44
|
+
const { ctx, config, log, warn, store } = api;
|
|
45
|
+
/** `sessionId -> { desk, title, at }` —— ⚠️ **缓存,不是权威**(标题能被用户改)。 */
|
|
46
|
+
const sessions = store.tables.sessions;
|
|
47
|
+
const recheckMs = Number.isFinite(Number(config.recheckMs)) ? Math.max(0, Number(config.recheckMs)) : DEFAULT_RECHECK_MS;
|
|
48
|
+
|
|
49
|
+
ctx.effect(() => ctx.on('agent/pre-step', async (payload, next) => {
|
|
50
|
+
// ⚠️ 顺序很重要:**先 `await next()` 拿到 decision**(waterfall 必须往下走),
|
|
51
|
+
// 再做自己的事,最后**原样或加一条**返回。
|
|
52
|
+
const decision = await next();
|
|
53
|
+
try {
|
|
54
|
+
const agent = payload?.agent;
|
|
55
|
+
const sessionId = agent?.session?.id;
|
|
56
|
+
if (typeof sessionId !== 'string' || sessionId === '') return decision;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ⭐⭐ **子代理不是"桌"—— 直接不认。**(2026-09-30 加)
|
|
60
|
+
*
|
|
61
|
+
* ## 为什么(真事)
|
|
62
|
+
*
|
|
63
|
+
* 用户看到状态表里有一行「**办公室** `—` 你是一个独立的技术评审。请\*」,
|
|
64
|
+
* 问"这是谁"。查出来是**我自己派出去的一个子代理**(`origin: 'subagent'`,
|
|
65
|
+
* `parentSession` 就是我这条会话),**而且它压根没跑起来**(解压后只有 header)。
|
|
66
|
+
*
|
|
67
|
+
* ## 根因:**子代理不可能有桌**
|
|
68
|
+
*
|
|
69
|
+
* 认桌靠的是**会话标题的 `NN-` 前缀**,而子代理的标题是
|
|
70
|
+
* **派出去时那段提示词的开头**("你是一个独立的技术评审…")——
|
|
71
|
+
* **它天然不可能有 `NN-` 前缀**。⇒ 认出来必然是"办公室"。
|
|
72
|
+
*
|
|
73
|
+
* ⇒ 于是**只要子代理跑过一步,状态表里就会多一行"办公室"** ——
|
|
74
|
+
* 不是偶发 bug,是**设计上必然**。
|
|
75
|
+
*
|
|
76
|
+
* ## 这一改的两个效果
|
|
77
|
+
*
|
|
78
|
+
* | # | 效果 |
|
|
79
|
+
* |---|---|
|
|
80
|
+
* | **1** | **根治**:子代理不再进 `sessions` 表 ⇒ 状态表里再也不会出现它 |
|
|
81
|
+
* | **2** | 顺带让状态表**更准**:那张表本来只该有"桌" |
|
|
82
|
+
*
|
|
83
|
+
* ⚠️ **判据本身已搬到 `src/identity.js` 的 `isSubagentSession`**
|
|
84
|
+
* (2026-10-01)—— 因为 `f2-dispatch.js` 有**自己的入口**,
|
|
85
|
+
* 而当时那里**没有这个检查** ⇒ 子代理绕过了排除。
|
|
86
|
+
* **⇒ 现在所有入口 import 同一个判定,不在各处再抄一遍。**
|
|
87
|
+
*/
|
|
88
|
+
if (isSubagentSession(agent?.session)) return decision;
|
|
89
|
+
|
|
90
|
+
// ⭐ **先查"当前真相"视图**,没有才查权威存储。
|
|
91
|
+
// 视图是同步的、进程内的 ⇒ **同一轮里所有读者看到同一份**
|
|
92
|
+
// (修的就是"注入说桌 02、系统提示还说认不出"那个矛盾)。
|
|
93
|
+
const known = lookupDesk(sessionId) ?? sessions.get(sessionId);
|
|
94
|
+
const cachedDesk = typeof known?.desk === 'string' ? known.desk : undefined;
|
|
95
|
+
const cachedTitle = typeof known?.title === 'string' ? known.title : undefined;
|
|
96
|
+
const at = typeof known?.at === 'number' ? known.at : 0;
|
|
97
|
+
const cachedTurn = typeof known?.turn === 'number' ? known.turn : undefined;
|
|
98
|
+
|
|
99
|
+
// ── 决定"这次要不要读标题" ──────────────────────────────────────────────
|
|
100
|
+
//
|
|
101
|
+
// ⚠️⚠️ 这条规则**改过两次**,两次都是真 bug 逼出来的:
|
|
102
|
+
//
|
|
103
|
+
// **第一版:"只读一次就缓存"** ⇒ 会话刚建时平台**先给自动标题**(「打招呼问候」),
|
|
104
|
+
// 读到它就认成"办公室"并缓存 ⇒ **用户改名后永不重读** ⇒ 永远认错桌。
|
|
105
|
+
//
|
|
106
|
+
// **第二版:"没认出桌就按冷却重读"**(10 秒)⇒ **仍然会漏**。实测(2026-09-30):
|
|
107
|
+
// 09:44:38 读了一次(超时,仍是「你好」)⇒ 刷新计时器;
|
|
108
|
+
// 09:44:46 **用户改名** ⇒ 距上次读才 8 秒 < 冷却 ⇒ `needRead=False` ⇒ **不重读**;
|
|
109
|
+
// 会话结束 ⇒ **永久停在"办公室"**。
|
|
110
|
+
// **⇒ 根因:我拿"上次读的时间"当计时器,可"用户改名"是个事件,不是时间流逝。**
|
|
111
|
+
//
|
|
112
|
+
// **第三版(现在):按 turn 边界检查** ——
|
|
113
|
+
// · 一个 turn 里读**最多一次**(同 turn 的后续 step 跳过,不费)
|
|
114
|
+
// · **每个新 turn 都重新检查** ⇒ 用户在**任何两轮之间**改名,下一轮就认对
|
|
115
|
+
// · ⚠️ **"已经认对桌的会话不重读"** —— 这一条 **2026-10-02 去掉了**,
|
|
116
|
+
// 理由见下面 `needRead` 那段(**改编号就是换桌**,而那正是文档一直说的)。
|
|
117
|
+
// · `recheckMs` 只在**同 turn 内**当二次保险(turn 信息缺失时退回按时间)
|
|
118
|
+
const turn = typeof payload?.turn === 'number' ? payload.turn : undefined;
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
const newTurn = turn !== undefined && turn !== cachedTurn;
|
|
121
|
+
/**
|
|
122
|
+
* ⭐ **一次性补齐**:老记录里没有 `sessionId` 副本 ⇒ **强制读一次标题**把它补上。
|
|
123
|
+
*
|
|
124
|
+
* ## 为什么需要(2026-09-30,两个 bug 叠在一起才暴露出来)
|
|
125
|
+
*
|
|
126
|
+
* 状态表的"认过桌的会话"那一列**会话名全渲染成 `—`**:因为 `sessions` 记录里
|
|
127
|
+
* **没有 `sessionId` 副本**(渲染时 `s.sessionId ?? s.id` 两边都是 undefined)。
|
|
128
|
+
*
|
|
129
|
+
* ① 第一个 bug:`sessionId` 原来**只在"认桌结果变了"那条分支里写** ⇒
|
|
130
|
+
* **早就认好桌的会话永远走不到那条分支** ⇒ 永远没有这个字段。
|
|
131
|
+
* ② 第二个 bug(**修完 ① 才发现**):那段补写代码在 `if (!needRead) return` **之后**,
|
|
132
|
+
* 而"已经认好桌"的会话 `needRead` 恒为 `false` ⇒ **补写代码根本执行不到**。
|
|
133
|
+
*
|
|
134
|
+
* ⇒ 所以判据要放到**这道闸门之前**:**"老记录缺这个字段"本身就是该读一次的理由**。
|
|
135
|
+
* **一次性** —— 补上之后就不再触发(不会变成每步都读)。
|
|
136
|
+
*/
|
|
137
|
+
/**
|
|
138
|
+
* ## ⚠️⚠️ **`cachedDesk` 必须是"真桌号"**(2026-10-03 补,外部终审指出)
|
|
139
|
+
*
|
|
140
|
+
* ### 原来是什么样
|
|
141
|
+
*
|
|
142
|
+
* ```js
|
|
143
|
+
* const needBackfill = cachedDesk !== undefined && (…缺 sessionId…);
|
|
144
|
+
* ```
|
|
145
|
+
*
|
|
146
|
+
* ⚠️ **而 `cachedDesk` 可能是 `'办公室'`**(标题没编号 / 压根没读到)——
|
|
147
|
+
* 那种记录**本来就不该存在**(见下面 `:215` 那处修正),
|
|
148
|
+
* **⇒ 拿它当"该补字段"的理由,会让没编号的会话每一步都重读一次标题。**
|
|
149
|
+
*
|
|
150
|
+
* ⇒ 收紧成 **`isResolvedDesk(cachedDesk)`**(`/^\d{2}$/`)。
|
|
151
|
+
*/
|
|
152
|
+
const needBackfill = isResolvedDesk(cachedDesk)
|
|
153
|
+
&& (sessions.get(sessionId)?.sessionId === undefined);
|
|
154
|
+
/**
|
|
155
|
+
* ## ⭐⭐ **"桌号认定后不随标题变"这条规矩,2026-10-02 改掉了**
|
|
156
|
+
*
|
|
157
|
+
* ### 原来是什么样
|
|
158
|
+
*
|
|
159
|
+
* ```js
|
|
160
|
+
* const needRead = cachedDesk === undefined
|
|
161
|
+
* ? (turn !== undefined ? newTurn : now - at >= recheckMs)
|
|
162
|
+
* : (cachedDesk === OFFICE_DESK && newTurn); // ⚠️ 认出来的桌,再也不重读
|
|
163
|
+
* ```
|
|
164
|
+
*
|
|
165
|
+
* **⇒ 已经认对桌的会话永远不重读标题。**
|
|
166
|
+
* 而 `f2-dispatch` 那边**每轮都重读**(它只拿标题刷新显示名)——
|
|
167
|
+
* **⇒ 结果是"两条路不一样"**:手动把标题从 `02-x` 改成 `03-y`,
|
|
168
|
+
* **显示名跟着变了,桌号却还是 02**;而**用插件的改名工具改,会当场变成 03。**
|
|
169
|
+
*
|
|
170
|
+
* ### 为什么改成"也重读"(一份外部审查顺着源码走了一遍,说服了我们)
|
|
171
|
+
*
|
|
172
|
+
* **原来那条"不让改名悄悄换桌"的理由是**:
|
|
173
|
+
* *"老单子会找错人"* —— ⚠️ **而它不成立**:
|
|
174
|
+
*
|
|
175
|
+
* | 顾虑 | 实际 |
|
|
176
|
+
* |---|---|
|
|
177
|
+
* | 换成 03 之后,先前的 02 单子会不会被它取走 | ❌ 不会 —— 它现在只取 03 的单子 |
|
|
178
|
+
* | 那 02 的单子怎么办 | ✅ **留在那儿**,等 **02 桌别的会话**来取(单子挂在**桌**上,不挂在会话上) |
|
|
179
|
+
* | 已经取走的会不会受影响 | ❌ 不会 |
|
|
180
|
+
*
|
|
181
|
+
* **⭐ 另外,README 和文档里的说法一直是"桌号就是标题前缀"** ——
|
|
182
|
+
* **⇒ 手动改标题本身就是一次"我换桌了"的声明**,而我们却当它没发生。
|
|
183
|
+
* **那不只是不一致,是"文档说的和做的不一样"。**
|
|
184
|
+
*
|
|
185
|
+
* ### 代价(说清楚)
|
|
186
|
+
*
|
|
187
|
+
* **每个新 turn 多读一次标题**(`readSessionTitle` 读的是会话日志)。
|
|
188
|
+
* ⚠️ 而这件事**本来就在发生** —— `f2-dispatch` 每轮都读它。
|
|
189
|
+
* **⇒ 现在两边共用这一次判定,总量没变。**
|
|
190
|
+
*
|
|
191
|
+
* ⚠️ **只在 turn 边界重读**(不在同 turn 的每个 step 重复读)——
|
|
192
|
+
* **标题是用户在"两轮之间"改的**,同一个 turn 里不会变。
|
|
193
|
+
*/
|
|
194
|
+
const needRead = cachedDesk === undefined
|
|
195
|
+
? (turn !== undefined ? newTurn : now - at >= recheckMs)
|
|
196
|
+
: newTurn;
|
|
197
|
+
// ⭐ **这一步要留痕**(0.1.3 加):排查"处理器到底有没有跑、为什么没重读"时,
|
|
198
|
+
// 没有这条就无法区分"没被调用"和"被调用了但判定不用读"。
|
|
199
|
+
log('认桌/检查', {
|
|
200
|
+
session: sessionId.slice(0, 20), step: payload?.step, turn,
|
|
201
|
+
cached: cachedDesk ?? null, cachedTurn: cachedTurn ?? null,
|
|
202
|
+
ageMs: at === 0 ? null : now - at, newTurn, needRead, needBackfill,
|
|
203
|
+
});
|
|
204
|
+
if (!needRead && !needBackfill) return decision;
|
|
205
|
+
|
|
206
|
+
const title = await readSessionTitle(agent?.ctx, sessionId);
|
|
207
|
+
// ⚠️ 读不到标题 ⇒ **不要写缓存**,下一步再试(标题是平台异步生成的,第一次常常还没有)。
|
|
208
|
+
if (title === null) {
|
|
209
|
+
log('认桌/标题暂时读不到,下次再试', { session: sessionId.slice(0, 20) });
|
|
210
|
+
return decision;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const desk = deskFromTitle(title);
|
|
214
|
+
/**
|
|
215
|
+
* ## ⚠️⚠️⚠️ **"认不出来"的会话不该进名单**(2026-10-03 修)
|
|
216
|
+
*
|
|
217
|
+
* ### 原来错在哪(**外部终审指出来的,而且我一读源码就确认了**)
|
|
218
|
+
*
|
|
219
|
+
* 下面那条"变了"的分支**无条件 `sessions.put(...)`** ——
|
|
220
|
+
* **而 `desk` 可能是 `OFFICE_DESK`('办公室')**(标题没 `NN-` 前缀时 `deskFromTitle` 就回落到它)。
|
|
221
|
+
*
|
|
222
|
+
* **⇒ 症状**:**随手开的、没编号的会话会以"办公室"出现在「认过桌的会话」名单里**,
|
|
223
|
+
* **面板顶上那个计数也算它。**
|
|
224
|
+
*
|
|
225
|
+
* ### ⚠️ 而这是我们**自己文档说不会发生**的事
|
|
226
|
+
*
|
|
227
|
+
* `docs/01` · `docs/02` · `docs/03` 里**五处**都写着"认不出就不记" ——
|
|
228
|
+
* **⇒ 代码和文档相反,而错的是代码。**
|
|
229
|
+
*
|
|
230
|
+
* ### ⭐ 而且是**我自己留的伏笔**
|
|
231
|
+
*
|
|
232
|
+
* 下面 `:244` 那行注释写着:*"只对**真的读出来了**的会话报身份 —— 回落到'办公室'的会话没什么可报的"*。
|
|
233
|
+
* **⇒ 说明我当时就知道这个区别,只是把它用在了"报身份"上,没用在"记名单"上。**
|
|
234
|
+
*
|
|
235
|
+
* ### 修法(三处一起)
|
|
236
|
+
*
|
|
237
|
+
* | 处 | 改成 |
|
|
238
|
+
* |---|---|
|
|
239
|
+
* | `needBackfill` | 只给**真桌号**补字段 |
|
|
240
|
+
* | "没变"那支的 `missingId` | 同上(否则没编号的会话每步都写一次存储) |
|
|
241
|
+
* | **这一支的 `put`** | **只有真桌号才进库**;**原来在某张桌、现在标题没编号了 ⇒ 从名单里删掉**(它离开办公室了) |
|
|
242
|
+
*
|
|
243
|
+
* ⚠️ **而 `rememberDesk(...)` 照旧要写** —— 那是**内存里的"当前真相"视图**,
|
|
244
|
+
* `f1` 的"邀请一次"靠它(**邀请和"算不算成员"是两件事**)。
|
|
245
|
+
*/
|
|
246
|
+
// ⚠️ **认出来过、且没变** ⇒ 只刷新时间戳与 turn,不重复注入。
|
|
247
|
+
if (cachedDesk === desk && cachedTitle === title) {
|
|
248
|
+
/**
|
|
249
|
+
* ⚠️⚠️ **这里有一个真 bug,2026-09-30 由 01 桌报出来**:
|
|
250
|
+
*
|
|
251
|
+
* `sessionId` 副本原来**只在下面那条"变了"的分支里加** ⇒
|
|
252
|
+
* 而那些**桌早就认好了**的会话永远走这条分支 ⇒ **它们的记录里永远没有 `sessionId`**
|
|
253
|
+
* ⇒ 状态表的"认过桌的会话"那一列**全渲染成 `—`**(实测 5 条记录,0 条有 `sessionId`)。
|
|
254
|
+
*
|
|
255
|
+
* ⇒ 修法:**补齐缺失的字段**(不是"每次都写",那会每步都动存储)。
|
|
256
|
+
*
|
|
257
|
+
* ⚠️ ⚠️ **判断要查存储,不能查 `known`**(内存视图)——
|
|
258
|
+
* `known` 是本步开头取的快照,**它自己就可能缺这个字段**
|
|
259
|
+
* ⇒ 拿它当判据会**每次都认为"缺",每次都写一遍**。
|
|
260
|
+
* (`needBackfill` 那道闸门用的是 `sessions.get(...)`,正是为了同一个理由。)
|
|
261
|
+
*/
|
|
262
|
+
const missingId = sessions.get(sessionId)?.sessionId !== sessionId;
|
|
263
|
+
/** ⚠️ **只给真桌号补** —— 否则没编号的会话每一步都会重写一次存储。 */
|
|
264
|
+
if (missingId && isResolvedDesk(desk)) {
|
|
265
|
+
sessions.put(sessionId, { sessionId, desk, title, at: now, turn: turn ?? cachedTurn ?? 0 });
|
|
266
|
+
// ⭐ 会话表变了 ⇒ 让纠偏看得见(否则仪表盘会漏掉这条,01 桌报的就是这个)
|
|
267
|
+
api.onStateChanged?.('认桌');
|
|
268
|
+
}
|
|
269
|
+
rememberDesk(sessionId, { sessionId, desk, title, at: known?.at ?? now, turn: known?.turn ?? 0 });
|
|
270
|
+
return decision;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const record = { sessionId, desk, title, at: now, turn: turn ?? cachedTurn ?? 0 };
|
|
274
|
+
/**
|
|
275
|
+
* ⭐ **只有"真桌号"才进名单**(见上面那段长注释)。
|
|
276
|
+
*
|
|
277
|
+
* ⚠️ 而**"原来在某张桌、现在标题没编号了" ⇒ 从名单里删掉** ——
|
|
278
|
+
* 那是"它离开办公室了"(**用户把标题改掉了**),不是"它还在,只是名字读不到"
|
|
279
|
+
* (后者在前面的 `title === null` 那一步就 `return` 了,走不到这里)。
|
|
280
|
+
*/
|
|
281
|
+
if (isResolvedDesk(desk)) {
|
|
282
|
+
sessions.put(sessionId, record);
|
|
283
|
+
} else if (sessions.get(sessionId) !== undefined) {
|
|
284
|
+
await sessions.delete(sessionId);
|
|
285
|
+
}
|
|
286
|
+
// ⭐ 同步写进"当前真相"视图 —— **让同轮里还没渲染的读者立刻看到**。
|
|
287
|
+
rememberDesk(sessionId, record);
|
|
288
|
+
/**
|
|
289
|
+
* ⭐ **告诉仪表盘"会话表变了"**(2026-09-30,01 桌报的漏会话 bug)。
|
|
290
|
+
*
|
|
291
|
+
* 为什么必须有这一步:**两个 `agent/pre-step` 处理器在同一个 turn 里并行跑**
|
|
292
|
+
* —— 投递那边可能**先**纠偏(那时这条会话还没落库),9 毫秒后这里才写。
|
|
293
|
+
* 没有这一声,就**没人再纠偏一次**,仪表盘就一直漏着它。
|
|
294
|
+
* (而且 `onStateChanged` 走的是**同步视图 + 下一 tick 合并**,
|
|
295
|
+
* 所以这一次喊话会读到**已经落库的**会话表 —— 实测 01 桌的时序差正是 9 ms。)
|
|
296
|
+
*/
|
|
297
|
+
api.onStateChanged?.('认桌');
|
|
298
|
+
log(cachedDesk === undefined ? '认桌/已认' : '认桌/刷新', {
|
|
299
|
+
session: sessionId.slice(0, 20), title, desk: deskLabel(desk),
|
|
300
|
+
resolved: isResolvedDesk(desk), was: cachedDesk === undefined ? null : deskLabel(cachedDesk),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// 只对"真的读出来了"的会话报身份 —— 回落到"办公室"的会话没什么可报的
|
|
304
|
+
// (它还没起名字,平台的自动标题会一直变)。
|
|
305
|
+
if (!isResolvedDesk(desk)) return decision;
|
|
306
|
+
|
|
307
|
+
const text = String(config.identityNotice ?? '')
|
|
308
|
+
.replaceAll('{desk}', deskLabel(desk))
|
|
309
|
+
.replaceAll('{title}', title);
|
|
310
|
+
if (text.trim() === '') return decision;
|
|
311
|
+
|
|
312
|
+
const messages = Array.isArray(decision?.messages) ? decision.messages : [];
|
|
313
|
+
return { ...decision, messages: [...messages, makeNotice(text)] };
|
|
314
|
+
} catch (error) {
|
|
315
|
+
// fail-open:认桌失败绝不影响会话。
|
|
316
|
+
warn('认桌失败(已忽略)', { error: errText(error) });
|
|
317
|
+
return decision;
|
|
318
|
+
}
|
|
319
|
+
}));
|
|
320
|
+
|
|
321
|
+
return () => { /* 监听器由 ctx.effect 自己的 disposer 管 */ };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 造那条"报身份"的消息。
|
|
326
|
+
*
|
|
327
|
+
* ⚠️⚠️ **必须有 `source`**(2026-09-30 真炸过一次,教训血淋淋):
|
|
328
|
+
* 平台下游到处在读 `message.source.kind`(消息种类的判别联合)。
|
|
329
|
+
* 我第一版只传了 `content` ⇒ `source` 是 `undefined` ⇒
|
|
330
|
+
* 下游一读就是 **`Cannot read properties of undefined (reading 'kind')`**,
|
|
331
|
+
* **整个 turn 直接失败**(用户看到"本轮运行失败")。
|
|
332
|
+
*
|
|
333
|
+
* ⇒ 所以对齐**已验证能跑**的公告插件写法:`source: { kind: …, form: …, summary: … }`。
|
|
334
|
+
* 这里用 `kind: 'user'` —— 它是 `MessageSourceMap` 里**确定存在**的一种,
|
|
335
|
+
* 不为了好看去造一个新 kind(那要平台认,不值当)。
|
|
336
|
+
*/
|
|
337
|
+
function makeNotice(text) {
|
|
338
|
+
return createUserMessage({
|
|
339
|
+
source: { kind: 'user' },
|
|
340
|
+
content: [{ type: 'text', text }],
|
|
341
|
+
});
|
|
342
|
+
}
|