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