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/kernel.js CHANGED
@@ -27,11 +27,14 @@ import { createRequire } from 'node:module'
27
27
  import { existsSync } from 'node:fs'
28
28
 
29
29
  const HOST_LLM = '@deepseek-ai/dsh-llm'
30
+ const HOST_AGENT = '@deepseek-ai/dsh-agent'
30
31
 
31
32
  /** @type {Function | null} */
32
33
  let _createUserMessage = null
33
34
  /** @type {Error | null} */
34
35
  let _loadError = null
36
+ /** 宿主模块缓存:`spec → module | Error`(同一个 spec 只解析一次,失败也缓存)。 */
37
+ const _hostModules = new Map()
35
38
 
36
39
  /**
37
40
  * 探测 dsh 安装锚点(按可靠性排序)。
@@ -60,13 +63,23 @@ function dshAnchors() {
60
63
  }
61
64
 
62
65
  /**
63
- * 惰性加载宿主的 `createUserMessage`。
64
- * @returns {Function}
65
- * @throws {Error} 所有锚点都失败时(**带排查提示**,不静默)
66
+ * 惰性加载一个宿主包(**通用版**)。
67
+ *
68
+ * 为什么不用静态 import:官方包只存在于 dsh 安装目录(本机
69
+ * `/home/ubuntu/.npm-global/lib/node_modules/@deepseek-ai/dsh/node_modules/`),
70
+ * 而 plugin 的 profile `node_modules/@deepseek-ai/` 只有 cosmokit 与 schemastery
71
+ * ——**解析不到**任何 dsh 子包(2026-11-09 实测)。所以必须从锚点 `createRequire`。
72
+ *
73
+ * 结果按 spec 缓存:同一个 spec 只解析一次,**失败也缓存**(避免反复重试空转)。
74
+ *
75
+ * @param {string} spec 包名(如 `@deepseek-ai/dsh-agent`)
76
+ * @returns {unknown} 模块导出
77
+ * @throws {Error} 所有锚点都失败时(带排查提示,不静默)
66
78
  */
67
- function loadCreateUserMessage() {
68
- if (_createUserMessage) return _createUserMessage
69
- if (_loadError) throw _loadError
79
+ export function loadHostModule(spec) {
80
+ const cached = _hostModules.get(spec)
81
+ if (cached instanceof Error) throw cached
82
+ if (cached !== undefined) return cached
70
83
 
71
84
  const tried = []
72
85
  for (const anchor of dshAnchors()) {
@@ -74,22 +87,45 @@ function loadCreateUserMessage() {
74
87
  // 文件锚点需先存在,createRequire 才不会因怪路径抛错
75
88
  if (anchor.startsWith('/') && !existsSync(anchor)) continue
76
89
  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
90
+ const mod = req(spec)
91
+ if (mod !== undefined) {
92
+ _hostModules.set(spec, mod)
93
+ return mod
82
94
  }
83
- tried.push(`${anchor}: 已解析但无 createUserMessage 导出`)
95
+ tried.push(`${anchor}: 已解析但模块为空`)
84
96
  } catch (error) {
85
97
  tried.push(`${anchor}: ${error?.code ?? error?.message ?? error}`)
86
98
  }
87
99
  }
88
- _loadError = new Error(
89
- `cs-bots kernel: 无法解析宿主包 ${HOST_LLM}。\n` +
100
+ const err = new Error(
101
+ `cs-bots kernel: 无法解析宿主包 ${spec}。\n` +
90
102
  ` 已知锚点尝试:\n ${tried.join('\n ')}\n` +
91
103
  ` 对策:设 CS_BOTS_DSH_ROOT=<dsh 安装目录>,或用 \`dsh --profile <p>\` 启动(宿主会暴露解析锚)。`,
92
104
  )
105
+ _hostModules.set(spec, err)
106
+ throw err
107
+ }
108
+
109
+ /**
110
+ * 惰性加载宿主的 `createUserMessage`。
111
+ * @returns {Function}
112
+ * @throws {Error} 所有锚点都失败时(**带排查提示**,不静默)
113
+ */
114
+ function loadCreateUserMessage() {
115
+ if (_createUserMessage) return _createUserMessage
116
+ if (_loadError) throw _loadError
117
+
118
+ try {
119
+ const mod = /** @type {any} */ (loadHostModule(HOST_LLM))
120
+ const fn = mod?.createUserMessage
121
+ if (typeof fn === 'function') {
122
+ _createUserMessage = fn
123
+ return fn
124
+ }
125
+ } catch (error) {
126
+ _loadError = /** @type {Error} */ (error)
127
+ }
128
+ _loadError = _loadError ?? new Error(`cs-bots kernel: 已解析 ${HOST_LLM} 但没有 createUserMessage 导出`)
93
129
  throw _loadError
94
130
  }
95
131
 
@@ -209,10 +245,25 @@ export function listAgents(ctx) {
209
245
  // ── 投递(唤醒)─────────────────────────────────────────────────────────────
210
246
 
211
247
  /**
212
- * 消息来源标记 —— 官方 `UserMessage.source` 需要它。
213
- * 未经标识的消息在会话日志里无法追溯,故统一走这里。
248
+ * 消息来源标记 —— 官方 `UserMessage.source` 需要它。未经标识的消息在会话日志里
249
+ * 无法追溯,故统一走这里。
250
+ *
251
+ * ## ⚠️ 这里踩过一个坐实的坑(2026-11-09)
252
+ * 原来写的是 `{ kind: 'plugin', name: 'cs-bots' }`。看起来「很官方」,实际上
253
+ * **v4 格式明令拒收 `kind:'plugin'`**(`dsh-session-format-v3-to-v4/lib/index.js:126`):
254
+ *
255
+ * if (... source["kind"] === "plugin") throw new SessionFormatError(
256
+ * "format v4 message requires a producer-owned source kind")
257
+ *
258
+ * 'plugin' 是 **v3 时代的「包装写法」**,只允许出现在**历史回放**里由迁移层
259
+ * 改写成 `plugin:<name>`;**新建消息**必须直接给「生产者自己的 kind」。
260
+ * 后果:cs-bots 每次唤醒投递都被宿主在准入处拒收,而 `followup()` 早已返回
261
+ * ok —— 于是任务静默卡在「进行中」,页面看不到任何异常。
262
+ *
263
+ * 取值与迁移层的映射**逐字对齐**(`producerKind()`:非已发布同名生产者 →
264
+ * `plugin:<name>`),这样新建的消息与历史回放后的形状完全一致。
214
265
  */
215
- const SOURCE = { kind: 'plugin', name: 'cs-bots' }
266
+ const SOURCE = { kind: 'plugin:cs-bots' }
216
267
 
217
268
  /**
218
269
  * 构造一条可投递的用户消息。**不投递**,只构造(便于幂等键先记账再发送)。
@@ -289,6 +340,116 @@ export function steer(ctx, sessionId, text) {
289
340
 
290
341
  // ── 会话创建(**唯一能指定 cwd 的路径**)──────────────────────────────────────
291
342
 
343
+ /**
344
+ * 组装「Agent 级 setup 回调」—— 把 preset 的工具面挂上去。
345
+ *
346
+ * ## 为什么必须自己拼(2026-11-09 复核,源码级证据链)
347
+ *
348
+ * 官方 `agents.create(options)` 只透传 `AgentOptions`,而
349
+ * `AgentOptions`(`dsh-agent/lib/types/runtime-types.d.ts:27-36`)**没有
350
+ * tools/preset 字段**;preset 挂载完全发生在 `setup` 回调里:
351
+ * 1. `dsh-api-session-controller/lib/index.js:264-278` 的 `composeAgent(presetId)`
352
+ * 才是官方唯一的装配入口 —— 它返回
353
+ * `{ agentPreset, setup: async (agentCtx) => { installModelSelection(agentCtx); await presets.mount(agentCtx, resolvedId) } }`
354
+ * 2. `dsh-agent-loop/lib/index.js:1574-1576` 在 `initializeAgent` 里真正调用
355
+ * `setup?.(agent.ctx, agent)`;`:543` 证实 `agent.ctx` 带 scope(`mount` 的前置条件)
356
+ * 3. `dsh-agent-preset-registry/lib/index.js:702-711` 的 `mount()` → `retain` + `bind(ctx, generation)`
357
+ * **这一步才挂载工具面**
358
+ *
359
+ * cs-bots 走的 `agents.create({ sessionId, meta, agentOptions })` 这条窄接口
360
+ * **从不传 `setup`** ⇒ preset 从未被挂载 ⇒ Bot 只有 cs-bots 自己注册的
361
+ * `bot_*` 工具,**没有 read / exec / web_fetch 等任何宿主能力**。
362
+ * 这就是「Bot 说它没有文件读写能力」的根因(用户:「无法调用工具会失去很多功能」)。
363
+ *
364
+ * ## 降级策略(沿用本文件「绝不阻塞启动」纪律)
365
+ * - 缺 `agentPresets` 服务 → 退化成只装 model selection(官方同款降级,见 ①)
366
+ * - 缺宿主包 / `mount` 抛错 → warn + 返回 setup 的降级版,**不抛**
367
+ * (「没挂上工具面」仍然好过「会话建不起来」)
368
+ *
369
+ * @param {object} ctx
370
+ * @param {string | undefined} presetId 调用方显式给的 preset;不给则用宿主默认
371
+ * @param {(msg: string) => void} [warn] 告警出口(缺省静默)
372
+ * @returns {Promise<{ setup?: (agentCtx: object) => Promise<void>, agentPreset?: string }>}
373
+ */
374
+ export async function composePresetSetup(ctx, presetId, warn = () => {}) {
375
+ /** @type {(agentCtx: object) => Promise<void>} */
376
+ let setup = async () => {}
377
+ let agentPreset
378
+
379
+ // ① 惰性取宿主的 installModelSelection(复用 loadHostModule 的锚点机制)
380
+ // 注意:不能静态 import —— profile 的 node_modules/@deepseek-ai/ 解析不到
381
+ // dsh 子包(实测 ERR_MODULE_NOT_FOUND),必须从锚点 createRequire。
382
+ let installModelSelection = null
383
+ try {
384
+ const mod = /** @type {any} */ (loadHostModule(HOST_AGENT))
385
+ installModelSelection = typeof mod?.installModelSelection === 'function' ? mod.installModelSelection : null
386
+ } catch (error) {
387
+ warn(`cs-bots: 无法解析 ${HOST_AGENT}(model-selection 兜底将缺失):${error?.message ?? error}`)
388
+ }
389
+
390
+ /** 安全装 model selection:任何异常都只 warn,绝不向上抛(见下方 mount 同纪律)。 */
391
+ const safeInstall = async (agentCtx) => {
392
+ if (!installModelSelection) return
393
+ try {
394
+ installModelSelection(agentCtx)
395
+ } catch (error) {
396
+ warn(`cs-bots: installModelSelection 失败(不影响工具面挂载):${error?.message ?? error}`)
397
+ }
398
+ }
399
+
400
+ const presets = service(ctx, 'agentPresets')
401
+ if (!presets || typeof presets.mount !== 'function') {
402
+ // 官方同款降级:只有 model selection,没有 preset 工具面
403
+ if (installModelSelection) setup = safeInstall
404
+ warn('cs-bots: agentPresets 服务不可用 —— Bot 将只挂 model selection,不会挂 preset 工具面')
405
+ return { setup: installModelSelection ? setup : undefined }
406
+ }
407
+
408
+ // ② resolve → 拿到真实 id(未给则用宿主默认;官方 `composeAgent` 同款)
409
+ let resolvedId
410
+ try {
411
+ const resolved = await presets.resolve(presetId)
412
+ resolvedId = /** @type {any} */ (resolved)?.id
413
+ } catch (error) {
414
+ // 请求的 preset 不可用(未知 / 已下架)⇒ 回落**宿主默认**。
415
+ // ⚠️ 回落目标必须是 `presets.defaultId`,不能是 `presetId` —— 那等于拿同一个
416
+ // 坏值再 resolve 一次,必然再失败(2026-11-09 测试 P4 揪出来的真 bug:
417
+ // 上一版写 `presetId ?? presets.defaultId`,presetId 非空所以永远不回落)。
418
+ const fallback = presets.defaultId
419
+ warn(
420
+ `cs-bots: preset ${JSON.stringify(presetId ?? null)} 不可用(${error?.message ?? error})`
421
+ + `,回落到宿主默认 ${JSON.stringify(fallback ?? null)}`,
422
+ )
423
+ if (typeof fallback === 'string' && fallback) {
424
+ try {
425
+ const resolved = await presets.resolve(fallback)
426
+ resolvedId = /** @type {any} */ (resolved)?.id
427
+ } catch (err) {
428
+ warn(`cs-bots: 连默认 preset ${JSON.stringify(fallback)} 都 resolve 不了,跳过挂载:${err?.message ?? err}`)
429
+ }
430
+ }
431
+ }
432
+ if (typeof resolvedId !== 'string' || !resolvedId) {
433
+ warn('cs-bots: preset resolve 返回的 id 无效,跳过挂载')
434
+ return { setup: installModelSelection ? safeInstall : undefined }
435
+ }
436
+ agentPreset = resolvedId
437
+
438
+ // ③ 真正拼 setup:installModelSelection + presets.mount
439
+ // 两步必须**各自独立兜底**:installModelSelection 抛错(宿主版本不匹配、
440
+ // agentCtx 形状不符)时绝不允许连带跳过 mount —— 那等于把整个工具面也丢了。
441
+ // 纪律:会话必须建得起来,挂不上的部分如实 warn。
442
+ setup = async (agentCtx) => {
443
+ await safeInstall(agentCtx)
444
+ try {
445
+ await presets.mount(agentCtx, resolvedId)
446
+ } catch (error) {
447
+ warn(`cs-bots: preset ${resolvedId} mount 失败(会话仍可用,但工具面未挂上):${error?.message ?? error}`)
448
+ }
449
+ }
450
+ return { setup, agentPreset }
451
+ }
452
+
292
453
  /**
293
454
  * 创建一个 root Agent 会话,**可指定工作目录**。
294
455
  *
@@ -310,27 +471,132 @@ export async function createSession(ctx, options) {
310
471
  if (!agents || typeof agents.create !== 'function') {
311
472
  throw new Error('cs-bots kernel: agents.create 不可用(宿主未提供 agents 服务)')
312
473
  }
313
- const { sessionId, cwd, parentAgent, agentPreset, agentOptions, signal } = options ?? {}
474
+ const { sessionId, cwd, parentAgent, agentPreset, agentOptions, signal, warn } = options ?? {}
314
475
  if (!sessionId) throw new Error('cs-bots kernel: createSession 需要 sessionId')
315
476
 
477
+ // ── preset 装配(2026-11-09 修复「Bot 没有任何工具」)──────────────────────
478
+ // 必须走 setup 回调,理由见 composePresetSetup 的注释(源码级证据链)。
479
+ // 先 resolve,再拿它回填 meta.agentPreset —— 这样落盘的 preset 与会话真正
480
+ // 挂载的 preset **是同一个**(官方 composeAgent 也是先 resolve 再写 meta)。
481
+ const composition = await composePresetSetup(ctx, agentPreset, warn)
482
+
316
483
  const meta = {}
317
484
  if (cwd) meta.cwd = cwd
318
- if (agentPreset) meta.agentPreset = agentPreset
485
+ const effectivePreset = composition.agentPreset ?? agentPreset
486
+ if (effectivePreset) meta.agentPreset = effectivePreset
319
487
  if (parentAgent) meta.parentSession = parentAgent.id
320
488
 
321
- return agents.create({
489
+ // ── 模型选择(2026-11-09 修复「bot has no provider/model」)───────────────
490
+ // 官方 agent-loop 在每一步 preStep 都会 `this.model.current()`,
491
+ // 得到 undefined 就直接抛 `agent "X" has no provider/model`。
492
+ // 官方 Web UI 建会话时由 session-controller 记录一次 model selection;
493
+ // 我们走 agents.create 这条窄接口,**没有人替我们记录** ⇒ 必须自己带上。
494
+ // 取值优先级:调用方显式给 > 宿主的全局默认(agentDefaultModel.currentSelection())。
495
+ const route = agentOptions ?? defaultModelSelection(ctx)
496
+
497
+ const handle = await agents.create({
322
498
  sessionId,
323
499
  ...(parentAgent ? { parentAgent } : {}),
324
500
  ...(Object.keys(meta).length ? { meta } : {}),
325
- ...(agentOptions ? { agentOptions } : {}),
501
+ ...(route ? { agentOptions: route } : {}),
502
+ ...(composition.setup ? { setup: composition.setup } : {}),
326
503
  ...(signal ? { signal } : {}),
327
504
  })
505
+
506
+ // ── 挂进工作区(2026-11-09 修复「左侧栏未分组」)─────────────────────────
507
+ // 官方把会话归组进 Workspace(`workspaceRegistry`),attachSession 的
508
+ // 前置条件是「会话 header 的 cwd 与 workspace.path 完全相同」。
509
+ // 我们建会话时正好指定了 cwd,所以这里补上「建/复用 workspace + attach」,
510
+ // 否则会话永远落在「未分组」里(2026-11-09 现场:bot-6b654c19… 就是)。
511
+
512
+ // 注意顺序:attach 必须在会话创建之后(attach 要读会话 header 的 cwd)。
513
+ if (cwd) {
514
+ try {
515
+ await attachToWorkspace(ctx, cwd, pickSessionId(handle, sessionId))
516
+ } catch {
517
+ /* 工作区挂载是「锦上添花」:失败不影响会话可用(只是仍显示未分组) */
518
+ }
519
+ }
520
+ return handle
521
+ }
522
+
523
+ /**
524
+ * 宿主全局默认模型(`agentDefaultModel.currentSelection()`)。
525
+ * 缺失/异常一律返回 undefined —— 不因拿不到默认模型就让建会话失败。
526
+ * @param {object} ctx
527
+ * @returns {{ provider: string, model: string, reasoningEffort?: string } | undefined}
528
+ */
529
+ function defaultModelSelection(ctx) {
530
+ try {
531
+ const svc = service(ctx, 'agentDefaultModel')
532
+ if (!svc || typeof svc.currentSelection !== 'function') return undefined
533
+ const sel = svc.currentSelection()
534
+ if (!sel || typeof sel.provider !== 'string' || typeof sel.model !== 'string') return undefined
535
+ if (sel.provider === '' || sel.model === '') return undefined
536
+ return {
537
+ provider: sel.provider,
538
+ model: sel.model,
539
+ ...(sel.reasoningEffort === undefined ? {} : { reasoningEffort: sel.reasoningEffort }),
540
+ }
541
+ } catch {
542
+ return undefined
543
+ }
544
+ }
545
+
546
+ /** 从 AgentHandle 里取 session id(宿主可能返回 { agent } / { sessionId } / 字符串)。 */
547
+ function pickSessionId(handle, fallback) {
548
+ if (typeof handle === 'string' && handle) return handle
549
+ const id = handle?.agent?.id ?? handle?.sessionId ?? handle?.id
550
+ return typeof id === 'string' && id ? id : fallback
551
+ }
552
+
553
+ /**
554
+ * 把会话挂到「路径 = 该会话 cwd」的工作区(不存在则先建)。
555
+ *
556
+ * 官方契约(`dsh-workspace/lib/index.js`):
557
+ * - `workspaceRegistry.create(path)` 建(同路径已存在时会复用,返回 created=false)
558
+ * - `workspace.attachSession(sessionId)` 挂,前提是会话 cwd === workspace.path
559
+ * - 两个方法都可能不存在(老宿主)⇒ 全程 try/catch,静默降级
560
+ *
561
+ * @param {object} ctx
562
+ * @param {string} cwd 已存在的绝对目录
563
+ * @param {string} sessionId
564
+ * @returns {Promise<boolean>} 是否挂上
565
+ */
566
+ export async function attachToWorkspace(ctx, cwd, sessionId) {
567
+ const registry = service(ctx, 'workspaceRegistry')
568
+ if (!registry || typeof sessionId !== 'string' || !sessionId) return false
569
+ let workspace
570
+ try {
571
+ if (typeof registry.resolveByPath === 'function') {
572
+ workspace = await registry.resolveByPath(cwd)
573
+ }
574
+ } catch {
575
+ workspace = undefined
576
+ }
577
+ if (!workspace) {
578
+ if (typeof registry.create !== 'function') return false
579
+ const out = await registry.create(cwd) // 官方可能返回 { workspace, created } 或直接返回 workspace
580
+ workspace = out?.workspace ?? out
581
+ }
582
+ if (!workspace || typeof workspace.attachSession !== 'function') return false
583
+ await workspace.attachSession(sessionId)
584
+ return true
328
585
  }
329
586
 
330
587
  /**
331
588
  * 在既有持久会话上恢复 Agent(重启后复活会话用)。
589
+ *
590
+ * ⚠️ resume 也必须传 `setup`(2026-11-09 同源修复)。
591
+ * 官方 resume 路径同样装配 preset:`dsh-api-session-controller/lib/index.js:364-370`
592
+ * 传的是 `setup: this.reuseSetup(composition)`,其中 `reuseSetup`(382-397)
593
+ * 除了 installModelSelection + mount,还会在 preset 变化时 append
594
+ * `agent-preset/selected` 事件。**不传 setup 的 resume 出来的 Agent 同样没有工具面**
595
+ * —— 存量 Bot 光靠「复活」不会自动长出 read/exec,必须在这里补挂。
596
+ *
332
597
  * @param {object} ctx
333
- * @param {{ resumeSessionId: string, signal?: AbortSignal }} options
598
+ * @param {{ resumeSessionId: string, signal?: AbortSignal, agentPreset?: string,
599
+ * agentOptions?: object, warn?: (msg: string) => void }} options
334
600
  * @returns {Promise<object>} AgentHandle
335
601
  */
336
602
  export async function resumeSession(ctx, options) {
@@ -338,9 +604,22 @@ export async function resumeSession(ctx, options) {
338
604
  if (!agents || typeof agents.resume !== 'function') {
339
605
  throw new Error('cs-bots kernel: agents.resume 不可用')
340
606
  }
341
- const { resumeSessionId, signal } = options ?? {}
607
+ const { resumeSessionId, signal, agentOptions, agentPreset, warn } = options ?? {}
342
608
  if (!resumeSessionId) throw new Error('cs-bots kernel: resumeSession 需要 resumeSessionId')
343
- return agents.resume({ resumeSessionId, ...(signal ? { signal } : {}) })
609
+ // 2026-11-09 同源修复:**存量会话**从来没有记录过模型选择(当时走的是不带
610
+ // agentOptions 的创建路径),复活后依然 `model.current() === undefined`,
611
+ // 下一次唤醒照样炸 `has no provider/model`。所以复活也要给兜底路由:
612
+ // 调用方显式给 > 会话自己已记录的 > 宿主全局默认(由宿主负责,我们只在
613
+ // 前两者都没有时才传,避免把用户后来改过的会话级选择覆盖回默认值)。
614
+ const route = agentOptions ?? defaultModelSelection(ctx)
615
+ // preset 装配:与 createSession 同源(见 composePresetSetup 的注释)。
616
+ const composition = await composePresetSetup(ctx, agentPreset, warn)
617
+ return agents.resume({
618
+ resumeSessionId,
619
+ ...(route ? { agentOptions: route } : {}),
620
+ ...(composition.setup ? { setup: composition.setup } : {}),
621
+ ...(signal ? { signal } : {}),
622
+ })
344
623
  }
345
624
 
346
625
  // ── HTTP 路由注册 ───────────────────────────────────────────────────────────
@@ -433,6 +712,100 @@ export function restrictTools(agent, names) {
433
712
  }
434
713
  }
435
714
 
715
+ // ── LLM 请求拦截(复杂度路由唯一需要的宿主触点)──────────────────────────────
716
+
717
+ /**
718
+ * 注册一条 `llm/stream` 瀑布拦截(**接口 v1,2026-11-04 新增**)。
719
+ *
720
+ * 官方语义一手依据:
721
+ * - 调用点:`dsh-llm/lib/index.js:2397`
722
+ * `this.ctx.waterfall(this, 'llm/stream', options, () => this.adapterStream(...))`
723
+ * - 签名:`'llm/stream'(this: LlmRuntime, options: GenerateOptions,
724
+ * next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>`
725
+ * - 官方插件用法样板:`dsh-session-checkpoint-policy/lib/index.js:61`
726
+ * `ctx.on('llm/stream', (options, next) => { ... return next() })`
727
+ *
728
+ * ⚠️ `options` 是**冻结的**(`dsh-agent-loop/lib/index.js:1286` 处 `Object.freeze`):
729
+ * 处理函数只能读、不能写;想让下游知道点什么,必须走旁路通道
730
+ * (本平台用 `globalThis[Symbol.for('cs-myfree.routing.hints.v1')]`)。
731
+ *
732
+ * ⚠️ handler 必须**同步返回 `next()`**(那是 AsyncIterable,不是 Promise),
733
+ * 所以本函数不做任何 await,也不包 async。
734
+ *
735
+ * 缺 `ctx.on`(测试替身/更老宿主)时返回 `false`,调用方按降级处理,**不抛**。
736
+ *
737
+ * @param {object} ctx
738
+ * @param {(options: object, next: Function) => any} handler
739
+ * @returns {boolean} 是否注册成功
740
+ */
741
+ export function onLlmStream(ctx, handler) {
742
+ if (!ctx || typeof ctx.on !== 'function') return false
743
+ if (typeof handler !== 'function') return false
744
+ try {
745
+ ctx.on('llm/stream', handler)
746
+ return true
747
+ } catch {
748
+ return false
749
+ }
750
+ }
751
+
752
+ // ── 会话元数据(谱系)──────────────────────────────────────────────────────
753
+
754
+ /**
755
+ * 读一个会话的**谱系元数据**(接口 v1,2026-11-04 新增)。
756
+ *
757
+ * 为什么需要(授权泄漏修复):官方 `spawn_teammate` 生成的子会话**不在 cs-bots 的
758
+ * roster 注册表里**,但宿主 session header 带谱系字段。没有这条信息,平台只能把
759
+ * 「不是 Bot 的会话」当成主对话 ⇒ 官方子智能体反而拿到 `paid_allowed`(越权付费)。
760
+ *
761
+ * 一手依据(宿主源码,已核验):
762
+ * - `dsh-session/lib/index.js:1340` `Session.header`(公开属性,deep-frozen);
763
+ * - `dsh-session/lib/index.js:1823-1827` header 由 `meta` 组装,含
764
+ * `parentSession` / `origin`(子代理为 `'subagent'`)/ `delegationDepth` / `agentPreset`;
765
+ * - `dsh-session/lib/index.js:1980` `sessions.get(id)` ⇒ Session。
766
+ *
767
+ * 纪律:
768
+ * - **只取白名单四个字段**并转成纯数据(绝不把宿主对象/header 引用漏给业务层);
769
+ * - 服务缺失、无此会话、任何抛错 ⇒ `null`(调用方据此 fail-closed,**本函数绝不抛**);
770
+ * - 返回 `{}` 表示「会话存在但没有任何谱系字段」= 可确认是顶层会话(主对话)。
771
+ *
772
+ * @param {object} ctx
773
+ * @param {string} sessionId
774
+ * @returns {{ origin?: string, parentSession?: string, delegationDepth?: number,
775
+ * agentPreset?: string } | null}
776
+ */
777
+ export function sessionMeta(ctx, sessionId) {
778
+ if (typeof sessionId !== 'string' || sessionId === '') return null
779
+ const sessions = service(ctx, 'sessions')
780
+ if (!sessions || typeof sessions.get !== 'function') return null
781
+ let header
782
+ try {
783
+ header = sessions.get(sessionId)?.header
784
+ } catch {
785
+ return null
786
+ }
787
+ if (!header || typeof header !== 'object') return null
788
+
789
+ /** @type {{ origin?: string, parentSession?: string, delegationDepth?: number, agentPreset?: string }} */
790
+ const meta = {}
791
+ try {
792
+ if (typeof header.origin === 'string' && header.origin !== '') meta.origin = header.origin
793
+ if (typeof header.parentSession === 'string' && header.parentSession !== '') {
794
+ meta.parentSession = header.parentSession
795
+ }
796
+ if (typeof header.delegationDepth === 'number' && Number.isFinite(header.delegationDepth)) {
797
+ meta.delegationDepth = header.delegationDepth
798
+ }
799
+ if (typeof header.agentPreset === 'string' && header.agentPreset !== '') {
800
+ meta.agentPreset = header.agentPreset
801
+ }
802
+ } catch {
803
+ // 代理陷阱/只读失败等 ⇒ 视为拿不到元数据(调用方 fail-closed)
804
+ return null
805
+ }
806
+ return meta
807
+ }
808
+
436
809
  // ── 状态目录 ────────────────────────────────────────────────────────────────
437
810
 
438
811
  /**
@@ -478,3 +851,242 @@ export function defaultPreset(ctx) {
478
851
  return undefined
479
852
  }
480
853
  }
854
+
855
+ // ── 会话内容读取(需求2:任务详情里直接看对话,不必跳走)──────────────────────
856
+
857
+ /**
858
+ * 读一个会话的历史事件,转成「人类可读的对话片段」。
859
+ *
860
+ * 为什么需要它(2026-11-08 用户反馈):
861
+ * 「任务栏页面指派 bot 后,无法追踪任务进度,无法看到产出物,
862
+ * 没有跳转对话也没有对话内容查看」
863
+ * ⇒ 跳转会话(openSession)只能把人带到会话界面,**看不到这个任务相关的对话**;
864
+ * 人想看「Bot 到底干了什么」,必须能在任务详情里直接读。
865
+ *
866
+ * 数据来源:官方 `ctx.sessionPersistence`(durable 事件日志,**真相源**)。
867
+ * 为什么不用 live Agent 的内存:进程重启后 live 注册表是空的,但磁盘上的会话仍在——
868
+ * 用持久化层读才能保证「重启后照样能看历史」。
869
+ *
870
+ * @param {object} ctx
871
+ * @param {string} sessionId
872
+ * @param {{ limit?: number, offset?: number }} [options]
873
+ * - `limit`:最多返回多少条**消息片段**(默认 40;取**末尾**的,因为人关心最近发生了什么)
874
+ * @returns {Promise<{
875
+ * ok: boolean, sessionId?: string, messages?: object[], total?: number,
876
+ * reason?: string, available?: boolean,
877
+ * }>}
878
+ */
879
+ export async function readSessionMessages(ctx, sessionId, options = {}) {
880
+ if (typeof sessionId !== 'string' || sessionId === '') {
881
+ return { ok: false, reason: 'missing-session-id', available: true }
882
+ }
883
+ const persistence = service(ctx, 'sessionPersistence')
884
+ if (!persistence || typeof persistence.open !== 'function') {
885
+ return {
886
+ ok: false,
887
+ available: false,
888
+ reason: 'sessionPersistence 不可用(宿主未装配持久化层)——无法读取会话历史',
889
+ }
890
+ }
891
+
892
+ let handle
893
+ try {
894
+ // access: 'read' —— 只读打开,绝不抢所有权(会话可能正在被 live agent 使用)
895
+ handle = await persistence.open(sessionId, 'read')
896
+ } catch (error) {
897
+ return {
898
+ ok: false,
899
+ available: true,
900
+ reason: `打开会话失败:${error?.message ?? error}`,
901
+ }
902
+ }
903
+
904
+ try {
905
+ const result = await handle.read(0)
906
+ const events = Array.isArray(result?.events) ? result.events : []
907
+ const messages = eventsToMessages(events)
908
+ const limit = Number.isFinite(options.limit) && options.limit > 0 ? Math.floor(options.limit) : 40
909
+ // 取末尾 limit 条:人关心的是「最近在干什么」,不是开场白
910
+ const tail = messages.length > limit ? messages.slice(-limit) : messages
911
+ return {
912
+ ok: true,
913
+ sessionId,
914
+ messages: tail,
915
+ total: messages.length,
916
+ truncated: messages.length > limit,
917
+ }
918
+ } catch (error) {
919
+ return { ok: false, available: true, reason: `读取会话事件失败:${error?.message ?? error}` }
920
+ } finally {
921
+ try {
922
+ await handle.close?.()
923
+ } catch {
924
+ /* 关闭失败不影响已读到的数据 */
925
+ }
926
+ }
927
+ }
928
+
929
+ /**
930
+ * 会话的「水位」:最新事件序号 + 最新事件时间 + 是否已有用户消息。
931
+ *
932
+ * ## 为什么要它(2026-11-09「投递成功但执行者不动」)
933
+ * `dispatcher` 只记 `sent`(= 我们把消息交给了宿主),这不等于「执行者真的开始了
934
+ * 一个回合」。现场就出现过:wake 记 `sent`,而会话 `turns: 0 / steps: 0`、
935
+ * 事件文件还停在几天前 —— 任务卡在「进行中」,页面上看不到任何异常。
936
+ *
937
+ * 要判「它到底动没动」,最可靠、成本最低的依据就是**会话事件水位**:
938
+ * 投递后一段时间内,如果 seq 没有增长、也没有新的 `user/message`,
939
+ * 那这条输入就是没被消费。这就是 superagent `inputsRead` 那一层的最小实现。
940
+ *
941
+ * 只读打开(`access:'read'`),绝不抢会话所有权。
942
+ *
943
+ * @param {object} ctx
944
+ * @param {string} sessionId
945
+ * @returns {Promise<{ ok: boolean, available?: boolean, seq?: number, lastAt?: number,
946
+ * userMessages?: number, reason?: string }>}
947
+ */
948
+ export async function readSessionWatermark(ctx, sessionId) {
949
+ if (typeof sessionId !== 'string' || sessionId === '') {
950
+ return { ok: false, reason: 'missing-session-id', available: true }
951
+ }
952
+ const persistence = service(ctx, 'sessionPersistence')
953
+ if (!persistence || typeof persistence.open !== 'function') {
954
+ return { ok: false, available: false, reason: 'sessionPersistence 不可用' }
955
+ }
956
+ let handle
957
+ try {
958
+ handle = await persistence.open(sessionId, 'read')
959
+ } catch (error) {
960
+ return { ok: false, available: true, reason: `打开会话失败:${error?.message ?? error}` }
961
+ }
962
+ try {
963
+ const result = await handle.read(0)
964
+ const events = Array.isArray(result?.events) ? result.events : []
965
+ let seq = -1
966
+ let lastAt
967
+ let userMessages = 0
968
+ for (const ev of events) {
969
+ if (!ev || typeof ev !== 'object') continue
970
+ if (Number.isFinite(ev.seq) && ev.seq > seq) seq = ev.seq
971
+ if (Number.isFinite(ev.time)) lastAt = ev.time
972
+ if (ev.type === 'user/message') userMessages += 1
973
+ }
974
+ return { ok: true, available: true, seq, lastAt, userMessages }
975
+ } catch (error) {
976
+ return { ok: false, available: true, reason: `读取会话事件失败:${error?.message ?? error}` }
977
+ } finally {
978
+ try { await handle.close?.() } catch { /* 关闭失败不影响已读到的数据 */ }
979
+ }
980
+ }
981
+
982
+ /**
983
+ * 把官方 SessionEvent 转成人类可读的消息片段。
984
+ *
985
+ * 只保留**有对话价值**的事件类型:用户消息、助手文本、工具调用(摘要)、工具结果(摘要)。
986
+ * 其余事件(会话元数据、内存写入等)一律跳过——它们对「看 Bot 干了什么」没有帮助,
987
+ * 塞进来只会让人淹没在噪声里。
988
+ *
989
+ * 每条输出统一形状:`{ role, kind, text, at?, toolName? }`
990
+ * - role: 'user' | 'assistant' | 'tool'
991
+ * - kind: 'text' | 'tool-call' | 'tool-result' | 'reasoning'
992
+ *
993
+ * @param {object[]} events
994
+ * @returns {object[]}
995
+ */
996
+ export function eventsToMessages(events) {
997
+ const out = []
998
+ for (const ev of events) {
999
+ if (!ev || typeof ev !== 'object') continue
1000
+ // 官方 SessionEvent 的形状是 `{ type, seq, time, data }` —— 内容在 `data` 里。
1001
+ // 早期实现只读 `ev.content`,结果对**任何真实会话**都产出空数组(需求2「看不到对话内容」
1002
+ // 的真因)。这里同时兼容两种形状,避免新旧数据各有盲区。
1003
+ const type = ev.type
1004
+ const at = typeof ev.time === 'number' ? ev.time : undefined
1005
+ const data = (ev.data && typeof ev.data === 'object') ? ev.data : {}
1006
+
1007
+ const pushBlocks = (role, blocks) => {
1008
+ if (!Array.isArray(blocks)) return
1009
+ for (const b of blocks) {
1010
+ if (!b || typeof b !== 'object') continue
1011
+ if (b.type === 'text' && typeof b.text === 'string' && b.text.trim()) {
1012
+ out.push({ role, kind: 'text', text: b.text, at })
1013
+ } else if (b.type === 'reasoning' && typeof b.text === 'string' && b.text.trim()) {
1014
+ out.push({ role: 'assistant', kind: 'reasoning', text: b.text.slice(0, 2000), at })
1015
+ } else if (b.type === 'tool-call' || b.type === 'toolCall') {
1016
+ out.push({ role: 'assistant', kind: 'tool-call', toolName: String(b.name ?? b.toolName ?? '?'), text: summarizeInput(b.input ?? b.arguments), at })
1017
+ }
1018
+ }
1019
+ }
1020
+
1021
+ // 用户消息(含 system/developer 注入的「用户侧」文本,人看任务上下文需要)
1022
+ if (type === 'user/message' || type === 'user-message') {
1023
+ pushBlocks('user', data.content ?? ev.content ?? data.message?.content ?? ev.message?.content)
1024
+ continue
1025
+ }
1026
+
1027
+ // 助手消息:内容在 data.message.content
1028
+ if (type === 'assistant/message' || type === 'assistant-message') {
1029
+ const msg = data.message ?? ev.message ?? {}
1030
+ pushBlocks('assistant', msg.content ?? data.content ?? ev.content)
1031
+ continue
1032
+ }
1033
+
1034
+ // 工具调用:data.name + data.arguments(顶层事件,不嵌在 assistant 消息里)
1035
+ if (type === 'tool/call' || type === 'tool-call' || type === 'toolCall') {
1036
+ const name = data.name ?? data.toolName ?? ev.name
1037
+ const summary = summarizeInput(data.arguments ?? data.input ?? ev.arguments)
1038
+ // 既没名字也没入参 ⇒ 对「看 Bot 干了什么」毫无信息量,跳过而不是画一个空壳
1039
+ if (!name && !summary) continue
1040
+ out.push({
1041
+ role: 'assistant',
1042
+ kind: 'tool-call',
1043
+ toolName: String(name ?? '?'),
1044
+ text: summary,
1045
+ at,
1046
+ })
1047
+ continue
1048
+ }
1049
+
1050
+ // 工具结果:data.message.content
1051
+ if (type === 'tool/result' || type === 'tool-result' || type === 'toolResult') {
1052
+ const msg = data.message ?? ev.message ?? {}
1053
+ const text = blocksToText(msg.content ?? data.content ?? ev.content ?? ev.result)
1054
+ const name = msg.toolName ?? data.name ?? ev.toolName
1055
+ if (text) {
1056
+ out.push({
1057
+ role: 'tool',
1058
+ kind: 'tool-result',
1059
+ ...(typeof name === 'string' && name ? { toolName: name } : {}),
1060
+ text: text.length > 1500 ? `${text.slice(0, 1500)}\u2026` : text,
1061
+ at,
1062
+ })
1063
+ }
1064
+ continue
1065
+ }
1066
+ }
1067
+ return out
1068
+ }
1069
+
1070
+ /** 工具入参压成一行摘要(对象转 JSON,超长截断)。 */
1071
+ function summarizeInput(input) {
1072
+ if (input === undefined || input === null) return ''
1073
+ let summary = ''
1074
+ try {
1075
+ summary = typeof input === 'string' ? input : JSON.stringify(input)
1076
+ } catch {
1077
+ summary = ''
1078
+ }
1079
+ return summary && summary.length > 500 ? `${summary.slice(0, 500)}\u2026` : summary
1080
+ }
1081
+
1082
+ /** 把 content blocks 拍平成一段文本(只取 text 类型块)。 */
1083
+ function blocksToText(content) {
1084
+ if (typeof content === 'string') return content.trim()
1085
+ if (!Array.isArray(content)) return ''
1086
+ const parts = []
1087
+ for (const b of content) {
1088
+ if (typeof b === 'string') parts.push(b)
1089
+ else if (b && typeof b === 'object' && typeof b.text === 'string') parts.push(b.text)
1090
+ }
1091
+ return parts.join('\n').trim()
1092
+ }