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/api.js ADDED
@@ -0,0 +1,706 @@
1
+ /**
2
+ * cs-bots /api —— 宿主 HTTP 面(**给 L3 页面层用的数据接口**)。
3
+ *
4
+ * 为什么需要它:L2 域服务是宿主对象,浏览器里的客户端拿不到;
5
+ * 页面必须有一条**稳定的读/写接口**。本文件就是那条接口。
6
+ *
7
+ * 设计纪律:
8
+ * - **零第三方依赖**:只用 node 内置 + kernel.registerRoute。
9
+ * - **不碰宿主内部实现**:所有业务都转调域服务(board/roster/room/memory/dispatcher)。
10
+ * - **缺服务降级**:域服务缺失时返回 503 + 可读原因(不是 500 崩)。
11
+ * - **写操作必须回显成败**:让页面能显示"为什么失败",不静默。
12
+ * - **前缀路由**:`/cs-bots/api`(与包名一致,避免和其他插件撞路径)。
13
+ */
14
+
15
+ import { registerRoute, listPresets, defaultPreset } from './kernel.js'
16
+ import { createLibrary } from './library.js'
17
+ import { createDirBrowser } from './dirbrowse.js'
18
+ import { getServices } from './registry.js'
19
+
20
+ /** 请求体上限(防大包打爆内存)。 */
21
+ const MAX_BODY = 2 * 1024 * 1024
22
+
23
+ /** API 前缀。 */
24
+ export const API_BASE = '/cs-bots/api'
25
+
26
+ /** 读请求体(JSON)。 */
27
+ async function readBody(request) {
28
+ const chunks = []
29
+ let size = 0
30
+ for await (const chunk of request) {
31
+ size += chunk.length
32
+ if (size > MAX_BODY) throw new Error('请求体过大(>2MB)')
33
+ chunks.push(chunk)
34
+ }
35
+ if (!chunks.length) return undefined
36
+ const text = Buffer.concat(chunks).toString('utf8').trim()
37
+ return text ? JSON.parse(text) : undefined
38
+ }
39
+
40
+ /** 回 JSON(不缓存)。 */
41
+ function sendJson(response, code, body) {
42
+ response.writeHead(code, {
43
+ 'content-type': 'application/json; charset=utf-8',
44
+ 'cache-control': 'no-store',
45
+ })
46
+ response.end(JSON.stringify(body ?? null))
47
+ }
48
+
49
+ /** 从查询串取字符串参数。 */
50
+ function qp(url, name) {
51
+ const v = url.searchParams.get(name)
52
+ return v === null ? undefined : v
53
+ }
54
+
55
+ /** 从查询串取数字参数。 */
56
+ function qn(url, name) {
57
+ const v = url.searchParams.get(name)
58
+ if (v === null || v === '') return undefined
59
+ const n = Number(v)
60
+ return Number.isFinite(n) ? n : undefined
61
+ }
62
+
63
+ /**
64
+ * 统一包装:域服务缺失 → 503;业务异常 → 400 带可读原因;其它 → 500。
65
+ * @param {object} services
66
+ * @param {string} key
67
+ * @param {(svc: object) => Promise<object>|object} fn
68
+ */
69
+ async function withService(services, key, fn) {
70
+ const svc = services[key]
71
+ if (!svc) {
72
+ const error = new Error(`${key} 服务未装配(检查状态目录是否可写、宿主版本是否匹配)`)
73
+ error.status = 503
74
+ error.code = `${String(key).toUpperCase()}_UNAVAILABLE`
75
+ throw error
76
+ }
77
+ return fn(svc)
78
+ }
79
+
80
+
81
+ /**
82
+ * 把 Bot 档案里的「预制记忆」同步进记忆服务(带 botId)。
83
+ * 建 Bot 与改档案两处都要调用 —— 否则会出现"AGENTS.md 有、记忆库里没有"的不一致。
84
+ * @param {object} services
85
+ * @param {{ id: string, profile?: object }} bot
86
+ * @param {string} by
87
+ * @returns {{ added: number, failed: object[] } | undefined}
88
+ */
89
+ function seedBotMemory(services, bot, by) {
90
+ const seed = Array.isArray(bot?.profile?.memory) ? bot.profile.memory : []
91
+ if (!services.memory || seed.length === 0) return undefined
92
+ const added = []
93
+ const failed = []
94
+ for (const content of seed) {
95
+ try {
96
+ const entry = services.memory.add({
97
+ layer: 'L2',
98
+ content: String(content).slice(0, 600),
99
+ by,
100
+ source: 'explicit',
101
+ botId: bot.id,
102
+ })
103
+ const id = Array.isArray(entry) ? entry[0]?.id : entry?.id
104
+ if (id) added.push(id)
105
+ } catch (error) {
106
+ failed.push({ content: String(content).slice(0, 40), reason: error?.message ?? String(error) })
107
+ }
108
+ }
109
+ return { added: added.length, failed }
110
+ }
111
+
112
+ /**
113
+ * 注册全部 API 路由。
114
+ *
115
+ * @param {object} ctx 宿主上下文
116
+ * @param {{ warn?: Function }} [deps]
117
+ * @returns {(() => void) | undefined} disposer
118
+ */
119
+ export function installApi(ctx, { warn } = {}) {
120
+ const handler = async (request, response) => {
121
+ const services = getServices(ctx)
122
+ let url
123
+ try {
124
+ url = new URL(request.url || '/', 'http://localhost')
125
+ } catch {
126
+ sendJson(response, 400, { ok: false, reason: 'bad-url' })
127
+ return
128
+ }
129
+ // 去掉前缀,得到相对路径
130
+ const path = url.pathname.startsWith(API_BASE) ? url.pathname.slice(API_BASE.length) || '/' : url.pathname
131
+
132
+ try {
133
+ // ── 读:状态总览 ──────────────────────────────────────────────────────
134
+ if (request.method === 'GET' && path === '/status') {
135
+ const out = { ok: true, services: {}, compat: services.compat ?? null }
136
+ for (const key of ['roster', 'board', 'dispatcher', 'room', 'memory']) {
137
+ const svc = services[key]
138
+ out.services[key] = svc
139
+ ? {
140
+ available: true,
141
+ stats: typeof svc.stats === 'function' ? svc.stats() : undefined,
142
+ stateDir: svc.paths?.stateDir,
143
+ }
144
+ : { available: false }
145
+ }
146
+ sendJson(response, 200, out)
147
+ return
148
+ }
149
+
150
+ // ── 任务板 ────────────────────────────────────────────────────────────
151
+ if (request.method === 'GET' && path === '/tasks') {
152
+ const status = qp(url, 'status')
153
+ const onlyReady = qp(url, 'ready') === '1'
154
+ const tasks = await withService(services, 'board', (b) =>
155
+ onlyReady ? b.ready() : b.list(status ? { status } : {}),
156
+ )
157
+ sendJson(response, 200, { ok: true, tasks })
158
+ return
159
+ }
160
+
161
+ if (request.method === 'POST' && path === '/tasks') {
162
+ const body = (await readBody(request)) ?? {}
163
+ const task = await withService(services, 'board', (b) =>
164
+ b.create({ ...body, byId: body.byId ?? 'ui' }),
165
+ )
166
+ sendJson(response, 200, { ok: true, task })
167
+ return
168
+ }
169
+
170
+ if (request.method === 'POST' && path === '/tasks/update') {
171
+ const body = (await readBody(request)) ?? {}
172
+ const task = await withService(services, 'board', (b) =>
173
+ b.update({ ...body, byId: body.byId ?? 'ui' }),
174
+ )
175
+ sendJson(response, 200, { ok: true, task })
176
+ return
177
+ }
178
+
179
+ if (request.method === 'POST' && path === '/tasks/accept') {
180
+ const body = (await readBody(request)) ?? {}
181
+ const task = await withService(services, 'board', (b) =>
182
+ b.accept({ ...body, by: body.by ?? 'ui' }),
183
+ )
184
+ sendJson(response, 200, { ok: true, task })
185
+ return
186
+ }
187
+
188
+ // ── agent 预设(建 Bot 时可选)─────────────────────────────────────────
189
+ if (request.method === 'GET' && path === '/presets') {
190
+ let presets = []
191
+ let defaultId
192
+ try {
193
+ presets = await listPresets(ctx)
194
+ defaultId = defaultPreset(ctx)
195
+ } catch (error) {
196
+ sendJson(response, 200, { ok: true, presets: [], defaultId: undefined, note: `预设服务不可用:${error?.message ?? error}` })
197
+ return
198
+ }
199
+ sendJson(response, 200, {
200
+ ok: true,
201
+ presets: presets.map((p) => ({
202
+ id: p.id,
203
+ name: p.name ?? p.id,
204
+ description: p.description,
205
+ order: p.order,
206
+ broken: p.broken,
207
+ })),
208
+ defaultId,
209
+ note: presets.length ? undefined : '宿主未提供 agentPresets 服务(Bot 将使用默认预设)',
210
+ })
211
+ return
212
+ }
213
+
214
+ // ── Bot 注册表 ────────────────────────────────────────────────────────
215
+ if (request.method === 'GET' && path === '/roster') {
216
+ const includeRetired = qp(url, 'includeRetired') === '1'
217
+ const bots = await withService(services, 'roster', (r) => [
218
+ ...r.list(),
219
+ ...(includeRetired ? r.listRetired() : []),
220
+ ])
221
+ sendJson(response, 200, { ok: true, bots })
222
+ return
223
+ }
224
+
225
+ if (request.method === 'POST' && path === '/roster') {
226
+ const body = (await readBody(request)) ?? {}
227
+ const bot = await withService(services, 'roster', (r) =>
228
+ r.create({ ...body, byId: body.byId ?? 'ui' }),
229
+ )
230
+ // 预制记忆同步(建 Bot 与改档案必须一致)
231
+ const memory = seedBotMemory(services, bot, body.byId ?? 'ui')
232
+ // 同目录共享检测:**允许**,但必须告知(两个 Bot 在同一目录会互相覆盖文件)
233
+ let sharedWith = []
234
+ try {
235
+ const all = services.roster?.list?.() ?? []
236
+ sharedWith = all
237
+ .filter((b) => b.id !== bot.id && b.state !== 'retired' && String(b.cwd) === String(bot.cwd))
238
+ .map((b) => ({ id: b.id, name: b.name }))
239
+ } catch {
240
+ /* 检测失败不影响创建 */
241
+ }
242
+ sendJson(response, 200, {
243
+ ok: true,
244
+ bot,
245
+ ...(memory ? { memory } : {}),
246
+ ...(sharedWith.length
247
+ ? {
248
+ warnings: [
249
+ `目录 ${bot.cwd} 已被 ${sharedWith.map((b) => b.name).join('、')} 使用 —— 两个 Bot 会互相覆盖文件`,
250
+ ],
251
+ sharedWith,
252
+ }
253
+ : {}),
254
+ })
255
+ return
256
+ }
257
+
258
+ // ── Bot 档案(身份 / 经验 / 技能 / 预制记忆)──────────────────────────
259
+ if (request.method === 'GET' && path === '/roster/profile') {
260
+ const id = qp(url, 'id')
261
+ if (!id) {
262
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
263
+ return
264
+ }
265
+ const detail = await withService(services, 'roster', (r) => r.getProfile(id))
266
+ sendJson(response, 200, {
267
+ ok: true,
268
+ bot: detail.bot,
269
+ profile: detail.profile ?? {},
270
+ profilePath: detail.profilePath,
271
+ managedText: detail.managedText,
272
+ hasOutside: detail.hasOutside,
273
+ profileError: detail.profileError,
274
+ })
275
+ return
276
+ }
277
+
278
+ if (request.method === 'POST' && path === '/roster/profile') {
279
+ const body = (await readBody(request)) ?? {}
280
+ if (!body.id) {
281
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
282
+ return
283
+ }
284
+ const bot = await withService(services, 'roster', (r) =>
285
+ r.setProfile({ id: body.id, profile: body.profile ?? {}, byId: body.byId ?? 'ui', expectedRev: body.expectedRev }),
286
+ )
287
+ // 预制记忆同步(与建 Bot 共用同一段逻辑)
288
+ const memory = seedBotMemory(services, bot, body.byId ?? 'ui')
289
+ sendJson(response, 200, { ok: true, bot, ...(memory ? { memory } : {}) })
290
+ return
291
+ }
292
+
293
+ if (request.method === 'POST' && path === '/roster/manage') {
294
+ const body = (await readBody(request)) ?? {}
295
+ const byId = body.byId ?? 'ui'
296
+ const result = await withService(services, 'roster', (r) => {
297
+ switch (body.action) {
298
+ case 'rename':
299
+ return r.rename({ id: body.id, name: body.name, expectedRev: body.expectedRev, byId })
300
+ case 'retire':
301
+ return r.retire({ id: body.id, reason: body.reason, byId })
302
+ case 'setCwd':
303
+ return r.setCwd({ id: body.id, cwd: body.cwd, expectedRev: body.expectedRev, byId })
304
+ case 'claim':
305
+ return r.claim({ id: body.id, owner: byId, expectedRev: body.expectedRev })
306
+ case 'release':
307
+ return r.release({ id: body.id, owner: byId, expectedRev: body.expectedRev })
308
+ case 'heartbeat':
309
+ return r.heartbeat({ id: body.id, owner: byId, expectedRev: body.expectedRev })
310
+ default:
311
+ throw new Error(`未知 action: ${body.action}`)
312
+ }
313
+ })
314
+ sendJson(response, 200, { ok: true, bot: result })
315
+ return
316
+ }
317
+
318
+ // ── 房间 ──────────────────────────────────────────────────────────────
319
+ if (request.method === 'GET' && path === '/rooms') {
320
+ const rooms = await withService(services, 'room', (r) => r.list())
321
+ sendJson(response, 200, { ok: true, rooms })
322
+ return
323
+ }
324
+
325
+ if (request.method === 'POST' && path === '/rooms') {
326
+ const body = (await readBody(request)) ?? {}
327
+ const room = await withService(services, 'room', (r) =>
328
+ r.create({ ...body, byId: body.byId ?? 'ui' }),
329
+ )
330
+ sendJson(response, 200, { ok: true, room })
331
+ return
332
+ }
333
+
334
+ if (request.method === 'GET' && path === '/rooms/messages') {
335
+ const roomId = qp(url, 'roomId')
336
+ if (!roomId) {
337
+ sendJson(response, 400, { ok: false, reason: 'roomId 必填' })
338
+ return
339
+ }
340
+ const messages = await withService(services, 'room', (r) =>
341
+ r.listMessages({ roomId, sinceSeq: qn(url, 'sinceSeq'), limit: qn(url, 'limit') }),
342
+ )
343
+ sendJson(response, 200, { ok: true, messages })
344
+ return
345
+ }
346
+
347
+ if (request.method === 'POST' && path === '/rooms/post') {
348
+ const body = (await readBody(request)) ?? {}
349
+ const message = await withService(services, 'room', (r) =>
350
+ r.post({ ...body, from: body.from ?? 'ui' }),
351
+ )
352
+ sendJson(response, 200, { ok: true, message })
353
+ return
354
+ }
355
+
356
+ if (request.method === 'GET' && path === '/rooms/body') {
357
+ const roomId = qp(url, 'roomId')
358
+ const messageId = qp(url, 'messageId')
359
+ if (!roomId || !messageId) {
360
+ sendJson(response, 400, { ok: false, reason: 'roomId 与 messageId 必填' })
361
+ return
362
+ }
363
+ const text = await withService(services, 'room', (r) => r.readBody({ roomId, messageId }))
364
+ sendJson(response, 200, { ok: true, text })
365
+ return
366
+ }
367
+
368
+ // ── 记忆 ──────────────────────────────────────────────────────────────
369
+ if (request.method === 'GET' && path === '/memory') {
370
+ const layer = qp(url, 'layer')
371
+ const query = qp(url, 'query')
372
+ const entries = await withService(services, 'memory', (m) =>
373
+ query ? m.search({ query, layers: layer ? [layer] : undefined, limit: qn(url, 'limit') })
374
+ : m.list({ layer, limit: qn(url, 'limit') }),
375
+ )
376
+ sendJson(response, 200, { ok: true, entries })
377
+ return
378
+ }
379
+
380
+ if (request.method === 'POST' && path === '/memory') {
381
+ const body = (await readBody(request)) ?? {}
382
+ const entry = await withService(services, 'memory', (m) =>
383
+ m.add({ ...body, by: body.by ?? 'ui' }),
384
+ )
385
+ sendJson(response, 200, { ok: true, entry })
386
+ return
387
+ }
388
+
389
+ if (request.method === 'GET' && path === '/memory/context') {
390
+ const text = await withService(services, 'memory', (m) => m.contextFor({ branch: qp(url, 'branch') }))
391
+ sendJson(response, 200, { ok: true, text })
392
+ return
393
+ }
394
+
395
+ // ── 任务:详情穿透 / 指派 / 终止 / 编辑 ──────────────────────────────
396
+ if (request.method === 'GET' && path === '/tasks/detail') {
397
+ const id = qp(url, 'id')
398
+ if (!id) {
399
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
400
+ return
401
+ }
402
+ const detail = await withService(services, 'board', (b) => ({
403
+ task: b.get(id),
404
+ history: typeof b.history === 'function' ? b.history(id) : [],
405
+ }))
406
+ if (!detail.task) {
407
+ sendJson(response, 404, { ok: false, reason: `任务 ${id} 不存在` })
408
+ return
409
+ }
410
+ sendJson(response, 200, { ok: true, ...detail })
411
+ return
412
+ }
413
+
414
+ if (request.method === 'POST' && path === '/tasks/assign') {
415
+ const body = (await readBody(request)) ?? {}
416
+ if (!body.id || !body.ownerId) {
417
+ sendJson(response, 400, { ok: false, reason: 'id 与 ownerId 必填' })
418
+ return
419
+ }
420
+ // 指派 = 用 ownerId 作为 claim 的 byId(board 的 claim 把 by 记为 owner)
421
+ const task = await withService(services, 'board', (b) =>
422
+ b.update({
423
+ id: body.id,
424
+ expectedRev: body.expectedRev,
425
+ action: 'claim',
426
+ byId: body.ownerId,
427
+ ...(body.leaseMs ? { leaseMs: body.leaseMs } : {}),
428
+ }),
429
+ )
430
+ // 指派后立刻定向唤醒(dedupKey 幂等:同任务同原因只唤一次)
431
+ let wake
432
+ if (services.dispatcher && body.wake !== false) {
433
+ wake = services.dispatcher.notify({
434
+ target: body.ownerId,
435
+ reason: 'task-assigned',
436
+ dedupKey: `task-assigned:${task.id}:${task.rev}`,
437
+ text: `新任务 ${task.id}:${task.subject}\n${task.description ?? ''}`.trim(),
438
+ })
439
+ }
440
+ sendJson(response, 200, { ok: true, task, wake })
441
+ return
442
+ }
443
+
444
+ if (request.method === 'POST' && path === '/tasks/abort') {
445
+ const body = (await readBody(request)) ?? {}
446
+ if (!body.id) {
447
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
448
+ return
449
+ }
450
+ // 终止 = block + 原因(board 的 block 会置 blocked 并留 reason;可 reopen)
451
+ const task = await withService(services, 'board', (b) =>
452
+ b.update({
453
+ id: body.id,
454
+ expectedRev: body.expectedRev,
455
+ action: 'block',
456
+ note: body.reason ?? '用户终止',
457
+ byId: body.byId ?? 'ui',
458
+ }),
459
+ )
460
+ sendJson(response, 200, { ok: true, task })
461
+ return
462
+ }
463
+
464
+ if (request.method === 'POST' && path === '/tasks/edit') {
465
+ const body = (await readBody(request)) ?? {}
466
+ if (!body.id) {
467
+ sendJson(response, 400, { ok: false, reason: 'id 必填' })
468
+ return
469
+ }
470
+ const task = await withService(services, 'board', (b) =>
471
+ b.update({ ...body, action: 'edit', byId: body.byId ?? 'ui' }),
472
+ )
473
+ sendJson(response, 200, { ok: true, task })
474
+ return
475
+ }
476
+
477
+ if (request.method === 'GET' && path === '/tasks/history') {
478
+ const id = qp(url, 'id')
479
+ const history = await withService(services, 'board', (b) =>
480
+ typeof b.history === 'function' ? b.history(id) : [],
481
+ )
482
+ sendJson(response, 200, { ok: true, history })
483
+ return
484
+ }
485
+
486
+ // ── 房间:成员管理 / 转发 ─────────────────────────────────────────────
487
+ if (request.method === 'POST' && path === '/rooms/update') {
488
+ const body = (await readBody(request)) ?? {}
489
+ const room = await withService(services, 'room', (r) =>
490
+ r.update({ ...body, byId: body.byId ?? 'ui' }),
491
+ )
492
+ sendJson(response, 200, { ok: true, room })
493
+ return
494
+ }
495
+
496
+ if (request.method === 'POST' && path === '/rooms/join') {
497
+ const body = (await readBody(request)) ?? {}
498
+ const room = await withService(services, 'room', (r) =>
499
+ r.join({ roomId: body.roomId, sessionId: body.sessionId, byId: body.byId ?? 'ui' }),
500
+ )
501
+ sendJson(response, 200, { ok: true, room })
502
+ return
503
+ }
504
+
505
+ if (request.method === 'POST' && path === '/rooms/leave') {
506
+ const body = (await readBody(request)) ?? {}
507
+ const room = await withService(services, 'room', (r) =>
508
+ r.leave({ roomId: body.roomId, sessionId: body.sessionId, byId: body.byId ?? 'ui' }),
509
+ )
510
+ sendJson(response, 200, { ok: true, room })
511
+ return
512
+ }
513
+
514
+ if (request.method === 'POST' && path === '/rooms/relay') {
515
+ const body = (await readBody(request)) ?? {}
516
+ const result = await withService(services, 'room', (r) =>
517
+ r.relay({ roomId: body.roomId, messageId: body.messageId, targets: body.targets }),
518
+ )
519
+ sendJson(response, 200, { ok: true, ...result })
520
+ return
521
+ }
522
+
523
+ // ── 记忆:改 / 归档 / 恢复 ────────────────────────────────────────────
524
+ if (request.method === 'POST' && path === '/memory/update') {
525
+ const body = (await readBody(request)) ?? {}
526
+ const entry = await withService(services, 'memory', (m) =>
527
+ m.update({ ...body, by: body.by ?? 'ui' }),
528
+ )
529
+ sendJson(response, 200, { ok: true, entry })
530
+ return
531
+ }
532
+
533
+ if (request.method === 'POST' && path === '/memory/remove') {
534
+ const body = (await readBody(request)) ?? {}
535
+ const entry = await withService(services, 'memory', (m) =>
536
+ m.remove({ ...body, by: body.by ?? 'ui' }),
537
+ )
538
+ sendJson(response, 200, { ok: true, entry })
539
+ return
540
+ }
541
+
542
+ if (request.method === 'POST' && path === '/memory/archive') {
543
+ const body = (await readBody(request)) ?? {}
544
+ const entry = await withService(services, 'memory', (m) =>
545
+ m.archive({ ...body, by: body.by ?? 'ui' }),
546
+ )
547
+ sendJson(response, 200, { ok: true, entry })
548
+ return
549
+ }
550
+
551
+ if (request.method === 'POST' && path === '/memory/restore') {
552
+ const body = (await readBody(request)) ?? {}
553
+ const entry = await withService(services, 'memory', (m) =>
554
+ m.restore({ ...body, by: body.by ?? 'ui' }),
555
+ )
556
+ sendJson(response, 200, { ok: true, entry })
557
+ return
558
+ }
559
+
560
+ // ── 目录浏览(宿主原生选择器不可用时的兜底,也是唯一能「新建/重命名」的路径)──
561
+ if (request.method === 'GET' && path === '/fs/roots') {
562
+ const r = await withService(services, 'dirbrowse', (d) => d.roots())
563
+ sendJson(response, 200, { ok: true, ...r })
564
+ return
565
+ }
566
+ if (request.method === 'GET' && path === '/fs/list') {
567
+ const dir = qp(url, 'path')
568
+ const showHidden = qp(url, 'showHidden') === '1'
569
+ const listing = await withService(services, 'dirbrowse', (d) =>
570
+ d.list({ ...(dir ? { path: dir } : {}), showHidden }),
571
+ )
572
+ sendJson(response, 200, { ok: true, ...listing })
573
+ return
574
+ }
575
+ if (request.method === 'POST' && path === '/fs/mkdir') {
576
+ const body = (await readBody(request)) ?? {}
577
+ const created = await withService(services, 'dirbrowse', (d) => d.mkdir(body))
578
+ sendJson(response, 200, { ok: true, ...created })
579
+ return
580
+ }
581
+ if (request.method === 'POST' && path === '/fs/rename') {
582
+ const body = (await readBody(request)) ?? {}
583
+ const renamed = await withService(services, 'dirbrowse', (d) => d.rename(body))
584
+ sendJson(response, 200, { ok: true, ...renamed })
585
+ return
586
+ }
587
+ if (request.method === 'POST' && path === '/fs/validate') {
588
+ const body = (await readBody(request)) ?? {}
589
+ const verdict = await withService(services, 'dirbrowse', (d) => d.validateAsBotCwd({ path: body.path }))
590
+ sendJson(response, 200, { ok: true, verdict })
591
+ return
592
+ }
593
+
594
+ // ── 技能库 / 工作流库 ─────────────────────────────────────────────────
595
+ if (request.method === 'GET' && path === '/library/skills') {
596
+ const rows = await withService(services, 'library', (l) => l.listSkills({ tag: qp(url, 'tag') }))
597
+ sendJson(response, 200, { ok: true, skills: rows })
598
+ return
599
+ }
600
+ if (request.method === 'POST' && path === '/library/skills') {
601
+ const body = (await readBody(request)) ?? {}
602
+ const skill = await withService(services, 'library', (l) => l.createSkill({ ...body, by: body.by ?? 'ui' }))
603
+ sendJson(response, 200, { ok: true, skill })
604
+ return
605
+ }
606
+ if (request.method === 'POST' && path === '/library/skills/update') {
607
+ const body = (await readBody(request)) ?? {}
608
+ const skill = await withService(services, 'library', (l) => l.updateSkill({ ...body, by: body.by ?? 'ui' }))
609
+ sendJson(response, 200, { ok: true, skill })
610
+ return
611
+ }
612
+ if (request.method === 'POST' && path === '/library/skills/remove') {
613
+ const body = (await readBody(request)) ?? {}
614
+ const result = await withService(services, 'library', (l) => l.removeSkill({ id: body.id, by: body.by ?? 'ui' }))
615
+ sendJson(response, 200, { ok: true, result })
616
+ return
617
+ }
618
+ if (request.method === 'GET' && path === '/library/workflows') {
619
+ const rows = await withService(services, 'library', (l) => l.listWorkflows({ tag: qp(url, 'tag') }))
620
+ sendJson(response, 200, { ok: true, workflows: rows })
621
+ return
622
+ }
623
+ if (request.method === 'POST' && path === '/library/workflows') {
624
+ const body = (await readBody(request)) ?? {}
625
+ const workflow = await withService(services, 'library', (l) => l.createWorkflow({ ...body, by: body.by ?? 'ui' }))
626
+ sendJson(response, 200, { ok: true, workflow })
627
+ return
628
+ }
629
+ if (request.method === 'POST' && path === '/library/workflows/update') {
630
+ const body = (await readBody(request)) ?? {}
631
+ const workflow = await withService(services, 'library', (l) => l.updateWorkflow({ ...body, by: body.by ?? 'ui' }))
632
+ sendJson(response, 200, { ok: true, workflow })
633
+ return
634
+ }
635
+ if (request.method === 'POST' && path === '/library/workflows/remove') {
636
+ const body = (await readBody(request)) ?? {}
637
+ const result = await withService(services, 'library', (l) => l.removeWorkflow({ id: body.id, by: body.by ?? 'ui' }))
638
+ sendJson(response, 200, { ok: true, result })
639
+ return
640
+ }
641
+
642
+ // ── 设置 ──────────────────────────────────────────────────────────────
643
+ if (request.method === 'GET' && path === '/settings') {
644
+ const { SETTING_SPECS } = await import('./settings.js')
645
+ sendJson(response, 200, {
646
+ ok: true,
647
+ values: services.settings ?? {},
648
+ specs: SETTING_SPECS,
649
+ diagnostics: services.settingsDiagnostics,
650
+ baseDir: services.board?.paths?.stateDir ?? services.roster?.paths?.stateDir,
651
+ })
652
+ return
653
+ }
654
+
655
+ if (request.method === 'POST' && path === '/settings') {
656
+ const body = (await readBody(request)) ?? {}
657
+ const { saveSettings } = await import('./settings.js')
658
+ const dir = services.board?.paths?.stateDir ?? services.roster?.paths?.stateDir
659
+ const saved = saveSettings(dir, body)
660
+ if (!saved.ok) {
661
+ sendJson(response, 400, { ok: false, reason: saved.reason })
662
+ return
663
+ }
664
+ sendJson(response, 200, {
665
+ ok: true,
666
+ values: saved.values,
667
+ note: '已写入 settings.json;内核级参数需重启 DSH 生效',
668
+ })
669
+ return
670
+ }
671
+
672
+ // ── 手动唤醒 ──────────────────────────────────────────────────────────
673
+ if (request.method === 'POST' && path === '/wakes/notify') {
674
+ const body = (await readBody(request)) ?? {}
675
+ const result = await withService(services, 'dispatcher', (d) => d.notify(body))
676
+ sendJson(response, 200, { ok: true, result })
677
+ return
678
+ }
679
+
680
+ // ── 唤醒审计 ──────────────────────────────────────────────────────────
681
+ if (request.method === 'GET' && path === '/wakes') {
682
+ const wakes = await withService(services, 'dispatcher', (d) =>
683
+ d.list({ target: qp(url, 'target'), limit: qn(url, 'limit') ?? 100 }),
684
+ )
685
+ sendJson(response, 200, { ok: true, wakes })
686
+ return
687
+ }
688
+
689
+ sendJson(response, 404, { ok: false, reason: `未知端点: ${request.method} ${path}` })
690
+ } catch (error) {
691
+ const status = Number.isFinite(error?.status) ? error.status : 400
692
+ sendJson(response, status, {
693
+ ok: false,
694
+ code: error?.code ?? 'API_ERROR',
695
+ reason: error?.message ?? String(error),
696
+ })
697
+ }
698
+ }
699
+
700
+ const dispose = registerRoute(ctx, API_BASE, handler)
701
+ if (!dispose) {
702
+ warn?.(`[cs-bots] webServer 不可用 → HTTP API 未注册(页面将无数据)`)
703
+ return undefined
704
+ }
705
+ return dispose
706
+ }