@bolloon/bolloon-agent 0.1.32 → 0.1.34

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 (37) hide show
  1. package/README.md +7 -2
  2. package/dist/agents/pi-sdk.js +10 -1
  3. package/dist/bollharness-integration/index.js +8 -1
  4. package/dist/heartbeat/Watchdog.js +9 -1
  5. package/dist/llm/audio-config-store.js +199 -0
  6. package/dist/llm/config-store.js +20 -10
  7. package/dist/llm/pi-ai.js +2 -2
  8. package/dist/llm/video-config-store.js +31 -1
  9. package/dist/network/p2p-direct.js +59 -2
  10. package/dist/pi-ecosystem/index.js +10 -7
  11. package/dist/pi-ecosystem-judgment/decision.js +5 -2
  12. package/dist/social/heartbeat.js +19 -2
  13. package/dist/web/api-config.html +16 -4
  14. package/dist/web/client.js +1017 -137
  15. package/dist/web/index.html +10 -27
  16. package/dist/web/server.js +865 -52
  17. package/dist/web/style.css +370 -0
  18. package/package.json +2 -1
  19. package/src/agents/pi-sdk.ts +9 -1
  20. package/src/bollharness-integration/index.ts +8 -32
  21. package/src/heartbeat/Watchdog.ts +9 -1
  22. package/src/llm/audio-config-store.ts +6 -1
  23. package/src/llm/config-store.ts +21 -11
  24. package/src/llm/pi-ai.ts +2 -2
  25. package/src/llm/video-config-store.ts +7 -1
  26. package/src/network/p2p-direct.ts +59 -3
  27. package/src/social/ant-colony/index.js +19 -0
  28. package/src/social/heartbeat.ts +18 -2
  29. package/src/web/api-config.html +16 -4
  30. package/src/web/client.js +1017 -137
  31. package/src/web/index.html +10 -27
  32. package/src/web/server.ts +810 -47
  33. package/src/web/style.css +370 -0
  34. package/src/social/ant-colony/AdaptiveHeartbeat.ts +0 -131
  35. package/src/social/ant-colony/PheromoneEngine.ts +0 -302
  36. package/src/social/ant-colony/index.ts +0 -18
  37. package/src/social/ant-colony/types.ts +0 -94
@@ -11,6 +11,7 @@ import { initMinimax, getMinimax } from '../constraints/index.js';
11
11
  import { createAgentSession } from '../agents/pi-sdk.js';
12
12
  import { llmConfigStore } from '../llm/config-store.js';
13
13
  import { videoConfigStore } from '../llm/video-config-store.js';
14
+ import { audioConfigStore } from '../llm/audio-config-store.js';
14
15
  import { irohTransport } from '../network/iroh-transport.js';
15
16
  import { createAgentDelegateApp } from './agent-delegate-server.js';
16
17
  import { createIrohDelegateTransport } from './iroh-delegate-transport.js';
@@ -280,8 +281,54 @@ let sseClients = new Set();
280
281
  // v3: 远端 channel UI 元数据缓存 — key: peerId, value: sanitize 过的 channel 列表
281
282
  // in-memory only, 进程重启清空 (judgment 内容永远不在这里)
282
283
  let remoteChannelCache = new Map();
284
+ // 2026-06-10: 持久化 remote channel cache 到 ~/.bolloon/remote-channels-cache.json
285
+ // 之前是纯内存 Map, nodeA 重启后所有对端 channel 列表丢失, 需要等对面再推一次
286
+ const REMOTE_CACHE_FILE = `${process.env.HOME || '/tmp'}/.bolloon/remote-channels-cache.json`;
287
+ async function loadRemoteChannelCacheFromDisk() {
288
+ try {
289
+ const { readFile, mkdir } = await import('fs/promises');
290
+ const { existsSync } = await import('fs');
291
+ if (!existsSync(REMOTE_CACHE_FILE))
292
+ return;
293
+ const raw = await readFile(REMOTE_CACHE_FILE, 'utf-8');
294
+ const obj = JSON.parse(raw);
295
+ if (obj && typeof obj === 'object') {
296
+ for (const [pk, list] of Object.entries(obj)) {
297
+ if (Array.isArray(list)) {
298
+ remoteChannelCache.set(pk, list);
299
+ }
300
+ }
301
+ console.log(`[v3-meta] 从磁盘恢复 ${remoteChannelCache.size} 个 peer 的 channel cache`);
302
+ }
303
+ }
304
+ catch (err) {
305
+ console.warn('[v3-meta] 恢复 remote channel cache 失败 (非致命):', err.message);
306
+ }
307
+ }
308
+ async function persistRemoteChannelCache() {
309
+ try {
310
+ const { writeFile, mkdir } = await import('fs/promises');
311
+ const { existsSync } = await import('fs');
312
+ if (!existsSync(`${process.env.HOME || '/tmp'}/.bolloon`)) {
313
+ await mkdir(`${process.env.HOME || '/tmp'}/.bolloon`, { recursive: true });
314
+ }
315
+ const obj = {};
316
+ for (const [pk, list] of remoteChannelCache.entries()) {
317
+ obj[pk] = list;
318
+ }
319
+ await writeFile(REMOTE_CACHE_FILE, JSON.stringify(obj, null, 2), 'utf-8');
320
+ }
321
+ catch (err) {
322
+ console.warn('[v3-meta] 持久化 remote channel cache 失败 (非致命):', err.message);
323
+ }
324
+ }
325
+ // 启动时立即同步读一次 (异步, 不阻塞)
326
+ loadRemoteChannelCacheFromDisk();
283
327
  // v3: P2PDirect 引用 (Hyperswarm 薄包装) - 模块级, 因为 web server 闭包里不可用
284
328
  let v3P2PRef = null;
329
+ // 2026-06-10: watchdog 提升到 module-level, 让 broadcast() / 模块级业务函数能埋点喂活动
330
+ // 之前在 createWebServer 闭包内, 闭包外的 broadcast() 拿不到 → 误判 30min 无活动 → 自杀.
331
+ let watchdogRef = null;
285
332
  // v3: 等待中的 history RPC (B 端 chat-history endpoint 用) — rpcId → { resolve, reject }
286
333
  const v3PendingHistoryGets = new Map();
287
334
  let channelSessions = new Map(); // key: channelId
@@ -317,7 +364,6 @@ function sanitizeChannelForPeer(ch, peerPublicKey) {
317
364
  createdAt: ch.createdAt,
318
365
  updatedAt: ch.updatedAt,
319
366
  hasWallet: !!ch.walletAddress,
320
- boundJudgmentCount: Array.isArray(ch.bound_judgment_ids) ? ch.bound_judgment_ids.length : 0,
321
367
  share_id: ch.share_id,
322
368
  // 🔒 不返回: bound_judgment_ids, walletAddress, walletBinding, autoInvokeTools, sessions, shared_with_peers
323
369
  };
@@ -327,6 +373,114 @@ function isSharedWith(ch, peerPublicKey) {
327
373
  const shared = Array.isArray(ch.shared_with_peers) ? ch.shared_with_peers : [];
328
374
  return shared.includes(peerPublicKey);
329
375
  }
376
+ /**
377
+ * v3 新增: 解析 LLM 回复里的 @-mentions, 把消息发到目标 channel.
378
+ *
379
+ * 语法: "@渠道名 消息内容" — 渠道名匹配 local channels by name, 或 remote channels by name.
380
+ * - 本地 channel: 直接 push 到 session
381
+ * - 远端 channel: 通过 P2P RPC 转发到对端
382
+ *
383
+ * 返回: 解析到的 mention 列表, 供 SSE 广播
384
+ */
385
+ async function routeMentionsInReply(originChannelId, replyText, localChannels, remoteChannels) {
386
+ const results = [];
387
+ // 解析: 匹配 @渠道名 后面跟一段文字 (到下一个 @ 或 行尾)
388
+ // 渠道名: 中文/英文/数字/下划线/连字符, 1-30 字符
389
+ const regex = /@([一-龥A-Za-z0-9_\-]{1,30})\s+([^\n@]+?)(?=(?:\s*@[一-龥A-Za-z0-9_\-]{1,30}\s)|$)/g;
390
+ const matches = [...replyText.matchAll(regex)];
391
+ if (matches.length === 0)
392
+ return results;
393
+ // 找当前 channel 的 name (用于日志)
394
+ let originChannelName = originChannelId;
395
+ try {
396
+ const chs = await loadChannels();
397
+ const oc = chs.find(c => c.id === originChannelId);
398
+ if (oc)
399
+ originChannelName = oc.name;
400
+ }
401
+ catch { }
402
+ console.log(`[v3-cross] (${originChannelName}) 解析到 ${matches.length} 个 @-mention`);
403
+ for (const m of matches) {
404
+ const targetName = m[1].trim();
405
+ const text = m[2].trim();
406
+ if (!text)
407
+ continue;
408
+ // 优先本地 (本地 channel 不能有 ownerPublicKey)
409
+ const localTarget = localChannels.find(c => c.name === targetName);
410
+ const remoteTarget = !localTarget ? remoteChannels.find(c => c.name === targetName) : null;
411
+ if (localTarget) {
412
+ // 本地: 直接 push 到 session
413
+ try {
414
+ const existing = await loadSession(localTarget.id, 'default');
415
+ const session = existing || {
416
+ channelId: localTarget.id, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString()
417
+ };
418
+ session.messages.push({
419
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
420
+ type: 'ai',
421
+ content: text,
422
+ timestamp: new Date().toISOString(),
423
+ source: 'ai-mention', // v3: 标记是其他 channel 的 AI @-mention 进来的
424
+ originChannelId, // 谁 @ 过来的
425
+ originChannelName // 渠道名 (方便显示)
426
+ });
427
+ session.lastUpdated = new Date().toISOString();
428
+ await saveSession(session);
429
+ console.log(`[v3-cross] (${originChannelName}) @${targetName} → 本地 channel ${localTarget.id}, 存了 ${text.length} chars`);
430
+ // 推 SSE 让本地 UI 知道有 AI 跨渠道消息
431
+ broadcast({
432
+ type: 'cross-mention-received',
433
+ originChannelId, originChannelName,
434
+ targetChannelId: localTarget.id, targetChannelName: localTarget.name,
435
+ text, source: 'ai-mention'
436
+ }, 'broadcast');
437
+ results.push({ targetName, targetId: localTarget.id, source: 'local', text, status: 'sent' });
438
+ }
439
+ catch (err) {
440
+ console.error(`[v3-cross] @${targetName} 本地存失败:`, err.message);
441
+ results.push({ targetName, targetId: localTarget.id, source: 'local', text, status: 'failed' });
442
+ }
443
+ }
444
+ else if (remoteTarget) {
445
+ // 远端: 通过 P2P RPC 转发
446
+ const ownerPk = remoteTarget._ownerPublicKey;
447
+ if (!v3P2PRef) {
448
+ console.warn(`[v3-cross] P2PDirect 未启动, 跳过远端 @${targetName}`);
449
+ results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'failed' });
450
+ continue;
451
+ }
452
+ try {
453
+ const rpc = JSON.stringify({
454
+ v: 3, op: 'agent.cross.post',
455
+ payload: {
456
+ targetChannelId: remoteTarget.id,
457
+ targetChannelName: remoteTarget.name,
458
+ originChannelId,
459
+ originChannelName,
460
+ text,
461
+ fromPublicKey: v3P2PRef.getPublicKey()
462
+ }
463
+ });
464
+ const ok = v3P2PRef.sendTo(ownerPk, rpc);
465
+ if (ok) {
466
+ console.log(`[v3-cross] (${originChannelName}) @${targetName} → 远端 peer ${ownerPk.substring(0, 12)}... (channelId=${remoteTarget.id})`);
467
+ results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'sent' });
468
+ }
469
+ else {
470
+ results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'failed' });
471
+ }
472
+ }
473
+ catch (err) {
474
+ console.error(`[v3-cross] @${targetName} 远端 RPC 失败:`, err.message);
475
+ results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'failed' });
476
+ }
477
+ }
478
+ else {
479
+ console.warn(`[v3-cross] @${targetName} 找不到匹配 channel (本地 ${localChannels.length} 个, 远端 ${remoteChannels.length} 个)`);
480
+ }
481
+ }
482
+ return results;
483
+ }
330
484
  /**
331
485
  * v3: 处理 Hyperswarm 通道收到的 v3 RPC 消息
332
486
  * 设计: 用 HyperswarmCommunicator (DHT topic 自动发现) 取代 iroh 直接 connect
@@ -436,15 +590,25 @@ async function handleV3P2PMessage(parsed, conn, comm) {
436
590
  id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
437
591
  type: 'user',
438
592
  content: text,
439
- timestamp: new Date().toISOString()
593
+ timestamp: new Date().toISOString(),
594
+ source: 'remote', // v3: 标记远端访客
595
+ fromPublicKey: senderKey // v3: 记录对方 publicKey
440
596
  });
441
597
  session.lastUpdated = new Date().toISOString();
442
598
  await saveSession(session);
443
- console.log(`[v3] (${channelId}) 存 user 消息 (${text.length} chars) 到 A 的 session`);
599
+ console.log(`[v3] (${channelId}) 存 user 消息 (${text.length} chars) 到 A 的 session (来自 ${senderKey.substring(0, 12)}...)`);
444
600
  }
445
601
  catch (saveErr) {
446
602
  console.warn(`[v3] 存 user 消息失败 (不影响 chat):`, saveErr.message);
447
603
  }
604
+ // v3 修复: 同步给 A 自己的 UI — broadcast SSE 事件让 A 的 owner 实时看到 B 的消息
605
+ broadcast({
606
+ type: 'user',
607
+ content: text,
608
+ channelId,
609
+ source: 'remote',
610
+ fromPublicKey: senderKey
611
+ }, channelId);
448
612
  // v3 新增: 告诉 B "我开始想了, 用了哪些 judgment" — 让 B 看到决策依据
449
613
  const judgmentHint = await buildJudgmentHint(ch, channelId);
450
614
  const usedJudgments = await extractJudgmentsFromHint(ch);
@@ -466,7 +630,30 @@ async function handleV3P2PMessage(parsed, conn, comm) {
466
630
  // 2. 跑 LLM (复用 Phase 1 的 buildJudgmentHint — 注入 channel 的 judgment)
467
631
  const { getMinimax } = await import('../constraints/index.js');
468
632
  const llm = getMinimax();
469
- const fullPrompt = `${judgmentHint}${text}`;
633
+ // v3 新增: 在 prompt 头部标记"这是远端访客", 让 AI 知道对方不是自己 owner
634
+ const visitorHint = `[系统上下文] 消息来源: 远端访客 (P2P 连接, publicKey=${senderKey.substring(0, 12)}...). 对方不是你 owner, 是通过 P2P 网络访问你这个 channel 的合作者. 称呼对方时可用 "远端访客" / "朋友" / "合作者", 不要叫 "主人".\n\n`;
635
+ // v3 新增: 也注入 channel 目录给 LLM (B 的 channel 也可以 @-mention 其他)
636
+ let dirHint = '';
637
+ const localChannels = (await loadChannels()).filter(c => c.id !== channelId);
638
+ const remoteChannels = [];
639
+ for (const [peerPk, list] of remoteChannelCache.entries()) {
640
+ if (peerPk === senderKey)
641
+ continue; // 跳过发起方
642
+ for (const ch of list) {
643
+ remoteChannels.push({ ...ch, _ownerPublicKey: peerPk });
644
+ }
645
+ }
646
+ if (localChannels.length > 0 || remoteChannels.length > 0) {
647
+ dirHint += '[系统上下文] 可用渠道 (你可以用 @渠道名 消息内容 给它们发消息):\n';
648
+ for (const c of localChannels) {
649
+ dirHint += ` - [本地] @${c.name} (id=${c.id})\n`;
650
+ }
651
+ for (const c of remoteChannels) {
652
+ dirHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0, 8)}…] @${c.name} (id=${c.id})\n`;
653
+ }
654
+ dirHint += '语法: 在回复中写 "@渠道名 我要说的话" 即可. 消息会持久化到目标 channel 的 session.\n\n';
655
+ }
656
+ const fullPrompt = `${visitorHint}${dirHint}${judgmentHint}${text}`;
470
657
  let fullResponse = '';
471
658
  // v3 新增: 流式 token 节流推给 B — 让 B 看到过程
472
659
  let lastFlushAt = 0;
@@ -504,6 +691,12 @@ async function handleV3P2PMessage(parsed, conn, comm) {
504
691
  catch (saveErr) {
505
692
  console.warn(`[v3] 存 assistant 消息失败 (不影响):`, saveErr.message);
506
693
  }
694
+ // v3 修复: 同步给 A 自己的 UI — broadcast AI 回复给 A 的 owner 实时看到
695
+ broadcast({
696
+ type: 'ai',
697
+ content: fullResponse,
698
+ channelId
699
+ }, channelId);
507
700
  // 3. 把完整回复发给 B
508
701
  const reply = JSON.stringify({
509
702
  v: 3, op: 'agent.chat.reply',
@@ -589,6 +782,53 @@ async function handleV3P2PMessage(parsed, conn, comm) {
589
782
  }
590
783
  return;
591
784
  }
785
+ // v3 新增: 收到远端发来的 @-mention 跨渠道消息, 存到本地 target channel
786
+ if (op === 'agent.cross.post') {
787
+ const { targetChannelId, targetChannelName, originChannelId, originChannelName, text, fromPublicKey } = parsed.payload || {};
788
+ if (!targetChannelId || !text) {
789
+ console.warn(`[v3-cross] agent.cross.post 缺少 targetChannelId/text`);
790
+ return;
791
+ }
792
+ try {
793
+ // 找 channel — 必须存在于本节点
794
+ const channels = await loadChannels();
795
+ const ch = channels.find(c => c.id === targetChannelId);
796
+ if (!ch) {
797
+ console.warn(`[v3-cross] agent.cross.post: 本节点无 channel ${targetChannelId}, 忽略`);
798
+ return;
799
+ }
800
+ // 存到 session — 这是一条来自其他节点的 LLM @-mention
801
+ const existing = await loadSession(targetChannelId, 'default');
802
+ const session = existing || {
803
+ channelId: targetChannelId, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString()
804
+ };
805
+ session.messages.push({
806
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
807
+ type: 'ai',
808
+ content: text,
809
+ timestamp: new Date().toISOString(),
810
+ source: 'ai-mention-remote', // v3: 来自其他节点的 AI @-mention
811
+ originChannelId, // 哪个 channel 触发的
812
+ originChannelName,
813
+ fromPublicKey // 哪个节点来的
814
+ });
815
+ session.lastUpdated = new Date().toISOString();
816
+ await saveSession(session);
817
+ console.log(`[v3-cross] 收到远端 @-mention: ${originChannelName} → 本地 ${targetChannelName} (${text.length} chars)`);
818
+ // 推 SSE 让本地 UI 知道有跨渠道消息到达
819
+ broadcast({
820
+ type: 'cross-mention-received',
821
+ originChannelId, originChannelName,
822
+ targetChannelId, targetChannelName: ch.name,
823
+ text, source: 'ai-mention-remote',
824
+ fromPublicKey
825
+ }, 'broadcast');
826
+ }
827
+ catch (err) {
828
+ console.error(`[v3-cross] 处理 agent.cross.post 失败:`, err.message);
829
+ }
830
+ return;
831
+ }
592
832
  console.log(`[v3] 收到未知 op: ${op}`);
593
833
  }
594
834
  async function buildJudgmentHint(channel, channelIdForLog) {
@@ -740,6 +980,50 @@ export async function createWebServer(port = 3000, options = {}) {
740
980
  keypair: null
741
981
  };
742
982
  let p2pCommunicator = null;
983
+ // v3: 定期 broadcast — 每个 peer 只收到分享给他的 channel (按 peer 个性化)
984
+ // 走 known_peers (持久化) + sendTo (自动 joinPeer 重连), 不只 conns
985
+ // 定义在此处 (所有 try 外部), 确保 route handlers 也能访问
986
+ const v3BroadcastOwn = async () => {
987
+ if (!v3P2PRef)
988
+ return { sent: 0, total: 0 };
989
+ const channels = await loadChannels();
990
+ const { listPeers } = await import('../network/known-peers.js');
991
+ const peers = await listPeers();
992
+ const myPk = v3P2PRef.getPublicKey();
993
+ // 2026-06-10: 本机名字一起携带, 对端能直接显示 + 落到自己的 known_peers
994
+ let myName = process.env.BOLLOON_USER_NAME || process.env.USER || 'node';
995
+ try {
996
+ const { readFileSync, existsSync } = await import('fs');
997
+ const cfgPath = `${process.env.HOME || '/tmp'}/.bolloon/config.json`;
998
+ if (existsSync(cfgPath)) {
999
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
1000
+ if (cfg.userName)
1001
+ myName = cfg.userName;
1002
+ }
1003
+ }
1004
+ catch { }
1005
+ let sent = 0;
1006
+ for (const peer of peers) {
1007
+ if (peer.publicKey === myPk)
1008
+ continue;
1009
+ const sharedForPeer = channels
1010
+ .map(ch => sanitizeChannelForPeer(ch, peer.publicKey))
1011
+ .filter((x) => x !== null);
1012
+ if (sharedForPeer.length > 0) {
1013
+ const msg = JSON.stringify({
1014
+ v: 3, op: 'agent.meta.list.reply',
1015
+ payload: { channels: sharedForPeer, name: myName, fromPublicKey: myPk }
1016
+ });
1017
+ const ok = v3P2PRef.sendTo(peer.publicKey, msg);
1018
+ if (ok) {
1019
+ sent++;
1020
+ console.log(`[v3] broadcast: ${peer.name || peer.publicKey.substring(0, 8)} → ${sharedForPeer.length} 个 channel`);
1021
+ }
1022
+ }
1023
+ }
1024
+ console.log(`[v3] broadcast 完成: sent=${sent}/${peers.length} 个 peer`);
1025
+ return { sent, total: peers.length };
1026
+ };
743
1027
  try {
744
1028
  console.log('开始生成 P2P 身份...');
745
1029
  // 生成 DIAP 身份
@@ -836,6 +1120,82 @@ export async function createWebServer(port = 3000, options = {}) {
836
1120
  return Promise.resolve();
837
1121
  }
838
1122
  };
1123
+ // v3 新增: 好友申请 RPC — 任何对端可以发, 推到前端 UI 让用户接受
1124
+ if (parsed.op === 'agent.friend.request') {
1125
+ console.log(`[v3-friend] 收到 ${evt.fromPublicKey.substring(0, 12)}... 的好友申请: ${parsed.payload?.name || '(无名字)'}`);
1126
+ broadcast({
1127
+ type: 'friend-request',
1128
+ fromPublicKey: evt.fromPublicKey,
1129
+ fromName: parsed.payload?.name || ('peer-' + evt.fromPublicKey.substring(0, 8)),
1130
+ message: parsed.payload?.message || '想加你为 P2P 好友',
1131
+ requestId: parsed.payload?.requestId, // 2026-06-10: 透传 requestId 给前端
1132
+ timestamp: Date.now()
1133
+ }, 'p2p-global');
1134
+ // 2026-06-10 新增: 立刻发 ack 回给发送方, 让发送方 UI 知道"对方收到了"
1135
+ try {
1136
+ const ackRpc = JSON.stringify({
1137
+ v: 3,
1138
+ op: 'agent.friend.request.ack',
1139
+ payload: {
1140
+ requestId: parsed.payload?.requestId,
1141
+ receivedBy: v3P2PRef?.getPublicKey(),
1142
+ timestamp: Date.now()
1143
+ }
1144
+ });
1145
+ v3P2PRef?.sendTo(evt.fromPublicKey, ackRpc);
1146
+ }
1147
+ catch (err) {
1148
+ console.warn('[v3-friend] 发 ack 失败 (不阻塞):', err.message);
1149
+ }
1150
+ return;
1151
+ }
1152
+ // 2026-06-10 新增: 发送方收到对方 ack → SSE 推前端, 显示"对方已收到"
1153
+ if (parsed.op === 'agent.friend.request.ack') {
1154
+ console.log(`[v3-friend] 收到 ack: requestId=${(parsed.payload?.requestId || '').substring(0, 8)} 来自 ${evt.fromPublicKey.substring(0, 12)}...`);
1155
+ broadcast({
1156
+ type: 'friend-request-ack',
1157
+ requestId: parsed.payload?.requestId,
1158
+ receivedBy: parsed.payload?.receivedBy,
1159
+ timestamp: Date.now()
1160
+ }, 'p2p-global');
1161
+ return;
1162
+ }
1163
+ // v3 修复: agent.meta.list.reply 也走 v3P2PRef.on('data') (因为 handleV3P2PMessage 只走老通道)
1164
+ if (parsed.op === 'agent.meta.list.reply') {
1165
+ const list = parsed.payload?.channels || [];
1166
+ remoteChannelCache.set(evt.fromPublicKey, list);
1167
+ // 2026-06-10: 持久化到 ~/.bolloon/remote-channels-cache.json, 重启后不丢
1168
+ persistRemoteChannelCache();
1169
+ // 2026-06-10: 接收侧记录对方名字 (来自 list.reply payload.name), 落 known_peers
1170
+ const senderName = parsed.payload?.name;
1171
+ if (senderName && typeof senderName === 'string') {
1172
+ import('../network/known-peers.js').then(({ addOrUpdatePeer }) => addOrUpdatePeer(senderName, evt.fromPublicKey)).catch(err => console.warn('[v3] 记录对端名字失败:', err.message));
1173
+ }
1174
+ console.log(`[v3] 收到 ${evt.fromPublicKey.substring(0, 12)}... 的 ${list.length} 个 channel, 已缓存 (sender=${senderName || '?'})`);
1175
+ broadcast({
1176
+ type: 'remote-channel-update',
1177
+ peerId: evt.fromPublicKey,
1178
+ peerName: senderName, // 2026-06-10: 一并带名字到 UI
1179
+ channels: list
1180
+ }, 'p2p-global');
1181
+ return;
1182
+ }
1183
+ // 2026-06-10: 收到对方请求本机的 channel 列表 (启动时主动发请求, 加速 cache 填充)
1184
+ if (parsed.op === 'agent.meta.list.request') {
1185
+ console.log(`[v3-meta] 收到 ${evt.fromPublicKey.substring(0, 12)}... 的 channel 列表请求 → 立刻回包`);
1186
+ // 不能 await (在 on('data') sync 回调里), 改用 .then 异步处理
1187
+ loadChannels().then(channels => {
1188
+ const sharedForPeer = channels
1189
+ .map(ch => sanitizeChannelForPeer(ch, evt.fromPublicKey))
1190
+ .filter((x) => x !== null);
1191
+ const msg = JSON.stringify({
1192
+ v: 3, op: 'agent.meta.list.reply',
1193
+ payload: { channels: sharedForPeer }
1194
+ });
1195
+ v3P2PRef.sendTo(evt.fromPublicKey, msg);
1196
+ }).catch(err => console.warn('[v3-meta] 回应 channel 列表失败:', err.message));
1197
+ return;
1198
+ }
839
1199
  handleV3P2PMessage(parsed, { id: evt.fromPublicKey, publicKey: evt.fromPublicKey }, commShim);
840
1200
  }
841
1201
  }
@@ -845,6 +1205,8 @@ export async function createWebServer(port = 3000, options = {}) {
845
1205
  });
846
1206
  // 新连接进来 → 主动发我分享给 ta 的 channel 列表
847
1207
  v3P2PRef.on('connection', (evt) => {
1208
+ // 2026-06-10: 喂 watchdog —— 新连接到来是真实业务活动
1209
+ watchdogRef?.recordActivity?.();
848
1210
  setTimeout(async () => {
849
1211
  try {
850
1212
  const channels = await loadChannels();
@@ -885,45 +1247,50 @@ export async function createWebServer(port = 3000, options = {}) {
885
1247
  }
886
1248
  // 触发一次 broadcast 推送给所有重连的 peer
887
1249
  setTimeout(() => v3BroadcastOwn(), 2000);
1250
+ // 2026-06-10: 同时主动请求每个 known peer 把 ta 的 channel 列表推过来
1251
+ // 避免对面 publicKey 没变但 cache 丢了(本机重启) → 一直空
1252
+ setTimeout(() => requestChannelsFromAllPeers(), 3500);
888
1253
  }
889
1254
  catch (err) {
890
1255
  console.error('[v3] 自动重连失败:', err.message);
891
1256
  }
892
1257
  }, 5000); // 5s 后再重连, 让 swarm 充分 bootstrap
1258
+ // 2026-06-10 新增: 主动向所有 known peer 发起 channel 列表请求
1259
+ async function requestChannelsFromAllPeers() {
1260
+ if (!v3P2PRef)
1261
+ return;
1262
+ try {
1263
+ const { listPeers } = await import('../network/known-peers.js');
1264
+ const peers = await listPeers();
1265
+ const myPk = v3P2PRef.getPublicKey();
1266
+ const req = JSON.stringify({ v: 3, op: 'agent.meta.list.request', payload: { fromPublicKey: myPk } });
1267
+ let sent = 0;
1268
+ for (const peer of peers) {
1269
+ if (peer.publicKey === myPk)
1270
+ continue;
1271
+ // 用 sendToWithWait, 等 conn 就绪再发 (同 Step 5 sendToWithWait 修复)
1272
+ const r = await v3P2PRef.sendToWithWait(peer.publicKey, req, 3000);
1273
+ if (r === 'SENT')
1274
+ sent++;
1275
+ }
1276
+ console.log(`[v3-meta] requestChannelsFromAllPeers → sent=${sent}/${peers.length - 1}`);
1277
+ }
1278
+ catch (err) {
1279
+ console.warn('[v3-meta] requestChannelsFromAllPeers failed:', err.message);
1280
+ }
1281
+ }
1282
+ // 立即跑一次 + 每 30s 兜底 (跟 v3BroadcastOwn 一样的节奏)
1283
+ setTimeout(requestChannelsFromAllPeers, 4000);
1284
+ setInterval(requestChannelsFromAllPeers, 30000);
893
1285
  }
894
1286
  catch (err) {
895
1287
  console.error('[v3] P2PDirect 启动失败:', err.message);
896
1288
  v3P2PRef = null;
897
1289
  }
898
- // v3: 定期 broadcast 每个 peer 只收到分享给他的 channel (按 peer 个性化)
899
- const v3BroadcastOwn = () => {
900
- if (!v3P2PRef)
901
- return;
902
- loadChannels().then(channels => {
903
- const conns = v3P2PRef.conns;
904
- if (!conns)
905
- return;
906
- for (const [peerPk, conn] of conns.entries()) {
907
- if (conn?.destroyed)
908
- continue;
909
- const sharedForPeer = channels
910
- .map(ch => sanitizeChannelForPeer(ch, peerPk))
911
- .filter((x) => x !== null);
912
- if (sharedForPeer.length > 0) {
913
- const msg = JSON.stringify({ v: 3, op: 'agent.meta.list.reply', payload: { channels: sharedForPeer } });
914
- try {
915
- conn.write(Buffer.from(msg));
916
- }
917
- catch { }
918
- }
919
- }
920
- console.log(`[v3] broadcast 个性化: ${conns.size} 个 peer, 各自收到分享的 channel`);
921
- }).catch(err => console.error('[v3] broadcast 失败:', err.message));
922
- };
1290
+ // 首次广播: swarm bootstrap 完成后推一次
923
1291
  setTimeout(v3BroadcastOwn, 3000);
924
- setTimeout(v3BroadcastOwn, 10000);
925
- setTimeout(v3BroadcastOwn, 20000);
926
- setTimeout(v3BroadcastOwn, 40000);
1292
+ // v3 修复: 用 setInterval 替代一次性 setTimeout, 确保分享变更后能持续推送给 peer
1293
+ setInterval(v3BroadcastOwn, 30000);
927
1294
  // 保留 @diap/sdk 的旧实例 (它的 Hyperswarm 实例能帮 P2PDirect 做 DHT bootstrap)
928
1295
  try {
929
1296
  const rawSeed = crypto.getRandomValues(new Uint8Array(32));
@@ -995,7 +1362,10 @@ export async function createWebServer(port = 3000, options = {}) {
995
1362
  const channelId = req.query.channelId;
996
1363
  res.setHeader('Content-Type', 'text/event-stream');
997
1364
  res.setHeader('Cache-Control', 'no-cache');
998
- res.setHeader('Connection', 'keep-alive');
1365
+ // 2026-06-11: 改 keep-alive → close
1366
+ // 原因: SSE 长连接占着 keep-alive 槽 (HTTP/1.1 + 浏览器 max 6 并发), 后续同源 fetch 排队 30s+
1367
+ // 设 close 让浏览器把 SSE 当长期流, 不抢占普通请求的 keep-alive 槽
1368
+ res.setHeader('Connection', 'close');
999
1369
  // 反向代理 (nginx/cloudflair) 需要: 禁用缓冲 + 立即 flush
1000
1370
  res.setHeader('X-Accel-Buffering', 'no');
1001
1371
  res.flushHeaders();
@@ -1027,6 +1397,13 @@ export async function createWebServer(port = 3000, options = {}) {
1027
1397
  const realChannelName = channel?.name || '';
1028
1398
  const realChannelDidDoc = channel?.didDocRef;
1029
1399
  broadcast({ type: 'user', content: text }, channelId);
1400
+ // 2026-06-11: /message 端点立即返回 202, LLM 后续处理挪到 setImmediate 后台跑
1401
+ // 之前 res.json 在 try 块末尾 (line 1815), 需要等 LLM (5-15s) + 落盘 + suggestRename (5-8s) = 13s+
1402
+ // 客户端 fetch 占用 13s, 视觉像"卡死", 切其他 channel 也感觉"无法加载"
1403
+ // 修法: 立即 res.json(202), try 块主体仍跑 (LLM 流 + 落盘) 但不阻塞 HTTP 响应
1404
+ // 关键: res.json 之后不能再调用 res.json (会抛 ERR_HTTP_HEADERS_SENT), 所以 try 块末尾的 res.json 必须用 res.headersSent 守卫
1405
+ res.status(202).json({ ok: true, async: true, channelId, sessionId: currentSessionId });
1406
+ console.log(`[v3-async] /message 立即返回 202, channel=${channelId}, text length=${text.length}`);
1030
1407
  // 提前捕获 wallet/autoTools 到本地变量, 避免下面 try 块内的 inner const channel
1031
1408
  // (line ~638) 与这里外层的 const channel 形成 shadowing 让 TS 误报"使用前未声明"
1032
1409
  const boundWalletAddress = channel?.walletAddress;
@@ -1059,6 +1436,8 @@ export async function createWebServer(port = 3000, options = {}) {
1059
1436
  let contextHint = '';
1060
1437
  if (realChannelDid)
1061
1438
  contextHint += `[系统上下文] 当前频道名称: ${realChannelName}, 你的真实 DID: ${realChannelDid}\n`;
1439
+ // v3 新增: 标识发送方 — 让 AI 分清内部 owner vs 远端访客
1440
+ contextHint += `[系统上下文] 消息来源: 本地 (channel 内部 owner / 此机器上的用户). 称呼对方时用 "你" 或 "主人" 即可.\n`;
1062
1441
  if (boundWalletAddress) {
1063
1442
  contextHint += `[系统上下文] 已绑定的加密钱包地址: ${boundWalletAddress}。当用户授权或启用自动工具调用时, 可使用该地址发起链上操作。\n`;
1064
1443
  }
@@ -1073,38 +1452,168 @@ export async function createWebServer(port = 3000, options = {}) {
1073
1452
  const judgmentHint = await buildJudgmentHint(channelForJudgment, channelId);
1074
1453
  if (judgmentHint)
1075
1454
  contextHint += judgmentHint;
1455
+ // 2026-06-10: 注入 skills 列表 (本机 ~/.bolloon/skills/ 下所有 skills)
1456
+ // 让 LLM 知道有哪些 skill 可用, 在回复中提示用户
1457
+ try {
1458
+ const { loadSkillsFromPaths, defaultSkillPaths, describeSkill } = await import('../agents/skill-loader.js');
1459
+ const paths = defaultSkillPaths();
1460
+ const skills = await loadSkillsFromPaths(paths);
1461
+ if (skills.length > 0) {
1462
+ contextHint += `[系统上下文] 本机已加载的 skills (${skills.length} 个, 你可以提示用户主动调用):\n`;
1463
+ for (const s of skills.slice(0, 20)) { // 上限 20 避免 prompt 过长
1464
+ const desc = (s.description || '').slice(0, 80);
1465
+ contextHint += ` - /${s.name}${desc ? ' — ' + desc : ''}\n`;
1466
+ }
1467
+ contextHint += '调用语法: 用户说 "/技能名 ..." 或 你回复时建议 "/技能名 ..." 让用户主动触发.\n\n';
1468
+ }
1469
+ }
1470
+ catch (err) {
1471
+ // 静默失败 — skills 不是核心, 加载失败不阻塞
1472
+ }
1473
+ // 2026-06-10: 注入 human values 摘要 (最常用的 judgment / 价值偏好)
1474
+ // 与 judgment 不同: values 是更宏观的"用户偏好", judgment 是针对具体决策的约束
1475
+ try {
1476
+ const { loadAllJudgments } = await import('../pi-ecosystem-judgment/human-value-store.js');
1477
+ const allJudgments = await loadAllJudgments().catch(() => []);
1478
+ // 把所有 judgment 视作软参考 (跟 buildJudgmentHint 的 candidates 同理)
1479
+ if (Array.isArray(allJudgments) && allJudgments.length > 0) {
1480
+ contextHint += `[系统上下文] 用户的核心价值倾向 (来自 ${allJudgments.length} 条历史 judgment, 软参考, 体现而非复述):\n`;
1481
+ for (const j of allJudgments.slice(0, 8)) {
1482
+ const decision = (j.decision || '').slice(0, 80);
1483
+ contextHint += ` - ${decision}\n`;
1484
+ }
1485
+ contextHint += '\n';
1486
+ }
1487
+ }
1488
+ catch (err) {
1489
+ // 静默失败
1490
+ }
1491
+ // 2026-06-10: 注入 documents 列表 (本机 documents/ 目录的文档元数据)
1492
+ // 让 LLM 知道有文档存在, 用户可主动要求读
1493
+ try {
1494
+ const { documentStore } = await import('../documents/store.js');
1495
+ const docs = await documentStore.getReceivedDocuments(50).catch(() => []);
1496
+ if (Array.isArray(docs) && docs.length > 0) {
1497
+ contextHint += `[系统上下文] 本机 documents (${docs.length} 篇, 用户可让你读):\n`;
1498
+ for (const d of docs.slice(0, 10)) {
1499
+ const name = d.fileName || d.id || '(未命名)';
1500
+ const size = d.fileSize ? ` (${Math.round(d.fileSize / 1024)}KB)` : '';
1501
+ const sender = d.fromNodeId ? ` [来自 ${d.fromNodeIdShort || d.fromNodeId.substring(0, 8)}…]` : '';
1502
+ contextHint += ` - ${name}${size}${sender}\n`;
1503
+ }
1504
+ contextHint += '用户提到某文档时, 你可以调用读文档工具读取并总结.\n\n';
1505
+ }
1506
+ }
1507
+ catch (err) {
1508
+ // 静默失败
1509
+ }
1510
+ // 2026-06-11: 注入此 channel 专属的 persona + 关联文档 (从 channel 字段读, LLM 长期记忆)
1511
+ const chPersona = channelForJudgment?.persona;
1512
+ if (chPersona && typeof chPersona === 'object') {
1513
+ contextHint += '[系统上下文] 此 channel 的人设 (你是这个角色):\n';
1514
+ if (chPersona.name)
1515
+ contextHint += ` 名字: ${chPersona.name}\n`;
1516
+ if (chPersona.description)
1517
+ contextHint += ` 描述: ${chPersona.description}\n`;
1518
+ if (chPersona.personality)
1519
+ contextHint += ` 性格: ${chPersona.personality}\n`;
1520
+ if (chPersona.greeting)
1521
+ contextHint += ` 问候: ${chPersona.greeting}\n`;
1522
+ if (Array.isArray(chPersona.capabilities) && chPersona.capabilities.length > 0) {
1523
+ contextHint += ` 能力: ${chPersona.capabilities.join('、')}\n`;
1524
+ }
1525
+ if (Array.isArray(chPersona.interests) && chPersona.interests.length > 0) {
1526
+ contextHint += ` 兴趣: ${chPersona.interests.join('、')}\n`;
1527
+ }
1528
+ contextHint += '回复时应自然体现这个角色 (不要硬搬原文, 像这个角色说话即可).\n\n';
1529
+ }
1530
+ const linkedIds = channelForJudgment?.linkedDocumentIds;
1531
+ if (Array.isArray(linkedIds) && linkedIds.length > 0) {
1532
+ try {
1533
+ const { documentStore } = await import('../documents/store.js');
1534
+ contextHint += `[系统上下文] 此 channel 关联了 ${linkedIds.length} 篇文档 (已自动加载内容, 你应基于它们回答):\n`;
1535
+ let loaded = 0;
1536
+ for (const docId of linkedIds.slice(0, 10)) {
1537
+ const doc = await documentStore.readDocument(docId).catch(() => null);
1538
+ if (!doc)
1539
+ continue;
1540
+ const name = doc.metadata?.fileName || docId;
1541
+ const content = (doc.content || '').slice(0, 1500); // 单篇 1.5KB 上限, 总 prompt 防爆
1542
+ contextHint += `\n--- 文档: ${name} ---\n${content}\n--- 文档结束 ---\n`;
1543
+ loaded++;
1544
+ }
1545
+ if (loaded === 0) {
1546
+ contextHint += `(但加载失败, 文档可能已被删除)\n\n`;
1547
+ }
1548
+ else {
1549
+ contextHint += '\n';
1550
+ }
1551
+ }
1552
+ catch (err) {
1553
+ console.warn('[v3-persona] 加载关联文档失败 (非致命):', err.message);
1554
+ }
1555
+ }
1556
+ // v3 新增: 注入"可用渠道"目录, 让 LLM 知道可以 @-mention 哪些 channel
1557
+ // - 本地 channels (除了自己)
1558
+ // - 远端 channels (remoteChannelCache 缓存的)
1559
+ const localChannels = (await loadChannels()).filter(c => c.id !== channelId);
1560
+ const remoteChannels = [];
1561
+ for (const [peerPk, list] of remoteChannelCache.entries()) {
1562
+ for (const ch of list) {
1563
+ remoteChannels.push({ ...ch, _ownerPublicKey: peerPk });
1564
+ }
1565
+ }
1566
+ if (localChannels.length > 0 || remoteChannels.length > 0) {
1567
+ contextHint += '[系统上下文] 可用渠道 (你可以用 @渠道名 消息内容 给它们发消息):\n';
1568
+ for (const c of localChannels) {
1569
+ contextHint += ` - [本地] @${c.name} (id=${c.id})\n`;
1570
+ }
1571
+ for (const c of remoteChannels) {
1572
+ contextHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0, 8)}…] @${c.name} (id=${c.id})\n`;
1573
+ }
1574
+ contextHint += '语法: 当你想给其他渠道发消息, 在回复中写 "@渠道名 我要说的话" 即可. 消息会持久化到目标 channel 的 session, 你之后能看到"自己"在那里说的话.\n';
1575
+ // 2026-06-10 强化: 当用户消息里出现 @渠道名, 默认是请你代为转发, 务必在回复里包含对应的 @ 转发
1576
+ if (remoteChannels.length > 0) {
1577
+ contextHint += '重要: 上面列表里 [远端] 标记的 channel 在另一台机器上, 你可以像 @本地 channel 一样 @ 它们 — 我会通过 P2P 自动把消息送达对方智能体, 对方智能体的回复也会同步回来.\n';
1578
+ contextHint += '当用户在消息里 @ 了某个 (本地或远端) channel, 默认意图是希望你代为转发 — 你应该在回复中写出对应的 "@渠道名 转发内容", 否则用户的请求不会被路由出去.\n\n';
1579
+ }
1580
+ else {
1581
+ contextHint += '\n';
1582
+ }
1583
+ }
1076
1584
  if (contextHint)
1077
1585
  contextHint += '\n';
1078
1586
  fullResponse = await agent.promptStream(contextHint + text, streamCallback);
1587
+ // v3 新增: 解析 LLM 回复里的 @-mentions, 转发到目标 channel
1588
+ await routeMentionsInReply(channelId, fullResponse, localChannels, remoteChannels);
1079
1589
  broadcast({ type: 'ai', content: fullResponse }, channelId);
1080
1590
  const existingSession = await loadSession(channelId, currentSessionId);
1081
1591
  const session = existingSession || { channelId, sessionId: currentSessionId, messages: [], lastUpdated: new Date().toISOString() };
1082
1592
  session.sessionId = currentSessionId;
1083
- session.messages.push({ id: crypto.randomUUID(), type: 'user', content: text, timestamp: new Date().toISOString() });
1084
- session.messages.push({ id: crypto.randomUUID(), type: 'ai', content: fullResponse, timestamp: new Date().toISOString() });
1593
+ // v3: 加 source 标记 (local = 内部 owner, remote = 远端访客)
1594
+ session.messages.push({ id: crypto.randomUUID(), type: 'user', content: text, timestamp: new Date().toISOString(), source: 'local' });
1595
+ session.messages.push({ id: crypto.randomUUID(), type: 'ai', content: fullResponse, timestamp: new Date().toISOString(), source: 'local' });
1085
1596
  session.lastUpdated = new Date().toISOString();
1086
1597
  await saveSession(session);
1087
1598
  const channels = await loadChannels();
1088
1599
  const channel = channels.find(c => c.id === channelId);
1089
- if (channel && channel.name === '智能体') {
1090
- const renameSuggestion = await agent.suggestRename(session.messages);
1091
- if (renameSuggestion) {
1092
- channel.name = renameSuggestion;
1093
- await saveChannels(channels);
1094
- broadcast({ type: 'renamed', channelId, newName: renameSuggestion }, channelId);
1095
- }
1096
- }
1600
+ // 2026-06-11: 移除 suggestRename 的二次 LLM 调用 — 之前每次用户发消息, 智能体 channel 都会再调一次 LLM (5-8s) 自动改名
1601
+ // 影响: (1) /message 端点被拖慢 5-8s (2) LLM 客户端排队, 其他 channel 跟着卡
1602
+ // 现在改名逻辑挪到 /api/agent-rename 端点, 用户主动触发才跑
1097
1603
  if (channel) {
1098
1604
  channel.updatedAt = new Date().toISOString();
1099
1605
  await saveChannels(channels);
1100
1606
  }
1101
1607
  broadcast({ type: 'done' }, channelId);
1102
- res.json({ ok: true });
1608
+ // 2026-06-11: 202 已发的话, 不要重复 res.json (会抛 ERR_HTTP_HEADERS_SENT)
1609
+ if (!res.headersSent)
1610
+ res.json({ ok: true });
1103
1611
  }
1104
1612
  catch (err) {
1105
1613
  broadcast({ type: 'error', content: err.message }, channelId);
1106
1614
  broadcast({ type: 'done' }, channelId);
1107
- res.status(500).json({ error: err.message });
1615
+ if (!res.headersSent)
1616
+ res.status(500).json({ error: err.message });
1108
1617
  }
1109
1618
  });
1110
1619
  // ---------- 频道元数据后台修复队列 ----------
@@ -1233,8 +1742,21 @@ export async function createWebServer(port = 3000, options = {}) {
1233
1742
  app.get('/api/remote-channels', async (_req, res) => {
1234
1743
  try {
1235
1744
  const out = [];
1745
+ // 2026-06-11: 合并 known_peers + cache, 避免 cache 空时 UI 一个 peer 都看不到
1746
+ // (cache 是纯内存, 重启即丢; known_peers 持久化, 至少能让 UI 显示"这些 peer 我认识")
1747
+ const { listPeers } = await import('../network/known-peers.js');
1748
+ const knownPeers = await listPeers();
1749
+ const knownByPk = new Map();
1750
+ for (const p of knownPeers)
1751
+ knownByPk.set(p.publicKey, { name: p.name });
1236
1752
  for (const [peerId, list] of remoteChannelCache.entries()) {
1237
- out.push({ peerId, channels: list });
1753
+ out.push({ peerId, channels: list, peerName: knownByPk.get(peerId)?.name });
1754
+ }
1755
+ // known_peers 里但 cache 没的, 占位推进 out (channels=[]) 让 UI 能渲染 peer header
1756
+ for (const [peerId, info] of knownByPk.entries()) {
1757
+ if (!remoteChannelCache.has(peerId)) {
1758
+ out.push({ peerId, channels: [], peerName: info.name });
1759
+ }
1238
1760
  }
1239
1761
  res.json({ count: out.length, peers: out });
1240
1762
  }
@@ -1242,6 +1764,27 @@ export async function createWebServer(port = 3000, options = {}) {
1242
1764
  res.status(500).json({ error: err.message });
1243
1765
  }
1244
1766
  });
1767
+ // v3 测试专用: 直接注入远端频道缓存 (绕过 P2P)
1768
+ // 仅当 NODE_ENV=test 时可用
1769
+ app.post('/api/test/inject-remote-channel', async (req, res) => {
1770
+ if (process.env.NODE_ENV !== 'test') {
1771
+ return res.status(403).json({ error: 'only available in test mode' });
1772
+ }
1773
+ try {
1774
+ const { peerPublicKey, channel } = req.body || {};
1775
+ if (!peerPublicKey || !channel) {
1776
+ return res.status(400).json({ error: 'peerPublicKey and channel required' });
1777
+ }
1778
+ const list = remoteChannelCache.get(peerPublicKey) || [];
1779
+ list.push(channel);
1780
+ remoteChannelCache.set(peerPublicKey, list);
1781
+ broadcast({ type: 'remote-channel-update', peerId: peerPublicKey, channels: list }, 'p2p-global');
1782
+ res.json({ ok: true, count: list.length });
1783
+ }
1784
+ catch (err) {
1785
+ res.status(500).json({ error: err.message });
1786
+ }
1787
+ });
1245
1788
  // v3: 主动向所有已连接 P2P peer 拉 channel 列表
1246
1789
  // 用法: B 端用户点 "刷新远端智能体" → 触发本 endpoint
1247
1790
  app.post('/api/remote-channels/refresh', async (_req, res) => {
@@ -1315,7 +1858,7 @@ export async function createWebServer(port = 3000, options = {}) {
1315
1858
  });
1316
1859
  app.post('/channels', async (req, res) => {
1317
1860
  try {
1318
- const { name, agentId, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
1861
+ const { name, agentId, walletAddress, autoInvokeTools, bound_judgment_ids, personaOverride, linkedDocumentIds } = req.body;
1319
1862
  console.log(`[创建频道] 收到请求: name=${name}, agentId=${agentId}, wallet=${walletAddress ? 'yes' : 'no'}, boundJudgments=${Array.isArray(bound_judgment_ids) ? bound_judgment_ids.length : 0}`);
1320
1863
  if (!name || !agentId) {
1321
1864
  return res.status(400).json({ error: 'name and agentId required' });
@@ -1328,6 +1871,42 @@ export async function createWebServer(port = 3000, options = {}) {
1328
1871
  const safeBoundIds = Array.isArray(bound_judgment_ids)
1329
1872
  ? bound_judgment_ids.filter((x) => typeof x === 'string' && x.length > 0)
1330
1873
  : [];
1874
+ // 2026-06-11: persona 加载 — 优先用 personaOverride, 否则从 ~/.bolloon/persona.json 读全局默认
1875
+ let channelPersona;
1876
+ if (personaOverride && typeof personaOverride === 'object') {
1877
+ channelPersona = {
1878
+ name: personaOverride.name,
1879
+ description: personaOverride.description,
1880
+ personality: personaOverride.personality,
1881
+ greeting: personaOverride.greeting,
1882
+ capabilities: Array.isArray(personaOverride.capabilities) ? personaOverride.capabilities.slice(0, 20) : undefined,
1883
+ interests: Array.isArray(personaOverride.interests) ? personaOverride.interests.slice(0, 20) : undefined,
1884
+ };
1885
+ }
1886
+ else {
1887
+ try {
1888
+ const { readFileSync, existsSync } = await import('fs');
1889
+ const personaPath = `${process.env.HOME || '/tmp'}/.bolloon/persona.json`;
1890
+ if (existsSync(personaPath)) {
1891
+ const p = JSON.parse(readFileSync(personaPath, 'utf-8'));
1892
+ channelPersona = {
1893
+ name: p.name,
1894
+ description: p.description,
1895
+ personality: p.personality,
1896
+ greeting: p.greeting,
1897
+ capabilities: Array.isArray(p.capabilities) ? p.capabilities.slice(0, 20) : undefined,
1898
+ interests: Array.isArray(p.interests) ? p.interests.slice(0, 20) : undefined,
1899
+ };
1900
+ }
1901
+ }
1902
+ catch (err) {
1903
+ console.warn('[创建频道] 加载 persona.json 失败 (非致命):', err.message);
1904
+ }
1905
+ }
1906
+ // 过滤 linkedDocumentIds: 只保留 string
1907
+ const safeLinkedDocIds = Array.isArray(linkedDocumentIds)
1908
+ ? linkedDocumentIds.filter((x) => typeof x === 'string' && x.length > 0).slice(0, 50)
1909
+ : [];
1331
1910
  // 先创建频道(不阻塞等待 DID 生成)
1332
1911
  const channel = {
1333
1912
  id,
@@ -1340,6 +1919,8 @@ export async function createWebServer(port = 3000, options = {}) {
1340
1919
  walletRegisteredAt: validWallet ? new Date().toISOString() : undefined,
1341
1920
  autoInvokeTools: autoInvokeTools !== false, // 默认 true
1342
1921
  bound_judgment_ids: safeBoundIds,
1922
+ persona: channelPersona,
1923
+ linkedDocumentIds: safeLinkedDocIds,
1343
1924
  sessions: [{
1344
1925
  id: `sess_${Date.now()}`,
1345
1926
  createdAt: new Date().toISOString(),
@@ -1498,7 +2079,7 @@ export async function createWebServer(port = 3000, options = {}) {
1498
2079
  app.patch('/channels/:channelId', async (req, res) => {
1499
2080
  try {
1500
2081
  const { channelId } = req.params;
1501
- const { name, walletAddress, autoInvokeTools, bound_judgment_ids, shared_with_peers } = req.body;
2082
+ const { name, walletAddress, autoInvokeTools, bound_judgment_ids, shared_with_peers, persona, linkedDocumentIds } = req.body;
1502
2083
  const channels = await loadChannels();
1503
2084
  const channel = channels.find(c => c.id === channelId);
1504
2085
  if (!channel) {
@@ -1507,6 +2088,26 @@ export async function createWebServer(port = 3000, options = {}) {
1507
2088
  if (typeof name === 'string' && name.trim()) {
1508
2089
  channel.name = name.trim();
1509
2090
  }
2091
+ // 2026-06-11: 改 persona (允许 null 重置回全局默认)
2092
+ if (persona !== undefined) {
2093
+ if (persona === null) {
2094
+ channel.persona = undefined;
2095
+ }
2096
+ else if (typeof persona === 'object') {
2097
+ channel.persona = {
2098
+ name: persona.name,
2099
+ description: persona.description,
2100
+ personality: persona.personality,
2101
+ greeting: persona.greeting,
2102
+ capabilities: Array.isArray(persona.capabilities) ? persona.capabilities.slice(0, 20) : channel.persona?.capabilities,
2103
+ interests: Array.isArray(persona.interests) ? persona.interests.slice(0, 20) : channel.persona?.interests,
2104
+ };
2105
+ }
2106
+ }
2107
+ // 2026-06-11: 改关联文档列表 (数组整体替换, 空数组 = 解绑所有)
2108
+ if (Array.isArray(linkedDocumentIds)) {
2109
+ channel.linkedDocumentIds = linkedDocumentIds.filter((x) => typeof x === 'string' && x.length > 0).slice(0, 50);
2110
+ }
1510
2111
  // walletAddress 允许 null/'' 来解绑
1511
2112
  if (walletAddress !== undefined) {
1512
2113
  if (walletAddress === null || walletAddress === '') {
@@ -1559,6 +2160,10 @@ export async function createWebServer(port = 3000, options = {}) {
1559
2160
  }
1560
2161
  channel.updatedAt = new Date().toISOString();
1561
2162
  await saveChannels(channels);
2163
+ // v3 修复: 分享变更后立即广播给所有 peer, 不用等对方手动刷新
2164
+ if (shared_with_peers !== undefined) {
2165
+ v3BroadcastOwn().catch(err => console.error('[v3] broadcast after share update failed:', err));
2166
+ }
1562
2167
  res.json(channel);
1563
2168
  }
1564
2169
  catch (err) {
@@ -1953,6 +2558,11 @@ export async function createWebServer(port = 3000, options = {}) {
1953
2558
  if (!provider || !config) {
1954
2559
  return res.status(400).json({ error: 'provider and config required' });
1955
2560
  }
2561
+ // 如果前端发的是掩码(***xxx),从当前配置里取真实 key
2562
+ const currentConfig = await llmConfigStore.getProvider(provider);
2563
+ if (currentConfig && config.apiKey && config.apiKey.startsWith('***')) {
2564
+ config.apiKey = currentConfig.apiKey;
2565
+ }
1956
2566
  await llmConfigStore.updateProvider(provider, config);
1957
2567
  // 如果是活跃供应商,重新初始化 Pi SDK
1958
2568
  const currentActive = await llmConfigStore.getActiveProvider();
@@ -2065,6 +2675,59 @@ export async function createWebServer(port = 3000, options = {}) {
2065
2675
  res.status(500).json({ error: err.message });
2066
2676
  }
2067
2677
  });
2678
+ // ==================== 音频生成配置 (TTS / Music) ====================
2679
+ // 获取音频配置
2680
+ app.get('/api/audio-config', async (req, res) => {
2681
+ try {
2682
+ const config = await audioConfigStore.getConfig();
2683
+ const providerInfo = audioConfigStore.getAllProviderInfo();
2684
+ const masked = Object.fromEntries(Object.entries(config.providers).map(([key, val]) => [
2685
+ key,
2686
+ { ...val, apiKey: val.apiKey ? '***' + val.apiKey.slice(-4) : '' }
2687
+ ]));
2688
+ res.json({
2689
+ activeProvider: config.activeProvider,
2690
+ providers: masked,
2691
+ providerInfo
2692
+ });
2693
+ }
2694
+ catch (err) {
2695
+ res.status(500).json({ error: err.message });
2696
+ }
2697
+ });
2698
+ // 更新音频供应商配置
2699
+ app.post('/api/audio-config', async (req, res) => {
2700
+ try {
2701
+ const { provider, config } = req.body;
2702
+ if (!provider || !config) {
2703
+ return res.status(400).json({ error: 'provider and config required' });
2704
+ }
2705
+ // 掩码回写真实 key
2706
+ const currentConfig = await audioConfigStore.getProvider(provider);
2707
+ if (currentConfig && config.apiKey && config.apiKey.startsWith('***')) {
2708
+ config.apiKey = currentConfig.apiKey;
2709
+ }
2710
+ await audioConfigStore.updateProvider(provider, config);
2711
+ res.json({ ok: true });
2712
+ }
2713
+ catch (err) {
2714
+ res.status(500).json({ error: err.message });
2715
+ }
2716
+ });
2717
+ // 测试音频供应商连接
2718
+ app.post('/api/audio-test', async (req, res) => {
2719
+ try {
2720
+ const { provider } = req.body;
2721
+ if (!provider) {
2722
+ return res.status(400).json({ error: 'provider required' });
2723
+ }
2724
+ const result = await audioConfigStore.testProvider(provider);
2725
+ res.json(result);
2726
+ }
2727
+ catch (err) {
2728
+ res.status(500).json({ error: err.message });
2729
+ }
2730
+ });
2068
2731
  // 统一 AI 解析入口:CLI / 接收方节点 调这里完成 LLM + judgment + harness
2069
2732
  // 入参: { text, mimeType, fileName, fromNodeId, source }
2070
2733
  // 出参: { summary, qualityScore, judgmentId?, gateArtifact? }
@@ -2163,13 +2826,26 @@ export async function createWebServer(port = 3000, options = {}) {
2163
2826
  res.status(500).json({ error: err.message });
2164
2827
  }
2165
2828
  });
2166
- // v3: 暴露 P2PDirect 自己的 publicKey, 对方可用它主动 connect
2829
+ // v3: 暴露 P2PDirect 自己的 publicKey + 本机名字, 对方可用它主动 connect 并自动取名
2167
2830
  app.get('/api/p2p-publickey', async (_req, res) => {
2168
2831
  try {
2169
2832
  if (!v3P2PRef) {
2170
2833
  return res.status(503).json({ error: 'P2PDirect not started' });
2171
2834
  }
2172
- res.json({ publicKey: v3P2PRef.getPublicKey() });
2835
+ const publicKey = v3P2PRef.getPublicKey();
2836
+ // 2026-06-10: 把本机 user/agent name 一起返回, 对方拿到后能直接用
2837
+ let name = process.env.BOLLOON_USER_NAME || process.env.USER || 'node';
2838
+ try {
2839
+ const { readFileSync, existsSync } = await import('fs');
2840
+ const cfgPath = `${process.env.HOME || '/tmp'}/.bolloon/config.json`;
2841
+ if (existsSync(cfgPath)) {
2842
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
2843
+ if (cfg.userName)
2844
+ name = cfg.userName;
2845
+ }
2846
+ }
2847
+ catch { }
2848
+ res.json({ publicKey, name, role: v3P2PRef.getRole() });
2173
2849
  }
2174
2850
  catch (err) {
2175
2851
  res.status(500).json({ error: err.message });
@@ -2213,6 +2889,40 @@ export async function createWebServer(port = 3000, options = {}) {
2213
2889
  res.status(500).json({ error: err.message });
2214
2890
  }
2215
2891
  });
2892
+ // 2026-06-10: PATCH 重命名 / 改备注 / 同时影响 publicKey
2893
+ // 用法: PATCH /api/p2p-peers/:name { name?, notes?, publicKey? }
2894
+ app.patch('/api/p2p-peers/:name', async (req, res) => {
2895
+ try {
2896
+ const { addOrUpdatePeer, removePeer } = await import('../network/known-peers.js');
2897
+ const { readFile, writeFile } = await import('fs/promises');
2898
+ const { existsSync } = await import('fs');
2899
+ const filePath = `${process.env.HOME || '/tmp'}/.bolloon/known_peers.json`;
2900
+ if (!existsSync(filePath))
2901
+ return res.status(404).json({ error: 'no known_peers.json' });
2902
+ const data = JSON.parse(await readFile(filePath, 'utf-8'));
2903
+ const oldName = req.params.name;
2904
+ const oldEntry = data.peers[oldName];
2905
+ if (!oldEntry)
2906
+ return res.status(404).json({ error: `peer "${oldName}" not found` });
2907
+ const { name: newName, notes, publicKey: newPk } = req.body || {};
2908
+ const finalName = newName || oldName;
2909
+ const finalPk = newPk || oldEntry.publicKey;
2910
+ if (finalName !== oldName) {
2911
+ delete data.peers[oldName];
2912
+ }
2913
+ data.peers[finalName] = {
2914
+ ...oldEntry,
2915
+ publicKey: finalPk,
2916
+ name: finalName,
2917
+ notes: notes !== undefined ? notes : oldEntry.notes
2918
+ };
2919
+ await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
2920
+ res.json({ ok: true, peer: data.peers[finalName] });
2921
+ }
2922
+ catch (err) {
2923
+ res.status(500).json({ error: err.message });
2924
+ }
2925
+ });
2216
2926
  // v3: 主动 connect 到对端的 P2PDirect publicKey
2217
2927
  // 用法: POST /api/remote-channels/p2p-connect { targetPublicKey: "<hex>" }
2218
2928
  app.post('/api/remote-channels/p2p-connect', async (req, res) => {
@@ -2249,6 +2959,99 @@ export async function createWebServer(port = 3000, options = {}) {
2249
2959
  res.status(500).json({ error: err.message });
2250
2960
  }
2251
2961
  });
2962
+ // v3: 主动给对端发好友申请 — 推到对端 UI 让对方接受
2963
+ // 用法: POST /api/friend-request { targetPublicKey, name, message }
2964
+ // 2026-06-10 改: 用 sendToWithWait 等握手完成, 不再 fire-and-forget; 返回结构化 code 让前端知道失败
2965
+ app.post('/api/friend-request', async (req, res) => {
2966
+ try {
2967
+ if (!v3P2PRef) {
2968
+ return res.status(503).json({ ok: false, code: 'P2P_NOT_STARTED', error: 'P2PDirect not started' });
2969
+ }
2970
+ const { targetPublicKey, name, message } = req.body || {};
2971
+ if (!targetPublicKey || typeof targetPublicKey !== 'string' || targetPublicKey.length !== 64) {
2972
+ return res.status(400).json({ ok: false, code: 'BAD_REQUEST', error: 'targetPublicKey (64 hex) required' });
2973
+ }
2974
+ // 先 joinPeer 触发握手 (注意: joinPeer 不阻塞到 conn 就绪)
2975
+ const swarm = v3P2PRef.swarm;
2976
+ if (swarm) {
2977
+ try {
2978
+ await swarm.joinPeer(Buffer.from(targetPublicKey, 'hex'));
2979
+ }
2980
+ catch { }
2981
+ }
2982
+ // 主动把对方加为本机 known_peers (本地视角认为对方是朋友)
2983
+ const { addOrUpdatePeer, findNameByPublicKey } = await import('../network/known-peers.js');
2984
+ const existing = await findNameByPublicKey(targetPublicKey);
2985
+ const peerName = name || existing || `peer-${targetPublicKey.substring(0, 8)}`;
2986
+ await addOrUpdatePeer(peerName, targetPublicKey);
2987
+ // 构造 RPC, 推到对端 — 对端会 SSE 推 friend-request 到前端
2988
+ const myPk = v3P2PRef.getPublicKey();
2989
+ const requestId = crypto.randomUUID();
2990
+ const rpc = JSON.stringify({
2991
+ v: 3,
2992
+ op: 'agent.friend.request',
2993
+ payload: {
2994
+ requestId, // 2026-06-10: 加 requestId, ack 时回带
2995
+ fromPublicKey: myPk,
2996
+ name: peerName,
2997
+ message: message || '想加你为 P2P 好友, 共享 channel 协作'
2998
+ }
2999
+ });
3000
+ // 2026-06-10: 用 sendToWithWait, 等 conn 真就绪后再发, 默认 5s 超时
3001
+ const result = await v3P2PRef.sendToWithWait(targetPublicKey, rpc, 5000);
3002
+ console.log(`[v3-friend] ${myPk.substring(0, 12)}... 发送好友申请给 ${targetPublicKey.substring(0, 12)}... (result=${result}, requestId=${requestId.substring(0, 8)})`);
3003
+ if (result !== 'SENT') {
3004
+ return res.status(502).json({
3005
+ ok: false,
3006
+ code: result, // NO_CONN / WRITE_FAIL
3007
+ error: result === 'NO_CONN'
3008
+ ? '对方未在线, 请确认对方已启动 bolloon 并互联'
3009
+ : '写入 P2P 通道失败, 请重试',
3010
+ persistedAs: peerName // 本地仍持久化, 等对方上线再 retry 即可
3011
+ });
3012
+ }
3013
+ res.json({ ok: true, sent: true, code: 'SENT', persistedAs: peerName, requestId });
3014
+ }
3015
+ catch (err) {
3016
+ console.error('[v3-friend] friend-request 失败:', err);
3017
+ res.status(500).json({ ok: false, code: 'EXCEPTION', error: err.message });
3018
+ }
3019
+ });
3020
+ // v3: 接受对方的好友申请 — 把对方加为 known_peers, 立即推我的 channel 列表给 ta
3021
+ // 用法: POST /api/friend-accept { fromPublicKey, name }
3022
+ app.post('/api/friend-accept', async (req, res) => {
3023
+ try {
3024
+ if (!v3P2PRef) {
3025
+ return res.status(503).json({ error: 'P2PDirect not started' });
3026
+ }
3027
+ const { fromPublicKey, name } = req.body || {};
3028
+ if (!fromPublicKey || typeof fromPublicKey !== 'string' || fromPublicKey.length !== 64) {
3029
+ return res.status(400).json({ error: 'fromPublicKey (64 hex) required' });
3030
+ }
3031
+ // 持久化
3032
+ const { addOrUpdatePeer, findNameByPublicKey } = await import('../network/known-peers.js');
3033
+ const existing = await findNameByPublicKey(fromPublicKey);
3034
+ const peerName = name || existing || `peer-${fromPublicKey.substring(0, 8)}`;
3035
+ await addOrUpdatePeer(peerName, fromPublicKey);
3036
+ // joinPeer 确保连接存在 (连接可能已在 friend-request 时建立, 这里可能是 no-op)
3037
+ const swarm = v3P2PRef.swarm;
3038
+ if (swarm) {
3039
+ try {
3040
+ await swarm.joinPeer(Buffer.from(fromPublicKey, 'hex'));
3041
+ }
3042
+ catch { }
3043
+ }
3044
+ // v3 修复: 主动广播自己的 channel 列表给新好友,
3045
+ // 不能依赖 connection handler, 因为连接在 friend-request 阶段已建立, 不会触发新 connection 事件
3046
+ v3BroadcastOwn().catch(err => console.error('[v3] broadcast after friend-accept failed:', err));
3047
+ console.log(`[v3-friend] 接受好友申请: ${fromPublicKey.substring(0, 12)}... → ${peerName}`);
3048
+ res.json({ ok: true, persistedAs: peerName });
3049
+ }
3050
+ catch (err) {
3051
+ console.error('[v3-friend] friend-accept 失败:', err);
3052
+ res.status(500).json({ error: err.message });
3053
+ }
3054
+ });
2252
3055
  // v3: 给远端 channel 发消息 (B 节点) - 通过 P2PDirect 转发到 A, A 跑 LLM, 回 B
2253
3056
  // 用法: POST /api/remote-channels/chat-send
2254
3057
  // { targetPublicKey, channelId, text }
@@ -2276,6 +3079,8 @@ export async function createWebServer(port = 3000, options = {}) {
2276
3079
  error: 'peer not connected. POST /api/remote-channels/p2p-connect first.'
2277
3080
  });
2278
3081
  }
3082
+ // 2026-06-10: 喂 watchdog — chat-send 成功是真实业务活动
3083
+ watchdogRef?.recordActivity?.();
2279
3084
  console.log(`[v3] chat-send 转发到 ${targetPublicKey.substring(0, 12)}... (channelId=${channelId})`);
2280
3085
  res.json({ ok: true, sent: true });
2281
3086
  }
@@ -3151,6 +3956,8 @@ export async function createWebServer(port = 3000, options = {}) {
3151
3956
  healthMonitor = createHealthMonitor();
3152
3957
  // 把 watchdog 静默阈值拉到 30 分钟, 避免开发期 / 用户空闲时被误杀
3153
3958
  watchdog = createWatchdog({ silentThresholdMs: 30 * 60 * 1000 });
3959
+ // 2026-06-10: 同步到 module-level, 让 broadcast() / P2P handler / chat-send 都能喂活动
3960
+ watchdogRef = watchdog;
3154
3961
  console.log('[24h] Heartbeat modules loaded');
3155
3962
  }
3156
3963
  catch (err) {
@@ -3495,8 +4302,12 @@ export async function createWebServer(port = 3000, options = {}) {
3495
4302
  // level 1 (内存爆) → 进程自杀, 依赖外层 supervisor / 用户重启 (Windows 任务计划/手动)
3496
4303
  // 否则 Node.js 高 GC 压力下 HTTP 响应丢失, 客户端 fetch 永远 pending
3497
4304
  watchdog.registerRestartStrategy(1, () => {
3498
- console.error('[Watchdog] memory critical, 进程退出 (期望外层重启)');
3499
- setTimeout(() => process.exit(1), 100);
4305
+ // 2026-06-10: 改为不退出, 因为我们直接后台 tsx 启动没有外层 supervisor.
4306
+ // 误判主要因 recordActivity 仅在显式调用时刷新, 而 broadcast/SSE/连接均不触发.
4307
+ // 退出策略原文保留在注释里:
4308
+ // console.error('[Watchdog] memory critical, 进程退出 (期望外层重启)');
4309
+ // setTimeout(() => process.exit(1), 100);
4310
+ console.warn('[Watchdog] silentThreshold 触发, 但跳过 process.exit (无 supervisor)');
3500
4311
  });
3501
4312
  watchdog.start();
3502
4313
  console.log('[24h] Watchdog started');
@@ -3655,6 +4466,8 @@ export async function createWebServer(port = 3000, options = {}) {
3655
4466
  });
3656
4467
  }
3657
4468
  function broadcast(data, channelId) {
4469
+ // 2026-06-10: 喂 watchdog, 避免 30min 空闲被误判 (recordActivity 内有 5s 去抖)
4470
+ watchdogRef?.recordActivity?.();
3658
4471
  const envelope = { ...data, channelId };
3659
4472
  const message = `data: ${JSON.stringify(envelope)}\n\n`;
3660
4473
  console.log(`[broadcast] type=${data.type}, channelId=${channelId}, clients=${sseClients.size}`);