cs-bots 0.0.0-stage → 0.1.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/src/tools.js ADDED
@@ -0,0 +1,469 @@
1
+ /**
2
+ * cs-bots /tools —— 模型面(**模型能调用的工具**)。
3
+ *
4
+ * 为什么必须有这一层:L2 域服务(board / roster / room / memory / dispatcher)只是
5
+ * 宿主侧的对象;**没有工具,模型在新机器上装完插件后看不到任何能力**,
6
+ * 平台等于不存在。工具是「域服务 ↔ 模型」的唯一桥。
7
+ *
8
+ * 设计纪律:
9
+ * - **零第三方依赖**:不 import 任何非内置包;宿主耦合全部经 `kernel.registerTool`。
10
+ * - **缺服务降级**:每个工具执行时先查域服务,缺则返回**可读失败**(不是抛崩)。
11
+ * - **绝不静默**:所有失败都返回 `{ ok: false, reason }` 让模型看见。
12
+ * - **工具名带 `bot_` 前缀**:避免与官方/其他插件撞名(`insert` 不去重,见硬约束 C1)。
13
+ */
14
+
15
+ import { registerTool } from './kernel.js'
16
+ import { getServices } from './registry.js'
17
+
18
+ /** 统一的失败构造(所有工具都返回它,绝不抛出去让宿主崩)。 */
19
+ function failure(reason, extra = {}) {
20
+ return { ok: false, reason, ...extra }
21
+ }
22
+
23
+ /** 统一的结果包装(成功)。 */
24
+ function success(payload) {
25
+ return { ok: true, ...payload }
26
+ }
27
+
28
+ /**
29
+ * 从宿主上下文取本平台的域服务。
30
+ * @param {object} ctx
31
+ * @returns {{ board?: object, roster?: object, room?: object, memory?: object, dispatcher?: object }}
32
+ */
33
+ function servicesOf(ctx) {
34
+ return getServices(ctx)
35
+ }
36
+
37
+ /** 取调用者标识(审计流必填字段用)。 */
38
+ function callerOf(exec) {
39
+ return exec?.agent?.id ?? exec?.agent?.session?.id ?? 'unknown'
40
+ }
41
+
42
+ // ── 工具定义表 ───────────────────────────────────────────────────────────────
43
+ //
44
+ // 每个条目:{ name, description, parameters, run(services, args, exec) }
45
+ // `run` 返回规范无损 JSON 值;失败走 `failure()`。
46
+
47
+ const DEFS = [
48
+ // ── 任务板(BoardService)───────────────────────────────────────────────────
49
+ {
50
+ name: 'bot_task_create',
51
+ description:
52
+ '在 cs-bots 跨会话任务板上创建一个任务。返回任务 id 与 rev;后续用 bot_task_update 推进状态(CAS 需带 expectedRev)。',
53
+ parameters: {
54
+ type: 'object',
55
+ properties: {
56
+ subject: { type: 'string', description: '任务标题(必填)' },
57
+ description: { type: 'string', description: '任务详情' },
58
+ blockedBy: { type: 'array', items: { type: 'string' }, description: '依赖的任务 id 列表' },
59
+ writeScopes: { type: 'array', items: { type: 'string' }, description: '预期写入的文件前缀(提示性)' },
60
+ priority: { type: 'number', description: '优先级(数值越大越先)' },
61
+ dueAt: { type: 'number', description: '截止时间(epoch ms)' },
62
+ sourceSeq: { type: 'string', description: '幂等键:同键重复创建只产生一条' },
63
+ },
64
+ required: ['subject'],
65
+ additionalProperties: false,
66
+ },
67
+ run: (s, a, exec) => {
68
+ if (!s.board) return failure('board-unavailable', { hint: '任务板未装配(状态目录不可写?)' })
69
+ return success({ task: s.board.create({ ...a, byId: callerOf(exec) }) })
70
+ },
71
+ },
72
+ {
73
+ name: 'bot_task_list',
74
+ description: '列出 cs-bots 任务板上的任务。默认不含已删除(tombstone);传 status 可只看某状态。',
75
+ parameters: {
76
+ type: 'object',
77
+ properties: {
78
+ status: {
79
+ type: 'string',
80
+ enum: ['pending', 'in_progress', 'completed', 'blocked', 'deleted'],
81
+ description: '只看该状态',
82
+ },
83
+ },
84
+ additionalProperties: false,
85
+ },
86
+ run: (s, a) => {
87
+ if (!s.board) return failure('board-unavailable')
88
+ return success({ tasks: s.board.list(a?.status ? { status: a.status } : {}) })
89
+ },
90
+ },
91
+ {
92
+ name: 'bot_task_ready',
93
+ description:
94
+ '列出**就绪**任务:状态为 pending 且全部 blockedBy 依赖已完成,按 priority 降序。用于「该我干了」的领活判断。',
95
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
96
+ run: (s) => {
97
+ if (!s.board) return failure('board-unavailable')
98
+ return success({ tasks: s.board.ready() })
99
+ },
100
+ },
101
+ {
102
+ name: 'bot_task_update',
103
+ description:
104
+ '推进任务状态(CAS:必须带当前 expectedRev,不匹配会被拒,绝不静默覆盖)。' +
105
+ 'action: claim=取租约并置 in_progress / heartbeat=续租 / release=放租约回 pending / ' +
106
+ 'complete=完成(**必须带非空 artifacts**)/ reopen=重开 / block / unblock / delete=tombstone / set=改字段。',
107
+ parameters: {
108
+ type: 'object',
109
+ properties: {
110
+ id: { type: 'string', description: '任务 id' },
111
+ expectedRev: { type: 'number', description: '当前 rev(CAS 必需)' },
112
+ action: {
113
+ type: 'string',
114
+ enum: ['claim', 'heartbeat', 'release', 'complete', 'reopen', 'block', 'unblock', 'delete', 'set'],
115
+ },
116
+ artifacts: { type: 'array', items: { type: 'string' }, description: 'complete 必填:交付物路径' },
117
+ reason: { type: 'string', description: 'block 的原因' },
118
+ subject: { type: 'string' },
119
+ description: { type: 'string' },
120
+ priority: { type: 'number' },
121
+ dueAt: { type: 'number' },
122
+ writeScopes: { type: 'array', items: { type: 'string' } },
123
+ blockedBy: { type: 'array', items: { type: 'string' } },
124
+ },
125
+ required: ['id', 'expectedRev', 'action'],
126
+ additionalProperties: false,
127
+ },
128
+ run: (s, a, exec) => {
129
+ if (!s.board) return failure('board-unavailable')
130
+ try {
131
+ return success({ task: s.board.update({ ...a, byId: callerOf(exec) }) })
132
+ } catch (error) {
133
+ // CAS 冲突等业务失败**返回**给模型(而不是抛崩),模型可重读 rev 后重试
134
+ return failure(error?.code ?? 'update-failed', { message: error?.message ?? String(error) })
135
+ }
136
+ },
137
+ },
138
+ {
139
+ name: 'bot_task_accept',
140
+ description: '对已完成任务记一次验收(官方没有这个动作):result=pass 通过 / reject 驳回(驳回会把任务打回 pending)。',
141
+ parameters: {
142
+ type: 'object',
143
+ properties: {
144
+ id: { type: 'string' },
145
+ result: { type: 'string', enum: ['pass', 'reject'] },
146
+ note: { type: 'string', description: '验收意见' },
147
+ },
148
+ required: ['id', 'result'],
149
+ additionalProperties: false,
150
+ },
151
+ run: (s, a, exec) => {
152
+ if (!s.board) return failure('board-unavailable')
153
+ try {
154
+ return success({ task: s.board.accept({ ...a, by: callerOf(exec) }) })
155
+ } catch (error) {
156
+ return failure(error?.code ?? 'accept-failed', { message: error?.message ?? String(error) })
157
+ }
158
+ },
159
+ },
160
+
161
+ // ── 唤醒桥(Dispatcher)────────────────────────────────────────────────────
162
+ {
163
+ name: 'bot_wake',
164
+ description:
165
+ '定向唤醒一个会话干新活(走 followup:新开一个回合)。**必须给 dedupKey**——同键只唤一次,防唤醒风暴。' +
166
+ 'reason: task-assigned / task-unblocked / room-message / lease-timeout / timer。',
167
+ parameters: {
168
+ type: 'object',
169
+ properties: {
170
+ target: { type: 'string', description: '目标会话 id' },
171
+ reason: {
172
+ type: 'string',
173
+ enum: ['task-assigned', 'task-unblocked', 'room-message', 'lease-timeout', 'timer'],
174
+ },
175
+ dedupKey: { type: 'string', description: '去重键(强烈建议:如 "task-assigned:<taskId>")' },
176
+ text: { type: 'string', description: '唤醒正文;缺省按 reason 生成' },
177
+ delayMs: { type: 'number', description: '延后投递(ms)' },
178
+ },
179
+ required: ['target', 'reason'],
180
+ additionalProperties: false,
181
+ },
182
+ run: (s, a) => {
183
+ if (!s.dispatcher) return failure('dispatcher-unavailable')
184
+ return s.dispatcher.notify(a)
185
+ },
186
+ },
187
+
188
+ // ── Bot 注册表(RosterService)──────────────────────────────────────────────
189
+ {
190
+ name: 'bot_roster_create',
191
+ description:
192
+ '创建一个持久 Bot(独立会话 + **独立工作目录**,cwd 同时是它的写入边界)。' +
193
+ '官方 spawn_teammate 无法指定目录,所以持久 Bot 必须走这里。cwd 必须是绝对路径,且不得在 DSH_HOME 内。',
194
+ parameters: {
195
+ type: 'object',
196
+ properties: {
197
+ name: { type: 'string', description: '唯一名(大小写不敏感)' },
198
+ cwd: { type: 'string', description: '独立工作目录(绝对路径,必填)' },
199
+ label: { type: 'string', description: '角色标签(≤24)' },
200
+ model: { type: 'string' },
201
+ instructions: { type: 'string', description: '该 Bot 的职责说明' },
202
+ permission: { type: 'string', enum: ['read-only', 'workspace-write', 'full'] },
203
+ preset: {
204
+ type: 'string',
205
+ description:
206
+ 'agent 预设 id(决定该 Bot 的人格/系统提示/技能/工具面)。' +
207
+ '不传则用宿主默认预设。可用 bot_roster_list 的 presets 字段或 GET /cs-bots/api/presets 查可选项。',
208
+ },
209
+ },
210
+ required: ['name', 'cwd'],
211
+ additionalProperties: false,
212
+ },
213
+ run: async (s, a, exec) => {
214
+ if (!s.roster) return failure('roster-unavailable')
215
+ try {
216
+ return success({ bot: await s.roster.create({ ...a, byId: callerOf(exec) }) })
217
+ } catch (error) {
218
+ return failure(error?.code ?? 'roster-create-failed', { message: error?.message ?? String(error) })
219
+ }
220
+ },
221
+ },
222
+ {
223
+ name: 'bot_roster_list',
224
+ description: '列出 Bot 注册表。includeRetired=true 时含已退役(官方 roster 做不到回收,本平台可以)。',
225
+ parameters: {
226
+ type: 'object',
227
+ properties: {
228
+ state: { type: 'string', description: '只看该状态' },
229
+ includeRetired: { type: 'boolean' },
230
+ },
231
+ additionalProperties: false,
232
+ },
233
+ run: (s, a) => {
234
+ if (!s.roster) return failure('roster-unavailable')
235
+ const live = s.roster.list(a?.state ? { state: a.state } : {})
236
+ const retired = a?.includeRetired ? s.roster.listRetired() : []
237
+ return success({ bots: [...live, ...retired] })
238
+ },
239
+ },
240
+ {
241
+ name: 'bot_roster_manage',
242
+ description:
243
+ '管理已注册的 Bot(**官方 roster 做不到的能力**):rename=改名(旧名随即释放)/ ' +
244
+ 'retire=退役(终态,名字与目录可被复用)/ setCwd=换工作目录 / claim=取租约 / release / heartbeat。',
245
+ parameters: {
246
+ type: 'object',
247
+ properties: {
248
+ id: { type: 'string' },
249
+ action: { type: 'string', enum: ['rename', 'retire', 'setCwd', 'claim', 'release', 'heartbeat'] },
250
+ name: { type: 'string', description: 'rename 用' },
251
+ cwd: { type: 'string', description: 'setCwd 用(绝对路径)' },
252
+ reason: { type: 'string' },
253
+ expectedRev: { type: 'number' },
254
+ },
255
+ required: ['id', 'action'],
256
+ additionalProperties: false,
257
+ },
258
+ run: (s, a, exec) => {
259
+ if (!s.roster) return failure('roster-unavailable')
260
+ const byId = callerOf(exec)
261
+ try {
262
+ switch (a.action) {
263
+ case 'rename':
264
+ return success({ bot: s.roster.rename({ id: a.id, name: a.name, expectedRev: a.expectedRev, byId }) })
265
+ case 'retire':
266
+ return success({ bot: s.roster.retire({ id: a.id, reason: a.reason, byId }) })
267
+ case 'setCwd':
268
+ return success({ bot: s.roster.setCwd({ id: a.id, cwd: a.cwd, expectedRev: a.expectedRev, byId }) })
269
+ case 'claim':
270
+ return success({ bot: s.roster.claim({ id: a.id, owner: byId, expectedRev: a.expectedRev }) })
271
+ case 'release':
272
+ return success({ bot: s.roster.release({ id: a.id, owner: byId, expectedRev: a.expectedRev }) })
273
+ case 'heartbeat':
274
+ return success({ bot: s.roster.heartbeat({ id: a.id, owner: byId, expectedRev: a.expectedRev }) })
275
+ default:
276
+ return failure('unknown-action', { action: a.action })
277
+ }
278
+ } catch (error) {
279
+ return failure(error?.code ?? 'roster-manage-failed', { message: error?.message ?? String(error) })
280
+ }
281
+ },
282
+ },
283
+
284
+ // ── 房间(RoomService,P3)──────────────────────────────────────────────────
285
+ {
286
+ name: 'bot_room_create',
287
+ description: '创建一个房间(多会话群聊)。members 为会话 id 列表。',
288
+ parameters: {
289
+ type: 'object',
290
+ properties: {
291
+ name: { type: 'string' },
292
+ members: { type: 'array', items: { type: 'string' } },
293
+ mode: { type: 'string', enum: ['admin', 'everyone'] },
294
+ adminId: { type: 'string' },
295
+ notice: { type: 'string' },
296
+ },
297
+ required: ['name'],
298
+ additionalProperties: false,
299
+ },
300
+ run: (s, a, exec) => {
301
+ if (!s.room) return failure('room-unavailable', { hint: 'P3 房间服务未装配' })
302
+ try {
303
+ // 注意:room 的公开方法是 `create`(内部实现名 createRoom_ 不对外)
304
+ return success({ room: s.room.create({ ...a, byId: callerOf(exec) }) })
305
+ } catch (error) {
306
+ return failure(error?.code ?? 'room-create-failed', { message: error?.message ?? String(error) })
307
+ }
308
+ },
309
+ },
310
+ {
311
+ name: 'bot_room_post',
312
+ description: '往房间发言(**确定性写入**:直接落盘,不经 HTTP、不经模型转手)。长正文自动落旁路文件。',
313
+ parameters: {
314
+ type: 'object',
315
+ properties: {
316
+ roomId: { type: 'string' },
317
+ text: { type: 'string' },
318
+ kind: { type: 'string', enum: ['normal', 'pass', 'notice', 'late', 'steer'] },
319
+ to: { type: 'string', description: '定向给某成员' },
320
+ dedupKey: { type: 'string', description: '幂等键' },
321
+ },
322
+ required: ['roomId', 'text'],
323
+ additionalProperties: false,
324
+ },
325
+ run: (s, a, exec) => {
326
+ if (!s.room) return failure('room-unavailable')
327
+ try {
328
+ return success({ message: s.room.post({ ...a, from: callerOf(exec) }) })
329
+ } catch (error) {
330
+ return failure(error?.code ?? 'room-post-failed', { message: error?.message ?? String(error) })
331
+ }
332
+ },
333
+ },
334
+ {
335
+ name: 'bot_room_read',
336
+ description: '读房间消息(含 seq 游标,用于断点续传)。长正文会带 bodyRef,用 bot_room_body 取全文。',
337
+ parameters: {
338
+ type: 'object',
339
+ properties: {
340
+ roomId: { type: 'string' },
341
+ sinceSeq: { type: 'number', description: '只取该 seq 之后的消息' },
342
+ limit: { type: 'number' },
343
+ },
344
+ required: ['roomId'],
345
+ additionalProperties: false,
346
+ },
347
+ run: (s, a) => {
348
+ if (!s.room) return failure('room-unavailable')
349
+ return success({ messages: s.room.listMessages(a) })
350
+ },
351
+ },
352
+ {
353
+ name: 'bot_room_body',
354
+ description: '取某条房间消息的**长正文全文**(log 里只存了引用与指纹)。',
355
+ parameters: {
356
+ type: 'object',
357
+ properties: { roomId: { type: 'string' }, messageId: { type: 'string' } },
358
+ required: ['roomId', 'messageId'],
359
+ additionalProperties: false,
360
+ },
361
+ run: (s, a) => {
362
+ if (!s.room) return failure('room-unavailable')
363
+ const text = s.room.readBody(a)
364
+ if (text === undefined) return failure('body-not-found')
365
+ return success({ text })
366
+ },
367
+ },
368
+
369
+ // ── 记忆(MemoryService,P4)────────────────────────────────────────────────
370
+ {
371
+ name: 'bot_memory_add',
372
+ description:
373
+ '往分层记忆写一条。layer: L0 人格(**仅人可写,模型调用会被拒**)/ L1 用户偏好 / L2 团队全局 / ' +
374
+ 'L3 项目 key / L4 任务笔记 / L5 日札。explicit=true 表示「人显式说的」——之后 review 只能建议不能改。',
375
+ parameters: {
376
+ type: 'object',
377
+ properties: {
378
+ layer: { type: 'string', enum: ['L0', 'L1', 'L2', 'L3', 'L4', 'L5'] },
379
+ content: { type: 'string' },
380
+ explicit: { type: 'boolean' },
381
+ branch: { type: 'string', description: 'L3 用:限定 git 分支' },
382
+ tags: { type: 'array', items: { type: 'string' } },
383
+ },
384
+ required: ['layer', 'content'],
385
+ additionalProperties: false,
386
+ },
387
+ run: (s, a, exec) => {
388
+ if (!s.memory) return failure('memory-unavailable', { hint: 'P4 记忆服务未装配' })
389
+ try {
390
+ return success({ entry: s.memory.add({ ...a, by: callerOf(exec) }) })
391
+ } catch (error) {
392
+ return failure(error?.code ?? 'memory-add-failed', { message: error?.message ?? String(error) })
393
+ }
394
+ },
395
+ },
396
+ {
397
+ name: 'bot_memory_search',
398
+ description: '检索记忆(FTS5,缺则退化字面匹配)。可按层与分支过滤。',
399
+ parameters: {
400
+ type: 'object',
401
+ properties: {
402
+ query: { type: 'string' },
403
+ layers: { type: 'array', items: { type: 'string' } },
404
+ branch: { type: 'string' },
405
+ limit: { type: 'number' },
406
+ },
407
+ required: ['query'],
408
+ additionalProperties: false,
409
+ },
410
+ run: (s, a) => {
411
+ if (!s.memory) return failure('memory-unavailable')
412
+ return success({ entries: s.memory.search(a) })
413
+ },
414
+ },
415
+ {
416
+ name: 'bot_memory_context',
417
+ description: '取常驻注入用的记忆文本(L0–L3 冻结快照)。用于拼自己的上下文。',
418
+ parameters: {
419
+ type: 'object',
420
+ properties: { branch: { type: 'string' } },
421
+ additionalProperties: false,
422
+ },
423
+ run: (s, a) => {
424
+ if (!s.memory) return failure('memory-unavailable')
425
+ return success({ text: s.memory.contextFor(a ?? {}) })
426
+ },
427
+ },
428
+ ]
429
+
430
+ /**
431
+ * 注册全部工具。缺 `tools` 服务时**只 warn 不抛**(降级:模型看不到工具,
432
+ * 但域服务仍可被其他插件/测试直接调用)。
433
+ *
434
+ * @param {object} ctx
435
+ * @param {{ warn?: Function }} [deps]
436
+ * @returns {string[]} 实际注册成功的工具名
437
+ */
438
+ export function installTools(ctx, { warn } = {}) {
439
+ const registered = []
440
+ for (const def of DEFS) {
441
+ try {
442
+ const dispose = registerTool(ctx, {
443
+ name: def.name,
444
+ description: def.description,
445
+ parameters: def.parameters,
446
+ outputSchema: { type: 'object' },
447
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value ?? null, null, 2) }],
448
+ execute: async (args, exec) => {
449
+ try {
450
+ return def.run(servicesOf(ctx), args ?? {}, exec)
451
+ } catch (error) {
452
+ // 域服务内部异常也要转成**模型可读的失败**,绝不让宿主崩
453
+ return failure('tool-crashed', {
454
+ tool: def.name,
455
+ message: error?.message ?? String(error),
456
+ })
457
+ }
458
+ },
459
+ })
460
+ if (dispose) registered.push(def.name)
461
+ } catch (error) {
462
+ warn?.(`[cs-bots] 注册工具 ${def.name} 失败:${error?.message ?? error}`)
463
+ }
464
+ }
465
+ return registered
466
+ }
467
+
468
+ /** 工具名清单(供测试与文档引用,避免各处硬编码)。 */
469
+ export const TOOL_NAMES = Object.freeze(DEFS.map((d) => d.name))