@bolloon/bolloon-agent 0.5.5 → 0.6.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.
Files changed (158) hide show
  1. package/dist/agents/execution-entry.js +10 -0
  2. package/dist/agents/goal-flywheel/wiring/seams.js +5 -1
  3. package/dist/agents/parse-tool-call.js +6 -1
  4. package/dist/agents/pi-sdk-session-factory.js +36 -3
  5. package/dist/agents/pi-sdk-tools.js +18 -8
  6. package/dist/agents/pi-sdk.js +749 -1390
  7. package/dist/agents/run-context.js +20 -0
  8. package/dist/agents/runner-resolver.js +3 -1
  9. package/dist/agents/tool-capability/index.js +51 -0
  10. package/dist/agents/workflow-pivot-loop.js +31 -10
  11. package/dist/agents/write-verify.js +60 -0
  12. package/dist/bollharness-integration/integration.js +8 -0
  13. package/dist/bollharness-integration/skill-adapter.js +32 -3
  14. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js +5 -1
  15. package/dist/electron-build/agents/goal-flywheel/wiring/seams.js.map +1 -1
  16. package/dist/electron-build/agents/parse-tool-call.js +6 -1
  17. package/dist/electron-build/agents/parse-tool-call.js.map +1 -1
  18. package/dist/electron-build/agents/pi-sdk-session-factory.js +36 -3
  19. package/dist/electron-build/agents/pi-sdk-session-factory.js.map +1 -1
  20. package/dist/electron-build/agents/pi-sdk-tools.js +21 -11
  21. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  22. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  23. package/dist/electron-build/agents/pi-sdk.js +750 -1389
  24. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  25. package/dist/electron-build/agents/run-context.js +24 -0
  26. package/dist/electron-build/agents/run-context.js.map +1 -0
  27. package/dist/electron-build/agents/runner-resolver.js +3 -1
  28. package/dist/electron-build/agents/runner-resolver.js.map +1 -1
  29. package/dist/electron-build/agents/tool-capability/index.js +90 -0
  30. package/dist/electron-build/agents/tool-capability/index.js.map +1 -0
  31. package/dist/electron-build/agents/workflow-pivot-loop.js +31 -10
  32. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  33. package/dist/electron-build/agents/write-verify.js +99 -0
  34. package/dist/electron-build/agents/write-verify.js.map +1 -0
  35. package/dist/electron-build/bollharness-integration/integration.js +8 -0
  36. package/dist/electron-build/bollharness-integration/integration.js.map +1 -1
  37. package/dist/electron-build/bollharness-integration/skill-adapter.js +32 -3
  38. package/dist/electron-build/bollharness-integration/skill-adapter.js.map +1 -1
  39. package/dist/electron-build/kernel/channel-actor.js +339 -0
  40. package/dist/electron-build/kernel/channel-actor.js.map +1 -0
  41. package/dist/electron-build/kernel/control.js +102 -0
  42. package/dist/electron-build/kernel/control.js.map +1 -0
  43. package/dist/electron-build/kernel/model-runtime.js +508 -0
  44. package/dist/electron-build/kernel/model-runtime.js.map +1 -0
  45. package/dist/electron-build/kernel/run-lifecycle.js +146 -0
  46. package/dist/electron-build/kernel/run-lifecycle.js.map +1 -0
  47. package/dist/electron-build/kernel/transport.js +119 -0
  48. package/dist/electron-build/kernel/transport.js.map +1 -0
  49. package/dist/electron-build/llm/native-adapter.js +122 -0
  50. package/dist/electron-build/llm/native-adapter.js.map +1 -0
  51. package/dist/index.js +43 -19
  52. package/dist/ios/bolloon-web.json +3 -3
  53. package/dist/ios/client.js +13 -7
  54. package/dist/ios/mobile-core.js +7762 -6289
  55. package/dist/ios/mobile.js +64 -0
  56. package/dist/ios/routes-mobile-tasks.js +2 -1
  57. package/dist/ios/routes-tasks.js +16 -1
  58. package/dist/ios/routes-x402-info.js +28 -1
  59. package/dist/ios/server-storage.js +26 -5
  60. package/dist/ios/server.js +840 -862
  61. package/dist/ios/sw.js +1 -1
  62. package/dist/ios/ui/message-renderer.js +473 -29
  63. package/dist/ios/ui/step-timeline.js +13 -12
  64. package/dist/kernel/channel-actor.js +321 -0
  65. package/dist/kernel/control.js +95 -0
  66. package/dist/kernel/gate-scan.js +1350 -0
  67. package/dist/kernel/model-runtime.js +494 -0
  68. package/dist/kernel/plan-channel-actor.js +305 -0
  69. package/dist/kernel/plan-communication.js +163 -0
  70. package/dist/kernel/plan-constraint.js +152 -0
  71. package/dist/kernel/plan-deletion.js +32 -0
  72. package/dist/kernel/plan-harness.js +78 -0
  73. package/dist/kernel/plan-modelruntime.js +60 -0
  74. package/dist/kernel/plan-runcontext.js +163 -0
  75. package/dist/kernel/plan.js +149 -0
  76. package/dist/kernel/roster.js +244 -0
  77. package/dist/kernel/run-lifecycle.js +138 -0
  78. package/dist/kernel/transport.js +111 -0
  79. package/dist/llm/native-adapter.js +117 -0
  80. package/dist/web/bolloon-web.json +3 -3
  81. package/dist/web/client.js +12 -7
  82. package/dist/web/routes-tasks.js +16 -1
  83. package/dist/web/server.js +838 -862
  84. package/dist/web/sw.js +1 -1
  85. package/dist/web/ui/message-renderer.js +473 -29
  86. package/dist/web/ui/step-timeline.js +13 -12
  87. package/package.json +1 -1
  88. package/scripts/build-web.ts +74 -1
  89. package/dist/constraint-runtime/_archive_helper.d.ts +0 -1
  90. package/dist/constraint-runtime/_archive_helper.js +0 -9
  91. package/dist/constraint-runtime/assistant/index.d.ts +0 -11
  92. package/dist/constraint-runtime/assistant/index.js +0 -12
  93. package/dist/constraint-runtime/bootstrap/index.d.ts +0 -11
  94. package/dist/constraint-runtime/bootstrap/index.js +0 -12
  95. package/dist/constraint-runtime/bridge/index.d.ts +0 -11
  96. package/dist/constraint-runtime/bridge/index.js +0 -12
  97. package/dist/constraint-runtime/buddy/index.d.ts +0 -11
  98. package/dist/constraint-runtime/buddy/index.js +0 -12
  99. package/dist/constraint-runtime/cli/index.d.ts +0 -11
  100. package/dist/constraint-runtime/cli/index.js +0 -12
  101. package/dist/constraint-runtime/components/index.d.ts +0 -11
  102. package/dist/constraint-runtime/components/index.js +0 -12
  103. package/dist/constraint-runtime/constants/index.d.ts +0 -11
  104. package/dist/constraint-runtime/constants/index.js +0 -12
  105. package/dist/constraint-runtime/coordinator/index.d.ts +0 -11
  106. package/dist/constraint-runtime/coordinator/index.js +0 -12
  107. package/dist/constraint-runtime/cost_hook.d.ts +0 -2
  108. package/dist/constraint-runtime/cost_hook.js +0 -4
  109. package/dist/constraint-runtime/entrypoints/index.d.ts +0 -11
  110. package/dist/constraint-runtime/entrypoints/index.js +0 -12
  111. package/dist/constraint-runtime/execution_registry.d.ts +0 -20
  112. package/dist/constraint-runtime/execution_registry.js +0 -44
  113. package/dist/constraint-runtime/hooks/index.d.ts +0 -11
  114. package/dist/constraint-runtime/hooks/index.js +0 -12
  115. package/dist/constraint-runtime/ink.d.ts +0 -1
  116. package/dist/constraint-runtime/ink.js +0 -4
  117. package/dist/constraint-runtime/keybindings/index.d.ts +0 -11
  118. package/dist/constraint-runtime/keybindings/index.js +0 -12
  119. package/dist/constraint-runtime/memdir/index.d.ts +0 -11
  120. package/dist/constraint-runtime/memdir/index.js +0 -12
  121. package/dist/constraint-runtime/migrations/index.d.ts +0 -11
  122. package/dist/constraint-runtime/migrations/index.js +0 -12
  123. package/dist/constraint-runtime/moreright/index.d.ts +0 -11
  124. package/dist/constraint-runtime/moreright/index.js +0 -12
  125. package/dist/constraint-runtime/native_ts/index.d.ts +0 -11
  126. package/dist/constraint-runtime/native_ts/index.js +0 -12
  127. package/dist/constraint-runtime/output_styles/index.d.ts +0 -11
  128. package/dist/constraint-runtime/output_styles/index.js +0 -12
  129. package/dist/constraint-runtime/plugins/index.d.ts +0 -11
  130. package/dist/constraint-runtime/plugins/index.js +0 -12
  131. package/dist/constraint-runtime/port_manifest.d.ts +0 -7
  132. package/dist/constraint-runtime/port_manifest.js +0 -11
  133. package/dist/constraint-runtime/query.d.ts +0 -6
  134. package/dist/constraint-runtime/query.js +0 -1
  135. package/dist/constraint-runtime/remote/index.d.ts +0 -11
  136. package/dist/constraint-runtime/remote/index.js +0 -12
  137. package/dist/constraint-runtime/schemas/index.d.ts +0 -11
  138. package/dist/constraint-runtime/schemas/index.js +0 -12
  139. package/dist/constraint-runtime/screens/index.d.ts +0 -11
  140. package/dist/constraint-runtime/screens/index.js +0 -12
  141. package/dist/constraint-runtime/server/index.d.ts +0 -11
  142. package/dist/constraint-runtime/server/index.js +0 -12
  143. package/dist/constraint-runtime/services/index.d.ts +0 -11
  144. package/dist/constraint-runtime/services/index.js +0 -12
  145. package/dist/constraint-runtime/state/index.d.ts +0 -11
  146. package/dist/constraint-runtime/state/index.js +0 -12
  147. package/dist/constraint-runtime/system_init.d.ts +0 -1
  148. package/dist/constraint-runtime/system_init.js +0 -20
  149. package/dist/constraint-runtime/types/index.d.ts +0 -11
  150. package/dist/constraint-runtime/types/index.js +0 -12
  151. package/dist/constraint-runtime/upstream_proxy/index.d.ts +0 -11
  152. package/dist/constraint-runtime/upstream_proxy/index.js +0 -12
  153. package/dist/constraint-runtime/utils/index.d.ts +0 -11
  154. package/dist/constraint-runtime/utils/index.js +0 -12
  155. package/dist/constraint-runtime/vim/index.d.ts +0 -11
  156. package/dist/constraint-runtime/vim/index.js +0 -12
  157. package/dist/constraint-runtime/voice/index.d.ts +0 -11
  158. package/dist/constraint-runtime/voice/index.js +0 -12
@@ -87,6 +87,9 @@ console.log = (...args) => {
87
87
  import { createHyperswarmCommunicator, createTopic, KeyManager, AgentAuthManager, } from '@diap/sdk';
88
88
  import { initMinimax } from '../constraints/index.js';
89
89
  import { createAgentSession } from '../agents/pi-sdk.js';
90
+ // **K5 步骤④ (入口投递)**: 把一次入口执行投进会话 Actor 的 mailbox ⇒ 同一会话身份的输入排队执行
91
+ import { deliverThroughActor, getChannelQueue, abortActorsOfChannel } from '../kernel/channel-actor.js';
92
+ import { requireRunExecution } from '../agents/execution-entry.js'; // K8 唯一执行入口的取用助手 (脱挂坑见该文件头) // K8: 待办执行经内核邮箱串行
90
93
  import { irohTransport } from '../network/iroh-transport.js';
91
94
  import { createAgentDelegateApp } from './agent-delegate-server.js';
92
95
  import { createIrohDelegateTransport } from './iroh-delegate-transport.js';
@@ -94,6 +97,14 @@ import { verifyMessage, isAddress, getAddress } from 'viem';
94
97
  // 2026-07-05: peer 目录管理 + manifest 协议
95
98
  import * as peerFs from '../network/peer-fs.js';
96
99
  import { loadLocalResources, writeRemoteResources } from '../network/peer-resource-bridge.js';
100
+ /**
101
+ * 2026-10-02 (K8): 通道拿**唯一执行入口**的统一姿势 —— `runExecution` 在 `AgentSession` 上是**可选**的
102
+ * (接口允许实现自定义), 所以直接调会报 possibly-undefined。这里**响亮失败**而不是 `!` 断言:
103
+ * 拿不到入口就说明"通道又要直呼 prompt 了", 必须当场炸掉, 不许静默回落 (否则收口只剩纸面)。
104
+ */
105
+ // 2026-10-02: 实现搬到 `src/agents/execution-entry.ts` (为了能被行为门测到 —— 那个"脱挂"坑源码看不出)。
106
+ // ⚠️ 曾经这里写的是 `return agent.runExecution;` (等于脱挂方法) ⇒ 调用时 this===undefined
107
+ // ⇒ 实例里 supervisor / 心跳 / 任务三条路径都报 `... reading 'applyExecutionRequest'`。
97
108
  // 前端资源路径: 兼容 src 运行 + dist 运行 + npm 全局安装
98
109
  // - src 跑 (tsx): __dirname = .../src/web → .../dist/web
99
110
  // - dist 跑 (npm): __dirname = .../dist/web → 自身就是 web 根
@@ -585,7 +596,8 @@ async function triggerRemoteFollowup(channelId, remoteReply, fromPublicKey, roun
585
596
  broadcast({ type: 'followup-step', ...event, channelId }, channelId);
586
597
  }
587
598
  };
588
- const fullResponse = await agent.promptStream(markedPrompt, streamCallback, undefined, channelId);
599
+ // 2026-10-02 (K8): 经唯一入口 `runExecution` —— 参数与派发**完全一致** ⇒ 按构造零行为改变
600
+ const fullResponse = await deliverThroughActor(agent, () => requireRunExecution(agent)({ input: markedPrompt, onStream: streamCallback, channelId }));
589
601
  if (!fullResponse.trim())
590
602
  return;
591
603
  // 广播续看结果给 UI
@@ -913,7 +925,8 @@ async function handleV3P2PMessage(parsed, conn, comm) {
913
925
  }
914
926
  };
915
927
  const agent = await getAgentForChannel(channelId, ch.did || '', ch.name, ch.didDocRef);
916
- fullResponse = await agent.promptStream(fullPrompt, streamCallback, undefined, channelId);
928
+ // 2026-10-02 (K8): 同上 (零差量)
929
+ fullResponse = await deliverThroughActor(agent, () => requireRunExecution(agent)({ input: fullPrompt, onStream: streamCallback, channelId }));
917
930
  // 2026-07-06: 防御性兜底
918
931
  if (!fullResponse.trim()) {
919
932
  fullResponse = '⚠️ AI 未返回内容, 请重试';
@@ -1065,7 +1078,8 @@ async function handleV3P2PMessage(parsed, conn, comm) {
1065
1078
  // 临时用 channelId = "__collab__" 拿个一次性 agent
1066
1079
  const collabChannelId = `__collab_${senderKey.substring(0, 8)}__`;
1067
1080
  const agent = await getAgentForChannel(collabChannelId, '', `collab-${senderKey.substring(0, 8)}`, undefined);
1068
- const resultText = await agent.prompt(fullPrompt);
1081
+ // 2026-10-02 (K8): 经唯一入口 (零差量: channelId 就是取这个 agent 用的 collabChannelId)
1082
+ const resultText = await deliverThroughActor(agent, () => requireRunExecution(agent)({ input: fullPrompt, channelId: collabChannelId }));
1069
1083
  const reply = JSON.stringify({
1070
1084
  v: 3,
1071
1085
  op: 'agent.collab.reply',
@@ -1679,7 +1693,8 @@ export async function createWebServer(port = 3000, options = {}) {
1679
1693
  // 跨预算继续: 新 Run 必须挂在同一个 Goal 下 (setGoalId), 并带上上一个 Run 的非幂等守卫
1680
1694
  agent.setGoalId?.(goal.goalId);
1681
1695
  agent.setContinuationGuards?.(r.guards || []);
1682
- const reply = await agent.prompt(r.instruction);
1696
+ // 2026-10-02 (K8): 经唯一入口 (零差量: agent 本来就是按 goal.channelId 解析的)
1697
+ const reply = await deliverThroughActor(agent, () => requireRunExecution(agent)({ input: r.instruction, channelId: goal.channelId }));
1683
1698
  // 注意: prompt 收尾会清空 currentRunId → 必须读 lastRunId (否则会拿上一条 run 做决策)
1684
1699
  const runId = agent.getLastRunId?.() || agent.getRunId?.();
1685
1700
  return { runId, status: 'done', reply: typeof reply === 'string' ? reply.slice(0, 500) : undefined };
@@ -1791,6 +1806,9 @@ export async function createWebServer(port = 3000, options = {}) {
1791
1806
  console.warn('[mcp] 初始化失败 (非致命):', e?.message?.slice(0, 120));
1792
1807
  }
1793
1808
  })();
1809
+ // 2026-07-28: 用户 DID 身份端点 — 静默生成/加载, 持久化到 ~/.bolloon/identity/user.json
1810
+ let userIdentityCache = null;
1811
+ const IDENTITY_DIR = `${process.env.HOME || '/tmp'}/.bolloon/identity`;
1794
1812
  // 2026-08-08: DID 目录启动接入 — 回填既有磁盘数据 + 启动 OrbitDB 自动复制.
1795
1813
  // fire-and-forget: 不阻塞启动; 失败静默 (catalog 是增强层, 原磁盘路径永远可用).
1796
1814
  (async () => {
@@ -2489,7 +2507,8 @@ export async function createWebServer(port = 3000, options = {}) {
2489
2507
  new Promise((res) => setTimeout(() => res(null), 8000)),
2490
2508
  ]);
2491
2509
  if (agent && typeof agent.promptStream === 'function') {
2492
- llm = (p) => agent.promptStream(p, () => { }, undefined, local.id);
2510
+ // 2026-10-02 (K8): 经唯一入口 (零差量: channelId=local.id 与旧调用同一个)
2511
+ llm = (p) => deliverThroughActor(agent, () => requireRunExecution(agent)({ input: p, onStream: () => { }, channelId: local.id }));
2493
2512
  }
2494
2513
  }
2495
2514
  }
@@ -2541,7 +2560,8 @@ export async function createWebServer(port = 3000, options = {}) {
2541
2560
  throw new Error('无可用 agent, 跳过定时任务');
2542
2561
  }
2543
2562
  // 前缀 [cron] 标记后台执行来源 (供审计/日志区分人类消息与定时任务)
2544
- await agent.promptStream(`[cron] ${job.name}: ${job.prompt}`, () => { }, undefined, target.id);
2563
+ // 2026-10-02 (K8): 经唯一入口 (零差量: channelId=target.id)
2564
+ await deliverThroughActor(agent, () => requireRunExecution(agent)({ input: `[cron] ${job.name}: ${job.prompt}`, onStream: () => { }, channelId: target.id }));
2545
2565
  },
2546
2566
  });
2547
2567
  global.cronScheduler = cronHandle;
@@ -2593,7 +2613,8 @@ ${goalDesc}
2593
2613
  现在是否要主动联系其中某个智能体? 只输出一个 JSON 对象, 不要任何其他文字:
2594
2614
  {"initiate": true 或 false, "goalAchieved": true 或 false, "targetPeerPublicKey": "对方 pk", "targetChannelId": "对方渠道 id", "message": "你要说的话"}
2595
2615
  若不想发起, 输出 {"initiate": false}。`;
2596
- const raw = await agent.promptStream(prompt, () => { }, undefined, local.id);
2616
+ // 2026-10-02 (K8): 经唯一入口 (零差量: channelId=local.id)
2617
+ const raw = await deliverThroughActor(agent, () => requireRunExecution(agent)({ input: prompt, onStream: () => { }, channelId: local.id }));
2597
2618
  const m = raw.match(/\{[\s\S]*\}/);
2598
2619
  if (!m)
2599
2620
  return { initiate: false };
@@ -3406,7 +3427,16 @@ fetchState();
3406
3427
  if (!woke) {
3407
3428
  // 没在等外部事件也要如实回答 (可能它其实在等人/在跑), 但允许显式加急
3408
3429
  if (req.body?.force) {
3409
- await setContinuation(goalId, { wakeReason: 'active', autoContinue: true, wakeAt: undefined, needsExternal: undefined });
3430
+ // **K4**: channel 不直接写 Goal —— 提交控制请求, 由内核控制面执行
3431
+ const { submitRunControl } = await import('../kernel/control.js');
3432
+ const outcome = await submitRunControl({
3433
+ kind: 'wake-goal', origin: 'web', goalId, reason: 'force 加急唤醒',
3434
+ payload: { wakeReason: 'active', autoContinue: true, wakeAt: undefined, needsExternal: undefined },
3435
+ }, { setContinuation });
3436
+ if (!outcome.ok) {
3437
+ res.status(500).json({ error: `控制面拒绝: ${outcome.detail}` });
3438
+ return;
3439
+ }
3410
3440
  res.json({ ok: true, goalId, woke: false, forced: true, note: `原状态 ${g.status} (不在等外部事件), 已按 force 加急` });
3411
3441
  return;
3412
3442
  }
@@ -3467,12 +3497,17 @@ fetchState();
3467
3497
  let boundary = view.runBoundary;
3468
3498
  if (boundary.action === 'stop_running_run' && !boundary.stopped) {
3469
3499
  const { setRunStatus } = await import('../agents/run-store.js');
3500
+ const { submitRunControl } = await import('../kernel/control.js');
3470
3501
  boundary = await seam.applyRunBoundary({
3471
3502
  plan: view.plan,
3472
3503
  now,
3473
3504
  stop: async (i) => {
3474
- const r = await setRunStatus(i.runId, i.runStatus, { error: `变更注入 (${i.goalId}): ${String(i.reason).slice(0, 120)}` });
3475
- return { ok: !!r.ok, reason: r.reason || '' };
3505
+ // **K4**: 提交控制请求 (端口返回值里的"拒绝"会被控制面归一化成 ok=false + detail)
3506
+ const outcome = await submitRunControl({
3507
+ kind: 'set-run-status', origin: 'web', runId: i.runId, reason: `变更注入 (${i.goalId})`,
3508
+ payload: { status: i.runStatus, error: `变更注入 (${i.goalId}): ${String(i.reason).slice(0, 120)}` },
3509
+ }, { setRunStatus });
3510
+ return { ok: outcome.ok, reason: outcome.ok ? '' : String(outcome.detail ?? '') };
3476
3511
  },
3477
3512
  });
3478
3513
  }
@@ -3606,7 +3641,19 @@ fetchState();
3606
3641
  res.status(409).json({ error: `只有 needs_human 的运行需要批准 (当前 ${run.status})` });
3607
3642
  return;
3608
3643
  }
3609
- await recordRecovery(runId, { errorClass: run.errorClass || 'unknown', message: '人工批准后继续', action: 'resume' });
3644
+ // **K4**: channel 不直接写 Run —— 提交控制请求, 由内核控制面执行 (这里只做 ports wiring)
3645
+ const { submitRunControl } = await import('../kernel/control.js');
3646
+ const outcome = await submitRunControl({
3647
+ kind: 'record-recovery',
3648
+ origin: 'web',
3649
+ runId,
3650
+ reason: '人工批准后继续',
3651
+ payload: { errorClass: run.errorClass || 'unknown', message: '人工批准后继续', action: 'resume' },
3652
+ }, { recordRecovery });
3653
+ if (!outcome.ok) {
3654
+ res.status(500).json({ error: `控制面拒绝: ${outcome.detail}` });
3655
+ return;
3656
+ }
3610
3657
  if (!run.channelId) {
3611
3658
  res.status(400).json({ error: '该 run 没有 channelId, 请在 CLI 用 /approve' });
3612
3659
  return;
@@ -3630,9 +3677,11 @@ fetchState();
3630
3677
  const runId = String(req.params.runId);
3631
3678
  try {
3632
3679
  const { setRunStatus } = await import('../agents/run-store.js');
3633
- const r = await setRunStatus(runId, to, { error: `外部请求 (${suffix})` });
3634
- if (!r.ok) {
3635
- res.status(409).json({ error: r.reason || '状态迁移被拒绝' });
3680
+ const { submitRunControl } = await import('../kernel/control.js');
3681
+ const outcome = await submitRunControl({ kind: 'set-run-status', origin: 'web', runId, reason: `外部请求 (${suffix})`, payload: { status: to, error: `外部请求 (${suffix})` } }, { setRunStatus });
3682
+ // 语义不变: 端口/store 拒绝 ⇒ 409 (原因由控制面归一化进 detail)
3683
+ if (!outcome.ok) {
3684
+ res.status(409).json({ error: String(outcome.detail ?? '状态迁移被拒绝').replace(/^端口拒绝: /, '') });
3636
3685
  return;
3637
3686
  }
3638
3687
  res.json({ ok: true, runId, status: to, note: '运行中的 agent 会在下一轮循环检查时停下 (轮内不打断)' });
@@ -3911,7 +3960,8 @@ fetchState();
3911
3960
  req.docContent ? `\n资料内容:\n${req.docContent}` : '',
3912
3961
  `\n(来自 agent ${req.fromAgentId || 'unknown'}),请直接给出可交付结果。`,
3913
3962
  ].join('');
3914
- const out = await agent.prompt(task);
3963
+ // 2026-10-02 (K8): 经唯一入口 (零差量: agent 就是按 ch.id 取的)
3964
+ const out = await deliverThroughActor(agent, () => requireRunExecution(agent)({ input: task, channelId: ch.id }));
3915
3965
  const text = String(out || '').trim();
3916
3966
  if (!text || text.startsWith('❌') || text.startsWith('[AI 服务调用失败]')) {
3917
3967
  return { ok: false, summary: text.slice(0, 800) || '(空结果)', error: 'execution-failed' };
@@ -4034,6 +4084,8 @@ fetchState();
4034
4084
  const r = await createGroup(String(req.body?.name || '').trim(), {
4035
4085
  from: String(req.body?.from || '').trim() || undefined,
4036
4086
  hello: req.body?.hello ? String(req.body.hello) : undefined,
4087
+ // 微信式群聊: 拿到邀请链接的人都能发言 ⇒ 显式声明 (2026-10-01 起不再是隐式默认)
4088
+ acl: 'open',
4037
4089
  });
4038
4090
  if (!r.ok)
4039
4091
  return res.status(400).json({ error: r.error });
@@ -4280,8 +4332,11 @@ fetchState();
4280
4332
  res.json([]); // tool-manifest 已废弃, 工具列表由 pi-sdk.ts this.tools 维护
4281
4333
  });
4282
4334
  // 2026-07-28: 用户 DID 身份端点 — 静默生成/加载, 持久化到 ~/.bolloon/identity/user.json
4283
- let userIdentityCache = null;
4284
- const IDENTITY_DIR = `${process.env.HOME || '/tmp'}/.bolloon/identity`;
4335
+ // 2026-10-02: **声明位置有硬约束** —— 必须早于下面 did-catalog 的启动 IIFE。
4336
+ // 踩过一次: `loadOrCreateUserIdentity()` 在 did-catalog 的 IIFE (1876 附近) 里被 `await` 调用,
4337
+ // 而 `let` 声明在 4200+ 行之外 ⇒ 微任务先跑到那次调用 ⇒ **TDZ** ("Cannot access 'userIdentityCache'
4338
+ // before initialization") ⇒ 开机时 **OrbitDB 复制根本没起来**, 只在日志里留了一条"非致命"。
4339
+ // 修法: 把声明挪到**首次使用之前** (同作用域, 语义不变); 位置由下面的门锁住。
4285
4340
  async function loadOrCreateUserIdentity() {
4286
4341
  if (userIdentityCache)
4287
4342
  return userIdentityCache;
@@ -4870,925 +4925,844 @@ fetchState();
4870
4925
  : channel?.autoInvokeTools !== false;
4871
4926
  // 捕获外层 channel 到独立变量, 避免被 try 块内 (line 740+) 的 const channel 遮蔽
4872
4927
  const channelForJudgment = channel;
4873
- // per-channel queue 检查: 已在跑就入队, 等当前跑完自动接上
4928
+ // K8 正刀 (2026-10-02): **每条消息都投内核邮箱** —— 取代原来的 check-then-set + 通道自带的 queue。
4929
+ // 为什么 (三条, 按重要性):
4930
+ // (a) 同通道串行交给内核邮箱 (与 K5 同一条语义), 通道不再兼任调度器;
4931
+ // (b) 消灭 `finishChannelRun` **同步**置 `running = false` 后**异步** handoff 造成的**静默丢消息**窗口
4932
+ // (先行门 `src/test/k8-mailbox-scheduling.test.ts` ③ 已把该机制等价复刻并判红);
4933
+ // (c) 过去排队消息只能走 `runMessageFromQueue` 的**简化版** (无 judgment hint / persona / slash / 附件上下文),
4934
+ // 现在每条消息都走**同一条**完整路径 —— 这是行为改善, **不是零差量** (已在 log 如实记)。
4874
4935
  const runState = getOrCreateRunState(channelId);
4875
- if (runState.running) {
4876
- // 2026-07-15 修 Bug 8: 入队时保留 attachments + channelDid, 否则下一轮执行时会丢附件
4877
- runState.queue.push({
4878
- channelId,
4879
- text,
4880
- boundWalletAddress,
4881
- autoToolsEnabled,
4882
- attachments: parsedAttachments,
4883
- channelDid,
4884
- });
4936
+ const runChannelMessage = async () => {
4937
+ runState.running = true; // 观测口径 (串行权威已归邮箱)
4938
+ // K8 收尾 (2026-10-02): **中止位归内核 actor** —— `beginCancellation()` 每次执行开一个新 controller
4939
+ // (与邮箱串行一致), web 不再自己持 abortController。取到 agent 后立刻开户 (见下面 getAgentForChannel 之后)。
4940
+ let abortSignal;
4941
+ // 2026-07-04: pivot loop safety net — 防止 LLM hang (minimax M3
4942
+ // 偶尔反复 think 不输出 <final gen>, pivot 连 5 次无进展时会 hang 在
4943
+ // quality 评估). setTimeout 让 LLM 客户端收 signal 主动 break.
4944
+ // 2026-07-06: 30s 太短 — minimax M3 单次 LLM 约 10s, pivot 默认 moderate profile 30 iter
4945
+ // 留 ~300s. 之前 30s 自动 abort 时, 一次"你好"调用就被截断, 用户看到 ❌ 循环异常 + 空内容.
4946
+ // 改为 5 分钟 = 5x 实际耗时上限.
4947
+ const PIVOT_FORCE_TIMEOUT_MS = 5 * 60 * 1000;
4948
+ const forceTimeout = setTimeout(() => {
4949
+ console.warn(`[server] /message pivot 强制 timeout (${PIVOT_FORCE_TIMEOUT_MS}ms), aborting`);
4950
+ // K8 收尾: 中止经 actor; 拿不到 actor 就**如实记**"没能中止" (不静默)
4951
+ const act = agent?.actor;
4952
+ if (act?.abort)
4953
+ act.abort('pivot 强制 timeout');
4954
+ else
4955
+ console.warn('[K8] 强制 timeout 时没有 actor ⇒ 本次没能中止 (如实记)');
4956
+ }, PIVOT_FORCE_TIMEOUT_MS);
4885
4957
  broadcastQueueUpdate(channelId);
4886
- console.log(`[queue] /message 入队 channel=${channelId}, queue len=${runState.queue.length}, attach=${parsedAttachments.length}`);
4887
- return;
4888
- }
4889
- runState.running = true;
4890
- runState.abortController = new AbortController();
4891
- // 2026-07-04: pivot loop safety net — 防止 LLM hang (minimax M3
4892
- // 偶尔反复 think 不输出 <final gen>, pivot 连 5 次无进展时会 hang 在
4893
- // quality 评估). setTimeout 让 LLM 客户端收 signal 主动 break.
4894
- // 2026-07-06: 30s 太短 — minimax M3 单次 LLM 约 10s, pivot 默认 moderate profile 30 iter
4895
- // 留 ~300s. 之前 30s 自动 abort 时, 一次"你好"调用就被截断, 用户看到 ❌ 循环异常 + 空内容.
4896
- // 改为 5 分钟 = 5x 实际耗时上限.
4897
- const PIVOT_FORCE_TIMEOUT_MS = 5 * 60 * 1000;
4898
- const forceTimeout = setTimeout(() => {
4899
- console.warn(`[server] /message pivot 强制 timeout (${PIVOT_FORCE_TIMEOUT_MS}ms), aborting`);
4900
- runState.abortController?.abort();
4901
- }, PIVOT_FORCE_TIMEOUT_MS);
4902
- broadcastQueueUpdate(channelId);
4903
- // 2026-07-01 (v0.2.5): hoist sessionKey 到 try 外, 让 finally 块的 saveCurrentSession 能用
4904
- const sessionKey = `${channelId}:${currentSessionId}`;
4905
- let agent = null;
4906
- try {
4907
- agent = await getAgentForChannel(channelId, realChannelDid, realChannelName, realChannelDidDoc);
4908
- let fullResponse = '';
4909
- // P0.5: 注入门回传的 usedIds, 落 session message metadata, UI 可查
4910
- let usedJudgmentIds = [];
4911
- const streamCallback = (event) => {
4912
- // P0.5: 捕获注入门回传
4913
- if (event.type === 'used_judgments' && Array.isArray(event.usedIds)) {
4914
- usedJudgmentIds = event.usedIds;
4915
- // 同步推给前端 (用于 finalizeTimelineAsMessage 时给 addMessage 传 usedIds)
4916
- broadcast({ type: 'used_judgments', usedIds: usedJudgmentIds }, channelId);
4917
- return;
4918
- }
4919
- // 阶段事件 (注入门 / D 触发)
4920
- if (event.type === 'phase') {
4921
- broadcast({
4922
- type: 'phase',
4923
- phase: event.phase,
4924
- detail: event.detail,
4925
- usedCount: event.usedCount,
4926
- }, channelId);
4927
- return;
4928
- }
4929
- // 同时发送给流式显示和工作流显示
4930
- if (event.type === 'token' || event.type === 'thinking') {
4931
- broadcast({ type: 'stream', streamType: event.type, content: event.content }, channelId);
4932
- // 同时作为 workflow_step 显示(用于动态 loop 循环)
4933
- if (event.content) {
4934
- broadcast({ type: 'workflow_step', step: 'AI 思考', content: event.content.substring(0, 100) }, channelId);
4958
+ // 2026-07-01 (v0.2.5): hoist sessionKey 到 try 外, 让 finally 块的 saveCurrentSession 能用
4959
+ const sessionKey = `${channelId}:${currentSessionId}`;
4960
+ let agent = null;
4961
+ try {
4962
+ agent = await getAgentForChannel(channelId, realChannelDid, realChannelName, realChannelDidDoc);
4963
+ // K8 收尾: 这一次执行的取消位由 actor 开 (web 只持有它返回的 signal)
4964
+ abortSignal = agent.actor ? agent.actor.beginCancellation() : undefined;
4965
+ let fullResponse = '';
4966
+ // P0.5: 注入门回传的 usedIds, 落 session message metadata, UI 可查
4967
+ let usedJudgmentIds = [];
4968
+ const streamCallback = (event) => {
4969
+ // P0.5: 捕获注入门回传
4970
+ if (event.type === 'used_judgments' && Array.isArray(event.usedIds)) {
4971
+ usedJudgmentIds = event.usedIds;
4972
+ // 同步推给前端 (用于 finalizeTimelineAsMessage 时给 addMessage 传 usedIds)
4973
+ broadcast({ type: 'used_judgments', usedIds: usedJudgmentIds }, channelId);
4974
+ return;
4935
4975
  }
4936
- }
4937
- else if (event.type === 'reply-preview') {
4938
- // 2026-07-06: pivot 每 iter 把完整 reply 推给前端 — 前端可以更新临时气泡
4939
- // 等 loop 退完 type=ai 终文事件覆盖. 不依赖整个 promptWithPivotLoop 返回.
4940
- // 这样 pivot 中途网络慢 / 5min-long 任务, 用户看到中间内容不是空白 + '任务处理超时'.
4941
- broadcast({
4942
- type: 'reply-preview',
4943
- content: event.content,
4944
- iteration: event.iteration,
4945
- }, channelId);
4946
- }
4947
- else if (event.type === 'status' || event.type === 'tool') {
4948
- broadcast({ type: 'status', tool: event.tool, content: event.content }, channelId);
4949
- broadcast({ type: 'workflow_step', step: event.tool || '系统', content: event.content }, channelId);
4950
- console.log(`[SSE 广播] workflow_step: step=${event.tool}, content="${event.content?.substring(0, 80)}..."`);
4951
- // 2026-08-02: 本地 @ 远端时, 工作流步骤也推给 P2P 对话框 (rcm-log)
4952
- // — 用户报告"进程看不到": 本地智能体执行过程 (即使没调工具, 只有 status/tool 事件)
4953
- // 也要在 P2P 对话框显示, 让用户看到"进程"
4954
- try {
4955
- const rs = channelRunState.get(channelId);
4956
- if (rs?.remoteFollowup?.remoteChannelId) {
4957
- broadcast({
4958
- type: 'remote-chat-step',
4959
- channelId: rs.remoteFollowup.remoteChannelId,
4960
- stepType: 'step_start',
4961
- tool: event.tool || '工作流',
4962
- content: event.content,
4963
- localStep: true,
4964
- });
4976
+ // 阶段事件 (注入门 / D 触发)
4977
+ if (event.type === 'phase') {
4978
+ broadcast({
4979
+ type: 'phase',
4980
+ phase: event.phase,
4981
+ detail: event.detail,
4982
+ usedCount: event.usedCount,
4983
+ }, channelId);
4984
+ return;
4985
+ }
4986
+ // 同时发送给流式显示和工作流显示
4987
+ if (event.type === 'token' || event.type === 'thinking') {
4988
+ broadcast({ type: 'stream', streamType: event.type, content: event.content }, channelId);
4989
+ // 同时作为 workflow_step 显示(用于动态 loop 循环)
4990
+ if (event.content) {
4991
+ broadcast({ type: 'workflow_step', step: 'AI 思考', content: event.content.substring(0, 100) }, channelId);
4965
4992
  }
4966
4993
  }
4967
- catch { /* 非致命 */ }
4968
- }
4969
- else if (event.type === 'step_start' || event.type === 'step_done' || event.type === 'step_error') {
4970
- // 2026-06-15: 步骤状态机事件 — 原样转发 (前端 step-timeline 组件订阅)
4971
- broadcast({
4972
- type: event.type,
4973
- tool: event.tool,
4974
- content: event.content,
4975
- success: event.success,
4976
- output: event.output,
4977
- error: event.error,
4978
- args: event.args,
4979
- }, channelId);
4980
- // 2026-08-02: 对称显示 — 本地智能体 @ 远端时, 本地工具调用过程也推给 P2P 对话框
4981
- // (rcm-log): remote-chat-step 事件带 remoteChannelId, 前端按对话框匹配显示
4982
- try {
4983
- const rs = channelRunState.get(channelId);
4984
- if (rs?.remoteFollowup?.remoteChannelId) {
4985
- broadcast({
4986
- type: 'remote-chat-step',
4987
- channelId: rs.remoteFollowup.remoteChannelId,
4988
- stepType: event.type,
4989
- tool: event.tool,
4990
- content: event.content,
4991
- success: event.success,
4992
- output: event.output,
4993
- error: event.error,
4994
- args: event.args,
4995
- localStep: true, // 标记: 这是本地智能体的工具过程 (区别于对方转发的)
4996
- });
4994
+ else if (event.type === 'reply-preview') {
4995
+ // 2026-07-06: pivot 每 iter 把完整 reply 推给前端 — 前端可以更新临时气泡
4996
+ // 等 loop 退完 type=ai 终文事件覆盖. 不依赖整个 promptWithPivotLoop 返回.
4997
+ // 这样 pivot 中途网络慢 / 5min-long 任务, 用户看到中间内容不是空白 + '任务处理超时'.
4998
+ broadcast({
4999
+ type: 'reply-preview',
5000
+ content: event.content,
5001
+ iteration: event.iteration,
5002
+ }, channelId);
5003
+ }
5004
+ else if (event.type === 'status' || event.type === 'tool') {
5005
+ broadcast({ type: 'status', tool: event.tool, content: event.content }, channelId);
5006
+ broadcast({ type: 'workflow_step', step: event.tool || '系统', content: event.content }, channelId);
5007
+ console.log(`[SSE 广播] workflow_step: step=${event.tool}, content="${event.content?.substring(0, 80)}..."`);
5008
+ // 2026-08-02: 本地 @ 远端时, 工作流步骤也推给 P2P 对话框 (rcm-log)
5009
+ // — 用户报告"进程看不到": 本地智能体执行过程 (即使没调工具, 只有 status/tool 事件)
5010
+ // 也要在 P2P 对话框显示, 让用户看到"进程"
5011
+ try {
5012
+ const rs = channelRunState.get(channelId);
5013
+ if (rs?.remoteFollowup?.remoteChannelId) {
5014
+ broadcast({
5015
+ type: 'remote-chat-step',
5016
+ channelId: rs.remoteFollowup.remoteChannelId,
5017
+ stepType: 'step_start',
5018
+ tool: event.tool || '工作流',
5019
+ content: event.content,
5020
+ localStep: true,
5021
+ });
5022
+ }
4997
5023
  }
5024
+ catch { /* 非致命 */ }
4998
5025
  }
4999
- catch { /* 非致命 */ }
5000
- // 2026-06-16: 累积 step 到 runState, 供 /api/loop/inspect 读取
5001
- try {
5002
- if (event.type === 'step_done' || event.type === 'step_error') {
5026
+ else if (event.type === 'step_start' || event.type === 'step_done' || event.type === 'step_error') {
5027
+ // 2026-06-15: 步骤状态机事件 — 原样转发 (前端 step-timeline 组件订阅)
5028
+ broadcast({
5029
+ type: event.type,
5030
+ tool: event.tool,
5031
+ content: event.content,
5032
+ success: event.success,
5033
+ output: event.output,
5034
+ error: event.error,
5035
+ args: event.args,
5036
+ }, channelId);
5037
+ // 2026-08-02: 对称显示 — 本地智能体 @ 远端时, 本地工具调用过程也推给 P2P 对话框
5038
+ // (rcm-log): remote-chat-step 事件带 remoteChannelId, 前端按对话框匹配显示
5039
+ try {
5003
5040
  const rs = channelRunState.get(channelId);
5004
- if (rs) {
5005
- if (!rs.lastSteps)
5006
- rs.lastSteps = [];
5007
- rs.lastSteps.push({
5008
- name: String(event.tool || event.content || 'step').slice(0, 60),
5009
- status: event.type === 'step_error' ? 'failed' : (event.success === false ? 'failed' : 'ok'),
5010
- durationMs: typeof event.durationMs === 'number' ? event.durationMs : undefined,
5011
- output: event.output ? String(event.output).slice(0, 800) : (event.error ? String(event.error).slice(0, 800) : undefined),
5041
+ if (rs?.remoteFollowup?.remoteChannelId) {
5042
+ broadcast({
5043
+ type: 'remote-chat-step',
5044
+ channelId: rs.remoteFollowup.remoteChannelId,
5045
+ stepType: event.type,
5046
+ tool: event.tool,
5047
+ content: event.content,
5048
+ success: event.success,
5049
+ output: event.output,
5050
+ error: event.error,
5051
+ args: event.args,
5052
+ localStep: true, // 标记: 这是本地智能体的工具过程 (区别于对方转发的)
5012
5053
  });
5013
5054
  }
5014
5055
  }
5056
+ catch { /* 非致命 */ }
5057
+ // 2026-06-16: 累积 step 到 runState, 供 /api/loop/inspect 读取
5058
+ try {
5059
+ if (event.type === 'step_done' || event.type === 'step_error') {
5060
+ const rs = channelRunState.get(channelId);
5061
+ if (rs) {
5062
+ if (!rs.lastSteps)
5063
+ rs.lastSteps = [];
5064
+ rs.lastSteps.push({
5065
+ name: String(event.tool || event.content || 'step').slice(0, 60),
5066
+ status: event.type === 'step_error' ? 'failed' : (event.success === false ? 'failed' : 'ok'),
5067
+ durationMs: typeof event.durationMs === 'number' ? event.durationMs : undefined,
5068
+ output: event.output ? String(event.output).slice(0, 800) : (event.error ? String(event.error).slice(0, 800) : undefined),
5069
+ });
5070
+ }
5071
+ }
5072
+ }
5073
+ catch { /* non-fatal */ }
5074
+ }
5075
+ else if (event.type === 'error') {
5076
+ broadcast({ type: 'error', content: event.content }, channelId);
5077
+ }
5078
+ };
5079
+ console.log(`[消息处理] 开始处理用户消息, channelId: ${channelId}, sessionId: ${currentSessionId}`);
5080
+ // 2026-08-02 fix: 预激活远端协作续看 — 用户消息含 @远端 时立即激活 remoteFollowup.
5081
+ // 之前只在 routeMentionsInReply (AI 回复后) 激活 → 首次 @ 时本地智能体的工具调用
5082
+ // (step 事件) 发生在激活前, P2P 对话框看不到本地工具过程 (用户报告"进程看不到").
5083
+ // 现在收到消息即激活 → promptStream 期间的工具 step 也能 broadcast remote-chat-step.
5084
+ try {
5085
+ const mentionMatch = /@([一-龥A-Za-z0-9_\-]{1,30})/.exec(text);
5086
+ if (mentionMatch && remoteChannelCache.size > 0) {
5087
+ const targetName = mentionMatch[1];
5088
+ let hitRemoteId = null;
5089
+ for (const list of remoteChannelCache.values()) {
5090
+ const rc = list.find((c) => c.name === targetName);
5091
+ if (rc) {
5092
+ hitRemoteId = String(rc.id);
5093
+ break;
5094
+ }
5095
+ }
5096
+ if (hitRemoteId) {
5097
+ const rs = channelRunState.get(channelId);
5098
+ if (rs && !rs.remoteFollowup) {
5099
+ rs.remoteFollowup = { rounds: 0, maxRounds: 3, remoteChannelId: hitRemoteId };
5100
+ console.log(`[v3-followup] ${channelId} 预激活远端协作续看 → ${hitRemoteId} (消息含 @${targetName})`);
5101
+ }
5102
+ }
5015
5103
  }
5016
- catch { /* non-fatal */ }
5017
5104
  }
5018
- else if (event.type === 'error') {
5019
- broadcast({ type: 'error', content: event.content }, channelId);
5105
+ catch { /* 预激活失败不阻塞 */ }
5106
+ // 将真实 DID 作为上下文前缀,让 AI 使用真实的 DID 而不是自己编造的
5107
+ let contextHint = '';
5108
+ // 2026-08-14: 本地消息带 gateway 链接 → 自动加入, 结果注入 agent 上下文 (5s 上限, 不阻塞 LLM)
5109
+ try {
5110
+ const { maybeAutoJoinGateway } = await import('../agents/gateway-network.js');
5111
+ const gwNote = await Promise.race([
5112
+ maybeAutoJoinGateway(text),
5113
+ new Promise((resolve) => setTimeout(() => resolve(null), 5000)),
5114
+ ]);
5115
+ if (gwNote)
5116
+ contextHint += `[系统上下文] ${gwNote}\n`;
5117
+ }
5118
+ catch { /* 自动加入失败不阻塞 */ }
5119
+ // 2026-08-02: slash 命令提示 (在 /message 开头解析, 这里注入)
5120
+ if (slashCommandHint)
5121
+ contextHint += slashCommandHint;
5122
+ if (realChannelDid)
5123
+ contextHint += `[系统上下文] 当前频道名称: ${realChannelName}, 你的真实 DID: ${realChannelDid}\n`;
5124
+ // v3 新增: 标识发送方 — 让 AI 分清内部 owner vs 远端访客
5125
+ contextHint += `[系统上下文] 消息来源: 本地 (channel 内部 owner / 此机器上的用户). 称呼对方时用 "你" 或 "用户" 即可.\n`;
5126
+ if (boundWalletAddress) {
5127
+ contextHint += `[系统上下文] 已绑定的加密钱包地址: ${boundWalletAddress}。当用户授权或启用自动工具调用时, 可使用该地址发起链上操作。\n`;
5128
+ }
5129
+ if (autoToolsEnabled) {
5130
+ contextHint += `[系统上下文] 自动工具调用已开启: 你可以使用受信任的本地工具 (shell / 文件 / skill) 而无需逐次询问用户。\n`;
5020
5131
  }
5021
- };
5022
- console.log(`[消息处理] 开始处理用户消息, channelId: ${channelId}, sessionId: ${currentSessionId}`);
5023
- // 2026-08-02 fix: 预激活远端协作续看 — 用户消息含 @远端 时立即激活 remoteFollowup.
5024
- // 之前只在 routeMentionsInReply (AI 回复后) 激活 → 首次 @ 时本地智能体的工具调用
5025
- // (step 事件) 发生在激活前, P2P 对话框看不到本地工具过程 (用户报告"进程看不到").
5026
- // 现在收到消息即激活 → promptStream 期间的工具 step 也能 broadcast remote-chat-step.
5027
- try {
5028
- const mentionMatch = /@([一-龥A-Za-z0-9_\-]{1,30})/.exec(text);
5029
- if (mentionMatch && remoteChannelCache.size > 0) {
5030
- const targetName = mentionMatch[1];
5031
- let hitRemoteId = null;
5032
- for (const list of remoteChannelCache.values()) {
5033
- const rc = list.find((c) => c.name === targetName);
5034
- if (rc) {
5035
- hitRemoteId = String(rc.id);
5036
- break;
5132
+ else {
5133
+ contextHint += `[系统上下文] 自动工具调用已关闭: 每次执行工具前必须先与用户确认。\n`;
5134
+ }
5135
+ // 2026-08-02 fix: 本地路径注入远端 channel 目录 (dirHint) — 之前只有远端路径 (agent.chat.send)
5136
+ // 有 dirHint, 本地智能体对话时看不到远端 channel 列表 → 无法 @ 远程智能体交流.
5137
+ // 现在注入: 可用渠道列表 (本地 + 远端), 让 LLM 知道 @ 谁、发什么.
5138
+ try {
5139
+ const localChs = await loadChannels();
5140
+ const remoteForDir = [];
5141
+ for (const [peerPk, list] of remoteChannelCache.entries()) {
5142
+ for (const ch of list) {
5143
+ remoteForDir.push({ ...ch, _ownerPublicKey: peerPk });
5037
5144
  }
5038
5145
  }
5039
- if (hitRemoteId) {
5040
- const rs = channelRunState.get(channelId);
5041
- if (rs && !rs.remoteFollowup) {
5042
- rs.remoteFollowup = { rounds: 0, maxRounds: 3, remoteChannelId: hitRemoteId };
5043
- console.log(`[v3-followup] ${channelId} 预激活远端协作续看 → ${hitRemoteId} (消息含 @${targetName})`);
5146
+ if (localChs.length > 0 || remoteForDir.length > 0) {
5147
+ contextHint += '[系统上下文] 可用渠道 (你可以在回复中写 "@渠道名 消息内容" 给它们发消息, 消息会持久化到目标 channel 的 session):\n';
5148
+ for (const c of localChs) {
5149
+ if (c.id === channelId)
5150
+ continue; // 跳过自己
5151
+ contextHint += ` - [本地] @${c.name} (id=${c.id})\n`;
5044
5152
  }
5153
+ for (const c of remoteForDir) {
5154
+ contextHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0, 8)}…] @${c.name} (id=${c.id})\n`;
5155
+ }
5156
+ contextHint += '语法: 在回复中写 "@渠道名 我要说的话" 即可, 系统会自动转发。\n\n';
5045
5157
  }
5046
5158
  }
5047
- }
5048
- catch { /* 预激活失败不阻塞 */ }
5049
- // 将真实 DID 作为上下文前缀,让 AI 使用真实的 DID 而不是自己编造的
5050
- let contextHint = '';
5051
- // 2026-08-14: 本地消息带 gateway 链接 → 自动加入, 结果注入 agent 上下文 (5s 上限, 不阻塞 LLM)
5052
- try {
5053
- const { maybeAutoJoinGateway } = await import('../agents/gateway-network.js');
5054
- const gwNote = await Promise.race([
5055
- maybeAutoJoinGateway(text),
5056
- new Promise((resolve) => setTimeout(() => resolve(null), 5000)),
5057
- ]);
5058
- if (gwNote)
5059
- contextHint += `[系统上下文] ${gwNote}\n`;
5060
- }
5061
- catch { /* 自动加入失败不阻塞 */ }
5062
- // 2026-08-02: slash 命令提示 (在 /message 开头解析, 这里注入)
5063
- if (slashCommandHint)
5064
- contextHint += slashCommandHint;
5065
- if (realChannelDid)
5066
- contextHint += `[系统上下文] 当前频道名称: ${realChannelName}, 你的真实 DID: ${realChannelDid}\n`;
5067
- // v3 新增: 标识发送方 — 让 AI 分清内部 owner vs 远端访客
5068
- contextHint += `[系统上下文] 消息来源: 本地 (channel 内部 owner / 此机器上的用户). 称呼对方时用 "你" 或 "用户" 即可.\n`;
5069
- if (boundWalletAddress) {
5070
- contextHint += `[系统上下文] 已绑定的加密钱包地址: ${boundWalletAddress}。当用户授权或启用自动工具调用时, 可使用该地址发起链上操作。\n`;
5071
- }
5072
- if (autoToolsEnabled) {
5073
- contextHint += `[系统上下文] 自动工具调用已开启: 你可以使用受信任的本地工具 (shell / 文件 / skill) 而无需逐次询问用户。\n`;
5074
- }
5075
- else {
5076
- contextHint += `[系统上下文] 自动工具调用已关闭: 每次执行工具前必须先与用户确认。\n`;
5077
- }
5078
- // 2026-08-02 fix: 本地路径注入远端 channel 目录 (dirHint) — 之前只有远端路径 (agent.chat.send)
5079
- // 有 dirHint, 本地智能体对话时看不到远端 channel 列表 → 无法 @ 远程智能体交流.
5080
- // 现在注入: 可用渠道列表 (本地 + 远端), 让 LLM 知道 @ 谁、发什么.
5081
- try {
5082
- const localChs = await loadChannels();
5083
- const remoteForDir = [];
5084
- for (const [peerPk, list] of remoteChannelCache.entries()) {
5085
- for (const ch of list) {
5086
- remoteForDir.push({ ...ch, _ownerPublicKey: peerPk });
5087
- }
5159
+ catch (dirErr) {
5160
+ // 静默 — dirHint 不是核心
5088
5161
  }
5089
- if (localChs.length > 0 || remoteForDir.length > 0) {
5090
- contextHint += '[系统上下文] 可用渠道 (你可以在回复中写 "@渠道名 消息内容" 给它们发消息, 消息会持久化到目标 channel 的 session):\n';
5091
- for (const c of localChs) {
5092
- if (c.id === channelId)
5093
- continue; // 跳过自己
5094
- contextHint += ` - [本地] @${c.name} (id=${c.id})\n`;
5095
- }
5096
- for (const c of remoteForDir) {
5097
- contextHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0, 8)}…] @${c.name} (id=${c.id})\n`;
5162
+ // v3: 注入 channel 绑定的判断力 (judgment_ids)
5163
+ // 这是 v3 的核心 — channel 跑 LLM 时, 它的判断力 = 绑定的 judgment 列表
5164
+ const judgmentHint = await buildJudgmentHint(channelForJudgment, channelId);
5165
+ if (judgmentHint)
5166
+ contextHint += judgmentHint;
5167
+ // 2026-06-10: 注入 skills 列表 (本机 ~/.bolloon/skills/ 下所有 skills)
5168
+ // 让 LLM 知道有哪些 skill 可用, 在回复中提示用户
5169
+ try {
5170
+ const { loadSkillsFromPaths, defaultSkillPaths, describeSkill } = await import('../agents/skill-loader.js');
5171
+ const paths = defaultSkillPaths();
5172
+ const skills = await loadSkillsFromPaths(paths);
5173
+ if (skills.length > 0) {
5174
+ contextHint += `[系统上下文] 本机已加载的 skills (${skills.length} 个, 你可以提示用户主动调用):\n`;
5175
+ for (const s of skills.slice(0, 20)) { // 上限 20 避免 prompt 过长
5176
+ const desc = (s.description || '').slice(0, 80);
5177
+ contextHint += ` - /${s.name}${desc ? ' — ' + desc : ''}\n`;
5178
+ }
5179
+ contextHint += '调用语法: 用户说 "/技能名 ..." 或 你回复时建议 "/技能名 ..." 让用户主动触发.\n\n';
5098
5180
  }
5099
- contextHint += '语法: 在回复中写 "@渠道名 我要说的话" 即可, 系统会自动转发。\n\n';
5100
5181
  }
5101
- }
5102
- catch (dirErr) {
5103
- // 静默 — dirHint 不是核心
5104
- }
5105
- // v3: 注入 channel 绑定的判断力 (judgment_ids)
5106
- // 这是 v3 的核心 — channel 跑 LLM 时, 它的判断力 = 绑定的 judgment 列表
5107
- const judgmentHint = await buildJudgmentHint(channelForJudgment, channelId);
5108
- if (judgmentHint)
5109
- contextHint += judgmentHint;
5110
- // 2026-06-10: 注入 skills 列表 (本机 ~/.bolloon/skills/ 下所有 skills)
5111
- // 让 LLM 知道有哪些 skill 可用, 在回复中提示用户
5112
- try {
5113
- const { loadSkillsFromPaths, defaultSkillPaths, describeSkill } = await import('../agents/skill-loader.js');
5114
- const paths = defaultSkillPaths();
5115
- const skills = await loadSkillsFromPaths(paths);
5116
- if (skills.length > 0) {
5117
- contextHint += `[系统上下文] 本机已加载的 skills (${skills.length} 个, 你可以提示用户主动调用):\n`;
5118
- for (const s of skills.slice(0, 20)) { // 上限 20 避免 prompt 过长
5119
- const desc = (s.description || '').slice(0, 80);
5120
- contextHint += ` - /${s.name}${desc ? ' — ' + desc : ''}\n`;
5121
- }
5122
- contextHint += '调用语法: 用户说 "/技能名 ..." 或 你回复时建议 "/技能名 ..." 让用户主动触发.\n\n';
5182
+ catch (err) {
5183
+ // 静默失败 — skills 不是核心, 加载失败不阻塞
5123
5184
  }
5124
- }
5125
- catch (err) {
5126
- // 静默失败 — skills 不是核心, 加载失败不阻塞
5127
- }
5128
- // 2026-06-10: 注入 human values 摘要 (最常用的 judgment / 价值偏好)
5129
- // 与 judgment 不同: values 是更宏观的"用户偏好", judgment 是针对具体决策的约束
5130
- try {
5131
- const { loadAllJudgments } = await import('../pi-ecosystem-judgment/human-value-store.js');
5132
- const allJudgments = await loadAllJudgments().catch(() => []);
5133
- // 把所有 judgment 视作软参考 (跟 buildJudgmentHint 的 candidates 同理)
5134
- if (Array.isArray(allJudgments) && allJudgments.length > 0) {
5135
- contextHint += `[系统上下文] 用户的核心价值倾向 (来自 ${allJudgments.length} 条历史 judgment, 软参考, 体现而非复述):\n`;
5136
- for (const j of allJudgments.slice(0, 8)) {
5137
- const decision = (j.decision || '').slice(0, 80);
5138
- contextHint += ` - ${decision}\n`;
5185
+ // 2026-06-10: 注入 human values 摘要 (最常用的 judgment / 价值偏好)
5186
+ // 与 judgment 不同: values 是更宏观的"用户偏好", judgment 是针对具体决策的约束
5187
+ try {
5188
+ const { loadAllJudgments } = await import('../pi-ecosystem-judgment/human-value-store.js');
5189
+ const allJudgments = await loadAllJudgments().catch(() => []);
5190
+ // 把所有 judgment 视作软参考 (跟 buildJudgmentHint 的 candidates 同理)
5191
+ if (Array.isArray(allJudgments) && allJudgments.length > 0) {
5192
+ contextHint += `[系统上下文] 用户的核心价值倾向 (来自 ${allJudgments.length} 条历史 judgment, 软参考, 体现而非复述):\n`;
5193
+ for (const j of allJudgments.slice(0, 8)) {
5194
+ const decision = (j.decision || '').slice(0, 80);
5195
+ contextHint += ` - ${decision}\n`;
5196
+ }
5197
+ contextHint += '\n';
5139
5198
  }
5140
- contextHint += '\n';
5141
5199
  }
5142
- }
5143
- catch (err) {
5144
- // 静默失败
5145
- }
5146
- // 2026-06-10: 注入 documents 列表 (本机 documents/ 目录的文档元数据)
5147
- // 让 LLM 知道有文档存在, 用户可主动要求读
5148
- try {
5149
- const { documentStore } = await import('../documents/store.js');
5150
- const docs = await documentStore.getReceivedDocuments(50).catch(() => []);
5151
- if (Array.isArray(docs) && docs.length > 0) {
5152
- contextHint += `[系统上下文] 本机 documents (${docs.length} 篇, 用户可让你读):\n`;
5153
- for (const d of docs.slice(0, 10)) {
5154
- const name = d.fileName || d.id || '(未命名)';
5155
- const size = d.fileSize ? ` (${Math.round(d.fileSize / 1024)}KB)` : '';
5156
- const sender = d.fromNodeId ? ` [来自 ${d.fromNodeIdShort || d.fromNodeId.substring(0, 8)}…]` : '';
5157
- contextHint += ` - ${name}${size}${sender}\n`;
5200
+ catch (err) {
5201
+ // 静默失败
5202
+ }
5203
+ // 2026-06-10: 注入 documents 列表 (本机 documents/ 目录的文档元数据)
5204
+ // 让 LLM 知道有文档存在, 用户可主动要求读
5205
+ try {
5206
+ const { documentStore } = await import('../documents/store.js');
5207
+ const docs = await documentStore.getReceivedDocuments(50).catch(() => []);
5208
+ if (Array.isArray(docs) && docs.length > 0) {
5209
+ contextHint += `[系统上下文] 本机 documents (${docs.length} 篇, 用户可让你读):\n`;
5210
+ for (const d of docs.slice(0, 10)) {
5211
+ const name = d.fileName || d.id || '(未命名)';
5212
+ const size = d.fileSize ? ` (${Math.round(d.fileSize / 1024)}KB)` : '';
5213
+ const sender = d.fromNodeId ? ` [来自 ${d.fromNodeIdShort || d.fromNodeId.substring(0, 8)}…]` : '';
5214
+ contextHint += ` - ${name}${size}${sender}\n`;
5215
+ }
5216
+ contextHint += '用户提到某文档时, 你可以调用读文档工具读取并总结.\n\n';
5158
5217
  }
5159
- contextHint += '用户提到某文档时, 你可以调用读文档工具读取并总结.\n\n';
5160
5218
  }
5161
- }
5162
- catch (err) {
5163
- // 静默失败
5164
- }
5165
- // 2026-06-11: 注入此 channel 专属的 persona + 关联文档 (从 channel 字段读, LLM 长期记忆)
5166
- const chPersona = channelForJudgment?.persona;
5167
- if (chPersona && typeof chPersona === 'object') {
5168
- contextHint += '[系统上下文] 此 channel 的人设 (你是这个角色):\n';
5169
- if (chPersona.name)
5170
- contextHint += ` 名字: ${chPersona.name}\n`;
5171
- if (chPersona.description)
5172
- contextHint += ` 描述: ${chPersona.description}\n`;
5173
- if (chPersona.personality)
5174
- contextHint += ` 性格: ${chPersona.personality}\n`;
5175
- if (chPersona.greeting)
5176
- contextHint += ` 问候: ${chPersona.greeting}\n`;
5177
- if (Array.isArray(chPersona.capabilities) && chPersona.capabilities.length > 0) {
5178
- contextHint += ` 能力: ${chPersona.capabilities.join('、')}\n`;
5179
- }
5180
- if (Array.isArray(chPersona.interests) && chPersona.interests.length > 0) {
5181
- contextHint += ` 兴趣: ${chPersona.interests.join('、')}\n`;
5182
- }
5183
- contextHint += '回复时应自然体现这个角色 (不要硬搬原文, 像这个角色说话即可).\n\n';
5184
- }
5185
- // 2026-08-02: memory 回读 — 把本 channel 的历史记忆摘要注入 contextHint.
5186
- // memory-compressor 每次 /message 后把增量摘要 append 到
5187
- // ~/.bolloon/memory/<agentId>/sessions/<safe-channel>__<safe-session>.summary.md,
5188
- // 但之前只写不读, 对话完全无记忆. 这里取尾部最近 3 段增量摘要回灌给 LLM.
5189
- try {
5190
- const { getSessionSummaryPath, getMemoryDir, sanitizeAgentId } = await import('../bootstrap/memory-compressor.js');
5191
- const { readFile, readdir } = await import('fs/promises');
5192
- const { join } = await import('path');
5193
- const agentId = channelForJudgment?.agentId;
5194
- if (agentId) {
5195
- // 优先读当前 channel 的 summary (如果有)
5196
- const currentSummary = getSessionSummaryPath(agentId, channelId, currentSessionId);
5197
- const summariesToRead = [];
5198
- try {
5199
- const raw = await readFile(currentSummary, 'utf-8');
5200
- if (raw.trim())
5201
- summariesToRead.push(raw);
5219
+ catch (err) {
5220
+ // 静默失败
5221
+ }
5222
+ // 2026-06-11: 注入此 channel 专属的 persona + 关联文档 (从 channel 字段读, LLM 长期记忆)
5223
+ const chPersona = channelForJudgment?.persona;
5224
+ if (chPersona && typeof chPersona === 'object') {
5225
+ contextHint += '[系统上下文] 此 channel 的人设 (你是这个角色):\n';
5226
+ if (chPersona.name)
5227
+ contextHint += ` 名字: ${chPersona.name}\n`;
5228
+ if (chPersona.description)
5229
+ contextHint += ` 描述: ${chPersona.description}\n`;
5230
+ if (chPersona.personality)
5231
+ contextHint += ` 性格: ${chPersona.personality}\n`;
5232
+ if (chPersona.greeting)
5233
+ contextHint += ` 问候: ${chPersona.greeting}\n`;
5234
+ if (Array.isArray(chPersona.capabilities) && chPersona.capabilities.length > 0) {
5235
+ contextHint += ` 能力: ${chPersona.capabilities.join('、')}\n`;
5236
+ }
5237
+ if (Array.isArray(chPersona.interests) && chPersona.interests.length > 0) {
5238
+ contextHint += ` 兴趣: ${chPersona.interests.join('、')}\n`;
5202
5239
  }
5203
- catch { /* 当前 channel 还没有摘要 */ }
5204
- // 再兜底读同 agent 其他 channel 的最近摘要 (跨 channel 记忆)
5205
- if (summariesToRead.length === 0) {
5240
+ contextHint += '回复时应自然体现这个角色 (不要硬搬原文, 像这个角色说话即可).\n\n';
5241
+ }
5242
+ // 2026-08-02: memory 回读 — 把本 channel 的历史记忆摘要注入 contextHint.
5243
+ // memory-compressor 每次 /message 后把增量摘要 append 到
5244
+ // ~/.bolloon/memory/<agentId>/sessions/<safe-channel>__<safe-session>.summary.md,
5245
+ // 但之前只写不读, 对话完全无记忆. 这里取尾部最近 3 段增量摘要回灌给 LLM.
5246
+ try {
5247
+ const { getSessionSummaryPath, getMemoryDir, sanitizeAgentId } = await import('../bootstrap/memory-compressor.js');
5248
+ const { readFile, readdir } = await import('fs/promises');
5249
+ const { join } = await import('path');
5250
+ const agentId = channelForJudgment?.agentId;
5251
+ if (agentId) {
5252
+ // 优先读当前 channel 的 summary (如果有)
5253
+ const currentSummary = getSessionSummaryPath(agentId, channelId, currentSessionId);
5254
+ const summariesToRead = [];
5206
5255
  try {
5207
- const sessionsDir = join(getMemoryDir(agentId), 'sessions');
5208
- const files = (await readdir(sessionsDir)).filter(f => f.endsWith('.summary.md'));
5209
- // 按文件名排序取最近 2 个 (文件名含时间戳, 近似排序)
5210
- const recent = files.sort().slice(-2);
5211
- for (const f of recent) {
5212
- if (f.includes(sanitizeAgentId(channelId)))
5213
- continue; // 跳过已读的当前 channel
5214
- try {
5215
- const raw = await readFile(join(sessionsDir, f), 'utf-8');
5216
- if (raw.trim())
5217
- summariesToRead.push(raw);
5256
+ const raw = await readFile(currentSummary, 'utf-8');
5257
+ if (raw.trim())
5258
+ summariesToRead.push(raw);
5259
+ }
5260
+ catch { /* 当前 channel 还没有摘要 */ }
5261
+ // 再兜底读同 agent 其他 channel 的最近摘要 (跨 channel 记忆)
5262
+ if (summariesToRead.length === 0) {
5263
+ try {
5264
+ const sessionsDir = join(getMemoryDir(agentId), 'sessions');
5265
+ const files = (await readdir(sessionsDir)).filter(f => f.endsWith('.summary.md'));
5266
+ // 按文件名排序取最近 2 个 (文件名含时间戳, 近似排序)
5267
+ const recent = files.sort().slice(-2);
5268
+ for (const f of recent) {
5269
+ if (f.includes(sanitizeAgentId(channelId)))
5270
+ continue; // 跳过已读的当前 channel
5271
+ try {
5272
+ const raw = await readFile(join(sessionsDir, f), 'utf-8');
5273
+ if (raw.trim())
5274
+ summariesToRead.push(raw);
5275
+ }
5276
+ catch { /* 单个摘要读失败跳过 */ }
5218
5277
  }
5219
- catch { /* 单个摘要读失败跳过 */ }
5220
5278
  }
5279
+ catch { /* 无 sessions 目录 */ }
5221
5280
  }
5222
- catch { /* 无 sessions 目录 */ }
5223
- }
5224
- if (summariesToRead.length > 0) {
5225
- const memBlock = summariesToRead.map(s => s.trim().slice(-1500)).join('\n\n---\n\n');
5226
- contextHint += `[系统上下文] 动态状态层 · chat-worksite (上次做到哪 — 本 channel 历史记忆, 来自 memory-compressor 摘要, 引用而非复述):\n${memBlock.slice(-2500)}\n\n`;
5227
- }
5228
- else {
5229
- // 2026-08-08 (DID 目录读侧): 磁盘无摘要时, 回退读 DID 目录 memory 表 (跨设备同步来的记忆)
5230
- try {
5231
- const { openUserCatalog, catalogMemoryRows } = await import('../storage/did-catalog-bridge.js');
5232
- const cat = await openUserCatalog();
5233
- if (cat) {
5234
- const rows = catalogMemoryRows(cat, agentId);
5235
- if (rows.length > 0) {
5236
- const memBlock = rows.slice(0, 2)
5237
- .map(({ row }) => String(row.data.summary || '').slice(-1500))
5238
- .filter(Boolean)
5239
- .join('\n\n---\n\n');
5240
- if (memBlock) {
5241
- contextHint += `[系统上下文] 动态状态层 · chat-worksite (DID 目录同步记忆, 引用而非复述):\n${memBlock.slice(-2500)}\n\n`;
5281
+ if (summariesToRead.length > 0) {
5282
+ const memBlock = summariesToRead.map(s => s.trim().slice(-1500)).join('\n\n---\n\n');
5283
+ contextHint += `[系统上下文] 动态状态层 · chat-worksite (上次做到哪 — 本 channel 历史记忆, 来自 memory-compressor 摘要, 引用而非复述):\n${memBlock.slice(-2500)}\n\n`;
5284
+ }
5285
+ else {
5286
+ // 2026-08-08 (DID 目录读侧): 磁盘无摘要时, 回退读 DID 目录 memory 表 (跨设备同步来的记忆)
5287
+ try {
5288
+ const { openUserCatalog, catalogMemoryRows } = await import('../storage/did-catalog-bridge.js');
5289
+ const cat = await openUserCatalog();
5290
+ if (cat) {
5291
+ const rows = catalogMemoryRows(cat, agentId);
5292
+ if (rows.length > 0) {
5293
+ const memBlock = rows.slice(0, 2)
5294
+ .map(({ row }) => String(row.data.summary || '').slice(-1500))
5295
+ .filter(Boolean)
5296
+ .join('\n\n---\n\n');
5297
+ if (memBlock) {
5298
+ contextHint += `[系统上下文] 动态状态层 · chat-worksite (DID 目录同步记忆, 引用而非复述):\n${memBlock.slice(-2500)}\n\n`;
5299
+ }
5242
5300
  }
5243
5301
  }
5244
5302
  }
5303
+ catch { /* 目录读失败静默 */ }
5245
5304
  }
5246
- catch { /* 目录读失败静默 */ }
5247
5305
  }
5248
5306
  }
5249
- }
5250
- catch (memErr) {
5251
- // 静默失败 — memory 回读不是核心, 失败不阻塞
5252
- }
5253
- // 2026-08-02: plan 回读 — 把进行中的计划注入 contextHint (在 memory 之后)
5254
- // 配合 create_plan / update_plan / review_plan 工具, 让 LLM 每次对话都能看到
5255
- // 未完成的计划并继续推进.
5256
- try {
5257
- const { listActivePlans, planToContext } = await import('../agents/plan-store.js');
5258
- const plans = await listActivePlans();
5259
- if (plans.length > 0) {
5260
- const plansBlock = plans.slice(0, 3).map(p => planToContext(p)).join('\n\n');
5261
- contextHint += `[系统上下文] 动态状态层 · focus (此刻优先级与时间边界 — 进行中的计划, 来自 plan-store, 执行中每完成一步调 update_plan 勾选):\n${plansBlock}\n\n`;
5262
- }
5263
- }
5264
- catch (planErr) {
5265
- // 静默失败
5266
- }
5267
- // 2026-08-03 (Context OS P5): 资产层目录注入 — LLM 知道有 12+3 层资产,
5268
- // 需要细节时用 read_context_assets 按层路由读取, 不全仓扫描 (Context OS §4).
5269
- try {
5270
- const { readContextAssets, formatLayerListing } = await import('../bootstrap/context-os.js');
5271
- const listings = await readContextAssets();
5272
- const total = listings.reduce((s, l) => s + l.fileCount, 0);
5273
- if (total > 0) {
5274
- contextHint += `[系统上下文] 资产层 (Context OS 12+3 层, ${total} 篇资产 — 任务需要细节时用 read_context_assets 按层读取, 不要假装知道没读过的内容):\n${formatLayerListing(listings)}`;
5307
+ catch (memErr) {
5308
+ // 静默失败 — memory 回读不是核心, 失败不阻塞
5275
5309
  }
5276
- }
5277
- catch (ctxErr) {
5278
- // 静默失败
5279
- }
5280
- const linkedIds = channelForJudgment?.linkedDocumentIds;
5281
- if (Array.isArray(linkedIds) && linkedIds.length > 0) {
5310
+ // 2026-08-02: plan 回读 — 把进行中的计划注入 contextHint (在 memory 之后)
5311
+ // 配合 create_plan / update_plan / review_plan 工具, 让 LLM 每次对话都能看到
5312
+ // 未完成的计划并继续推进.
5282
5313
  try {
5283
- const { documentStore } = await import('../documents/store.js');
5284
- contextHint += `[系统上下文] 此 channel 关联了 ${linkedIds.length} 篇文档 (已自动加载内容, 你应基于它们回答):\n`;
5285
- let loaded = 0;
5286
- for (const docId of linkedIds.slice(0, 10)) {
5287
- const doc = await documentStore.readDocument(docId).catch(() => null);
5288
- if (!doc)
5289
- continue;
5290
- const name = doc.metadata?.fileName || docId;
5291
- const content = (doc.content || '').slice(0, 1500); // 单篇 1.5KB 上限, 总 prompt 防爆
5292
- contextHint += `\n--- 文档: ${name} ---\n${content}\n--- 文档结束 ---\n`;
5293
- loaded++;
5314
+ const { listActivePlans, planToContext } = await import('../agents/plan-store.js');
5315
+ const plans = await listActivePlans();
5316
+ if (plans.length > 0) {
5317
+ const plansBlock = plans.slice(0, 3).map(p => planToContext(p)).join('\n\n');
5318
+ contextHint += `[系统上下文] 动态状态层 · focus (此刻优先级与时间边界 — 进行中的计划, 来自 plan-store, 执行中每完成一步调 update_plan 勾选):\n${plansBlock}\n\n`;
5294
5319
  }
5295
- if (loaded === 0) {
5296
- contextHint += `(但加载失败, 文档可能已被删除)\n\n`;
5297
- }
5298
- else {
5299
- contextHint += '\n';
5300
- }
5301
- }
5302
- catch (err) {
5303
- console.warn('[v3-persona] 加载关联文档失败 (非致命):', err.message);
5304
- }
5305
- }
5306
- // v3 新增: 注入"可用渠道"目录, 让 LLM 知道可以 @-mention 哪些 channel
5307
- // - 本地 channels (除了自己)
5308
- // - 远端 channels (remoteChannelCache 缓存的)
5309
- const localChannels = (await loadChannels()).filter(c => c.id !== channelId);
5310
- const remoteChannels = [];
5311
- for (const [peerPk, list] of remoteChannelCache.entries()) {
5312
- for (const ch of list) {
5313
- remoteChannels.push({ ...ch, _ownerPublicKey: peerPk });
5314
- }
5315
- }
5316
- if (localChannels.length > 0 || remoteChannels.length > 0) {
5317
- contextHint += '[系统上下文] 可用渠道 (你可以用 @渠道名 消息内容 给它们发消息):\n';
5318
- for (const c of localChannels) {
5319
- contextHint += ` - [本地] @${c.name} (id=${c.id})\n`;
5320
5320
  }
5321
- for (const c of remoteChannels) {
5322
- contextHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0, 8)}…] @${c.name} (id=${c.id})\n`;
5321
+ catch (planErr) {
5322
+ // 静默失败
5323
5323
  }
5324
- contextHint += '语法: 当你想给其他渠道发消息, 在回复中写 "@渠道名 我要说的话" 即可. 消息会持久化到目标 channel 的 session, 你之后能看到"自己"在那里说的话.\n';
5325
- // 2026-06-10 强化: 当用户消息里出现 @渠道名, 默认是请你代为转发, 务必在回复里包含对应的 @ 转发
5326
- if (remoteChannels.length > 0) {
5327
- contextHint += '重要: 上面列表里 [远端] 标记的 channel 在另一台机器上, 你可以像 @本地 channel 一样 @ 它们 — 我会通过 P2P 自动把消息送达对方智能体, 对方智能体的回复也会同步回来.\n';
5328
- contextHint += '当用户在消息里 @ 了某个 (本地或远端) channel, 默认意图是希望你代为转发 — 你应该在回复中写出对应的 "@渠道名 转发内容", 否则用户的请求不会被路由出去.\n\n';
5324
+ // 2026-08-03 (Context OS P5): 资产层目录注入 — LLM 知道有 12+3 层资产,
5325
+ // 需要细节时用 read_context_assets 按层路由读取, 不全仓扫描 (Context OS §4).
5326
+ try {
5327
+ const { readContextAssets, formatLayerListing } = await import('../bootstrap/context-os.js');
5328
+ const listings = await readContextAssets();
5329
+ const total = listings.reduce((s, l) => s + l.fileCount, 0);
5330
+ if (total > 0) {
5331
+ contextHint += `[系统上下文] 资产层 (Context OS 12+3 层, ${total} 篇资产 — 任务需要细节时用 read_context_assets 按层读取, 不要假装知道没读过的内容):\n${formatLayerListing(listings)}`;
5332
+ }
5329
5333
  }
5330
- else {
5331
- contextHint += '\n';
5334
+ catch (ctxErr) {
5335
+ // 静默失败
5332
5336
  }
5333
- }
5334
- if (contextHint)
5335
- contextHint += '\n';
5336
- // 2026-07-06: 全局 contextHint 硬裁 — MiniMax-M3 context window 8K token ≈ 32K 字符
5337
- // 如果 contextHint 超过 14K 字符 (留 ~18K 给 userText + LLM 输出 + system), 主动截断
5338
- const CONTEXT_LIMIT = 14000;
5339
- let contextTruncated = false;
5340
- if (contextHint.length > CONTEXT_LIMIT) {
5341
- const originalLen = contextHint.length;
5342
- contextHint = contextHint.slice(0, CONTEXT_LIMIT);
5343
- contextHint += `\n[系统上下文] (...剩余 ${originalLen - CONTEXT_LIMIT} 字符因 context window 限制已截断, 完整内容请用 /judgments 查)\n`;
5344
- contextTruncated = true;
5345
- console.warn(`[chat] channel=${channelId} contextHint truncated: ${originalLen} -> ${contextHint.length} chars`);
5346
- }
5347
- try {
5348
- // 2026-06-15: 把 user text 单独 marker 包起来, LLM 不会被 8K+ 的 system context 吞掉
5349
- // (之前 contextHint + text 拼成一整段当 user role, 24 字符的 user input 埋在 8K+ 里看不出)
5350
- // 修法: contextHint 当 "背景信息", text 当 "本轮用户请求" — 显式 marker 让 LLM 区分
5351
- // 2026-06-15 二次修: 把 text 放在最前 (LLM 看到 input 第一眼是 user text, 不会被 judgmentHint 末尾
5352
- // 的 "..." 误判为整个 input 截断)
5353
- // 2026-07-05: prepend nextPromptHints (manifest-loader 加载的对方能力, 仅本次生效)
5354
- let extraHint = '';
5355
- const hint = nextPromptHints.get(channelId);
5356
- if (hint) {
5357
- extraHint = hint + '\n\n';
5358
- nextPromptHints.delete(channelId);
5359
- }
5360
- // 2026-07-15 修 Bug 3: 拖拽附件 — attachmentContext 提到 contextHint 最前, LLM 第一眼看到文件清单
5361
- const markedPrompt = `${extraHint}【本轮用户请求】\n${text}\n【请求结束】\n\n${attachmentContext}${contextHint}`;
5362
- fullResponse = await agent.promptStream(markedPrompt, streamCallback, runState.abortController?.signal, channelId);
5363
- }
5364
- catch (err) {
5365
- // abort 抛错: 保留已输出的部分 (fullResponse 可能是空字符串)
5366
- if (runState.abortController?.signal.aborted || err?.name === 'AbortError') {
5367
- console.log(`[chat] aborted channel=${channelId}`);
5368
- // 2026-07-06: abort 时 fullResponse 可能为空 (LLM 还没输出), 必须给用户一个反馈
5369
- if (!fullResponse.trim()) {
5370
- fullResponse = '⚠️ 生成已中断 (用户取消)';
5337
+ const linkedIds = channelForJudgment?.linkedDocumentIds;
5338
+ if (Array.isArray(linkedIds) && linkedIds.length > 0) {
5339
+ try {
5340
+ const { documentStore } = await import('../documents/store.js');
5341
+ contextHint += `[系统上下文] 此 channel 关联了 ${linkedIds.length} 篇文档 (已自动加载内容, 你应基于它们回答):\n`;
5342
+ let loaded = 0;
5343
+ for (const docId of linkedIds.slice(0, 10)) {
5344
+ const doc = await documentStore.readDocument(docId).catch(() => null);
5345
+ if (!doc)
5346
+ continue;
5347
+ const name = doc.metadata?.fileName || docId;
5348
+ const content = (doc.content || '').slice(0, 1500); // 单篇 1.5KB 上限, 总 prompt 防爆
5349
+ contextHint += `\n--- 文档: ${name} ---\n${content}\n--- 文档结束 ---\n`;
5350
+ loaded++;
5351
+ }
5352
+ if (loaded === 0) {
5353
+ contextHint += `(但加载失败, 文档可能已被删除)\n\n`;
5354
+ }
5355
+ else {
5356
+ contextHint += '\n';
5357
+ }
5371
5358
  }
5372
- }
5373
- else {
5374
- // M1.2 (2026-06-17): 不再 rethrow — 把错误塞到 fullResponse 让 broadcast 出来, 后续 session 仍保存
5375
- // 旧行为: 抛到外层 catch, 触发 500 + 用户消息丢失 (session 不保存)
5376
- console.error(`[chat] LLM 调用失败 channel=${channelId}:`, err);
5377
- fullResponse = `[错误: LLM 调用失败] ${String(err?.message || err).slice(0, 300)}`;
5378
- broadcast({ type: 'error', content: fullResponse }, channelId);
5379
- }
5380
- }
5381
- // abort 模式: 给 partial 拼后缀 — 只在 LLM 真有输出时加中断标记,
5382
- // 跳过空响应 + 跳过 [AI 服务调用失败] fallback (这些本身就是错误占位, 拼
5383
- // "[生成已中断]" 会误导用户)
5384
- const hasRealLlmOutput = fullResponse.trim().length > 0 &&
5385
- !fullResponse.trimStart().startsWith('[AI 服务调用失败]');
5386
- if (runState.abortController?.signal.aborted && hasRealLlmOutput) {
5387
- fullResponse = fullResponse + '\n\n_[生成已中断]_';
5388
- }
5389
- // 2026-06-18: 修 lastFinalReply 没设 → /api/loop/inspect 永远返回空
5390
- runState.lastFinalReply = fullResponse;
5391
- // 2026-07-06: 防御性兜底 — fullResponse 为空时, 前端 segmentChatReply('') 返回 [],
5392
- // 导致气泡不渲染, 用户只看到"思考中"占位符. 这里保证永远有内容可渲染.
5393
- if (!fullResponse.trim()) {
5394
- fullResponse = '⚠️ AI 未返回内容, 请重试';
5395
- console.warn(`[chat] fullResponse empty for channel=${channelId}, using fallback`);
5396
- }
5397
- // v3 新增: 解析 LLM 回复里的 @-mentions, 转发到目标 channel
5398
- await routeMentionsInReply(channelId, fullResponse, localChannels, remoteChannels);
5399
- broadcast({ type: 'ai', content: fullResponse }, channelId);
5400
- const existingSession = await loadSession(channelId, currentSessionId);
5401
- const session = existingSession || { channelId, sessionId: currentSessionId, messages: [], lastUpdated: new Date().toISOString() };
5402
- session.sessionId = currentSessionId;
5403
- // v3: 加 source 标记 (local = 内部 owner, remote = 远端访客)
5404
- // 2026-07-15 修 Bug 4: client.ts sendMessage 已经通过 persistLastMessageToServer PATCH /sessions/.../...
5405
- // 把 user msg 落盘一次 (立即落, 切走再切回不丢). 这里再 push 一次 → session.messages 出现两条相同的 user msg,
5406
- // loadSession 重渲染时两条都上屏, 表现"每条 user 气泡重复两次".
5407
- // 修法: 持久化以 client PATCH 为准, /message 这边只 push ai 消息. 同时去重检查上次的 user 避免极端竞态 (并行 PATCH).
5408
- const lastMsg = session.messages[session.messages.length - 1];
5409
- const userAlreadyPushed = lastMsg && lastMsg.type === 'user' && lastMsg.content === text;
5410
- if (!userAlreadyPushed) {
5411
- session.messages.push({ id: crypto.randomUUID(), type: 'user', content: text, timestamp: new Date().toISOString(), source: 'local' });
5412
- }
5413
- session.messages.push({
5414
- id: crypto.randomUUID(),
5415
- type: 'ai',
5416
- content: fullResponse,
5417
- timestamp: new Date().toISOString(),
5418
- source: 'local',
5419
- // P0.5: 这条 AI 回复引用了哪些 judgment (注入门回传)
5420
- ...(usedJudgmentIds.length > 0 ? { metadata: { usedJudgmentIds } } : {}),
5421
- });
5422
- session.lastUpdated = new Date().toISOString();
5423
- await saveSession(session);
5424
- // 2026-07-04: 写 session 后, 调 memory-compressor 把消息历史压缩到
5425
- // ~/.bolloon/memory/<channel.agentId>/sessions/<safe-channel>__<safe-session>.summary.md
5426
- // 失败静默, 不阻塞 /message 主路径.
5427
- try {
5428
- const channelsForMemory = await loadChannels();
5429
- const channelForMemory = channelsForMemory.find(c => c.id === channelId);
5430
- if (channelForMemory?.agentId) {
5431
- const { compressSessionToMemory } = await import('../bootstrap/memory-compressor.js');
5432
- const compressRes = await compressSessionToMemory({
5433
- agentId: channelForMemory.agentId,
5434
- channelId,
5435
- sessionId: currentSessionId,
5436
- });
5437
- if (!compressRes.skipped && compressRes.bytesWritten > 0) {
5438
- console.log(`[memory] compressed ${compressRes.messagesCount} new messages for ${channelForMemory.agentId}/${channelId} → ${compressRes.summaryPath} (${compressRes.bytesWritten}B)`);
5359
+ catch (err) {
5360
+ console.warn('[v3-persona] 加载关联文档失败 (非致命):', err.message);
5439
5361
  }
5440
5362
  }
5441
- }
5442
- catch (memErr) {
5443
- console.warn('[memory] compressSessionToMemory failed (non-fatal):', memErr?.message?.slice(0, 200));
5444
- }
5445
- // 2026-07-05: 把 session 里的 user/ai 消息按涉及到的远端 peer 归档到月度 markdown
5446
- // 触发条件: 消息里出现过 @远端 channel, 或者 session 历史上与该 peer 通信过
5447
- // 失败静默, 不阻塞主对话
5448
- try {
5449
- const { appendChatArchive } = await import('../bootstrap/chat-archiver.js');
5450
- // 找到这条消息涉及到的远端 peer (从 routeMentionsInReply 的结果推断 — 简单起见直接遍历 remoteChannels)
5451
- const localChannels2 = (await loadChannels());
5452
- const remoteChannels2 = [];
5363
+ // v3 新增: 注入"可用渠道"目录, 让 LLM 知道可以 @-mention 哪些 channel
5364
+ // - 本地 channels (除了自己)
5365
+ // - 远端 channels (remoteChannelCache 缓存的)
5366
+ const localChannels = (await loadChannels()).filter(c => c.id !== channelId);
5367
+ const remoteChannels = [];
5453
5368
  for (const [peerPk, list] of remoteChannelCache.entries()) {
5454
5369
  for (const ch of list) {
5455
- remoteChannels2.push({ ...ch, _ownerPublicKey: peerPk });
5370
+ remoteChannels.push({ ...ch, _ownerPublicKey: peerPk });
5456
5371
  }
5457
5372
  }
5458
- // 找出本次 text/fullResponse 里出现 @ 的 channel 对应的 owner pk
5459
- const peerSet = new Set();
5460
- const mentionRe = /@([一-龥A-Za-z0-9_\-]{1,30})/g;
5461
- const mentionedNames = new Set();
5462
- for (const m of text.matchAll(mentionRe))
5463
- mentionedNames.add(m[1]);
5464
- for (const m of fullResponse.matchAll(mentionRe))
5465
- mentionedNames.add(m[1]);
5466
- for (const rc of remoteChannels2) {
5467
- if (mentionedNames.has(rc.name))
5468
- peerSet.add(rc._ownerPublicKey);
5469
- }
5470
- // 追加到每个相关 peer 的月度归档
5471
- for (const pk of peerSet) {
5472
- await appendChatArchive({
5473
- publicKey: pk,
5474
- entry: {
5475
- ts: new Date().toISOString(),
5476
- source: 'local',
5477
- channelId, channelName: (await loadChannels()).find(c => c.id === channelId)?.name,
5478
- text: `[本节点] user: ${text.slice(0, 500)}\n[本节点] ai: ${fullResponse.slice(0, 800)}`,
5479
- fromPublicKey: v3P2PRef?.getPublicKey(),
5480
- msgType: 'user',
5481
- }
5482
- });
5483
- }
5484
- }
5485
- catch (archiveErr) {
5486
- console.warn('[chat-archive] append failed (non-fatal):', archiveErr?.message?.slice(0, 200));
5487
- }
5488
- // 2026-07-05: 懒加载触发器 — 探测到 @-mention 远端 / 连续失败 / 关键词, 拉对方 manifest
5489
- // 把 capability-index 拼到下一次 prompt 上下文 (只生效 1 次, 不污染主循环)
5490
- try {
5491
- const { detectLoadTrigger, loadPeerManifest, clearFailure } = await import('../agents/peer-manifest-loader.js');
5492
- const remoteChannelsForDetect = [];
5493
- for (const [peerPk, list] of remoteChannelCache.entries()) {
5494
- for (const ch of list) {
5495
- remoteChannelsForDetect.push({ ...ch, _ownerPublicKey: peerPk });
5373
+ if (localChannels.length > 0 || remoteChannels.length > 0) {
5374
+ contextHint += '[系统上下文] 可用渠道 (你可以用 @渠道名 消息内容 给它们发消息):\n';
5375
+ for (const c of localChannels) {
5376
+ contextHint += ` - [本地] @${c.name} (id=${c.id})\n`;
5377
+ }
5378
+ for (const c of remoteChannels) {
5379
+ contextHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0, 8)}…] @${c.name} (id=${c.id})\n`;
5380
+ }
5381
+ contextHint += '语法: 当你想给其他渠道发消息, 在回复中写 "@渠道名 我要说的话" 即可. 消息会持久化到目标 channel 的 session, 你之后能看到"自己"在那里说的话.\n';
5382
+ // 2026-06-10 强化: 当用户消息里出现 @渠道名, 默认是请你代为转发, 务必在回复里包含对应的 @ 转发
5383
+ if (remoteChannels.length > 0) {
5384
+ contextHint += '重要: 上面列表里 [远端] 标记的 channel 在另一台机器上, 你可以像 @本地 channel 一样 @ 它们 — 我会通过 P2P 自动把消息送达对方智能体, 对方智能体的回复也会同步回来.\n';
5385
+ contextHint += '当用户在消息里 @ 了某个 (本地或远端) channel, 默认意图是希望你代为转发 — 你应该在回复中写出对应的 "@渠道名 转发内容", 否则用户的请求不会被路由出去.\n\n';
5386
+ }
5387
+ else {
5388
+ contextHint += '\n';
5496
5389
  }
5497
5390
  }
5498
- // 检测 + 加载
5499
- const detection = detectLoadTrigger({
5500
- text,
5501
- channelId,
5502
- remoteChannels: remoteChannelsForDetect,
5503
- });
5504
- if (detection.shouldLoad && detection.remotePublicKey && v3P2PRef) {
5505
- const result = await loadPeerManifest({
5391
+ if (contextHint)
5392
+ contextHint += '\n';
5393
+ // 2026-07-06: 全局 contextHint 硬裁 — MiniMax-M3 context window 8K token ≈ 32K 字符
5394
+ // 如果 contextHint 超过 14K 字符 (留 ~18K 给 userText + LLM 输出 + system), 主动截断
5395
+ const CONTEXT_LIMIT = 14000;
5396
+ let contextTruncated = false;
5397
+ if (contextHint.length > CONTEXT_LIMIT) {
5398
+ const originalLen = contextHint.length;
5399
+ contextHint = contextHint.slice(0, CONTEXT_LIMIT);
5400
+ contextHint += `\n[系统上下文] (...剩余 ${originalLen - CONTEXT_LIMIT} 字符因 context window 限制已截断, 完整内容请用 /judgments 查)\n`;
5401
+ contextTruncated = true;
5402
+ console.warn(`[chat] channel=${channelId} contextHint truncated: ${originalLen} -> ${contextHint.length} chars`);
5403
+ }
5404
+ try {
5405
+ // 2026-06-15: 把 user text 单独 marker 包起来, LLM 不会被 8K+ 的 system context 吞掉
5406
+ // (之前 contextHint + text 拼成一整段当 user role, 24 字符的 user input 埋在 8K+ 里看不出)
5407
+ // 修法: contextHint 当 "背景信息", text 当 "本轮用户请求" — 显式 marker 让 LLM 区分
5408
+ // 2026-06-15 二次修: 把 text 放在最前 (LLM 看到 input 第一眼是 user text, 不会被 judgmentHint 末尾
5409
+ // 的 "..." 误判为整个 input 截断)
5410
+ // 2026-07-05: prepend nextPromptHints (manifest-loader 加载的对方能力, 仅本次生效)
5411
+ let extraHint = '';
5412
+ const hint = nextPromptHints.get(channelId);
5413
+ if (hint) {
5414
+ extraHint = hint + '\n\n';
5415
+ nextPromptHints.delete(channelId);
5416
+ }
5417
+ // 2026-07-15 修 Bug 3: 拖拽附件 — attachmentContext 提到 contextHint 最前, LLM 第一眼看到文件清单
5418
+ const markedPrompt = `${extraHint}【本轮用户请求】\n${text}\n【请求结束】\n\n${attachmentContext}${contextHint}`;
5419
+ // 闭包里 TS 不再保留 null 收窄 ⇒ 先收成局部 const
5420
+ const agentForRun = agent;
5421
+ // **K5 步骤⑦**: 入口只交一个一次性 `ExecutionRequest` (绑定/流/取消都在请求里), Pi 侧 runExecution 统一派发
5422
+ const request = {
5423
+ input: markedPrompt,
5506
5424
  channelId,
5507
- channelName: (await loadChannels()).find(c => c.id === channelId)?.name,
5508
- reason: detection.reason,
5509
- triggerValue: detection.triggerValue,
5510
- remotePublicKey: detection.remotePublicKey,
5511
- }, { p2p: v3P2PRef });
5512
- if (result && result.promptBlock) {
5513
- console.log(`[manifest-loader] ${detection.reason} → ${detection.remotePublicKey.slice(0, 12)}... (${result.durationMs}ms, rpc=${result.rpcTriggered}, agents=${result.agentDescriptions.length})`);
5514
- // 把 promptBlock 推给前端 + 写入一次性的 prompt 附加 (nextPromptHint 全局变量, 在下次 agent.promptStream 前 prepend)
5515
- broadcast({
5516
- type: 'peer-manifest-loaded',
5517
- fromPublicKey: detection.remotePublicKey,
5518
- reason: detection.reason,
5519
- promptBlock: result.promptBlock,
5520
- capabilityIndex: result.capabilityIndex,
5521
- }, channelId);
5522
- // 缓存到 channel 维度的 nextPromptHint (下一次 LLM prompt 前 inject)
5523
- nextPromptHints.set(channelId, (nextPromptHints.get(channelId) || '') + '\n\n' + result.promptBlock);
5524
- clearFailure(channelId);
5425
+ signal: abortSignal,
5426
+ onStream: streamCallback,
5427
+ };
5428
+ fullResponse = await deliverThroughActor(agentForRun, () => agentForRun.runExecution(request));
5429
+ }
5430
+ catch (err) {
5431
+ // abort 抛错: 保留已输出的部分 (fullResponse 可能是空字符串)
5432
+ if (abortSignal?.aborted || err?.name === 'AbortError') {
5433
+ console.log(`[chat] aborted channel=${channelId}`);
5434
+ // 2026-07-06: abort 时 fullResponse 可能为空 (LLM 还没输出), 必须给用户一个反馈
5435
+ if (!fullResponse.trim()) {
5436
+ fullResponse = '⚠️ 生成已中断 (用户取消)';
5437
+ }
5438
+ }
5439
+ else {
5440
+ // M1.2 (2026-06-17): 不再 rethrow — 把错误塞到 fullResponse 让 broadcast 出来, 后续 session 仍保存
5441
+ // 旧行为: 抛到外层 catch, 触发 500 + 用户消息丢失 (session 不保存)
5442
+ console.error(`[chat] LLM 调用失败 channel=${channelId}:`, err);
5443
+ fullResponse = `[错误: LLM 调用失败] ${String(err?.message || err).slice(0, 300)}`;
5444
+ broadcast({ type: 'error', content: fullResponse }, channelId);
5525
5445
  }
5526
5446
  }
5527
- else {
5528
- // 没触发, 也清掉连续失败计数 (用户消息说明对话还在正常进行)
5529
- clearFailure(channelId);
5447
+ // abort 模式: 给 partial 拼后缀 — 只在 LLM 真有输出时加中断标记,
5448
+ // 跳过空响应 + 跳过 [AI 服务调用失败] fallback (这些本身就是错误占位, 拼
5449
+ // "[生成已中断]" 会误导用户)
5450
+ const hasRealLlmOutput = fullResponse.trim().length > 0 &&
5451
+ !fullResponse.trimStart().startsWith('[AI 服务调用失败]');
5452
+ if (abortSignal?.aborted && hasRealLlmOutput) {
5453
+ fullResponse = fullResponse + '\n\n_[生成已中断]_';
5454
+ }
5455
+ // 2026-06-18: 修 lastFinalReply 没设 → /api/loop/inspect 永远返回空
5456
+ runState.lastFinalReply = fullResponse;
5457
+ // 2026-07-06: 防御性兜底 — fullResponse 为空时, 前端 segmentChatReply('') 返回 [],
5458
+ // 导致气泡不渲染, 用户只看到"思考中"占位符. 这里保证永远有内容可渲染.
5459
+ if (!fullResponse.trim()) {
5460
+ fullResponse = '⚠️ AI 未返回内容, 请重试';
5461
+ console.warn(`[chat] fullResponse empty for channel=${channelId}, using fallback`);
5462
+ }
5463
+ // v3 新增: 解析 LLM 回复里的 @-mentions, 转发到目标 channel
5464
+ await routeMentionsInReply(channelId, fullResponse, localChannels, remoteChannels);
5465
+ broadcast({ type: 'ai', content: fullResponse }, channelId);
5466
+ const existingSession = await loadSession(channelId, currentSessionId);
5467
+ const session = existingSession || { channelId, sessionId: currentSessionId, messages: [], lastUpdated: new Date().toISOString() };
5468
+ session.sessionId = currentSessionId;
5469
+ // v3: 加 source 标记 (local = 内部 owner, remote = 远端访客)
5470
+ // 2026-07-15 修 Bug 4: client.ts sendMessage 已经通过 persistLastMessageToServer PATCH /sessions/.../...
5471
+ // 把 user msg 落盘一次 (立即落, 切走再切回不丢). 这里再 push 一次 → session.messages 出现两条相同的 user msg,
5472
+ // loadSession 重渲染时两条都上屏, 表现"每条 user 气泡重复两次".
5473
+ // 修法: 持久化以 client PATCH 为准, /message 这边只 push ai 消息. 同时去重检查上次的 user 避免极端竞态 (并行 PATCH).
5474
+ const lastMsg = session.messages[session.messages.length - 1];
5475
+ const userAlreadyPushed = lastMsg && lastMsg.type === 'user' && lastMsg.content === text;
5476
+ if (!userAlreadyPushed) {
5477
+ session.messages.push({ id: crypto.randomUUID(), type: 'user', content: text, timestamp: new Date().toISOString(), source: 'local' });
5530
5478
  }
5531
- }
5532
- catch (loaderErr) {
5533
- console.warn('[manifest-loader] failed (non-fatal):', loaderErr?.message?.slice(0, 200));
5534
- }
5535
- const channels = await loadChannels();
5536
- const channel = channels.find(c => c.id === channelId);
5537
- // 2026-06-11: 移除 suggestRename 的二次 LLM 调用 — 之前每次用户发消息, 智能体 channel 都会再调一次 LLM (5-8s) 自动改名
5538
- // 影响: (1) /message 端点被拖慢 5-8s (2) LLM 客户端排队, 其他 channel 跟着卡
5539
- // 现在改名逻辑挪到 /api/agent-rename 端点, 用户主动触发才跑
5540
- if (channel) {
5541
- channel.updatedAt = new Date().toISOString();
5542
- // 2026-08-02 fix: 原子写入, 防并发覆盖丢 channel
5543
- await updateChannels((chs) => {
5544
- const c = chs.find(x => x.id === channelId);
5545
- if (c)
5546
- c.updatedAt = channel.updatedAt;
5547
- return chs;
5479
+ session.messages.push({
5480
+ id: crypto.randomUUID(),
5481
+ type: 'ai',
5482
+ content: fullResponse,
5483
+ timestamp: new Date().toISOString(),
5484
+ source: 'local',
5485
+ // P0.5: 这条 AI 回复引用了哪些 judgment (注入门回传)
5486
+ ...(usedJudgmentIds.length > 0 ? { metadata: { usedJudgmentIds } } : {}),
5548
5487
  });
5549
- }
5550
- broadcast({ type: 'done' }, channelId);
5551
- // D 触发: AI 被动捕获判断力 (后台异步, 不阻塞主对话)
5552
- setImmediate(() => {
5488
+ session.lastUpdated = new Date().toISOString();
5489
+ await saveSession(session);
5490
+ // 2026-07-04: 写 session 后, 调 memory-compressor 把消息历史压缩到
5491
+ // ~/.bolloon/memory/<channel.agentId>/sessions/<safe-channel>__<safe-session>.summary.md
5492
+ // 失败静默, 不阻塞 /message 主路径.
5553
5493
  try {
5554
- const lastTurns = session.messages.slice(-6).map((m) => ({
5555
- role: (m.type === 'user' ? 'human' : 'agent'),
5556
- content: m.content,
5557
- }));
5558
- if (lastTurns.length < 2)
5559
- return;
5560
- broadcast({ type: 'phase', phase: 'd_detect', detail: '监测对话...' }, channelId);
5561
- import('../pi-ecosystem-judgment/human-value-pipeline.js')
5562
- .then(async ({ detectAndDistillFromChannel, throttleDHook }) => {
5563
- // channel 维度 5min 节流, 防对话卡顿时 LLM 反复触发
5564
- if (!throttleDHook(channelId, 5 * 60_000)) {
5565
- console.log(`[D-hook ${channelId}] throttled (within 5min)`);
5566
- broadcast({ type: 'phase', phase: 'd_skip', detail: 'throttled' }, channelId);
5567
- return null;
5494
+ const channelsForMemory = await loadChannels();
5495
+ const channelForMemory = channelsForMemory.find(c => c.id === channelId);
5496
+ if (channelForMemory?.agentId) {
5497
+ const { compressSessionToMemory } = await import('../bootstrap/memory-compressor.js');
5498
+ const compressRes = await compressSessionToMemory({
5499
+ agentId: channelForMemory.agentId,
5500
+ channelId,
5501
+ sessionId: currentSessionId,
5502
+ });
5503
+ if (!compressRes.skipped && compressRes.bytesWritten > 0) {
5504
+ console.log(`[memory] compressed ${compressRes.messagesCount} new messages for ${channelForMemory.agentId}/${channelId} → ${compressRes.summaryPath} (${compressRes.bytesWritten}B)`);
5568
5505
  }
5569
- broadcast({ type: 'phase', phase: 'd_distill', detail: '蒸馏判断力...' }, channelId);
5570
- return detectAndDistillFromChannel(lastTurns, { channelId });
5571
- })
5572
- .then((result) => {
5573
- if (result && result.triggered) {
5574
- console.log(`[D-hook ${channelId}] stored: ${result.reason}`, result.evolved);
5575
- broadcast({ type: 'phase', phase: 'd_done', detail: result.reason }, channelId);
5506
+ }
5507
+ }
5508
+ catch (memErr) {
5509
+ console.warn('[memory] compressSessionToMemory failed (non-fatal):', memErr?.message?.slice(0, 200));
5510
+ }
5511
+ // 2026-07-05: 把 session 里的 user/ai 消息按涉及到的远端 peer 归档到月度 markdown
5512
+ // 触发条件: 消息里出现过 @远端 channel, 或者 session 历史上与该 peer 通信过
5513
+ // 失败静默, 不阻塞主对话
5514
+ try {
5515
+ const { appendChatArchive } = await import('../bootstrap/chat-archiver.js');
5516
+ // 找到这条消息涉及到的远端 peer (从 routeMentionsInReply 的结果推断 — 简单起见直接遍历 remoteChannels)
5517
+ const localChannels2 = (await loadChannels());
5518
+ const remoteChannels2 = [];
5519
+ for (const [peerPk, list] of remoteChannelCache.entries()) {
5520
+ for (const ch of list) {
5521
+ remoteChannels2.push({ ...ch, _ownerPublicKey: peerPk });
5576
5522
  }
5577
- else if (result && result.reason) {
5578
- console.log(`[D-hook ${channelId}] skipped: ${result.reason}`);
5579
- broadcast({ type: 'phase', phase: 'd_skip', detail: result.reason }, channelId);
5523
+ }
5524
+ // 找出本次 text/fullResponse 里出现 @ 的 channel 对应的 owner pk
5525
+ const peerSet = new Set();
5526
+ const mentionRe = /@([一-龥A-Za-z0-9_\-]{1,30})/g;
5527
+ const mentionedNames = new Set();
5528
+ for (const m of text.matchAll(mentionRe))
5529
+ mentionedNames.add(m[1]);
5530
+ for (const m of fullResponse.matchAll(mentionRe))
5531
+ mentionedNames.add(m[1]);
5532
+ for (const rc of remoteChannels2) {
5533
+ if (mentionedNames.has(rc.name))
5534
+ peerSet.add(rc._ownerPublicKey);
5535
+ }
5536
+ // 追加到每个相关 peer 的月度归档
5537
+ for (const pk of peerSet) {
5538
+ await appendChatArchive({
5539
+ publicKey: pk,
5540
+ entry: {
5541
+ ts: new Date().toISOString(),
5542
+ source: 'local',
5543
+ channelId, channelName: (await loadChannels()).find(c => c.id === channelId)?.name,
5544
+ text: `[本节点] user: ${text.slice(0, 500)}\n[本节点] ai: ${fullResponse.slice(0, 800)}`,
5545
+ fromPublicKey: v3P2PRef?.getPublicKey(),
5546
+ msgType: 'user',
5547
+ }
5548
+ });
5549
+ }
5550
+ }
5551
+ catch (archiveErr) {
5552
+ console.warn('[chat-archive] append failed (non-fatal):', archiveErr?.message?.slice(0, 200));
5553
+ }
5554
+ // 2026-07-05: 懒加载触发器 — 探测到 @-mention 远端 / 连续失败 / 关键词, 拉对方 manifest
5555
+ // 把 capability-index 拼到下一次 prompt 上下文 (只生效 1 次, 不污染主循环)
5556
+ try {
5557
+ const { detectLoadTrigger, loadPeerManifest, clearFailure } = await import('../agents/peer-manifest-loader.js');
5558
+ const remoteChannelsForDetect = [];
5559
+ for (const [peerPk, list] of remoteChannelCache.entries()) {
5560
+ for (const ch of list) {
5561
+ remoteChannelsForDetect.push({ ...ch, _ownerPublicKey: peerPk });
5580
5562
  }
5581
- })
5582
- .catch((err) => {
5583
- console.warn(`[D-hook ${channelId}] failed:`, err);
5584
- broadcast({ type: 'phase', phase: 'd_error', detail: String(err) }, channelId);
5563
+ }
5564
+ // 检测 + 加载
5565
+ const detection = detectLoadTrigger({
5566
+ text,
5567
+ channelId,
5568
+ remoteChannels: remoteChannelsForDetect,
5585
5569
  });
5570
+ if (detection.shouldLoad && detection.remotePublicKey && v3P2PRef) {
5571
+ const result = await loadPeerManifest({
5572
+ channelId,
5573
+ channelName: (await loadChannels()).find(c => c.id === channelId)?.name,
5574
+ reason: detection.reason,
5575
+ triggerValue: detection.triggerValue,
5576
+ remotePublicKey: detection.remotePublicKey,
5577
+ }, { p2p: v3P2PRef });
5578
+ if (result && result.promptBlock) {
5579
+ console.log(`[manifest-loader] ${detection.reason} → ${detection.remotePublicKey.slice(0, 12)}... (${result.durationMs}ms, rpc=${result.rpcTriggered}, agents=${result.agentDescriptions.length})`);
5580
+ // 把 promptBlock 推给前端 + 写入一次性的 prompt 附加 (nextPromptHint 全局变量, 在下次 agent.promptStream 前 prepend)
5581
+ broadcast({
5582
+ type: 'peer-manifest-loaded',
5583
+ fromPublicKey: detection.remotePublicKey,
5584
+ reason: detection.reason,
5585
+ promptBlock: result.promptBlock,
5586
+ capabilityIndex: result.capabilityIndex,
5587
+ }, channelId);
5588
+ // 缓存到 channel 维度的 nextPromptHint (下一次 LLM prompt 前 inject)
5589
+ nextPromptHints.set(channelId, (nextPromptHints.get(channelId) || '') + '\n\n' + result.promptBlock);
5590
+ clearFailure(channelId);
5591
+ }
5592
+ }
5593
+ else {
5594
+ // 没触发, 也清掉连续失败计数 (用户消息说明对话还在正常进行)
5595
+ clearFailure(channelId);
5596
+ }
5586
5597
  }
5587
- catch (err) {
5588
- console.warn(`[D-hook ${channelId}] sync error:`, err);
5598
+ catch (loaderErr) {
5599
+ console.warn('[manifest-loader] failed (non-fatal):', loaderErr?.message?.slice(0, 200));
5600
+ }
5601
+ const channels = await loadChannels();
5602
+ const channel = channels.find(c => c.id === channelId);
5603
+ // 2026-06-11: 移除 suggestRename 的二次 LLM 调用 — 之前每次用户发消息, 智能体 channel 都会再调一次 LLM (5-8s) 自动改名
5604
+ // 影响: (1) /message 端点被拖慢 5-8s (2) LLM 客户端排队, 其他 channel 跟着卡
5605
+ // 现在改名逻辑挪到 /api/agent-rename 端点, 用户主动触发才跑
5606
+ if (channel) {
5607
+ channel.updatedAt = new Date().toISOString();
5608
+ // 2026-08-02 fix: 原子写入, 防并发覆盖丢 channel
5609
+ await updateChannels((chs) => {
5610
+ const c = chs.find(x => x.id === channelId);
5611
+ if (c)
5612
+ c.updatedAt = channel.updatedAt;
5613
+ return chs;
5614
+ });
5589
5615
  }
5590
- });
5591
- // 2026-06-11: 202 已发的话, 不要重复 res.json (会抛 ERR_HTTP_HEADERS_SENT)
5592
- if (!res.headersSent)
5593
- res.json({ ok: true });
5594
- }
5595
- catch (err) {
5596
- broadcast({ type: 'error', content: err.message }, channelId);
5597
- broadcast({ type: 'done' }, channelId);
5598
- if (!res.headersSent)
5599
- res.status(500).json({ error: err.message });
5600
- }
5601
- finally {
5602
- // 2026-07-04: 清掉 pivot 强制 timeout timer, 避免 hang 情况下 timer 在
5603
- // process 里一直挂着. forceTimeout 在 try 外创建, finally 在闭包里
5604
- // 能直接拿到引用.
5605
- clearTimeout(forceTimeout);
5606
- // queue dequeue: 跑完或失败都要清状态
5607
- runState.running = false;
5608
- runState.abortController = null;
5609
- broadcastQueueUpdate(channelId);
5610
- // 2026-07-15 修 Bug 8: 队列自动 drain — 之前只清状态不抽下一条, 用户连续发的所有
5611
- // 第二轮起全卡在 queue 里出不来, 表现"第二轮没反应".
5612
- // 修复: finally 里查 queue, 非空就异步 fire-and-forget 起下一轮.
5613
- // 实现要点:
5614
- // 1. 用 setImmediate / Promise.resolve() 让 res.headersSent 干净
5615
- // 2. 不能 await (否则阻塞 finally 后面的 saveSession; 而且这就是 fire-and-forget)
5616
- // 3. 重新 build contextHint, 走相同 LLM 路径
5617
- if (runState.queue.length > 0) {
5618
- const next = runState.queue.shift();
5619
- console.log(`[queue-drain] channel=${next.channelId} text="${next.text.slice(0, 30)}" attach=${(next.attachments?.length ?? 0)}`);
5620
- // 异步跑下一条 (fire-and-forget)
5616
+ broadcast({ type: 'done' }, channelId);
5617
+ // D 触发: AI 被动捕获判断力 (后台异步, 不阻塞主对话)
5621
5618
  setImmediate(() => {
5622
- void runMessageFromQueue(next).catch((e) => {
5623
- console.error('[queue-drain] error:', e?.message?.slice(0, 200));
5624
- });
5619
+ try {
5620
+ const lastTurns = session.messages.slice(-6).map((m) => ({
5621
+ role: (m.type === 'user' ? 'human' : 'agent'),
5622
+ content: m.content,
5623
+ }));
5624
+ if (lastTurns.length < 2)
5625
+ return;
5626
+ broadcast({ type: 'phase', phase: 'd_detect', detail: '监测对话...' }, channelId);
5627
+ import('../pi-ecosystem-judgment/human-value-pipeline.js')
5628
+ .then(async ({ detectAndDistillFromChannel, throttleDHook }) => {
5629
+ // channel 维度 5min 节流, 防对话卡顿时 LLM 反复触发
5630
+ if (!throttleDHook(channelId, 5 * 60_000)) {
5631
+ console.log(`[D-hook ${channelId}] throttled (within 5min)`);
5632
+ broadcast({ type: 'phase', phase: 'd_skip', detail: 'throttled' }, channelId);
5633
+ return null;
5634
+ }
5635
+ broadcast({ type: 'phase', phase: 'd_distill', detail: '蒸馏判断力...' }, channelId);
5636
+ return detectAndDistillFromChannel(lastTurns, { channelId });
5637
+ })
5638
+ .then((result) => {
5639
+ if (result && result.triggered) {
5640
+ console.log(`[D-hook ${channelId}] stored: ${result.reason}`, result.evolved);
5641
+ broadcast({ type: 'phase', phase: 'd_done', detail: result.reason }, channelId);
5642
+ }
5643
+ else if (result && result.reason) {
5644
+ console.log(`[D-hook ${channelId}] skipped: ${result.reason}`);
5645
+ broadcast({ type: 'phase', phase: 'd_skip', detail: result.reason }, channelId);
5646
+ }
5647
+ })
5648
+ .catch((err) => {
5649
+ console.warn(`[D-hook ${channelId}] failed:`, err);
5650
+ broadcast({ type: 'phase', phase: 'd_error', detail: String(err) }, channelId);
5651
+ });
5652
+ }
5653
+ catch (err) {
5654
+ console.warn(`[D-hook ${channelId}] sync error:`, err);
5655
+ }
5625
5656
  });
5657
+ // 2026-06-11: 202 已发的话, 不要重复 res.json (会抛 ERR_HTTP_HEADERS_SENT)
5658
+ if (!res.headersSent)
5659
+ res.json({ ok: true });
5626
5660
  }
5627
- // 2026-07-01 (v0.2.5): 持久化当前 messageHistory — 让 web 用户跨刷新保留对话.
5628
- // saveCurrentSession 失败静默, 不阻塞 channel 状态清理.
5629
- // saveCurrentSession 内部走 SessionStore (默认 ~/.bolloon/sessions/cache/<sessionKey>.json).
5630
- // agent 可能在 try 抛错后仍为 null (getAgentForChannel 失败), null-guard 跳过 save.
5631
- if (agent) {
5632
- try {
5633
- await agent.saveCurrentSession(sessionKey);
5661
+ catch (err) {
5662
+ broadcast({ type: 'error', content: err.message }, channelId);
5663
+ broadcast({ type: 'done' }, channelId);
5664
+ if (!res.headersSent)
5665
+ res.status(500).json({ error: err.message });
5666
+ }
5667
+ finally {
5668
+ // 2026-07-04: 清掉 pivot 强制 timeout timer, 避免 hang 情况下 timer 在
5669
+ // process 里一直挂着. forceTimeout 在 try 外创建, finally 在闭包里
5670
+ // 能直接拿到引用.
5671
+ clearTimeout(forceTimeout);
5672
+ finishChannelRun(channelId, runState);
5673
+ // 2026-07-01 (v0.2.5): 持久化当前 messageHistory — 让 web 用户跨刷新保留对话.
5674
+ // saveCurrentSession 失败静默, 不阻塞 channel 状态清理.
5675
+ // saveCurrentSession 内部走 SessionStore (默认 ~/.bolloon/sessions/cache/<sessionKey>.json).
5676
+ // agent 可能在 try 抛错后仍为 null (getAgentForChannel 失败), null-guard 跳过 save.
5677
+ if (agent) {
5678
+ try {
5679
+ await agent.saveCurrentSession(sessionKey);
5680
+ }
5681
+ catch (saveErr) {
5682
+ console.warn(`[web] saveCurrentSession failed (non-fatal): ${saveErr.message?.slice(0, 100)}`);
5683
+ }
5634
5684
  }
5635
- catch (saveErr) {
5636
- console.warn(`[web] saveCurrentSession failed (non-fatal): ${saveErr.message?.slice(0, 100)}`);
5685
+ // 2026-08-02: run-end skill 候选扫描 (fire-and-forget, 不阻塞 finally)
5686
+ // 从本轮 lastSteps 提取连续成功的工具调用模式, 写入 ~/.bolloon/skill-candidates/.
5687
+ // agent 之后可调 list_skill_candidates / promote_skill 决定是否转正.
5688
+ // 2026-08-04: 抽公共函数 writeRunEndSkillCandidates (Web server 与 CLI 共用)
5689
+ try {
5690
+ const steps = runState.lastSteps || [];
5691
+ const okTools = steps.filter(s => s.status === 'ok' && s.name && s.name !== 'system');
5692
+ if (okTools.length >= 2) {
5693
+ setImmediate(async () => {
5694
+ try {
5695
+ const { writeRunEndSkillCandidates } = await import('../agents/skill-writer.js');
5696
+ const r = await writeRunEndSkillCandidates(steps, `channel:${channelId}`);
5697
+ if (r.wrote)
5698
+ console.log(`[skill-candidates] 写入候选 ${r.file} (${r.count} tools)`);
5699
+ }
5700
+ catch (candErr) {
5701
+ console.warn('[skill-candidates] 写入失败 (non-fatal):', candErr?.message?.slice(0, 150));
5702
+ }
5703
+ });
5704
+ }
5637
5705
  }
5706
+ catch { /* 非致命 */ }
5638
5707
  }
5639
- // 2026-08-02: run-end skill 候选扫描 (fire-and-forget, 不阻塞 finally)
5640
- // 从本轮 lastSteps 提取连续成功的工具调用模式, 写入 ~/.bolloon/skill-candidates/.
5641
- // agent 之后可调 list_skill_candidates / promote_skill 决定是否转正.
5642
- // 2026-08-04: 抽公共函数 writeRunEndSkillCandidates (Web server 与 CLI 共用)
5708
+ };
5709
+ // 投进该 channel 的内核邮箱: 同通道 FIFO 串行 / 跨通道并行 (与 didFix 共用同一邮箱, 语义一致)。
5710
+ // 失败必须**响亮** (不许静默吞掉一条没跑的消息)。
5711
+ void getChannelQueue(channelId).submit(runChannelMessage).catch((e) => {
5712
+ console.error('[K8] runChannelMessage 失败:', e?.message?.slice(0, 200));
5643
5713
  try {
5644
- const steps = runState.lastSteps || [];
5645
- const okTools = steps.filter(s => s.status === 'ok' && s.name && s.name !== 'system');
5646
- if (okTools.length >= 2) {
5647
- setImmediate(async () => {
5648
- try {
5649
- const { writeRunEndSkillCandidates } = await import('../agents/skill-writer.js');
5650
- const r = await writeRunEndSkillCandidates(steps, `channel:${channelId}`);
5651
- if (r.wrote)
5652
- console.log(`[skill-candidates] 写入候选 ${r.file} (${r.count} tools)`);
5653
- }
5654
- catch (candErr) {
5655
- console.warn('[skill-candidates] 写入失败 (non-fatal):', candErr?.message?.slice(0, 150));
5656
- }
5657
- });
5658
- }
5714
+ broadcast({ type: 'error', content: `执行失败: ${String(e?.message || e).slice(0, 200)}` }, channelId);
5659
5715
  }
5660
- catch { /* 非致命 */ }
5661
- }
5716
+ catch { /* 广播失败不影响错误已记 */ }
5717
+ try {
5718
+ broadcast({ type: 'done' }, channelId);
5719
+ }
5720
+ catch { /* 同上 */ }
5721
+ });
5662
5722
  });
5663
5723
  // ---------- 频道元数据后台修复队列 ----------
5664
5724
  // 关键点: 旧实现会在每次 GET /channels 时同步执行 KeyManager.generate() + IPFS POST,
5665
5725
  // 多频道场景下持续分配密钥对 + 发起 HTTP 请求, 几轮就会把 Node 内存撑爆。
5666
5726
  // 新实现: 入队 + 节流(2s) + 单飞, 立刻返回当前 channels, 修复异步进行。
5667
- const didFixQueue = new Set(); // 待修复的 channelId
5668
- let didFixRunning = false;
5727
+ const didFixQueue = new Set(); // 待修复的 channelId (调度节流的**入队缓冲**, 执行已交内核邮箱)
5669
5728
  let didFixTimer = null;
5670
5729
  // 2026-08-02: channelRunState 已提升为模块级 (triggerRemoteFollowup 也访问), 这里复用
5671
5730
  function getOrCreateRunState(channelId) {
5672
5731
  let s = channelRunState.get(channelId);
5673
5732
  if (!s) {
5674
- s = { running: false, queue: [], abortController: null, lastSteps: [], lastSummary: '', lastFinalReply: '', lastTokens: {} };
5733
+ s = { running: false, lastSteps: [], lastSummary: '', lastFinalReply: '', lastTokens: {} };
5675
5734
  channelRunState.set(channelId, s);
5676
5735
  }
5677
5736
  return s;
5678
5737
  }
5679
- /** 抽离 attachment contextHint 出来 — 给 queue-drain 用; /message 主路径有 inline 等价版避免重复 hoist 错误 */
5680
- function buildAttachmentContextForQueue(parsedAttachments) {
5681
- if (!parsedAttachments || parsedAttachments.length === 0)
5682
- return '';
5683
- return `[系统上下文] 用户上传了 ${parsedAttachments.length} 个附件: ` +
5684
- parsedAttachments.map((a) => `${a.filename || a.attachmentId} (id=${a.attachmentId}, mime=${a.mimeType || '?'}, size=${a.size ?? '?'}B, URL=/api/attachments/${a.attachmentId})`).join('; ') +
5685
- `\n你可以调用文件读工具 (curl /api/attachments/<id>) 拉取真实内容。\n\n`;
5686
- }
5738
+ // K8 正刀 (2026-10-02): `runMessageFromQueue` (排在队尾执行的**简化版**第二条路径) 与它专用的
5739
+ // `buildAttachmentContextForQueue` 一并删除 —— 每条消息现在都走 `runChannelMessage` 这**一条**完整路径,
5740
+ // 通道自带的 queue 与异步 handoff 都不复存在 (旧 handoff 会静默丢消息, 见先行门 ③)。
5687
5741
  /**
5688
- * 2026-07-15 修 Bug 8: 队列消息的执行器 — finally 排空 queue 时调这里
5689
- * 跑下一条 queued 消息.
5742
+ * 一轮跑完的收尾 (2026-10-02 K8 → K8 正刀)。
5690
5743
  *
5691
- * 设计: 这是个 fire-and-forget wrapper, 复用 /message 主路径的 broadcast / save / etc.
5692
- * 简化路径 (跟主路径 500 行 try 块相比):
5693
- * - 不重新建载 judgment hint / persona / context — server.ts 这次明确把这些容
5694
- * 易"廉价放"在 /message 主路径, queue 路径只用基本标识
5695
- * - 仍然是合法: agent.promptStream → broadcast(type:user) → broadcast(type:ai) → done
5696
- * - 处理 attachments: 跟主路径一样
5744
+ * **它现在只做三件事**: 清"在飞"标志 · 清 abort 句柄 · 广播状态。
5745
+ * **不再排空队列** —— 正刀后每条消息一进来就投该 channel 的**内核邮箱**
5746
+ * (`getChannelQueue(channelId).submit(runChannelMessage)`), 同通道 FIFO 串行由内核保证 (与 K5 同一条语义),
5747
+ * 通道自己不再兼任调度器, 也不再有"同步置 `running = false` + 异步 handoff"这一步
5748
+ * (正是那一步会让排队消息被静默丢掉 —— 见 `src/test/k8-mailbox-scheduling.test.ts` ③ 的机制复刻)。
5697
5749
  *
5698
- * 如果要 1:1 复刻主路径的所有 hooks (judgment hint / persona / manifest / etc),
5699
- * 后续可以把 /message 主路径的 try 块抽成 runPromptChannel 共享.
5750
+ * 顺带删掉的: `runMessageFromQueue` (排在队尾执行的**简化版第二条路径**, 没有 judgment hint / persona /
5751
+ * slash 命令 / 附件上下文) 与它专用的 `buildAttachmentContextForQueue` —— 现在只有**一条**完整路径。
5700
5752
  */
5701
- async function runMessageFromQueue(queued) {
5702
- const { channelId, text, attachments, channelDid: reqChannelDid, boundWalletAddress, autoToolsEnabled } = queued;
5703
- const runState = getOrCreateRunState(channelId);
5704
- if (runState.running)
5705
- return; // 防重入 — queue 已经并发去重
5706
- runState.running = true;
5707
- runState.abortController = new AbortController();
5708
- const currentSessionId = (await loadChannels()).find(c => c.id === channelId)?.currentSessionId || 'default';
5709
- const realChannelDid = reqChannelDid || (await loadChannels()).find(c => c.id === channelId)?.did || '';
5710
- const parsedAttachments = Array.isArray(attachments) ? attachments : [];
5711
- const attachmentContext = buildAttachmentContextForQueue(parsedAttachments);
5712
- const sessionKey = `${channelId}:${currentSessionId}`;
5713
- // 防 LLM hang 安全网
5714
- const PIVOT_FORCE_TIMEOUT_MS = 5 * 60 * 1000;
5715
- const forceTimeout = setTimeout(() => {
5716
- console.warn(`[server] queue-drain pivot 强制 timeout, aborting`);
5717
- runState.abortController?.abort();
5718
- }, PIVOT_FORCE_TIMEOUT_MS);
5719
- let agent = null;
5720
- try {
5721
- // 1) broadcast user 给前端 (跟主路径一致)
5722
- broadcast({ type: 'user', content: text }, channelId);
5723
- // 2) 取 agent + session
5724
- agent = await getAgentForChannel(channelId, currentSessionId).catch(() => null);
5725
- if (!agent) {
5726
- throw new Error(`No agent for channel=${channelId}`);
5727
- }
5728
- // 3) 重 build contextHint (基本版, 不全 500 行 hook)
5729
- // 原因: queue-drain 是常见调试路径, 全量 hooks 性能大. 关键是 attachments 带上.
5730
- const contextHint = attachmentContext + `[系统上下文] 队列消息 (auto-drain)\n`;
5731
- // 4) promptStream
5732
- const markedPrompt = `【本轮用户请求】\n${text}\n【请求结束】\n\n${contextHint}`;
5733
- const fullResponse = await agent.promptStream(markedPrompt, () => { }, runState.abortController?.signal, channelId);
5734
- if (!fullResponse.trim()) {
5735
- broadcast({ type: 'error', content: '⚠️ AI 未返回内容' }, channelId);
5736
- }
5737
- else {
5738
- broadcast({ type: 'ai', content: fullResponse }, channelId);
5739
- // 落 session
5740
- try {
5741
- const existing = await loadSession(channelId, currentSessionId);
5742
- const session = existing || { channelId, sessionId: currentSessionId, messages: [], lastUpdated: new Date().toISOString() };
5743
- session.sessionId = currentSessionId;
5744
- // 跟主路径相同: 不重复 push user (主路径已 broadcast/push), 只 push ai
5745
- session.messages.push({
5746
- id: crypto.randomUUID(),
5747
- type: 'ai',
5748
- content: fullResponse,
5749
- timestamp: new Date().toISOString(),
5750
- source: 'local',
5751
- });
5752
- session.lastUpdated = new Date().toISOString();
5753
- await saveSession(session);
5754
- }
5755
- catch (e) {
5756
- console.warn('[queue-drain] saveSession failed:', e?.message?.slice(0, 100));
5757
- }
5758
- }
5759
- broadcast({ type: 'done' }, channelId);
5760
- }
5761
- catch (err) {
5762
- console.warn('[queue-drain] failed:', err?.message?.slice(0, 200));
5763
- broadcast({ type: 'error', content: 'queue-drain: ' + (err?.message || 'failed') }, channelId);
5764
- }
5765
- finally {
5766
- clearTimeout(forceTimeout);
5767
- runState.running = false;
5768
- runState.abortController = null;
5769
- broadcastQueueUpdate(channelId);
5770
- // 递归 drain — 同 /message 主路径 finally 行为保持一致
5771
- if (runState.queue.length > 0) {
5772
- const next = runState.queue.shift();
5773
- console.log(`[queue-drain-recursive] channel=${next.channelId} text="${next.text.slice(0, 30)}"`);
5774
- setImmediate(() => {
5775
- void runMessageFromQueue(next).catch((e) => {
5776
- console.error('[queue-drain-recursive] error:', e?.message?.slice(0, 200));
5777
- });
5778
- });
5779
- }
5780
- if (agent) {
5781
- try {
5782
- await agent.saveCurrentSession(sessionKey);
5783
- }
5784
- catch { }
5785
- }
5786
- }
5753
+ function finishChannelRun(channelId, runState) {
5754
+ // K8 正刀 (2026-10-02): **这里不再排空队列** —— 每条消息自己进内核邮箱, 通道不持有队列,
5755
+ // 也就不存在"同步置 running=false + 异步 handoff"那一步 (那一步会静默丢消息, 见先行门 ③)。
5756
+ runState.running = false;
5757
+ // K8 收尾: 取消位不在这里复位 —— 它属于 actor, 下一次 beginCancellation() 自然换代
5758
+ broadcastQueueUpdate(channelId);
5787
5759
  }
5788
5760
  function broadcastQueueUpdate(channelId) {
5789
5761
  const s = channelRunState.get(channelId);
5790
- const queueLength = s ? s.queue.length : 0;
5791
5762
  const running = s ? s.running : false;
5763
+ // K8 正刀 (2026-10-02): 队列长度改读**内核邮箱** (通道不再自己数) —— pending 含正在跑的那条, 减掉它才是"等待中"。
5764
+ const pending = getChannelQueue(channelId).pending;
5765
+ const queueLength = Math.max(0, pending - (running ? 1 : 0));
5792
5766
  try {
5793
5767
  broadcast({ type: 'queue_update', channelId, queueLength, running }, channelId);
5794
5768
  }
@@ -5803,25 +5777,28 @@ fetchState();
5803
5777
  void runDidFixOnce();
5804
5778
  }, 2000);
5805
5779
  }
5780
+ /**
5781
+ * K8 收口 (2026-10-02): 待办**不再由通道自己单飞**。
5782
+ *
5783
+ * 旧写法 = `didFixRunning` 全局单飞 + while 循环自己取件 ⇒ "各通道自己的调度状态"。
5784
+ * 新写法 = 执行投进该 channel 的**内核邮箱** (`getChannelQueue`), 由 `SerialMailbox` 串行:
5785
+ * · 同 channel 的修复**串行** (不会两个 repairs 同时 load/save 同一份 channels)
5786
+ * · 跨 channel 可并行 (K5 既定语义: 同通道串行 / 跨通道并行)
5787
+ * 2s 节流 + 入队 Set 保留 (那是**调度策略**, 不是执行机制)。
5788
+ *
5789
+ * ⚠️ 行为差量 (如实记): 旧实现全局一次一个 repair; 新实现同通道串行、跨通道可并行。
5790
+ */
5806
5791
  async function runDidFixOnce() {
5807
- if (didFixRunning)
5808
- return;
5809
- didFixRunning = true;
5810
- try {
5811
- while (didFixQueue.size > 0) {
5812
- const id = didFixQueue.values().next().value;
5813
- didFixQueue.delete(id);
5814
- try {
5815
- await fixOneChannelDID(id);
5816
- }
5817
- catch (e) {
5818
- console.log(`[DID 修复] ${id} 失败: ${e.message}`);
5819
- }
5792
+ const ids = [...didFixQueue];
5793
+ didFixQueue.clear();
5794
+ await Promise.all(ids.map((id) => getChannelQueue(id).submit(async () => {
5795
+ try {
5796
+ await fixOneChannelDID(id);
5820
5797
  }
5821
- }
5822
- finally {
5823
- didFixRunning = false;
5824
- }
5798
+ catch (e) {
5799
+ console.log(`[DID 修复] ${id} 失败: ${e.message}`);
5800
+ }
5801
+ })));
5825
5802
  }
5826
5803
  async function fixOneChannelDID(channelId) {
5827
5804
  const channels = await loadChannels();
@@ -6932,7 +6909,8 @@ fetchState();
6932
6909
  // 2026-06-15: 同 /message 路径, 用显式 marker 包裹 userMessage, 避免 LLM 把它当背景信息
6933
6910
  const regenHint = await buildJudgmentHint(channel, channelId);
6934
6911
  const markedRegen = `${regenHint}\n\n【本轮用户请求】\n${userMessage}\n【请求结束】\n`;
6935
- fullResponse = await agent.promptStream(markedRegen, streamCallback, undefined, channelId);
6912
+ // 2026-10-02 (K8): 同上 (零差量)
6913
+ fullResponse = await deliverThroughActor(agent, () => requireRunExecution(agent)({ input: markedRegen, onStream: streamCallback, channelId }));
6936
6914
  // 2026-07-06: 防御性兜底 — 同 /message 路径
6937
6915
  if (!fullResponse.trim()) {
6938
6916
  fullResponse = '⚠️ AI 未返回内容, 请重试';
@@ -8011,10 +7989,10 @@ fetchState();
8011
7989
  const { channelId } = req.body;
8012
7990
  if (!channelId)
8013
7991
  return res.status(400).json({ error: 'channelId required' });
8014
- const s = channelRunState.get(channelId);
8015
- if (s?.abortController) {
8016
- s.abortController.abort();
8017
- console.log(`[abort] user aborted channel=${channelId}`);
7992
+ // K8 收尾: 中止经内核 actor 注册表 (按 channel 扫描; 返回**真的中止了几个**)
7993
+ const aborted = abortActorsOfChannel(channelId, 'user abort');
7994
+ if (aborted > 0) {
7995
+ console.log(`[abort] user aborted channel=${channelId} (${aborted} 个 actor)`);
8018
7996
  // 2026-08-02 fix: abort 后立即广播 done — 之前前端靠 1.5s 兜底 setTimeout 切回 idle,
8019
7997
  // 视觉上"点了没反应". 这里主动推 done, 前端 finalizeTimelineAsMessage + setSendMode('idle') 立刻生效.
8020
7998
  try {