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
package/src/identity.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 桌身份:**会话标题开头的两位数字**。
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ 为什么是标题、不是别的(2026-09-30 四轮实测,三条原假设被推翻):
|
|
5
|
+
* · **血缘 `parent` 继承 —— 不行**:压缩不重建会话、手动开新会话 `ancestors` 为空、
|
|
6
|
+
* **会话头根本没有 `parent` 字段**(只有 `parentSession`,且新会话没有)。
|
|
7
|
+
* · **`agent.session.header.title` 永远是 `null`** —— 标题不在 header 里,
|
|
8
|
+
* **必须调 `ctx.sessionQuery.readTitle()`**(官方叫"基于日志的标题")。
|
|
9
|
+
* · ⭐ **`ctx.sessionQuery` 在插件根作用域取不到,只能在 `agent.ctx` 上拿。**
|
|
10
|
+
*
|
|
11
|
+
* ⭐ 而用户的命名方式让这件事变成一行正则(2026-09-30 明确):
|
|
12
|
+
* 「NN-」前缀**永远固定**,只有后面会变 —— 换会话继承同一张桌时他会命名成
|
|
13
|
+
* `02-环境维护1`、`02-环境维护2`… **不会改前面的编号**。
|
|
14
|
+
* ⇒ **所以只解析开头两位数字就够,不要去匹配整个标题。**
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* ⭐ **会话表改动计数**(进程内、单调递增)。
|
|
19
|
+
*
|
|
20
|
+
* ## 为什么需要它(2026-09-30,01 桌报的一个真 bug)
|
|
21
|
+
*
|
|
22
|
+
* 状态表(仪表盘)的"认过桌的会话"漏掉了**刚认桌的那个会话**。01 桌给的证据:
|
|
23
|
+
*
|
|
24
|
+
* ```
|
|
25
|
+
* ms=2619769 状态表/已更新 reason=纠偏 ← 先写仪表盘(此时 sessions 还是 4 条)
|
|
26
|
+
* ms=2619778 认桌/检查 session=b1fc6053 cached=01 ← 9 毫秒后才认桌落库
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* **根因**:两个 `agent/pre-step` 处理器**在同一个 turn 里并行跑**,而
|
|
30
|
+
* **纠偏的判据只看「单子计数」,完全不看会话表** ⇒ 认桌落库之后**没人再纠偏一次**。
|
|
31
|
+
*
|
|
32
|
+
* ⇒ 修法:**认桌每写一次会话表就 +1**,纠偏把它一起比。
|
|
33
|
+
* (为什么用计数器而不是"会话条数":**改一条已存在的记录时条数不变** ——
|
|
34
|
+
* 比如上面那个"补 `sessionId`"的修复就会改记录而不加条数。)
|
|
35
|
+
*/
|
|
36
|
+
let sessionsRevision = 0;
|
|
37
|
+
|
|
38
|
+
/** 会话表被改动了几次(**单调递增**,只用来比"变没变")。 */
|
|
39
|
+
export function getSessionsRevision() {
|
|
40
|
+
return sessionsRevision;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** ⚠️ **仅供自测**:归零。 */
|
|
44
|
+
export function resetSessionsRevisionForTest() {
|
|
45
|
+
sessionsRevision = 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 桌号正则。
|
|
50
|
+
*
|
|
51
|
+
* ⚠️ **不要图省事改松**(两种改松都实测会误判):
|
|
52
|
+
* · `/^(\d{1,2})\D/` → 会把 `2-环境维护1`(一位数字)也认了
|
|
53
|
+
* · `/^(\d{2})/` → 不要求分隔符 ⇒ ⚠️ **`2026-09-30 的事` 会被误判成「桌 20」**
|
|
54
|
+
*
|
|
55
|
+
* 容忍:前导空格、数字后的空格、半角 `-`、全角 `-`、连接号 `—`、短横 `–`。
|
|
56
|
+
*/
|
|
57
|
+
const DESK_RE = /^\s*(\d{2})\s*[--—–]\s*/u;
|
|
58
|
+
|
|
59
|
+
/** 拿不准时回落的身份 —— **诚实地说"不知道是哪张桌",不假装知道**。 */
|
|
60
|
+
export const OFFICE_DESK = '办公室';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* ⭐⭐ **这个会话是不是"子代理"**(`parentSession` 有值就是)。
|
|
64
|
+
*
|
|
65
|
+
* ## 为什么它必须是一个**共享**判定(2026-10-01 修的一个真 bug)
|
|
66
|
+
*
|
|
67
|
+
* 原来这段判定**只写在 `f0-identity.js` 里**(认桌那个功能)。
|
|
68
|
+
* 而 `f2-dispatch.js` 有**自己的一条入口**,它也读会话、也写 `sessions` 表 ——
|
|
69
|
+
* **却没有这个检查。**
|
|
70
|
+
*
|
|
71
|
+
* ⇒ 后果:**子代理绕过了排除,被投递功能重新写进了会话表**,
|
|
72
|
+
* 在状态表的"认过桌的会话"里变成一条"办公室"记录。
|
|
73
|
+
* **⇒ 于是"子代理不会出现在办公室状态表"这句话,当时是不成立的。**
|
|
74
|
+
*
|
|
75
|
+
* **⇒ 修法就是"让所有入口用同一个判定"** —— 所以它住在这儿(两边都 import 得到)。
|
|
76
|
+
*
|
|
77
|
+
* ## ⚠️ 判据用**平台自己标的血缘**(`parentSession`)
|
|
78
|
+
*
|
|
79
|
+
* **不要**去猜 `sessionId` 里有没有 `session-` 前缀 —— 那是**巧合**,不是契约。
|
|
80
|
+
*
|
|
81
|
+
* ⚠️ 而 `parentSession` **在类型上属于 header / record,不属于 `AgentSession`**
|
|
82
|
+
* ⇒ **两个地方都试**(运行时它在哪一份上,我不猜)。
|
|
83
|
+
*
|
|
84
|
+
* @param {object} session 会话对象(`agent.session` / `agent.session.header` 都行)
|
|
85
|
+
* @returns {boolean}
|
|
86
|
+
*/
|
|
87
|
+
export function isSubagentSession(session) {
|
|
88
|
+
if (session === undefined || session === null) return false;
|
|
89
|
+
try { if (session.parentSession !== undefined) return true; } catch { /* 试下一条 */ }
|
|
90
|
+
try { return session.header?.parentSession !== undefined; } catch { return false; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 从会话标题解析桌身份。
|
|
95
|
+
*
|
|
96
|
+
* ⚠️ **返回的是两位数字字符串**(如 `'02'`),不是 `'桌 02'` ——
|
|
97
|
+
* 投递单里存的 `desk` 也用这个形式,**匹配逻辑不该掺显示格式**。
|
|
98
|
+
* 要给人看的名字用 {@link deskLabel}。
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} rawTitle 会话标题(可能是 null / undefined / 非字符串)
|
|
101
|
+
* @returns {string} `'02'` 这样的两位数字,或 {@link OFFICE_DESK}
|
|
102
|
+
*/
|
|
103
|
+
export function deskFromTitle(rawTitle) {
|
|
104
|
+
if (typeof rawTitle !== 'string') return OFFICE_DESK;
|
|
105
|
+
const m = DESK_RE.exec(rawTitle.trim());
|
|
106
|
+
return m === null ? OFFICE_DESK : m[1];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 这个身份是不是"从标题真的读出来了"(而不是回落的)。 */
|
|
110
|
+
export function isResolvedDesk(desk) {
|
|
111
|
+
return typeof desk === 'string' && /^\d{2}$/u.test(desk);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 给人看的写法:`'02'` → `'桌 02'`;`'办公室'` 原样。 */
|
|
115
|
+
export function deskLabel(desk) {
|
|
116
|
+
return isResolvedDesk(desk) ? `桌 ${desk}` : OFFICE_DESK;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* **进程内的"当前真相"**(2026-09-30 加 —— 修"同一轮里两个读者看到不同数据")。
|
|
121
|
+
*
|
|
122
|
+
* ## 为什么需要它
|
|
123
|
+
*
|
|
124
|
+
* 实测暴露的矛盾:同一个 turn 里,
|
|
125
|
+
* · **消息注入**说"认作桌 02"(对,因为它是**异步读标题之后**才写的)
|
|
126
|
+
* · **系统提示**还在说"认不出桌"(错,因为**它的渲染是同步的**,读到的是改名前的快照)
|
|
127
|
+
*
|
|
128
|
+
* **⇒ 根因不是"读得对不对",而是"两个读者在什么时刻读"**:
|
|
129
|
+
* 一个在异步更新**之前**渲染,一个在**之后**注入 ⇒ 天然不同步。
|
|
130
|
+
*
|
|
131
|
+
* ## 它是什么
|
|
132
|
+
*
|
|
133
|
+
* 一个**进程内的同步小缓存**:`sessionId -> { desk, title, turn }`。
|
|
134
|
+
* · **写**:认桌 / 改名时立刻写(同步,零延迟)
|
|
135
|
+
* · **读**:谁都先查它 —— **同一进程里所有读者看到同一份**
|
|
136
|
+
* · 它**不是权威**(重启会丢),权威仍是存储域 —— 查不到时回落过去
|
|
137
|
+
*
|
|
138
|
+
* ⚠️ **不拿 `store.js` 的域表来干这件事**:域是**持久**层,
|
|
139
|
+
* 把"进程内一致性"塞进去会把两件事混在一起。**视图只解决"同一进程内一致"。**
|
|
140
|
+
*/
|
|
141
|
+
const VIEW = new Map();
|
|
142
|
+
|
|
143
|
+
/** 记录某个会话现在的身份(**同步**,调用后立刻对所有读者可见)。 */
|
|
144
|
+
export function rememberDesk(sessionId, patch) {
|
|
145
|
+
if (typeof sessionId !== 'string' || sessionId === '') return;
|
|
146
|
+
const prev = VIEW.get(sessionId) ?? {};
|
|
147
|
+
VIEW.set(sessionId, { ...prev, ...patch, at: Date.now() });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 读某个会话现在的身份;没有就 `undefined`(调用方再去查权威存储)。 */
|
|
151
|
+
export function lookupDesk(sessionId) {
|
|
152
|
+
return VIEW.get(sessionId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 在给定 ctx 上找 `sessionQuery`。
|
|
157
|
+
*
|
|
158
|
+
* ⚠️ **必须两处都试**(实测):插件**根作用域取不到**,`agent.ctx` 上才有。
|
|
159
|
+
* 而且 `Object.keys(服务)` **看不到方法**(方法在原型链上)⇒
|
|
160
|
+
* 判断"有没有"要 `typeof x.fn === 'function'`,不能看 key 列表。
|
|
161
|
+
*
|
|
162
|
+
* @param {object|undefined} c 候选 ctx
|
|
163
|
+
* @returns {object|undefined}
|
|
164
|
+
*/
|
|
165
|
+
export function findSessionQuery(c) {
|
|
166
|
+
if (c === undefined || c === null) return undefined;
|
|
167
|
+
try {
|
|
168
|
+
const direct = typeof c.get === 'function' ? c.get('sessionQuery') : undefined;
|
|
169
|
+
if (direct !== undefined && direct !== null) return direct;
|
|
170
|
+
} catch { /* 换下一个途径 */ }
|
|
171
|
+
try {
|
|
172
|
+
if (c.sessionQuery !== undefined && c.sessionQuery !== null) return c.sessionQuery;
|
|
173
|
+
} catch { /* 放弃 */ }
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 读一个会话的标题。
|
|
179
|
+
*
|
|
180
|
+
* @param {object|undefined} agentCtx `agent.ctx`(**不是插件根 ctx**)
|
|
181
|
+
* @param {string} sessionId
|
|
182
|
+
* @returns {Promise<string|null>} 标题,或 `null`(读不到 —— 调用方据此**不要缓存**)
|
|
183
|
+
*/
|
|
184
|
+
export async function readSessionTitle(agentCtx, sessionId) {
|
|
185
|
+
if (typeof sessionId !== 'string' || sessionId === '') return null;
|
|
186
|
+
const sq = findSessionQuery(agentCtx);
|
|
187
|
+
if (sq === undefined) return null;
|
|
188
|
+
if (typeof sq.readTitle !== 'function') return null;
|
|
189
|
+
try {
|
|
190
|
+
const snap = await sq.readTitle(sessionId);
|
|
191
|
+
const title = snap?.title;
|
|
192
|
+
return typeof title === 'string' && title !== '' ? title : null;
|
|
193
|
+
} catch {
|
|
194
|
+
// 读标题失败只是"这次不知道",绝不影响会话。
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 批量读标题(功能 1 状态表要用 —— 实测 14 个一次读通)。
|
|
201
|
+
*
|
|
202
|
+
* @param {object|undefined} agentCtx
|
|
203
|
+
* @param {readonly string[]} sessionIds
|
|
204
|
+
* @returns {Promise<Map<string,string>>} 只含读到的
|
|
205
|
+
*/
|
|
206
|
+
export async function readSessionTitles(agentCtx, sessionIds) {
|
|
207
|
+
const out = new Map();
|
|
208
|
+
const sq = findSessionQuery(agentCtx);
|
|
209
|
+
if (sq === undefined || typeof sq.readTitleSnapshots !== 'function') return out;
|
|
210
|
+
try {
|
|
211
|
+
const results = await sq.readTitleSnapshots([...sessionIds]);
|
|
212
|
+
for (const r of results ?? []) {
|
|
213
|
+
if (r?.status === 'fulfilled') {
|
|
214
|
+
const t = r.value?.title?.title;
|
|
215
|
+
if (typeof t === 'string' && t !== '') out.set(String(r.sessionId), t);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} catch { /* 批量读失败就当作都没读到 */ }
|
|
219
|
+
return out;
|
|
220
|
+
}
|
package/src/log.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 诊断日志:**任何失败都不许冒泡**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么自己写 JSONL 而不是用 `ctx.logger.info`(2026-09-29 实测):
|
|
5
|
+
* **`ctx.logger.info` 到不了** `%APPDATA%\dsh-desktop\logs\harness.log`
|
|
6
|
+
* —— 那边的桥只接 warn/error。于是"加了诊断却什么都看不到"。
|
|
7
|
+
* 现在:`stderr`(进程控制台)+ 自己的 JSONL,两条都留。
|
|
8
|
+
*
|
|
9
|
+
* ⚠️ 本文件属于"**共享**"层 —— 每个功能都用它,但它**绝不能**让插件挂掉。
|
|
10
|
+
*/
|
|
11
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
12
|
+
import { dirname } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/** 把任意值变成可 JSON 化的东西;失败也给可读字符串(永不抛)。 */
|
|
15
|
+
export function safe(value) {
|
|
16
|
+
try {
|
|
17
|
+
if (value === undefined) return null;
|
|
18
|
+
JSON.stringify(value);
|
|
19
|
+
return value;
|
|
20
|
+
} catch {
|
|
21
|
+
try { return String(value); } catch { return '<unserializable>'; }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 错误 → 一行文本(永不抛)。 */
|
|
26
|
+
export function errText(error) {
|
|
27
|
+
try {
|
|
28
|
+
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
|
29
|
+
} catch { return '<unknown error>'; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 截断长文本,保留头尾(命令文本的关键信息常常在两头)。 */
|
|
33
|
+
export function clip(text, max) {
|
|
34
|
+
const s = String(text ?? '');
|
|
35
|
+
if (s.length <= max) return s;
|
|
36
|
+
const half = Math.floor(max / 2);
|
|
37
|
+
return `${s.slice(0, half)}\n…〔省略 ${s.length - max} 字符〕…\n${s.slice(-half)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 造一个 logger。
|
|
42
|
+
*
|
|
43
|
+
* @param {{ logPath?: string, tag?: string, version?: string }} options
|
|
44
|
+
* @returns {{ log: (kind: string, payload?: object) => void, problems: string[] }}
|
|
45
|
+
*/
|
|
46
|
+
export function createLog(options = {}) {
|
|
47
|
+
const logPath = typeof options.logPath === 'string' && options.logPath !== '' ? options.logPath : undefined;
|
|
48
|
+
const tag = options.tag ?? 'dispatch';
|
|
49
|
+
const startedAt = Date.now();
|
|
50
|
+
/** 记录过的问题(给状态表/自检用)—— 不抛,只攒着。 */
|
|
51
|
+
const problems = [];
|
|
52
|
+
/** 日志坏掉之后只报一次,避免每步都刷。 */
|
|
53
|
+
let broken = '';
|
|
54
|
+
|
|
55
|
+
function log(kind, payload = {}) {
|
|
56
|
+
if (broken !== '') return;
|
|
57
|
+
try {
|
|
58
|
+
if (logPath === undefined) return;
|
|
59
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
60
|
+
appendFileSync(logPath, `${JSON.stringify({
|
|
61
|
+
t: new Date().toISOString(),
|
|
62
|
+
ms: Date.now() - startedAt,
|
|
63
|
+
kind,
|
|
64
|
+
...payload,
|
|
65
|
+
})}\n`, 'utf8');
|
|
66
|
+
} catch (error) {
|
|
67
|
+
broken = errText(error);
|
|
68
|
+
problems.push(`日志写入失败:${broken}`);
|
|
69
|
+
try { process.stderr.write(`[${tag}] 日志写入失败,之后不再记:${broken}\n`); } catch { /* 算了 */ }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 出问题时要能在控制台看见(JSONL 只有排查时才看)。 */
|
|
74
|
+
function warn(message, payload = {}) {
|
|
75
|
+
problems.push(message);
|
|
76
|
+
log('warn', { message, ...payload });
|
|
77
|
+
try { process.stderr.write(`[${tag}] ${message}\n`); } catch { /* 算了 */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { log, warn, problems };
|
|
81
|
+
}
|