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.
- package/CHANGELOG.md +533 -0
- package/README.md +65 -10
- package/docs/01-/346/236/266/346/236/204/346/226/207/346/241/243.md +364 -0
- package/docs/02-/351/234/200/346/261/202/350/247/204/346/240/274/344/271/246.md +284 -0
- package/docs/03-/350/257/246/347/273/206/350/256/276/350/256/241.md +567 -0
- package/docs/04-/346/223/215/344/275/234/346/211/213/345/206/214.md +569 -0
- package/docs/ARCHITECTURE.md +277 -0
- 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
- 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
- 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
- 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
- package/docs/steps/step-04-/344/270/212/347/272/277/344/270/216/351/252/214/350/257/201.md +46 -0
- package/docs//344/272/244/344/273/230/350/257/264/346/230/216-v0.2.0.md +95 -0
- package/docs//346/223/215/344/275/234/346/211/213/345/206/214-v1.md +509 -0
- package/docs//350/277/201/347/247/273/346/226/271/346/241/210-v1.md +64 -0
- package/docs//351/225/277/346/234/237/350/256/260/345/277/206/346/226/271/346/241/210-v1.md +85 -0
- package/package.json +4 -2
- package/src/admission.js +365 -0
- package/src/api.js +4097 -420
- package/src/board.js +883 -14
- package/src/botprofile.js +190 -3
- package/src/client.js +6596 -1082
- package/src/cycle.js +516 -0
- package/src/delivery-watch.js +255 -0
- package/src/dirbrowse.js +20 -0
- package/src/discipline.js +1188 -0
- package/src/fence.js +142 -0
- package/src/index.js +387 -4
- package/src/kernel.js +636 -24
- package/src/library.js +1224 -11
- package/src/memory.js +617 -37
- package/src/metrics.js +339 -0
- package/src/prompts.js +1318 -0
- package/src/reviewer.js +709 -0
- package/src/room.js +655 -14
- package/src/roster.js +346 -26
- package/src/routing/complexity.js +320 -0
- package/src/routing/hints.js +707 -0
- package/src/scratch.js +477 -0
- package/src/settings.js +97 -0
- package/src/stage.js +263 -0
- package/src/store.js +133 -1
- package/src/subscription.js +571 -0
- package/src/suggest.js +1203 -0
- package/src/teams.js +527 -0
- package/src/todo.js +1301 -0
- package/src/tools.js +734 -10
- 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
|
-
|
|
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, {
|
|
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, {
|
|
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
|
+
|