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,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 按桌投递的**工具**(给 AI 用)。
|
|
3
|
+
*
|
|
4
|
+
* ## 的边界(2026-09-30)
|
|
5
|
+
*
|
|
6
|
+
* | 项 | 决定 |
|
|
7
|
+
* |---|---|
|
|
8
|
+
* | 单子形态 | **指针型**:一句话 + 指向哪里(内容本体留在文档里) |
|
|
9
|
+
* | 谁发 | ⭐ **AI 用工具发** —— **不做监视文件夹那套** |
|
|
10
|
+
* | "已处理" | **不做** —— 只保证送到 |
|
|
11
|
+
* | 怎么送到 | 写进**系统提示**(不占消息流) |
|
|
12
|
+
* | 收方 | **谁先来谁取走**(同桌只有一个会话能拿到) |
|
|
13
|
+
*
|
|
14
|
+
* ## ⚠️ 一条写给模型的边界说明
|
|
15
|
+
*
|
|
16
|
+
* **投递不是"派活"** —— 它把"有这么件事、去哪看"放到对方桌上,
|
|
17
|
+
* **要不要做由对方(和用户)决定**。所以工具描述里写明"**不需要回执**",
|
|
18
|
+
* 免得收方纠结要不要回应(2026-09-30:"别让它重新分析/分心")。
|
|
19
|
+
*/
|
|
20
|
+
import { deskLabel, OFFICE_DESK } from '../identity.js';
|
|
21
|
+
import { isTaken } from './f2-dispatch.js';
|
|
22
|
+
import { lastWrite, listRecords, putTracked } from '../store.js';
|
|
23
|
+
|
|
24
|
+
/** 生成单子 id:时间 + 一小段内容哈希(**不引入新依赖**,用便宜的字符串哈希)。 */
|
|
25
|
+
function makeTicketId(desk, summary, at) {
|
|
26
|
+
let h = 0;
|
|
27
|
+
const s = `${desk}\u0000${summary}\u0000${at}`;
|
|
28
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
29
|
+
h = (h * 31 + s.charCodeAt(i)) | 0;
|
|
30
|
+
}
|
|
31
|
+
return `t${at.toString(36)}${(h >>> 0).toString(36).slice(0, 5)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 归一化目标桌的写法:接受 `02` / `2` / `桌 02` / `02 桌`。
|
|
36
|
+
*
|
|
37
|
+
* ## ⚠️⚠️ **"办公室"不再是合法的投递目标**(2026-10-01 定)
|
|
38
|
+
*
|
|
39
|
+
* ### 原来什么样(一个黑洞)
|
|
40
|
+
*
|
|
41
|
+
* 这个函数原来把 `办公室` 归一成 `OFFICE_DESK`,工具的说明里还写着
|
|
42
|
+
* *"不确定是哪张桌就填『办公室』"* —— **而接收端会跳过所有"办公室"会话**
|
|
43
|
+
* ⇒ **那些单子永远不会被任何人取走,静静地躺在状态表里显示"待取"。**
|
|
44
|
+
*
|
|
45
|
+
* ### 用户为什么说该删(**这个理由比"有个 bug"更根本**)
|
|
46
|
+
*
|
|
47
|
+
* > *"投给办公室不相当于全桌通告吗?那不就是公告功能吗?"*
|
|
48
|
+
*
|
|
49
|
+
* **⇒ 一针见血**:一个"发给所有人"的单子**就是公告**,而公告早就有了。
|
|
50
|
+
* **两套机制做同一件事,就是副本** —— 而我们整套东西的立场就是"副本是病根"。
|
|
51
|
+
*
|
|
52
|
+
* > *"而且真要投给办公室的话,那应该投给 02,02 是整个 DSH 环境的维护者和办公室管理员。"*
|
|
53
|
+
*
|
|
54
|
+
* **⇒ 所以"要通知全局"有两个正当出路,都不是"投给办公室"**:
|
|
55
|
+
* - **公告**(`announce` 工具)—— 真正的广播
|
|
56
|
+
* - **投给桌 02**(办公室管理员)—— 一个**有人负责**的明确地址
|
|
57
|
+
*
|
|
58
|
+
* @param {string} raw
|
|
59
|
+
* @returns {{desk: string}|{office: true}|{bad: true}}
|
|
60
|
+
* `office` 单独一档,因为**它需要一句专门的解释**(不是"拼错了")
|
|
61
|
+
*/
|
|
62
|
+
function normalizeDesk(raw) {
|
|
63
|
+
const s = String(raw ?? '').trim();
|
|
64
|
+
/** ⚠️ **完全没填**单独一档 —— 它和"填了办公室"是**两件事**,报错也该不一样。 */
|
|
65
|
+
if (s === '') return { blank: true };
|
|
66
|
+
if (s === OFFICE_DESK || s === '办公室') return { office: true };
|
|
67
|
+
const m = /(\d{1,2})/u.exec(s);
|
|
68
|
+
if (m === null) return { bad: true };
|
|
69
|
+
const n = Number(m[1]);
|
|
70
|
+
if (!Number.isInteger(n) || n < 1 || n > 99) return { bad: true };
|
|
71
|
+
return { desk: String(n).padStart(2, '0') };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 装配投递工具。
|
|
76
|
+
*
|
|
77
|
+
* @param {object} api 共享运行环境
|
|
78
|
+
* @returns {() => void} 卸载函数
|
|
79
|
+
*/
|
|
80
|
+
export function setup(api) {
|
|
81
|
+
const { ctx, config, log, warn, store } = api;
|
|
82
|
+
const tickets = store.tables.tickets;
|
|
83
|
+
const deskNames = config.deskNames ?? {};
|
|
84
|
+
|
|
85
|
+
const deskHint = () => Object.entries(deskNames)
|
|
86
|
+
.map(([id, name]) => `\`${id}\`(${name})`).join('、');
|
|
87
|
+
|
|
88
|
+
// ── 工具 ①:发单 ──────────────────────────────────────────────────────────
|
|
89
|
+
ctx.effect(() => ctx.inject(['tools'], (tctx) => tctx.tools.register({
|
|
90
|
+
name: 'dispatch_ticket',
|
|
91
|
+
description:
|
|
92
|
+
'把一件事**投给某张桌**:写一张"指针型"单子(一句话 + 详见哪里),'
|
|
93
|
+
+ '对方开桌/下一轮时会在系统提示里看到它,**谁先来谁取走**(同一张桌只有一个会话会拿到)。'
|
|
94
|
+
+ '⚠️ **单子只负责告知,不是派活** —— 要不要做由对方和用户决定,**不需要回执**。'
|
|
95
|
+
+ `目标桌用两位数编号:${deskHint()}。`
|
|
96
|
+
+ '⚠️ **没有"投给所有人"这回事** —— 要广播就用 `announce`(那是公告)。',
|
|
97
|
+
parameters: {
|
|
98
|
+
type: 'object',
|
|
99
|
+
properties: {
|
|
100
|
+
desk: { type: 'string', description: '目标桌,如 `02`(也接受 `2` / `桌 02`)' },
|
|
101
|
+
summary: { type: 'string', description: '一句话说清是什么(**不要复制内容本体**)' },
|
|
102
|
+
ptr: { type: 'string', description: '详见哪里:文件或目录路径(**指向权威位置**)' },
|
|
103
|
+
},
|
|
104
|
+
required: ['desk', 'summary', 'ptr'],
|
|
105
|
+
additionalProperties: false,
|
|
106
|
+
},
|
|
107
|
+
output: {
|
|
108
|
+
schema: { type: 'string' },
|
|
109
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
110
|
+
},
|
|
111
|
+
execute: async (args, exec) => {
|
|
112
|
+
const target = normalizeDesk(args?.desk);
|
|
113
|
+
/**
|
|
114
|
+
* ⭐⭐ **"办公室"要说一句专门的解释**(不是"拼错了")——
|
|
115
|
+
* 而且**要给出两条正当出路**,否则 AI 只会换个地方瞎试。
|
|
116
|
+
*
|
|
117
|
+
* 2026-10-01 的原话:
|
|
118
|
+
* > *"投给办公室不相当于全桌通告吗?那不就是公告功能吗?"*
|
|
119
|
+
* > *"真要投给办公室的话,那应该投给 02,02 是整个 DSH 环境的维护者和办公室管理员。"*
|
|
120
|
+
*/
|
|
121
|
+
if (target.blank === true) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`目标桌不能为空 —— 单子必须投给**某一张具体的桌**(可用:${deskHint()})。\n`
|
|
124
|
+
+ '⚠️ 如果这件事是"想让所有人都知道",那它不是单子,是**公告**(用 `announce` 工具)。',
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* ## ⚠️⚠️ 2026-10-03:**去掉了这两句"我们办公室的约定"**
|
|
129
|
+
*
|
|
130
|
+
* ### 原来写的是
|
|
131
|
+
*
|
|
132
|
+
* ```text
|
|
133
|
+
* … 要通知办公室管理员就投给桌 02。
|
|
134
|
+
* · 想通知**办公室管理员**(DSH 环境维护那张桌)⇒ 投给 `02`。
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* **⇒ 而"02 是管理员"是**我们自己那张桌的约定**,不是这套东西的规则。**
|
|
138
|
+
*
|
|
139
|
+
* ⚠️ **别人装上以后,他的模型也会被这么告知** —— 而人家的 `02`
|
|
140
|
+
* 完全可能是"前端"、是空的、或者根本不存在。
|
|
141
|
+
* **⇒ 一句写死的桌号,会在别人的办公室里指错人。**
|
|
142
|
+
*
|
|
143
|
+
* ⭐ 一份**公开的参考实现**里,**不该出现"我们办公室的编制"。**
|
|
144
|
+
* (外部终审指出来的,指得对。)
|
|
145
|
+
*/
|
|
146
|
+
if (target.office === true) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
'「办公室」**不是投递目标** —— 单子必须投给**某一张具体的桌**。\n'
|
|
149
|
+
+ '· 想让**所有人**都知道 ⇒ 那不是单子,是**公告**(用 `announce` 工具)。\n'
|
|
150
|
+
+ (deskHint() === '' ? '' : `· 这个办公室现有的桌:${deskHint()}。\n`)
|
|
151
|
+
+ '⚠️ 原来允许填"办公室",但**没有任何会话会去取那种单子** —— 它们会一直在状态表里显示"待取",'
|
|
152
|
+
+ '**等于投进了黑洞**。所以现在直接拒掉。',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (target.bad === true) {
|
|
156
|
+
throw new Error(`目标桌无法识别:「${String(args?.desk)}」—— 用两位数编号,如 \`02\`(可用:${deskHint()})`);
|
|
157
|
+
}
|
|
158
|
+
const desk = target.desk;
|
|
159
|
+
const summary = String(args?.summary ?? '').replace(/\s+/gu, ' ').trim();
|
|
160
|
+
if (summary === '') throw new Error('summary(一句话)不能为空');
|
|
161
|
+
const ptr = String(args?.ptr ?? '').trim();
|
|
162
|
+
if (ptr === '') throw new Error('ptr(详见哪里)不能为空 —— 单子是指针型的,必须给出去哪看');
|
|
163
|
+
if (summary.length > 300) throw new Error('summary 太长(>300 字)—— 单子只放一句话,内容本体留在文档里');
|
|
164
|
+
|
|
165
|
+
const at = Date.now();
|
|
166
|
+
const id = makeTicketId(desk, summary, at);
|
|
167
|
+
// 来源桌:从**发单这个会话**的身份推(认不出来就诚实写"办公室")。
|
|
168
|
+
const fromDesk = (() => {
|
|
169
|
+
try {
|
|
170
|
+
const sid = exec?.agent?.session?.id;
|
|
171
|
+
const known = sid === undefined ? undefined : store.tables.sessions.get(sid);
|
|
172
|
+
const d = typeof known?.desk === 'string' ? known.desk : undefined;
|
|
173
|
+
return d === undefined || d === OFFICE_DESK ? OFFICE_DESK : d;
|
|
174
|
+
} catch { return OFFICE_DESK; }
|
|
175
|
+
})();
|
|
176
|
+
|
|
177
|
+
const record = {
|
|
178
|
+
id, desk, summary, ptr, source: fromDesk, at, takenBy: null,
|
|
179
|
+
};
|
|
180
|
+
try {
|
|
181
|
+
// ⚠️ **必须 `await`**(2026-09-30 实测):域的 `put` 返回 Promise,
|
|
182
|
+
// 同步 `try/catch` **接不住拒绝** ⇒ 写失败会被静默吞掉,
|
|
183
|
+
// 而工具照样回执"已投给 XX" —— 我原来就是这样丢了两张真单子。
|
|
184
|
+
await putTracked(tickets, id, record);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
warn('单子写不进存储(本次投递未生效)', {
|
|
187
|
+
id, error: String(error?.message ?? error), code: error?.code ?? null,
|
|
188
|
+
});
|
|
189
|
+
throw new Error(`单子没能写入存储(本次投递未生效):${String(error?.message ?? error)}`);
|
|
190
|
+
}
|
|
191
|
+
log('投递/已发单', { id, desk: deskLabel(desk), from: deskLabel(fromDesk), summary: summary.slice(0, 40) });
|
|
192
|
+
// ⭐ 状态变了 ⇒ 让人看的仪表盘跟上(同一 tick 里多次变化只写一次)。
|
|
193
|
+
api.onStateChanged?.('发单');
|
|
194
|
+
return `已投给 **${deskLabel(desk)}**(单号 \`${id}\`):${summary}\n`
|
|
195
|
+
+ `详见:\`${ptr}\`\n`
|
|
196
|
+
+ '它会在对方**下一轮**出现在系统提示里;**谁先来谁取走**,取走之后同桌别的会话看不到。'
|
|
197
|
+
+ '(不需要回执 —— 对方做不做由它和用户决定。)';
|
|
198
|
+
},
|
|
199
|
+
})));
|
|
200
|
+
|
|
201
|
+
// ── 工具 ②:看本桌还有什么待取 ────────────────────────────────────────────
|
|
202
|
+
ctx.effect(() => ctx.inject(['tools'], (tctx) => tctx.tools.register({
|
|
203
|
+
name: 'list_tickets',
|
|
204
|
+
description:
|
|
205
|
+
'列出**本桌还没被取走**的投递单(只读)。用来跟进"还有哪些事挂着",'
|
|
206
|
+
+ '或排查"我是不是漏看了什么"。**不会取走任何单子** —— 取走发生在正常开桌/下一轮时。',
|
|
207
|
+
parameters: { type: 'object', properties: {}, required: [], additionalProperties: false },
|
|
208
|
+
output: {
|
|
209
|
+
schema: { type: 'string' },
|
|
210
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
211
|
+
},
|
|
212
|
+
execute: async (_args, exec) => {
|
|
213
|
+
const sid = exec?.agent?.session?.id;
|
|
214
|
+
const known = sid === undefined ? undefined : store.tables.sessions.get(sid);
|
|
215
|
+
const desk = typeof known?.desk === 'string' ? known.desk : OFFICE_DESK;
|
|
216
|
+
if (desk === OFFICE_DESK) {
|
|
217
|
+
return '本会话还没认出桌(标题没有 `NN-` 前缀),所以看不出"本桌"有哪些单子。'
|
|
218
|
+
+ '(先让用户定一下这个会话属于哪张桌。)';
|
|
219
|
+
}
|
|
220
|
+
const rows = [];
|
|
221
|
+
try {
|
|
222
|
+
// ⚠️ 用 `listRecords`(存储无关)—— 兜底文件表没有 `entries`,直接调会抛。
|
|
223
|
+
for (const [id, rec] of listRecords(tickets)) {
|
|
224
|
+
if (rec?.desk !== desk) continue;
|
|
225
|
+
if (isTaken(id)) continue;
|
|
226
|
+
if (rec?.takenBy !== null && rec?.takenBy !== undefined) continue;
|
|
227
|
+
rows.push({ id, ...rec });
|
|
228
|
+
}
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return `列单子失败:${String(error?.message ?? error)}`;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* ⚠️ **区分"真的没有"和"我列不出来"**(2026-09-30 测试抓到):
|
|
234
|
+
* 如果存储既不支持 `entries()` 也不支持 `keys()`,`listRecords` 会返回空 ——
|
|
235
|
+
* 那时报"没有待取的单子"是**静默漏报**,比报错更糟。
|
|
236
|
+
* ⇒ 只有确认"能枚举"时才敢说"没有"。
|
|
237
|
+
*/
|
|
238
|
+
const canEnumerate = typeof tickets?.entries === 'function' || typeof tickets?.keys === 'function';
|
|
239
|
+
if (rows.length === 0 && !canEnumerate) {
|
|
240
|
+
return `${deskLabel(desk)}:**列不出来** —— 当前存储(兜底文件表)没有"枚举全部"的能力。`
|
|
241
|
+
+ '这不代表没有单子,只代表我看不到。';
|
|
242
|
+
}
|
|
243
|
+
if (rows.length === 0) return `${deskLabel(desk)}:**没有待取的单子**。`;
|
|
244
|
+
const lines = rows
|
|
245
|
+
.sort((a, b) => (a.at ?? 0) - (b.at ?? 0))
|
|
246
|
+
.map((t) => `· \`${t.id}\` 来自 **${deskLabel(t.source ?? OFFICE_DESK)}**:${t.summary} —— 详见 \`${t.ptr}\``);
|
|
247
|
+
return `${deskLabel(desk)}:**待取 ${rows.length} 张**\n${lines.join('\n')}`;
|
|
248
|
+
},
|
|
249
|
+
})));
|
|
250
|
+
|
|
251
|
+
// ── 工具 ③:诊断(排查"写没落盘"这类问题)────────────────────────────────
|
|
252
|
+
ctx.effect(() => ctx.inject(['tools'], (tctx) => tctx.tools.register({
|
|
253
|
+
name: 'dispatch_diag',
|
|
254
|
+
description:
|
|
255
|
+
'【诊断】只读地报告投递存储的状态:用的是域还是兜底文件、最近一次写的结果、'
|
|
256
|
+
+ '单子总数与各自状态,并**现场试写一笔探测记录**(写到 `misc` 表,不碰业务数据)。'
|
|
257
|
+
+ '用来回答"单子到底有没有落盘"。',
|
|
258
|
+
parameters: { type: 'object', properties: {}, required: [], additionalProperties: false },
|
|
259
|
+
output: {
|
|
260
|
+
schema: { type: 'string' },
|
|
261
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }],
|
|
262
|
+
},
|
|
263
|
+
execute: async () => {
|
|
264
|
+
const out = [];
|
|
265
|
+
/**
|
|
266
|
+
* ⚠️ **先报版本**(2026-09-30 加)。
|
|
267
|
+
*
|
|
268
|
+
* 今天热装了 30 多次,反复出现"**服务工具的实例是旧的**"——
|
|
269
|
+
* 而我没有任何办法一眼认出来,只能靠推断(`refresh_status` 明明注册成功却调不到,
|
|
270
|
+
* 就是这样查了半天的)。⇒ **诊断工具都报版本号,下次一眼就知道在跟谁说话。**
|
|
271
|
+
*/
|
|
272
|
+
out.push(`插件版本:**${api.version ?? '?'}**(服务这次调用的就是这个实例)`);
|
|
273
|
+
out.push(`存储位置:**${store.where}**(domain=平台存储域 / file=兜底文件)`);
|
|
274
|
+
const lw = lastWrite;
|
|
275
|
+
out.push(`最近一次写:${lw.at === null ? '(本进程还没写过)' : `${new Date(lw.at).toISOString().slice(11, 19)}Z key=${lw.key} → ${lw.ok === true ? '成功' : `**失败** ${lw.error}(code=${lw.code})`}`}`);
|
|
276
|
+
|
|
277
|
+
let total = 0;
|
|
278
|
+
const byState = { 未取: 0, 已取: 0 };
|
|
279
|
+
try {
|
|
280
|
+
for (const [, rec] of listRecords(tickets)) {
|
|
281
|
+
total += 1;
|
|
282
|
+
if (rec?.takenBy === null || rec?.takenBy === undefined) byState.未取 += 1;
|
|
283
|
+
else byState.已取 += 1;
|
|
284
|
+
}
|
|
285
|
+
out.push(`单子总数:${total}(未取 ${byState.未取} / 已取 ${byState.已取})`);
|
|
286
|
+
} catch (error) {
|
|
287
|
+
out.push(`列单子失败:${String(error?.message ?? error)}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ⭐ **现场试写** —— 这是回答"能不能写"的唯一可靠办法。
|
|
291
|
+
try {
|
|
292
|
+
await putTracked(store.tables.misc, '__diag__', { value: new Date().toISOString(), at: Date.now() });
|
|
293
|
+
out.push('现场试写:**成功** ✅(存储可写)');
|
|
294
|
+
} catch (error) {
|
|
295
|
+
out.push(`现场试写:**失败** ❌ ${String(error?.message ?? error)}(code=${error?.code ?? '无'})`);
|
|
296
|
+
}
|
|
297
|
+
out.push('(探测键是 `misc.__diag__` / `misc.__probe__`,不属于业务数据。)');
|
|
298
|
+
return out.join('\n');
|
|
299
|
+
},
|
|
300
|
+
})));
|
|
301
|
+
|
|
302
|
+
return () => { /* 工具由 ctx.effect 的 disposer 管 */ };
|
|
303
|
+
}
|