cs-bots 0.1.1 → 0.7.4

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +533 -0
  2. package/README.md +65 -10
  3. package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
  4. package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
  5. package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
  6. package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
  7. package/docs/ARCHITECTURE.md +277 -0
  8. package/docs/adr/ADR-001-/351/241/265/347/255/276/347/273/223/346/236/204/345/233/236/345/210/260/344/270/203/351/241/265.md +70 -0
  9. package/docs/steps/step-01-/345/216/237/346/236/266/346/236/204/345/244/215/345/216/237/344/270/216IA/350/257/212/346/226/255.md +80 -0
  10. package/docs/steps/step-02-/351/241/265/347/255/276/346/224/266/346/225/233/344/270/216/345/215/207/347/272/247/351/200/232/351/201/223.md +51 -0
  11. package/docs/steps/step-03-/346/236/266/346/236/204/350/265/204/346/226/231/346/210/220/346/226/207.md +16 -0
  12. package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
  13. package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
  14. package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
  15. package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
  16. package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
  17. package/package.json +4 -2
  18. package/src/admission.js +365 -0
  19. package/src/api.js +4097 -420
  20. package/src/board.js +883 -14
  21. package/src/botprofile.js +190 -3
  22. package/src/client.js +6596 -1082
  23. package/src/cycle.js +516 -0
  24. package/src/delivery-watch.js +255 -0
  25. package/src/dirbrowse.js +20 -0
  26. package/src/discipline.js +1188 -0
  27. package/src/fence.js +142 -0
  28. package/src/index.js +387 -4
  29. package/src/kernel.js +636 -24
  30. package/src/library.js +1224 -11
  31. package/src/memory.js +617 -37
  32. package/src/metrics.js +339 -0
  33. package/src/prompts.js +1318 -0
  34. package/src/reviewer.js +709 -0
  35. package/src/room.js +655 -14
  36. package/src/roster.js +346 -26
  37. package/src/routing/complexity.js +320 -0
  38. package/src/routing/hints.js +707 -0
  39. package/src/scratch.js +477 -0
  40. package/src/settings.js +97 -0
  41. package/src/stage.js +263 -0
  42. package/src/store.js +133 -1
  43. package/src/subscription.js +571 -0
  44. package/src/suggest.js +1203 -0
  45. package/src/teams.js +527 -0
  46. package/src/todo.js +1301 -0
  47. package/src/tools.js +734 -10
  48. package/src/verify.js +356 -0
package/src/fence.js ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * cs-bots /api 鉴权围栏 —— **公网暴露的 API 必须有门**。
3
+ *
4
+ * 为什么需要这个文件(2026-11-04 实测):
5
+ * 修复前 cs-bots 的 HTTP 面**完全没有鉴权**——
6
+ * `curl -X POST http://<公网IP>/cs-bots/api/tasks -d '{"subject":"x"}'` 返回 200,
7
+ * 任何知道地址的人都能建任务、改记忆、写房间、改 Bot 注册表。
8
+ * 对比:cs-guard 有完整围栏、cs-myfree 有校验,唯独 cs-bots 裸奔。
9
+ * 这不仅是"数据被改",更会把「受保护区写闸门」这类下游防护**从更高层绕过去**
10
+ * (比如直接往 roster/memory 事件流里塞条目),所以必须在 API 层堵死。
11
+ *
12
+ * 判定顺序(与 cs-guard/web/guard.js 同款思路,但**本文件独立实现**——
13
+ * cs-bots 的铁律是"运行时不依赖其他插件",不能 import cs-guard):
14
+ * ① Origin 与 Host 不同源 → 403(防 CSRF)
15
+ * ② 宿主 connection 服务可用 → 交给它做**真实**会话校验(签名/cookie 有效性)
16
+ * ③ 宿主校验能力缺失 → 拒绝一切**经代理**的请求(fail-closed),
17
+ * 非代理请求放行(本机直连 / 测试替身;服务只监听 127.0.0.1,外部进不来)
18
+ *
19
+ * 为什么第 ③ 条不是一律拒绝:本仓的 `test/api.test.mjs` 用假 req/res 直接调 handler,
20
+ * 没有 socket 也没有 connection 服务。一律拒绝会让整个测试套件失效,
21
+ * 逼着人关掉围栏——那才是真正的安全隐患。区分"经代理"与"非经代理"是准确的边界。
22
+ */
23
+
24
+ /** 判定 loopback 地址。 */
25
+ function isLoopbackAddr(addr) {
26
+ if (typeof addr !== 'string' || addr === '') return false
27
+ return addr === '::1' || addr === '127.0.0.1' || addr.startsWith('127.') || addr === '::ffff:127.0.0.1'
28
+ }
29
+
30
+ /** 请求是否经过反向代理(nginx 等会加这些头)。 */
31
+ export function isProxiedRequest(request) {
32
+ const headers = request?.headers || {}
33
+ return Boolean(
34
+ headers['x-forwarded-for'] || headers['x-forwarded-proto']
35
+ || headers['x-forwarded-host'] || headers['x-real-ip'],
36
+ )
37
+ }
38
+
39
+ /** 安全取宿主的 connection 服务(拿不到就返回 null,不抛)。 */
40
+ export function safeGetConnection(getConnection) {
41
+ try {
42
+ const conn = typeof getConnection === 'function' ? getConnection() : getConnection
43
+ return conn && typeof conn.requestRejection === 'function' ? conn : null
44
+ } catch {
45
+ return null
46
+ }
47
+ }
48
+
49
+ /** 委托宿主做真实会话校验。 */
50
+ export function verifySession(request, conn) {
51
+ try {
52
+ const rejection = conn.requestRejection(request)
53
+ // 宿主契约:undefined/null/0 表示放行;401 未认证;其余为 403 禁止
54
+ if (rejection === undefined || rejection === null || rejection === 0) return { ok: true }
55
+ const status = rejection === 401 ? 401 : 403
56
+ return {
57
+ ok: false,
58
+ status,
59
+ reason: status === 401 ? 'invalid-or-missing-session' : 'untrusted-authority-or-forbidden',
60
+ }
61
+ } catch {
62
+ return { ok: false, status: 403, reason: 'auth-check-failed' }
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 判定一个 API 请求是否放行。
68
+ *
69
+ * @param {any} request Node IncomingMessage(或测试替身)
70
+ * @param {{ getConnection?: (() => any) | any }} [opts]
71
+ * @returns {{ ok: boolean, status?: number, reason?: string }}
72
+ */
73
+ export function judge(request, { getConnection } = {}) {
74
+ const headers = request?.headers || {}
75
+
76
+ // ① 同源校验(防 CSRF):带了 Origin 就必须与 Host 一致
77
+ const origin = headers.origin
78
+ const hostHeader = headers.host
79
+ if (typeof origin === 'string' && origin !== '') {
80
+ let originHost = null
81
+ try { originHost = new URL(origin).host } catch { originHost = null }
82
+ if (originHost === null || originHost !== hostHeader) {
83
+ return { ok: false, status: 403, reason: 'cross-origin' }
84
+ }
85
+ }
86
+
87
+ // ② 宿主校验能力可用 → 真实校验,**不论是否经代理**
88
+ const conn = safeGetConnection(getConnection)
89
+ if (conn) return verifySession(request, conn)
90
+
91
+ // ③ 宿主校验能力缺失:经代理的请求一律拒绝(公网路径 fail-closed)
92
+ if (isProxiedRequest(request)) {
93
+ return { ok: false, status: 403, reason: 'auth-unavailable-for-proxied' }
94
+ }
95
+
96
+ // 非代理:本机直连(服务只绑 127.0.0.1)或测试替身 → 放行
97
+ return { ok: true }
98
+ }
99
+
100
+ /**
101
+ * 造一个带缓存的判定器(避免每个请求都去 reflect 一次 ctx)。
102
+ *
103
+ * @param {{ ctx: any, warn?: Function }} input
104
+ * @returns {{ judgeHttp: (request: any) => {ok:boolean,status?:number,reason?:string},
105
+ * mode: () => 'host-session'|'proxy-denied' }}
106
+ */
107
+ export function createFence({ ctx, warn } = {}) {
108
+ let warnedNoConnection = false
109
+
110
+ const getConnection = () => {
111
+ try {
112
+ if (typeof ctx?.get === 'function') {
113
+ const viaGet = ctx.get('connection', false)
114
+ if (viaGet) return viaGet
115
+ }
116
+ return ctx?.connection ?? null
117
+ } catch {
118
+ return null
119
+ }
120
+ }
121
+
122
+ // 启动时探一次:把"围栏处在哪种模式"明确写进日志,
123
+ // 免得以后有人以为有门、其实门是虚掩的(这次 P0 就是这么漏的)。
124
+ const probe = safeGetConnection(getConnection)
125
+ if (!probe) {
126
+ warnedNoConnection = true
127
+ warn?.(
128
+ '[cs-bots/fence] 未拿到宿主 connection 服务 → API 围栏降级为「拒绝一切经代理请求」模式。'
129
+ + '本机直连(127.0.0.1)与测试替身仍可访问;公网路径一律 403。'
130
+ + '若你是在公网使用本插件,这说明会话校验失效,请检查 DSH 版本/装配。',
131
+ )
132
+ }
133
+
134
+ return {
135
+ judgeHttp(request) {
136
+ return judge(request, { getConnection })
137
+ },
138
+ mode() {
139
+ return safeGetConnection(getConnection) ? 'host-session' : 'proxy-denied'
140
+ },
141
+ }
142
+ }
package/src/index.js CHANGED
@@ -17,9 +17,22 @@ import { createRoom } from './room.js'
17
17
  import { createMemory } from './memory.js'
18
18
  import { installTools } from './tools.js'
19
19
  import { setServices } from './registry.js'
20
- import { service, stateDir as kernelStateDir } from './kernel.js'
20
+ import { service, stateDir as kernelStateDir, onLlmStream, sessionMeta, readSessionWatermark as kernelWatermark } from './kernel.js'
21
+ import { installLlmStreamHint } from './routing/hints.js'
21
22
  import { installApi } from './api.js'
23
+ import { createDeliveryWatch, DEFAULT_GRACE_MS, DEFAULT_TICK_MS } from './delivery-watch.js'
22
24
  import { createLibrary } from './library.js'
25
+ import { createDiscipline } from './discipline.js'
26
+ import { createTodo } from './todo.js'
27
+ import { createScratch } from './scratch.js'
28
+ import { createPrompts } from './prompts.js'
29
+ import { createSuggest } from './suggest.js'
30
+ import { createReviewer } from './reviewer.js'
31
+ import { createMetrics } from './metrics.js'
32
+ import { createVerify } from './verify.js'
33
+ import { createCycle } from './cycle.js'
34
+ import { createTeams } from './teams.js'
35
+ import { createSubscription } from './subscription.js'
23
36
  import { createDirBrowser } from './dirbrowse.js'
24
37
  import { loadSettings } from './settings.js'
25
38
  import { probeHost, formatReport } from './compat.js'
@@ -111,8 +124,20 @@ function applyInner(ctx, config, { log, warn }) {
111
124
  }
112
125
 
113
126
  try {
114
- services.board = createBoard(ctx, { stateDir: config.stateDir, leaseMs: settings.defaultLeaseMs })
127
+ // ⚠️ 2026-11-05(P0):createBoard 必须拿到 settings —— wipLimitPerBot(WIP 上限)
128
+ // 与 requireTriageForReady(Ready 闸门)都从 config.settings 读。只传 stateDir 会让
129
+ // 平台设置对任务板静默失效(永远退回代码内默认值),且用户改了设置看不到任何变化。
130
+ services.board = createBoard(ctx, { stateDir: config.stateDir, leaseMs: settings.defaultLeaseMs, settings })
115
131
  log(`[cs-bots] BoardService 就绪(stateDir=${services.board.paths?.stateDir ?? '?'})`)
132
+ // ⚠️ 修复(2026-11-04 测试,P1-4):expireLeases 之前无任何调用者,
133
+ // owner 崩溃/失联后任务永久停在 in_progress(连"重启时回收"都不做)。
134
+ // 现在启动时先回收一次过期租约(失败不阻塞装配)。
135
+ try {
136
+ const recycled = services.board.expireLeases()
137
+ if (recycled.length > 0) log(`[cs-bots] 启动回收过期租约:${recycled.join(', ')}`)
138
+ } catch (error) {
139
+ warn(`[cs-bots] 启动回收过期租约失败(不影响装配):${error?.message ?? error}`)
140
+ }
116
141
  } catch (error) {
117
142
  warn(`[cs-bots] BoardService 装配失败 → 降级(无任务板):${error?.message ?? error}`)
118
143
  services.board = undefined
@@ -138,7 +163,16 @@ function applyInner(ctx, config, { log, warn }) {
138
163
  }
139
164
 
140
165
  try {
141
- services.memory = createMemory(ctx, { stateDir: config.stateDir, entryMax: settings.memoryEntryMax, splitOnOverflow: settings.memorySplitOnOverflow })
166
+ services.memory = createMemory(ctx, {
167
+ stateDir: config.stateDir,
168
+ entryMax: settings.memoryEntryMax,
169
+ splitOnOverflow: settings.memorySplitOnOverflow,
170
+ progressiveDisclosure: settings.keyProgressiveDisclosure,
171
+ fullInjectThreshold: settings.keyFullInjectThreshold,
172
+ fullInjectCharLimit: settings.keyFullInjectCharLimit,
173
+ entryDatePrefix: settings.entryDatePrefix,
174
+ perTurnWrites: settings.perTurnWrites,
175
+ })
142
176
  log(`[cs-bots] MemoryService 就绪(stateDir=${services.memory.paths?.stateDir ?? '?'})`)
143
177
  } catch (error) {
144
178
  warn(`[cs-bots] MemoryService 装配失败 → 降级(无分层记忆):${error?.message ?? error}`)
@@ -170,12 +204,160 @@ function applyInner(ctx, config, { log, warn }) {
170
204
 
171
205
  // ── 技能库 / 工作流库(全局可复用资产,Bot 与任务可关联)──────────────
172
206
  try {
173
- services.library = createLibrary(ctx, { stateDir: config.stateDir ?? kernelStateDir() })
207
+ services.library = createLibrary(ctx, {
208
+ stateDir: config.stateDir ?? kernelStateDir(),
209
+ skillMaxBytes: settings.skillMaxBytes,
210
+ skillReviewEnabled: settings.skillReviewEnabled,
211
+ })
174
212
  log('[cs-bots] library 就绪')
175
213
  } catch (error) {
176
214
  warn(`[cs-bots] library 装配失败(技能库/工作流库不可用):${error?.message ?? error}`)
177
215
  }
178
216
 
217
+ // ── 分级纪律体系(全局/工作区/房间/Bot 四级,注入到各层级上下文)──────────
218
+ try {
219
+ services.discipline = createDiscipline(ctx, { stateDir: config.stateDir ?? kernelStateDir() })
220
+ log(`[cs-bots] discipline 就绪(${services.discipline.stats().total} 条纪律)`)
221
+ } catch (error) {
222
+ warn(`[cs-bots] discipline 装配失败(分级纪律不可用):${error?.message ?? error}`)
223
+ }
224
+
225
+ // ── 四轨待办(生活/工作/项目/每日 + 四象限 + 逾期)─────────────────────────
226
+ try {
227
+ services.todo = createTodo(ctx, { stateDir: config.stateDir ?? kernelStateDir() })
228
+ log(`[cs-bots] todo 就绪(${services.todo.stats?.().total ?? 0} 条待办)`)
229
+ } catch (error) {
230
+ warn(`[cs-bots] todo 装配失败(待办不可用):${error?.message ?? error}`)
231
+ }
232
+
233
+ // ── 便签(单文件随手记,原子写)────────────────────────────────────────────
234
+ try {
235
+ services.scratch = createScratch(ctx, { stateDir: config.stateDir ?? kernelStateDir() })
236
+ log('[cs-bots] scratch 就绪')
237
+ } catch (error) {
238
+ warn(`[cs-bots] scratch 装配失败(便签不可用):${error?.message ?? error}`)
239
+ }
240
+
241
+ // ── 提示词库 + 注入轨 ──────────────────────────────────────────────────────
242
+ try {
243
+ services.prompts = createPrompts(ctx, { stateDir: config.stateDir ?? kernelStateDir() })
244
+ log(`[cs-bots] prompts 就绪(${services.prompts.stats?.().total ?? 0} 条提示词)`)
245
+ } catch (error) {
246
+ warn(`[cs-bots] prompts 装配失败(提示词库不可用):${error?.message ?? error}`)
247
+ }
248
+
249
+ // ── 建议确认队列(模型只能提议,人点采纳才落库)───────────────────────────
250
+ try {
251
+ services.suggest = createSuggest(ctx, { stateDir: config.stateDir ?? kernelStateDir() })
252
+ log('[cs-bots] suggest 就绪')
253
+ } catch (error) {
254
+ warn(`[cs-bots] suggest 装配失败(建议队列不可用):${error?.message ?? error}`)
255
+ }
256
+
257
+ // ── 自审调度(每 N 回合提醒模型做一次记忆自检)─────────────────────────────
258
+ try {
259
+ services.reviewer = createReviewer(ctx, {
260
+ stateDir: config.stateDir ?? kernelStateDir(),
261
+ memory: services.memory,
262
+ suggest: services.suggest,
263
+ interval: settings.reviewInterval,
264
+ enabled: settings.reviewEnabled,
265
+ mode: settings.reviewMode,
266
+ writeGuard: settings.perTurnWriteGuard,
267
+ writeGuardThreshold: settings.writeGuardThreshold,
268
+ })
269
+ log(`[cs-bots] reviewer 就绪(每 ${services.reviewer.status?.().interval ?? '?'} 回合)`)
270
+ } catch (error) {
271
+ warn(`[cs-bots] reviewer 装配失败(自审调度不可用):${error?.message ?? error}`)
272
+ }
273
+
274
+ // ── 测量(PDCA 的 Check 数据面)────────────────────────────────────────────
275
+ try {
276
+ services.metrics = createMetrics(ctx, { stateDir: config.stateDir ?? kernelStateDir() })
277
+ log('[cs-bots] metrics 就绪(PDCA 测量面)')
278
+ } catch (error) {
279
+ warn(`[cs-bots] metrics 装配失败(测量不可用):${error?.message ?? error}`)
280
+ }
281
+
282
+ // ── 验收证据(PDCA 的 Check 证据自动化)─────────────────────────────────────
283
+ try {
284
+ services.verify = createVerify(ctx, { stateDir: config.stateDir ?? kernelStateDir() })
285
+ log('[cs-bots] verify 就绪(验收证据自动化)')
286
+ } catch (error) {
287
+ warn(`[cs-bots] verify 装配失败(验收证据不可用):${error?.message ?? error}`)
288
+ }
289
+
290
+ // ── 复盘与标准化(PDCA 的 Act)──────────────────────────────────────────────
291
+ // suggest 缺失也能装配(review() 会降级为「只记复盘内容」)。
292
+ try {
293
+ services.cycle = createCycle(ctx, {
294
+ stateDir: config.stateDir ?? kernelStateDir(),
295
+ suggest: services.suggest,
296
+ })
297
+ log('[cs-bots] cycle 就绪(复盘与标准化)')
298
+ } catch (error) {
299
+ warn(`[cs-bots] cycle 装配失败(复盘不可用):${error?.message ?? error}`)
300
+ }
301
+
302
+ // ── 大/小团队(Lead 聚合管理)───────────────────────────────────────────────
303
+ try {
304
+ services.teams = createTeams(ctx, { stateDir: config.stateDir ?? kernelStateDir() })
305
+ log(`[cs-bots] teams 就绪(${services.teams.stats().total} 个团队)`)
306
+ } catch (error) {
307
+ warn(`[cs-bots] teams 装配失败(团队不可用):${error?.message ?? error}`)
308
+ }
309
+
310
+ // ── 任务订阅(改造07 · 借鉴 Super-agent Subscription 扇出)──────────────────
311
+ // 让房间讨论的后续消息自动到达相关任务,不需要再 @ Bot。
312
+ try {
313
+ services.subscription = createSubscription(ctx, { stateDir: config.stateDir ?? kernelStateDir() })
314
+ log(`[cs-bots] subscription 就绪(${services.subscription.stats().active} 条活订阅)`)
315
+ } catch (error) {
316
+ warn(`[cs-bots] subscription 装配失败(房间→任务扇出不可用):${error?.message ?? error}`)
317
+ }
318
+
319
+ // ── 投递回执看护(2026-11-09 · 补 superagent 的 inputsRead 那一层)─────────────
320
+ // 「投递成功」不等于「真的在跑」:现场出现过 wake 记 sent、会话 turns/steps 全 0、
321
+ // 任务卡在「进行中」且页面无异常。看护在投递后盯事件水位,超时没涨就把
322
+ // 「已投递但执行者未开始动作」写进 task.delivery.error,让它以 person/delivery 显形。
323
+ try {
324
+ const graceMs = Number(process?.env?.CS_BOTS_DELIVERY_GRACE_MS) || DEFAULT_GRACE_MS
325
+ services.deliveryWatch = createDeliveryWatch(ctx, {
326
+ logger: { info: log, warn },
327
+ watermark: (sessionId) => kernelWatermark(ctx, sessionId),
328
+ markFailure: async (taskId, reason) => {
329
+ const board = services.board
330
+ if (!board) return false
331
+ const current = board.get(taskId)
332
+ if (!current || current.status === 'deleted' || current.status === 'completed') return false
333
+ try {
334
+ // ⚠️ **合并写入**(2026-11-09):`board.set` 对 `delivery` 是整体替换,
335
+ // 直接写 `{ error, at, by }` 会抹掉这个字段上已有的其它键
336
+ // (比如 api.js 记下的 `clearedAt`/`clearedBy`)。先读再展开。
337
+ const prev = (current.delivery && typeof current.delivery === 'object') ? current.delivery : {}
338
+ await board.update({
339
+ id: taskId,
340
+ expectedRev: current.rev,
341
+ action: 'set',
342
+ byId: 'cs-bots.watchdog',
343
+ delivery: { ...prev, error: reason, at: Date.now(), by: 'cs-bots.watchdog' },
344
+ })
345
+ return true
346
+ } catch {
347
+ return false
348
+ }
349
+ },
350
+ options: {
351
+ graceMs,
352
+ tickMs: Number(process?.env?.CS_BOTS_DELIVERY_TICK_MS) || DEFAULT_TICK_MS,
353
+ },
354
+ })
355
+ log(`[cs-bots] 投递回执看护就绪(宽限 ${Math.round(graceMs / 1000)}s)`)
356
+ } catch (error) {
357
+ warn(`[cs-bots] 投递回执看护装配失败(投递仍可用,只是不追踪回执):${error?.message ?? error}`)
358
+ services.deliveryWatch = undefined
359
+ }
360
+
179
361
  // ── 页面数据面:HTTP API(页面层靠它拿数据;没有它 UI 是空壳)──────────────
180
362
  services.apiDispose = installApi(ctx, { warn })
181
363
 
@@ -186,6 +368,45 @@ function applyInner(ctx, config, { log, warn }) {
186
368
  log(`[cs-bots] 已注册 ${toolNames.length} 个工具:${toolNames.join(', ')}`)
187
369
  }
188
370
 
371
+ // ── 复杂度路由:llm/stream 拦截 → cs-myfree 旁路 hint 注册表 ────────────────
372
+ // 契约 §6。三条纪律都落在这里:
373
+ // ① 钩子**总是尝试注册**(cs-myfree 后装载也能立刻生效);
374
+ // ② 没装 cs-myfree 只是 `installed:false`,不进日志噪音、更不影响对话;
375
+ // ③ 任何异常都只 warn —— 路由优化不值得拿可用性换。
376
+ try {
377
+ const routing = installLlmStreamHint(ctx, { onLlmStream }, {
378
+ warn,
379
+ // 每次请求按会话动态解析角色(闭包读 services.roster,不缓存结果):
380
+ // 注册表里的 Bot 会话 ⇒ subagent(free_only);其余 ⇒ main(paid_allowed);
381
+ // roster 不可用/查询抛错 ⇒ unknown(fail-closed,仍 free_only)。
382
+ roleOf: (sessionId) => roleOfSession(services.roster, sessionId, sessionMeta(ctx, sessionId)),
383
+ })
384
+ services.routing = routing
385
+ if (routing.installed) {
386
+ log('[cs-bots] 复杂度路由 hint 注入已就绪(llm/stream → cs-myfree 注册表)')
387
+ } else if (routing.reason === 'cs-myfree-registry-missing') {
388
+ // 正常情形:本插件可能先于 cs-myfree 装载。注册表在**每次请求时**重新解析,
389
+ // 所以 cs-myfree 后装载也能立刻生效 —— 这里只记 info,不当降级故障报。
390
+ log('[cs-bots] 未发现 cs-myfree 路由注册表(稍后装载亦可生效)→ 暂不注入 hint,cs-myfree 默认 free_only')
391
+ } else {
392
+ warn(
393
+ `[cs-bots] 复杂度路由 hint 未就绪(${routing.reason})→ 降级:不注入 hint,`
394
+ + 'cs-myfree 按默认 free_only 走(fail-closed,不影响对话)',
395
+ )
396
+ }
397
+ } catch (error) {
398
+ services.routing = { installed: false, reason: 'assembly-failed' }
399
+ warn(`[cs-bots] 复杂度路由装配失败(降级,不影响对话):${error?.message ?? error}`)
400
+ }
401
+
402
+ // ── PDCA-1:回合钩子(让自审计数真正被推进)─────────────────────────────────
403
+ installTurnHook(ctx, {
404
+ warn,
405
+ reviewer: services.reviewer,
406
+ metrics: services.metrics,
407
+ suggest: services.suggest,
408
+ })
409
+
189
410
  // ── 生命周期收尾:停止定时器,避免进程退出被挂起的 timer 拖住 ────────────────
190
411
  // 用官方 ctx.effect(若可用)保证插件卸载时被调用;不可用则退化为不注册。
191
412
  const dispose = () => {
@@ -233,6 +454,68 @@ function installBlockingToolGate(ctx, { warn, roster }) {
233
454
  }
234
455
  }
235
456
 
457
+ /**
458
+ * 按会话判定复杂度路由角色(契约 §6 + P3-fix/P3-fix2)。
459
+ *
460
+ * 判定顺序(**先严后宽**,任何一步拿不到证据都停在 `'unknown'` ⇒ free_only):
461
+ * a. `roster.hasSession(sessionId)` 命中(含退役 Bot)⇒ `'subagent'`;
462
+ * b. 宿主 session 谱系显示是子代理(`origin === 'subagent'` 或 `parentSession` 非空
463
+ * 或 `delegationDepth > 0`)⇒ `'subagent'`
464
+ * —— **这一条堵的是官方 `spawn_teammate` 子会话的付费泄漏**(它们不在 roster 里);
465
+ * c. 两条查询都拿不到信息(roster 不可用/抛错,或 `sessionMeta` 为 null:无 sessions
466
+ * 服务、无此会话、查询抛错)⇒ `'unknown'`(fail-closed,保持 free_only);
467
+ * d. 能确认是顶层会话(roster 明确「不是 Bot」且宿主给得出 header 但无谱系字段)
468
+ * ⇒ `'main'`(人类主对话 ⇒ 平台层注入 paid_allowed)。
469
+ *
470
+ * 为什么 c 这么严:宁可让主对话少拿一次付费授权,也不能让某个子智能体白拿 ——
471
+ * 两者的代价不对称(前者只是慢/弱,后者是持续花错钱)。
472
+ *
473
+ * @param {{ hasSession?: Function, list?: Function, listRetired?: Function } | undefined} roster
474
+ * @param {string} sessionId
475
+ * @param {{ origin?: string, parentSession?: string, delegationDepth?: number,
476
+ * agentPreset?: string } | null} [meta] `kernel.sessionMeta()` 的结果
477
+ * @returns {'main'|'subagent'|'unknown'}
478
+ */
479
+ export function roleOfSession(roster, sessionId, meta) {
480
+ if (typeof sessionId !== 'string' || sessionId === '') return 'unknown'
481
+
482
+ // ── a. 注册表:这是本平台的 Bot ⇒ 子智能体 ──
483
+ const fromRoster = rosterHasSession(roster, sessionId)
484
+ if (fromRoster === true) return 'subagent'
485
+ // roster 不可用 ⇒ 无法确认身份 ⇒ 停在这里(即使下面有谱系也不升级为主对话)
486
+ if (fromRoster === null) return 'unknown'
487
+
488
+ // ── b. 宿主谱系:官方子代理 / 任意带父会话的会话 ⇒ 子智能体 ──
489
+ if (meta === null || meta === undefined) return 'unknown'
490
+ if (typeof meta !== 'object') return 'unknown'
491
+ const isSubagent = meta.origin === 'subagent'
492
+ || (typeof meta.parentSession === 'string' && meta.parentSession !== '')
493
+ || (typeof meta.delegationDepth === 'number' && Number.isFinite(meta.delegationDepth) && meta.delegationDepth > 0)
494
+ if (isSubagent) return 'subagent'
495
+
496
+ // ── d. 确认是顶层会话 ⇒ 主对话 ──
497
+ return 'main'
498
+ }
499
+
500
+ /**
501
+ * 查 roster 判定某会话是否 Bot。
502
+ * @returns {true|false|null} `null` = 查询不可用(无 roster / 无查询能力 / 抛错)
503
+ */
504
+ function rosterHasSession(roster, sessionId) {
505
+ if (!roster) return null
506
+ try {
507
+ if (typeof roster.hasSession === 'function') return roster.hasSession(sessionId) === true
508
+ // 老版本 roster(无 hasSession):退化为扫一遍名录,不因此把 Bot 误判成主对话
509
+ const list = typeof roster.list === 'function' ? roster.list() : undefined
510
+ if (!Array.isArray(list)) return null
511
+ const retired = typeof roster.listRetired === 'function' ? roster.listRetired() : []
512
+ const rows = [...list, ...(Array.isArray(retired) ? retired : [])]
513
+ return rows.some((b) => b?.sessionId === sessionId)
514
+ } catch {
515
+ return null
516
+ }
517
+ }
518
+
236
519
  /**
237
520
  * 判定一个 agent 是否属于本平台的 Bot。
238
521
  *
@@ -309,3 +592,103 @@ function installBotToolTrimmer(ctx, { warn, roster }) {
309
592
  warn(`[cs-bots] 注册 agent/created 失败:${error?.message ?? error}`)
310
593
  }
311
594
  }
595
+
596
+ /**
597
+ * PDCA-1:回合钩子 —— 让 reviewer 的「每 N 回合自审」真正被触发。
598
+ *
599
+ * 为什么必须自己接:宿主把回合边界写成 session 事件(`turn/start` / `turn/end`),
600
+ * 但**没有**任何机制会自动调用本平台的 reviewer。此前 reviewer 只暴露
601
+ * HTTP 端点(`/review/note-turn`),客户端从不调用 ⇒ 计数恒为 0 ⇒ 自审永不到期。
602
+ * 这等于把 PDCA 的 Check 引擎写死了。
603
+ *
604
+ * 本钩子的三条纪律(与 dispatcher 的 fail-open 同源):
605
+ * ① **绝不抛**:钩子内部任何异常只 warn,绝不影响回合本身;
606
+ * ② **多路兜底**:优先按 session 事件判断回合边界;拿不到就退化为「每次
607
+ * `agent/turn-stopping` 也算一回合」(后者是宿主在回合收尾时发的);
608
+ * ③ **每会话每回合最多记一次**:防事件重投导致计数虚高。
609
+ */
610
+ function installTurnHook(ctx, { warn, reviewer, metrics, suggest }) {
611
+ if (typeof ctx.on !== 'function') {
612
+ warn('[cs-bots] ctx.on 不可用 → PDCA 回合钩子未装(自审计数将不自动推进)')
613
+ return
614
+ }
615
+ if (!reviewer) {
616
+ warn('[cs-bots] 无 ReviewerService → PDCA 回合钩子未装')
617
+ return
618
+ }
619
+
620
+ /** sessionId → 最近一次计数的标记(防重复计数) */
621
+ const lastCounted = new Map()
622
+
623
+ /** 从钩子 payload 里取会话 id(多路兜底:可能是字符串或 Session/agent 对象)。 */
624
+ function sessionIdOf(subject) {
625
+ if (typeof subject === 'string' && subject.trim() !== '') return subject
626
+ if (subject && typeof subject === 'object') {
627
+ const id = subject.id ?? subject.sessionId ?? subject.session?.id
628
+ if (typeof id === 'string' && id.trim() !== '') return id
629
+ }
630
+ return null
631
+ }
632
+
633
+ /** 到期时提一条「自审提醒」记忆建议(L2 全局,无需 scopeId ⇒ 任何场景都合法)。 */
634
+ function proposeSelfReview(sessionId, out) {
635
+ try {
636
+ suggest?.suggest?.({
637
+ kind: 'memory',
638
+ layer: 'L2',
639
+ payload: {
640
+ content: `【自审提醒】本会话已累计 ${out.turnsSinceLastReview} 回合未自审(阈值 ${out.interval})。下一次回复前请先自检:目标是否漂移?有无未兑现的承诺?有无过期/阻塞的待办或任务?必要时用 bot_memory_search 检索近期上下文。`,
641
+ tags: ['self-review', 'pdca'],
642
+ },
643
+ reason: `reviewer:会话 ${sessionId} 已 ${out.turnsSinceLastReview} 回合未自审(阈值 ${out.interval})`,
644
+ source: 'reviewer',
645
+ by: sessionId,
646
+ confidence: 0.8,
647
+ })
648
+ } catch (e) {
649
+ warn(`[cs-bots] PDCA 自审建议提议失败(session=${sessionId}):${e?.message ?? e}`)
650
+ }
651
+ }
652
+
653
+ try {
654
+ // ── 主通道:session 事件(宿主在回合开始/结束时写入 turn/start / turn/end)──
655
+ ctx.on('session/event', (subject, event) => {
656
+ try {
657
+ if (!event || typeof event.type !== 'string') return
658
+ if (event.type !== 'turn/end' && event.type !== 'turn/start') return
659
+ const sessionId = sessionIdOf(subject)
660
+ if (!sessionId) return
661
+
662
+ // 用回合序号做幂等:同 session 同一 turn 序号只计一次
663
+ const turn = Number.isFinite(event.data?.turn) ? event.data.turn : null
664
+ const key = turn !== null ? `${sessionId}:t${turn}` : null
665
+ if (key && lastCounted.get(sessionId) === key) return
666
+ if (key) lastCounted.set(sessionId, key)
667
+
668
+ const out = reviewer.noteTurn({ sessionId })
669
+ if (out.due) proposeSelfReview(sessionId, out)
670
+ try { metrics?.record?.({ kind: 'turn_end', sessionId, by: sessionId }) } catch { /* 测量失败不影响回合 */ }
671
+ } catch (error) {
672
+ warn(`[cs-bots] PDCA 回合钩子处理失败:${error?.message ?? error}`)
673
+ }
674
+ })
675
+
676
+ // ── 兜底通道:agent 回合收尾(session 事件拿不到时仍有一次计数机会)──
677
+ // ⚠️ 只在主通道没记过该会话时才计(用 lastCounted 防双计)。
678
+ ctx.on('agent/turn-stopping', (payload) => {
679
+ try {
680
+ const sessionId = sessionIdOf(payload?.session ?? payload?.agent ?? payload)
681
+ if (!sessionId || lastCounted.has(sessionId)) return
682
+ lastCounted.set(sessionId, 'fallback')
683
+ const out = reviewer.noteTurn({ sessionId })
684
+ if (out.due) proposeSelfReview(sessionId, out)
685
+ try { metrics?.record?.({ kind: 'turn_end', sessionId, by: sessionId }) } catch { /* */ }
686
+ } catch (error) {
687
+ warn(`[cs-bots] PDCA 回合兜底钩子处理失败:${error?.message ?? error}`)
688
+ }
689
+ })
690
+ } catch (error) {
691
+ warn(`[cs-bots] 注册 PDCA 回合钩子失败:${error?.message ?? error}`)
692
+ }
693
+ }
694
+