cs-bots 0.0.0-stage → 0.1.1

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/kernel.js ADDED
@@ -0,0 +1,480 @@
1
+ /**
2
+ * cs-bots kernel —— **全项目唯一允许 import `@deepseek-ai/*` 的文件**。
3
+ *
4
+ * 由 `test/ci-zero-dep.sh` 检查 1 机械强制:其余任何 `.js` 出现真实
5
+ * `@deepseek-ai/` 的 import/require 即判失败。
6
+ *
7
+ * 为什么单点隔离:DSH 与官方插件包**同版本号发布**(本机 `0.2.1-rc.2`),
8
+ * 宿主改版时只需改本文件;且「零依赖」这条纪律可被 CI 检验,而不是靠自觉。
9
+ *
10
+ * 本文件只暴露**窄接口**,不泄漏宿主类型;每个函数都对缺失服务降级:
11
+ * 缺服务 → 返回降级值(`undefined` / `false`)或抛结构化错误,**绝不阻塞插件装配**。
12
+ *
13
+ * 接口版本:v1(2026-11-01 冻结)。改动本文件即为一次接口变更,需同步更新
14
+ * `docs/kernel-interface.md` 与调用方。
15
+ */
16
+
17
+ // ── 唯一的宿主 import 面 ────────────────────────────────────────────────────
18
+ //
19
+ // ⚠️ 刻意**不用静态 import**,改惰性 + 多锚点解析。原因(2026-11-01 实测踩到):
20
+ // 官方包只存在于 **dsh 安装目录**(本机 `/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/`),
21
+ // 而它的**兄弟包**(本插件在 profile 的 `node_modules` 里)**解析不到** `@deepseek-ai/dsh-llm`
22
+ // —— profile 的 `node_modules/@deepseek-ai/` 只有 `cosmokit` 与 `schemastery`。
23
+ // 静态 import 会让「宿主包位置一变,插件直接加载失败」,与「绝不阻塞启动」纪律冲突。
24
+ //
25
+ // 惰性的另一收益:**不构造消息的调用路径完全不依赖宿主包** —— 单元测试因此可离线跑。
26
+ import { createRequire } from 'node:module'
27
+ import { existsSync } from 'node:fs'
28
+
29
+ const HOST_LLM = '@deepseek-ai/dsh-llm'
30
+
31
+ /** @type {Function | null} */
32
+ let _createUserMessage = null
33
+ /** @type {Error | null} */
34
+ let _loadError = null
35
+
36
+ /**
37
+ * 探测 dsh 安装锚点(按可靠性排序)。
38
+ * 环境变量 `CS_BOTS_DSH_ROOT` 可显式指定(测试与非常规安装用)。
39
+ * @returns {string[]}
40
+ */
41
+ function dshAnchors() {
42
+ const anchors = []
43
+ if (process.env.CS_BOTS_DSH_ROOT) anchors.push(process.env.CS_BOTS_DSH_ROOT)
44
+ // 1) 本文件自身位置:装进 dsh 内时通常已经可解析
45
+ anchors.push(import.meta.url)
46
+ // 2) 常见全局安装布局(本机实测命中第 3 条)
47
+ anchors.push('/usr/lib/node_modules/@deepseek-ai/dsh/package.json')
48
+ anchors.push('/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json')
49
+ // 3) 从 dsh 可执行文件反推(PATH 上的 dsh 多半在 <root>/bin/ 或 <root>/resources/...)
50
+ for (const dir of (process.env.PATH ?? '').split(':')) {
51
+ if (!dir) continue
52
+ anchors.push(`${dir}/../package.json`)
53
+ anchors.push(`${dir}/../node_modules/@deepseek-ai/dsh/package.json`)
54
+ anchors.push(`${dir}/../lib/node_modules/@deepseek-ai/dsh/package.json`)
55
+ }
56
+ // 4) 本机 dsh 默认 home 的邻居(供容器/云机兜底)
57
+ const home = process.env.HOME
58
+ if (home) anchors.push(`${home}/.dsh/../node_modules/@deepseek-ai/dsh/package.json`)
59
+ return anchors.filter((a) => typeof a === 'string' && a.length > 0)
60
+ }
61
+
62
+ /**
63
+ * 惰性加载宿主的 `createUserMessage`。
64
+ * @returns {Function}
65
+ * @throws {Error} 所有锚点都失败时(**带排查提示**,不静默)
66
+ */
67
+ function loadCreateUserMessage() {
68
+ if (_createUserMessage) return _createUserMessage
69
+ if (_loadError) throw _loadError
70
+
71
+ const tried = []
72
+ for (const anchor of dshAnchors()) {
73
+ try {
74
+ // 文件锚点需先存在,createRequire 才不会因怪路径抛错
75
+ if (anchor.startsWith('/') && !existsSync(anchor)) continue
76
+ const req = createRequire(anchor)
77
+ const mod = req(HOST_LLM)
78
+ const fn = mod?.createUserMessage
79
+ if (typeof fn === 'function') {
80
+ _createUserMessage = fn
81
+ return fn
82
+ }
83
+ tried.push(`${anchor}: 已解析但无 createUserMessage 导出`)
84
+ } catch (error) {
85
+ tried.push(`${anchor}: ${error?.code ?? error?.message ?? error}`)
86
+ }
87
+ }
88
+ _loadError = new Error(
89
+ `cs-bots kernel: 无法解析宿主包 ${HOST_LLM}。\n` +
90
+ ` 已知锚点尝试:\n ${tried.join('\n ')}\n` +
91
+ ` 对策:设 CS_BOTS_DSH_ROOT=<dsh 安装目录>,或用 \`dsh --profile <p>\` 启动(宿主会暴露解析锚)。`,
92
+ )
93
+ throw _loadError
94
+ }
95
+
96
+ /**
97
+ * 惰性取服务 —— **可选依赖**的正确姿势。
98
+ *
99
+ * 三条通路按优先级尝试(2026-11-02 实测,这是**真 bug 的修复**):
100
+ * ① `ctx.reflect.get(key, false)` —— 官方注释原文:
101
+ * "Read a service from the store **without the inject requirement**."
102
+ * ⇒ 这是唯一**不声明 inject 也能读**的服务通道,正是「缺服务就降级」所需。
103
+ * ② `ctx.get(key)` —— 老/测试上下文形态;在真实 Cordis ctx 上会抛
104
+ * `cannot get property "X" without inject`(实测),故必须 try/catch。
105
+ * ③ `key in ctx` 探路 —— 更老宿主可能没有 ctx.reflect,靠 `in` 判存在再取。
106
+ * ④ `ctx[key]` —— 直接挂属性的测试替身;同样会抛,故 try/catch。
107
+ *
108
+ * ⚠️ 教训:不做 try/catch 会让插件在真实宿主里**加载失败**
109
+ * (`Error: cannot get property "webServer" without inject`),
110
+ * 而单元测试用普通对象反而全绿 —— 典型的「测了但没测真」。
111
+ *
112
+ * @param {object} ctx
113
+ * @param {string} key
114
+ * @returns {unknown} 服务实例,或 undefined(缺失/不可读)
115
+ */
116
+ export function service(ctx, key) {
117
+ if (!ctx || typeof ctx !== 'object') return undefined
118
+ // ① 不要求 inject 的反射读取(首选)
119
+ try {
120
+ const viaReflect = ctx.reflect?.get?.(key, false)
121
+ if (viaReflect !== undefined) return viaReflect
122
+ } catch {
123
+ /* 反射层不可用 → 继续尝试其它通路 */
124
+ }
125
+ // ② ctx.get()
126
+ try {
127
+ const viaGet = ctx.get?.(key)
128
+ if (viaGet !== undefined) return viaGet
129
+ } catch {
130
+ /* 真实 ctx 未声明 inject 时会抛 → 忽略 */
131
+ }
132
+ // ③ `in` 探路 + 直接属性:更老宿主(如 rc1)可能没有 ctx.reflect,
133
+ // 用 `in` 先判存在再取值,可绕开代理 get 陷阱的抛错。
134
+ try {
135
+ if (key in ctx) return ctx[key]
136
+ } catch {
137
+ /* `in` 也被拒 → 继续 */
138
+ }
139
+ // ④ 直接属性(测试替身)
140
+ try {
141
+ return ctx[key]
142
+ } catch {
143
+ return undefined
144
+ }
145
+ }
146
+
147
+ // ── 能力探测 ───────────────────────────────────────────────────────────────
148
+
149
+ /**
150
+ * 探测宿主能力。**缺任一项都不抛错**,调用方按 `available` 决定降级行为。
151
+ * @param {object} ctx
152
+ * @returns {{
153
+ * agents: boolean, subagents: boolean, agentTeams: boolean,
154
+ * webServer: boolean, sandboxPolicy: boolean, sessionPersistence: boolean,
155
+ * missing: string[],
156
+ * }}
157
+ */
158
+ export function capabilities(ctx) {
159
+ const keys = ['agents', 'subagents', 'agentTeams', 'webServer', 'sandboxPolicy', 'sessionPersistence']
160
+ const missing = []
161
+ const found = {}
162
+ for (const key of keys) {
163
+ const ok = Boolean(service(ctx, key))
164
+ found[key] = ok
165
+ if (!ok) missing.push(key)
166
+ }
167
+ return { ...found, missing }
168
+ }
169
+
170
+ // ── Agent 注册表(live agent 访问)────────────────────────────────────────────
171
+
172
+ /**
173
+ * 取一个 live Agent 的句柄。
174
+ *
175
+ * 官方语义(`dsh-agent/lib/types/index.d.ts`):`ctx.agents` 是 `AgentRegistry`,
176
+ * 提供 `get(id)` / `list()`。Team 服务的所有方法都要 `caller: Agent`,
177
+ * 所以平台服务层必须先拿到 live Agent —— 本函数就是那条通路。
178
+ *
179
+ * @param {object} ctx
180
+ * @param {string} sessionId
181
+ * @returns {object | undefined} live Agent,或 undefined(服务缺失 / 会话不在)
182
+ */
183
+ export function getAgent(ctx, sessionId) {
184
+ const agents = service(ctx, 'agents')
185
+ if (!agents || typeof agents.get !== 'function') return undefined
186
+ try {
187
+ return agents.get(sessionId) ?? undefined
188
+ } catch {
189
+ return undefined
190
+ }
191
+ }
192
+
193
+ /**
194
+ * 列出全部 live Agent。
195
+ * @param {object} ctx
196
+ * @returns {object[]} 空数组表示服务缺失或无 agent(**不区分**,调用方按需再探测)
197
+ */
198
+ export function listAgents(ctx) {
199
+ const agents = service(ctx, 'agents')
200
+ if (!agents || typeof agents.list !== 'function') return []
201
+ try {
202
+ const list = agents.list()
203
+ return Array.isArray(list) ? list : []
204
+ } catch {
205
+ return []
206
+ }
207
+ }
208
+
209
+ // ── 投递(唤醒)─────────────────────────────────────────────────────────────
210
+
211
+ /**
212
+ * 消息来源标记 —— 官方 `UserMessage.source` 需要它。
213
+ * 未经标识的消息在会话日志里无法追溯,故统一走这里。
214
+ */
215
+ const SOURCE = { kind: 'plugin', name: 'cs-bots' }
216
+
217
+ /**
218
+ * 构造一条可投递的用户消息。**不投递**,只构造(便于幂等键先记账再发送)。
219
+ * 宿主包解析失败时抛错(调用方需知道失败原因,这里不降级)。
220
+ * @param {string} text
221
+ * @returns {object} 冻结的 UserMessage
222
+ */
223
+ export function buildMessage(text) {
224
+ const createUserMessage = loadCreateUserMessage()
225
+ return createUserMessage({
226
+ content: [{ type: 'text', text: String(text ?? '') }],
227
+ source: SOURCE,
228
+ })
229
+ }
230
+
231
+ /** 宿主包是否可解析(诊断用,**不抛错**)。 */
232
+ export function hostLlmAvailable() {
233
+ try {
234
+ loadCreateUserMessage()
235
+ return true
236
+ } catch {
237
+ return false
238
+ }
239
+ }
240
+
241
+ /**
242
+ * 投递一条消息并唤醒目标会话(`followup` 语义:**新开一个回合**)。
243
+ *
244
+ * 官方语义原文(`dsh-agent/lib/types/runtime-types.d.ts:191-193`):
245
+ * "Queue an ordinary follow-up turn and wake the driver. The item becomes the
246
+ * sole ordinary message of its own turn."
247
+ * ⇒ 适合「有新活要干」的唤醒。
248
+ *
249
+ * @param {object} ctx
250
+ * @param {string} sessionId
251
+ * @param {string} text
252
+ * @returns {{ ok: true } | { ok: false, reason: string }}
253
+ */
254
+ export function followup(ctx, sessionId, text) {
255
+ const agent = getAgent(ctx, sessionId)
256
+ if (!agent) return { ok: false, reason: 'agent-not-live' }
257
+ if (typeof agent.followup !== 'function') return { ok: false, reason: 'followup-unsupported' }
258
+ try {
259
+ agent.followup(buildMessage(text))
260
+ return { ok: true }
261
+ } catch (error) {
262
+ return { ok: false, reason: `followup-failed:${error?.message ?? error}` }
263
+ }
264
+ }
265
+
266
+ /**
267
+ * 把消息插入目标会话的**最近步骤边界**(`steer` 语义)。
268
+ *
269
+ * 官方语义原文(`:194-199`):"Submit steering for the nearest step. An idle driver
270
+ * starts a turn; a running driver consumes it at its next step boundary."
271
+ * ⇒ 适合「正在干活时插一句话」,不新开回合。
272
+ *
273
+ * @param {object} ctx
274
+ * @param {string} sessionId
275
+ * @param {string} text
276
+ * @returns {{ ok: true } | { ok: false, reason: string }}
277
+ */
278
+ export function steer(ctx, sessionId, text) {
279
+ const agent = getAgent(ctx, sessionId)
280
+ if (!agent) return { ok: false, reason: 'agent-not-live' }
281
+ if (typeof agent.steer !== 'function') return { ok: false, reason: 'steer-unsupported' }
282
+ try {
283
+ agent.steer(buildMessage(text))
284
+ return { ok: true }
285
+ } catch (error) {
286
+ return { ok: false, reason: `steer-failed:${error?.message ?? error}` }
287
+ }
288
+ }
289
+
290
+ // ── 会话创建(**唯一能指定 cwd 的路径**)──────────────────────────────────────
291
+
292
+ /**
293
+ * 创建一个 root Agent 会话,**可指定工作目录**。
294
+ *
295
+ * ⚠️ 这是本平台「共用工作目录」问题的**唯一解法**,务必不要用官方
296
+ * `spawnTeammate` 代替(已实测:`SpawnTeammateRequest` 无 `cwd` 字段,
297
+ * 且 teammate 强制继承父 cwd —— 见 `11-融合最终稿-v1.md` §0.1)。
298
+ *
299
+ * 官方语义(`dsh-agent/lib/types/index.d.ts:48-90`):`CreateAgentOptions.meta.cwd`
300
+ * 是 "validated absolute `cwd`",且 **"This is durable session data"**。
301
+ *
302
+ * @param {object} ctx
303
+ * @param {{ sessionId: string, cwd?: string, parentAgent?: object, agentPreset?: string,
304
+ * agentOptions?: object, signal?: AbortSignal }} options
305
+ * @returns {Promise<object>} AgentHandle
306
+ * @throws {Error} 服务缺失或创建失败(**这一条不降级**:调用方需要知道失败原因)
307
+ */
308
+ export async function createSession(ctx, options) {
309
+ const agents = service(ctx, 'agents')
310
+ if (!agents || typeof agents.create !== 'function') {
311
+ throw new Error('cs-bots kernel: agents.create 不可用(宿主未提供 agents 服务)')
312
+ }
313
+ const { sessionId, cwd, parentAgent, agentPreset, agentOptions, signal } = options ?? {}
314
+ if (!sessionId) throw new Error('cs-bots kernel: createSession 需要 sessionId')
315
+
316
+ const meta = {}
317
+ if (cwd) meta.cwd = cwd
318
+ if (agentPreset) meta.agentPreset = agentPreset
319
+ if (parentAgent) meta.parentSession = parentAgent.id
320
+
321
+ return agents.create({
322
+ sessionId,
323
+ ...(parentAgent ? { parentAgent } : {}),
324
+ ...(Object.keys(meta).length ? { meta } : {}),
325
+ ...(agentOptions ? { agentOptions } : {}),
326
+ ...(signal ? { signal } : {}),
327
+ })
328
+ }
329
+
330
+ /**
331
+ * 在既有持久会话上恢复 Agent(重启后复活会话用)。
332
+ * @param {object} ctx
333
+ * @param {{ resumeSessionId: string, signal?: AbortSignal }} options
334
+ * @returns {Promise<object>} AgentHandle
335
+ */
336
+ export async function resumeSession(ctx, options) {
337
+ const agents = service(ctx, 'agents')
338
+ if (!agents || typeof agents.resume !== 'function') {
339
+ throw new Error('cs-bots kernel: agents.resume 不可用')
340
+ }
341
+ const { resumeSessionId, signal } = options ?? {}
342
+ if (!resumeSessionId) throw new Error('cs-bots kernel: resumeSession 需要 resumeSessionId')
343
+ return agents.resume({ resumeSessionId, ...(signal ? { signal } : {}) })
344
+ }
345
+
346
+ // ── HTTP 路由注册 ───────────────────────────────────────────────────────────
347
+
348
+ /**
349
+ * 在宿主 webServer 上注册一条前缀路由(平台 API 面)。
350
+ * 缺 webServer 时返回 `undefined`(降级:平台无 HTTP 面,但服务照常工作)。
351
+ *
352
+ * @param {object} ctx
353
+ * @param {string} path 必须以 `/` 开头
354
+ * @param {(req: object, res: object) => void | Promise<void>} handler
355
+ * @returns {(() => void) | undefined} disposer
356
+ */
357
+ export function registerRoute(ctx, path, handler) {
358
+ const webServer = service(ctx, 'webServer')
359
+ if (!webServer || typeof webServer.register !== 'function') return undefined
360
+ return webServer.register({ kind: 'prefix', path, handler })
361
+ }
362
+
363
+ // ── 工具注册(模型面)────────────────────────────────────────────────────────
364
+
365
+ /**
366
+ * 注册一条模型可见工具。
367
+ *
368
+ * 官方契约(`dsh-tools/lib/types/index.d.ts:115-146`):
369
+ * `ToolDefinition = ToolSchema + { output: { schema, render }, execute(args, exec) }`
370
+ * —— `output` 是**强制**的,且 `execute` 只返回**规范无损 JSON 值**(渲染交给 `render`)。
371
+ *
372
+ * 缺 tools 服务时返回 `undefined`(降级:模型看不到该工具,但域服务照常工作)。
373
+ *
374
+ * @param {object} ctx
375
+ * @param {{ name: string, description: string, parameters: object,
376
+ * outputSchema: object, render: Function, execute: Function }} spec
377
+ * @returns {(() => void) | undefined} disposer
378
+ */
379
+ export function registerTool(ctx, spec) {
380
+ const tools = service(ctx, 'tools')
381
+ if (!tools || typeof tools.register !== 'function') return undefined
382
+ const { name, description, parameters, outputSchema, render, execute } = spec ?? {}
383
+ if (!name || typeof execute !== 'function') {
384
+ throw new Error('cs-bots kernel.registerTool: 需要 name 与 execute')
385
+ }
386
+ return tools.register({
387
+ name,
388
+ description: description ?? '',
389
+ parameters: parameters ?? { type: 'object', properties: {}, additionalProperties: false },
390
+ output: {
391
+ schema: outputSchema ?? { type: 'object' },
392
+ render:
393
+ typeof render === 'function'
394
+ ? render
395
+ : (_args, value) => [{ type: 'text', text: JSON.stringify(value ?? null, null, 2) }],
396
+ },
397
+ execute,
398
+ })
399
+ }
400
+
401
+ /**
402
+ * 装一条全局工具执行闸门(K1 用)。缺 tools 服务时返回 `undefined`。
403
+ * @param {object} ctx
404
+ * @param {(exec: object) => string | undefined} guard 返回字符串=拒绝执行(作为理由)
405
+ * @returns {(() => void) | undefined}
406
+ */
407
+ export function guardTool(ctx, guard) {
408
+ const tools = service(ctx, 'tools')
409
+ if (!tools || typeof tools.guard !== 'function') return undefined
410
+ return tools.guard(guard)
411
+ }
412
+
413
+ /**
414
+ * 从某个 agent 的工具表中移除工具名(K1 双保险之二)。
415
+ * @param {object} agent
416
+ * @param {string[]} names
417
+ * @returns {boolean} 是否施加成功
418
+ */
419
+ export function restrictTools(agent, names) {
420
+ const agentTools = agent?.ctx?.tools
421
+ if (typeof agentTools?.restrict !== 'function') return false
422
+ try {
423
+ // restrict() 对「本 profile 不提供的工具名」会抛错 → 先与实际工具表求交集
424
+ const served = typeof agentTools.schemas === 'function'
425
+ ? new Set(agentTools.schemas(agent).map((schema) => schema?.name))
426
+ : undefined
427
+ const deny = served ? names.filter((n) => served.has(n)) : names
428
+ if (deny.length === 0) return false
429
+ agentTools.restrict({ deny })
430
+ return true
431
+ } catch {
432
+ return false
433
+ }
434
+ }
435
+
436
+ // ── 状态目录 ────────────────────────────────────────────────────────────────
437
+
438
+ /**
439
+ * 平台状态目录:`$DSH_HOME/cs-bots/`。
440
+ * @returns {string}
441
+ */
442
+ export function stateDir() {
443
+ const home = process.env.DSH_HOME || `${process.env.HOME ?? '.'}/.dsh`
444
+ return `${home}/cs-bots`
445
+ }
446
+
447
+ /**
448
+ * 列出可用 agent 预设(**缺服务返回空数组,不抛**)。
449
+ *
450
+ * 预设是什么:一组「模型 + 系统提示 + 技能 + 工具白名单」的声明式组合。
451
+ * 同一个 DSH 里可以有 dev-steward / 普通助手 / 记忆整理 等多个预设。
452
+ * Bot 建会话时把 preset 传进 `meta.agentPreset`,新会话就用那套人格与工具面。
453
+ *
454
+ * @param {object} ctx
455
+ * @returns {Promise<Array<{id:string,name?:string,description?:string,order?:number,broken?:string}>>}
456
+ */
457
+ export async function listPresets(ctx) {
458
+ const registry = service(ctx, 'agentPresets')
459
+ if (!registry || typeof registry.list !== 'function') return []
460
+ try {
461
+ const rows = await registry.list()
462
+ return Array.isArray(rows) ? rows : []
463
+ } catch {
464
+ return []
465
+ }
466
+ }
467
+
468
+ /**
469
+ * 取默认预设 id(缺服务返回 undefined)。
470
+ * @param {object} ctx
471
+ * @returns {string | undefined}
472
+ */
473
+ export function defaultPreset(ctx) {
474
+ const registry = service(ctx, 'agentPresets')
475
+ try {
476
+ return typeof registry?.defaultId === 'string' ? registry.defaultId : undefined
477
+ } catch {
478
+ return undefined
479
+ }
480
+ }