@bolloon/bolloon-agent 0.1.26 → 0.1.28

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.
@@ -257,6 +257,8 @@ let sseClients = new Set();
257
257
  // v3: 远端 channel UI 元数据缓存 — key: peerId, value: sanitize 过的 channel 列表
258
258
  // in-memory only, 进程重启清空 (judgment 内容永远不在这里)
259
259
  let remoteChannelCache = new Map();
260
+ // v3: P2PDirect 引用 (Hyperswarm 薄包装) - 模块级, 因为 web server 闭包里不可用
261
+ let v3P2PRef = null;
260
262
  let channelSessions = new Map(); // key: channelId
261
263
  let sessionMessages = new Map(); // key: channelId + sessionId
262
264
  /**
@@ -270,8 +272,18 @@ let sessionMessages = new Map(); // key: channelId + sessionId
270
272
  * v3: 过滤 channel 元数据, 只返回对远端 peer 安全的字段.
271
273
  * 关键: bound_judgment_ids / walletBinding / autoInvokeTools 内部状态不外传.
272
274
  * judgment 内容永远不会出现在 RPC 响应里 (judgment 始终在 A 节点内存, 由 A 跑 LLM).
275
+ *
276
+ * Phase 3 分享模式: 加 peerPublicKey 参数 — 只有 shared_with_peers 包含此 peer 的 channel 才返回.
277
+ * peerPublicKey 不传 = admin 路径, 返回所有 channel (老行为).
273
278
  */
274
- function sanitizeChannelForPeer(ch) {
279
+ function sanitizeChannelForPeer(ch, peerPublicKey) {
280
+ // Phase 3 核心: 分享过滤
281
+ if (peerPublicKey) {
282
+ const shared = Array.isArray(ch.shared_with_peers) ? ch.shared_with_peers : [];
283
+ if (!shared.includes(peerPublicKey)) {
284
+ return null; // 没分享给这个 peer, 不返回
285
+ }
286
+ }
275
287
  return {
276
288
  id: ch.id,
277
289
  name: ch.name,
@@ -279,11 +291,151 @@ function sanitizeChannelForPeer(ch) {
279
291
  publicKey: ch.publicKey,
280
292
  createdAt: ch.createdAt,
281
293
  updatedAt: ch.updatedAt,
282
- hasWallet: !!ch.walletAddress, // 只告诉 B "有没有钱包", 不传地址
294
+ hasWallet: !!ch.walletAddress,
283
295
  boundJudgmentCount: Array.isArray(ch.bound_judgment_ids) ? ch.bound_judgment_ids.length : 0,
284
- // 🔒 不返回: bound_judgment_ids, walletAddress, walletBinding, autoInvokeTools, sessions
296
+ share_id: ch.share_id,
297
+ // 🔒 不返回: bound_judgment_ids, walletAddress, walletBinding, autoInvokeTools, sessions, shared_with_peers
285
298
  };
286
299
  }
300
+ /**
301
+ * v3: 处理 Hyperswarm 通道收到的 v3 RPC 消息
302
+ * 设计: 用 HyperswarmCommunicator (DHT topic 自动发现) 取代 iroh 直接 connect
303
+ * - A 启动 → broadcast(agent.meta.list.reply) → 所有已连接 peer 缓存 A 的 channel
304
+ * - B 启动 → 同样 broadcast
305
+ * - 任何节点收到 list 请求 → 回 list.reply
306
+ */
307
+ async function handleV3P2PMessage(parsed, conn, comm) {
308
+ const op = parsed.op;
309
+ const peerKey = conn.publicKey;
310
+ if (op === 'agent.meta.list') {
311
+ // 对方问我的 channel 列表 — 只返回分享给他的
312
+ try {
313
+ const channels = await loadChannels();
314
+ const publicMeta = channels
315
+ .map(ch => sanitizeChannelForPeer(ch, peerKey))
316
+ .filter((x) => x !== null);
317
+ const reply = JSON.stringify({ v: 3, op: 'agent.meta.list.reply', payload: { channels: publicMeta } });
318
+ await comm.sendToConnection(conn.id, reply);
319
+ console.log(`[v3] 回 ${peerKey.substring(0, 12)}... list.reply (${publicMeta.length} 个分享给 ta)`);
320
+ }
321
+ catch (err) {
322
+ console.error('[v3] 处理 agent.meta.list 失败:', err.message);
323
+ }
324
+ return;
325
+ }
326
+ if (op === 'agent.meta.list.reply') {
327
+ // 对方把他自己的 channel 列表推给我 — 缓存
328
+ const list = parsed.payload?.channels || [];
329
+ remoteChannelCache.set(peerKey, list);
330
+ console.log(`[v3] 收到 ${peerKey.substring(0, 12)}... 的 ${list.length} 个 channel, 已缓存`);
331
+ broadcast({
332
+ type: 'remote-channel-update',
333
+ peerId: peerKey,
334
+ channels: list
335
+ }, 'p2p-global');
336
+ return;
337
+ }
338
+ if (op === 'agent.meta.get') {
339
+ // 对方问单条 channel — 回
340
+ const channelId = parsed.payload?.channelId;
341
+ if (channelId) {
342
+ const channels = await loadChannels();
343
+ const ch = channels.find(c => c.id === channelId);
344
+ if (ch) {
345
+ // Phase 3: 分享过滤 — 必须分享给该 peer
346
+ const sanitized = sanitizeChannelForPeer(ch, peerKey);
347
+ if (sanitized) {
348
+ const reply = JSON.stringify({ v: 3, op: 'agent.meta.get.reply', payload: { channel: sanitized } });
349
+ await comm.sendToConnection(conn.id, reply);
350
+ }
351
+ else {
352
+ const reply = JSON.stringify({ v: 3, op: 'agent.meta.get.reply', payload: { error: 'not shared with you' } });
353
+ await comm.sendToConnection(conn.id, reply);
354
+ }
355
+ }
356
+ }
357
+ return;
358
+ }
359
+ if (op === 'agent.meta.get.reply') {
360
+ const ch = parsed.payload?.channel;
361
+ if (ch && ch.id) {
362
+ const list = remoteChannelCache.get(peerKey) || [];
363
+ const idx = list.findIndex((c) => c.id === ch.id);
364
+ if (idx >= 0)
365
+ list[idx] = ch;
366
+ else
367
+ list.push(ch);
368
+ remoteChannelCache.set(peerKey, list);
369
+ broadcast({
370
+ type: 'remote-channel-update',
371
+ peerId: peerKey,
372
+ channels: list
373
+ }, 'p2p-global');
374
+ }
375
+ return;
376
+ }
377
+ if (op === 'agent.chat.send') {
378
+ // B 端发来: 在 A 节点上对 channelId 跑 LLM, 结果回 B
379
+ // judgment 永远在 A 节点 (buildJudgmentHint 已经用 bound_judgment_ids)
380
+ const { channelId, text, fromPublicKey } = parsed.payload || {};
381
+ if (!channelId || !text) {
382
+ console.warn(`[v3] agent.chat.send 缺少 channelId/text`);
383
+ return;
384
+ }
385
+ console.log(`[v3] 收到 ${fromPublicKey?.substring(0, 12) || peerKey.substring(0, 12)}... 对 channel ${channelId} 的 chat: "${text.substring(0, 40)}..."`);
386
+ try {
387
+ // 1. 找到 channel
388
+ const channels = await loadChannels();
389
+ const ch = channels.find(c => c.id === channelId);
390
+ if (!ch) {
391
+ const reply = JSON.stringify({
392
+ v: 3, op: 'agent.chat.reply',
393
+ payload: { channelId, fromPublicKey: v3P2PRef?.getPublicKey() || '', error: 'channel not found', text: '' }
394
+ });
395
+ await comm.sendToConnection(conn.id, reply);
396
+ return;
397
+ }
398
+ // 2. 跑 LLM (复用 Phase 1 的 buildJudgmentHint — 注入 channel 的 judgment)
399
+ const judgmentHint = await buildJudgmentHint(ch, channelId);
400
+ const { getMinimax } = await import('../constraints/index.js');
401
+ const llm = getMinimax();
402
+ const fullPrompt = `${judgmentHint}${text}`;
403
+ let fullResponse = '';
404
+ const streamCallback = (event) => {
405
+ // 流式 token, 不广播给 B (避免半成品噪音), 只记 A 自己的日志
406
+ if (event.type === 'token') {
407
+ fullResponse += event.content;
408
+ }
409
+ };
410
+ const agent = await getAgentForChannel(channelId, ch.did || '', ch.name, ch.didDocRef);
411
+ fullResponse = await agent.promptStream(fullPrompt, streamCallback);
412
+ // 3. 把完整回复发给 B
413
+ const reply = JSON.stringify({
414
+ v: 3, op: 'agent.chat.reply',
415
+ payload: {
416
+ channelId,
417
+ fromPublicKey: v3P2PRef?.getPublicKey() || '',
418
+ text: fullResponse
419
+ }
420
+ });
421
+ await comm.sendToConnection(conn.id, reply);
422
+ console.log(`[v3] 回 chat.reply 给 ${fromPublicKey?.substring(0, 12) || peerKey.substring(0, 12)}... (${fullResponse.length} chars)`);
423
+ }
424
+ catch (err) {
425
+ console.error(`[v3] agent.chat.send 处理失败:`, err.message);
426
+ try {
427
+ const reply = JSON.stringify({
428
+ v: 3, op: 'agent.chat.reply',
429
+ payload: { channelId, fromPublicKey: v3P2PRef?.getPublicKey() || '', error: err.message, text: '' }
430
+ });
431
+ await comm.sendToConnection(conn.id, reply);
432
+ }
433
+ catch { }
434
+ }
435
+ return;
436
+ }
437
+ console.log(`[v3] 收到未知 op: ${op}`);
438
+ }
287
439
  async function buildJudgmentHint(channel, channelIdForLog) {
288
440
  try {
289
441
  const { loadAllJudgments, initializeValueStore } = await import('../pi-ecosystem-judgment/human-value-store.js');
@@ -428,7 +580,125 @@ export async function createWebServer(port = 3000, options = {}) {
428
580
  catch (e) {
429
581
  console.log('P2P DID 本地模式运行');
430
582
  }
431
- // 初始化 P2P 通信器
583
+ // v3: 完全用 P2PDirect 取代 @diap/sdk 的 HyperswarmCommunicator
584
+ // 原因: @diap/sdk 的 sendToConnection 是 stub, 不真发数据
585
+ // 这里故意不启动 p2pCommunicator (保持 null), 让 P2PDirect 独占 hyperswarm 通道
586
+ try {
587
+ const { P2PDirect } = await import('../network/p2p-direct.js');
588
+ v3P2PRef = new P2PDirect({ name: 'v3' });
589
+ await v3P2PRef.start();
590
+ await v3P2PRef.joinTopic(Buffer.from('bolloon-agent-harness'));
591
+ v3P2PRef.on('data', (evt) => {
592
+ try {
593
+ const parsed = JSON.parse(evt.data.toString('utf-8'));
594
+ if (parsed && parsed.v === 3 && parsed.op) {
595
+ // v3 跨用户 chat: B 端收到 A 的 chat.reply, 直接 SSE 推给前端
596
+ if (parsed.op === 'agent.chat.reply') {
597
+ console.log(`[v3] 收到来自 ${evt.fromPublicKey.substring(0, 12)}... 的 chat.reply (${(parsed.payload?.text || '').length} chars)`);
598
+ broadcast({
599
+ type: 'remote-chat-reply',
600
+ fromPublicKey: evt.fromPublicKey,
601
+ channelId: parsed.payload?.channelId,
602
+ text: parsed.payload?.text || '',
603
+ error: parsed.payload?.error
604
+ }, 'p2p-global');
605
+ return;
606
+ }
607
+ const commShim = {
608
+ sendToConnection: (_id, data) => {
609
+ v3P2PRef.sendTo(evt.fromPublicKey, data);
610
+ return Promise.resolve();
611
+ }
612
+ };
613
+ handleV3P2PMessage(parsed, { id: evt.fromPublicKey, publicKey: evt.fromPublicKey }, commShim);
614
+ }
615
+ }
616
+ catch (err) {
617
+ console.error('[v3-P2PDirect] 解析/处理消息失败:', err.message);
618
+ }
619
+ });
620
+ // 新连接进来 → 主动发我分享给 ta 的 channel 列表
621
+ v3P2PRef.on('connection', (evt) => {
622
+ setTimeout(async () => {
623
+ try {
624
+ const channels = await loadChannels();
625
+ const publicMeta = channels
626
+ .map(ch => sanitizeChannelForPeer(ch, evt.remotePublicKey))
627
+ .filter((x) => x !== null);
628
+ const msg = JSON.stringify({ v: 3, op: 'agent.meta.list.reply', payload: { channels: publicMeta } });
629
+ v3P2PRef.sendTo(evt.remotePublicKey, msg);
630
+ console.log(`[v3] 新连接 ${evt.remotePublicKey.substring(0, 12)}... → 发 ${publicMeta.length} 个分享给 ta`);
631
+ }
632
+ catch (err) {
633
+ console.error('[v3] 新连接发 list.reply 失败:', err.message);
634
+ }
635
+ }, 500);
636
+ });
637
+ console.log(`[v3] P2PDirect 已启动, role=${v3P2PRef.getRole()}, publicKey=${v3P2PRef.getPublicKey().substring(0, 12)}...`);
638
+ // v3: 启动后自动重连 known peers — 让"启动就互联"成为现实
639
+ setTimeout(async () => {
640
+ try {
641
+ const { listPeers, markConnected } = await import('../network/known-peers.js');
642
+ const peers = await listPeers();
643
+ if (peers.length === 0) {
644
+ console.log(`[v3] 没有 known peers, 跳过自动重连`);
645
+ return;
646
+ }
647
+ const swarm = v3P2PRef.swarm;
648
+ if (!swarm)
649
+ return;
650
+ for (const peer of peers) {
651
+ try {
652
+ await swarm.joinPeer(Buffer.from(peer.publicKey, 'hex'));
653
+ await markConnected(peer.name || '');
654
+ console.log(`[v3] 自动重连 ${peer.name} (${peer.publicKey.substring(0, 12)}...) ✓`);
655
+ }
656
+ catch (err) {
657
+ console.warn(`[v3] 自动重连 ${peer.name} 失败:`, err.message);
658
+ }
659
+ }
660
+ // 触发一次 broadcast 推送给所有重连的 peer
661
+ setTimeout(() => v3BroadcastOwn(), 2000);
662
+ }
663
+ catch (err) {
664
+ console.error('[v3] 自动重连失败:', err.message);
665
+ }
666
+ }, 5000); // 5s 后再重连, 让 swarm 充分 bootstrap
667
+ }
668
+ catch (err) {
669
+ console.error('[v3] P2PDirect 启动失败:', err.message);
670
+ v3P2PRef = null;
671
+ }
672
+ // v3: 定期 broadcast — 每个 peer 只收到分享给他的 channel (按 peer 个性化)
673
+ const v3BroadcastOwn = () => {
674
+ if (!v3P2PRef)
675
+ return;
676
+ loadChannels().then(channels => {
677
+ const conns = v3P2PRef.conns;
678
+ if (!conns)
679
+ return;
680
+ for (const [peerPk, conn] of conns.entries()) {
681
+ if (conn?.destroyed)
682
+ continue;
683
+ const sharedForPeer = channels
684
+ .map(ch => sanitizeChannelForPeer(ch, peerPk))
685
+ .filter((x) => x !== null);
686
+ if (sharedForPeer.length > 0) {
687
+ const msg = JSON.stringify({ v: 3, op: 'agent.meta.list.reply', payload: { channels: sharedForPeer } });
688
+ try {
689
+ conn.write(Buffer.from(msg));
690
+ }
691
+ catch { }
692
+ }
693
+ }
694
+ console.log(`[v3] broadcast 个性化: ${conns.size} 个 peer, 各自收到分享的 channel`);
695
+ }).catch(err => console.error('[v3] broadcast 失败:', err.message));
696
+ };
697
+ setTimeout(v3BroadcastOwn, 3000);
698
+ setTimeout(v3BroadcastOwn, 10000);
699
+ setTimeout(v3BroadcastOwn, 20000);
700
+ setTimeout(v3BroadcastOwn, 40000);
701
+ // 保留 @diap/sdk 的旧实例 (它的 Hyperswarm 实例能帮 P2PDirect 做 DHT bootstrap)
432
702
  try {
433
703
  const rawSeed = crypto.getRandomValues(new Uint8Array(32));
434
704
  p2pCommunicator = createHyperswarmCommunicator({
@@ -438,22 +708,20 @@ export async function createWebServer(port = 3000, options = {}) {
438
708
  maxConnections: 50,
439
709
  seed: rawSeed
440
710
  });
441
- p2pCommunicator.on('connection', (conn) => {
442
- console.log(`P2P 连接: ${conn.publicKey.substring(0, 8)}...`);
443
- });
444
711
  p2pCommunicator.on('message', async (msg, conn) => {
712
+ // 旧 p2p_message 路径 (非 v3)
445
713
  const content = new TextDecoder().decode(msg.content);
446
- console.log(`P2P 收到消息: ${content.substring(0, 50)}...`);
447
- // 可以在这里处理接收到的消息
448
714
  broadcast({ type: 'p2p_message', from: conn.publicKey.substring(0, 8), content }, undefined);
449
715
  });
450
716
  await p2pCommunicator.start();
451
- const topic = createTopic('bolloon-agent-harness');
452
- await p2pCommunicator.joinTopic(topic);
453
- console.log(`P2P 网络已就绪`);
717
+ // @diap/sdk 也 join topic 它的 Hyperswarm 实例帮 P2PDirect 做 DHT 引导
718
+ // @diap/sdk 收到的数据是 mock (不真发), 但 DHT 发现 + 节点连接是 OK 的
719
+ const oldTopic = createTopic('bolloon-agent-harness');
720
+ await p2pCommunicator.joinTopic(oldTopic);
721
+ console.log(`P2P 老通道已就绪 (DHT bootstrap 帮 P2PDirect, 实际数据走 P2PDirect)`);
454
722
  }
455
723
  catch (e) {
456
- console.log(`P2P 网络初始化失败: ${e.message}`);
724
+ console.log(`P2P 老通道初始化失败: ${e.message}`);
457
725
  }
458
726
  }
459
727
  catch (e) {
@@ -752,6 +1020,26 @@ export async function createWebServer(port = 3000, options = {}) {
752
1020
  // 用法: B 端用户点 "刷新远端智能体" → 触发本 endpoint
753
1021
  app.post('/api/remote-channels/refresh', async (_req, res) => {
754
1022
  try {
1023
+ // Phase 3: 优先用 P2PDirect conns (Phase 2/3 的真实通道)
1024
+ if (v3P2PRef) {
1025
+ const conns = v3P2PRef.conns;
1026
+ const peerIds = Array.from(conns.keys()).filter(pk => {
1027
+ const c = conns.get(pk);
1028
+ return c && !c.destroyed;
1029
+ });
1030
+ if (peerIds.length === 0) {
1031
+ return res.json({ ok: true, sent: 0, note: 'no connected peers (P2PDirect)' });
1032
+ }
1033
+ // 让每个 peer 拉 list — Phase 3 个性化分享过滤
1034
+ let sent = 0;
1035
+ for (const peerPk of peerIds) {
1036
+ const ok = await v3P2PRef.sendTo(peerPk, JSON.stringify({ v: 3, op: 'agent.meta.list', payload: {} }));
1037
+ if (ok)
1038
+ sent++;
1039
+ }
1040
+ return res.json({ ok: true, sent, total: peerIds.length });
1041
+ }
1042
+ // Fallback: 老 iroh 路径
755
1043
  const peers = irohTransport.getPeers ? irohTransport.getPeers() : [];
756
1044
  const peerIds = Array.isArray(peers) ? peers.map((p) => p.nodeId || p) : [];
757
1045
  if (peerIds.length === 0) {
@@ -769,6 +1057,36 @@ export async function createWebServer(port = 3000, options = {}) {
769
1057
  res.status(500).json({ error: err.message });
770
1058
  }
771
1059
  });
1060
+ // ===== v3: 主动 connect 到对端, 再发 agent.meta.list =====
1061
+ // 用法: POST /api/remote-channels/connect { targetAddr: "<完整 EndpointAddr 含 relay URL>" }
1062
+ // targetAddr 应来自对端 GET /api/iroh-addr (完整字符串, 不只是 nodeId)
1063
+ // 兼容旧用法: 也接受 targetNodeId, 但只有 nodeId 不一定能 connect 成功
1064
+ app.post('/api/remote-channels/connect', async (req, res) => {
1065
+ try {
1066
+ const { targetAddr, targetNodeId } = req.body || {};
1067
+ const target = targetAddr || targetNodeId;
1068
+ if (!target || typeof target !== 'string') {
1069
+ return res.status(400).json({ error: 'targetAddr (or targetNodeId) required' });
1070
+ }
1071
+ console.log(`[v3] 主动 connect 到 ${target.substring(0, 32)}...`);
1072
+ // iroh connect 接受 nodeId 或完整 addr 字符串 — 用完整 addr 才会成功
1073
+ const ok = await irohTransport.connect(target);
1074
+ if (!ok) {
1075
+ return res.status(502).json({
1076
+ error: 'connect failed',
1077
+ hint: '传 targetAddr (完整 EndpointAddr 字符串, 含 relay URL) 而非仅 nodeId'
1078
+ });
1079
+ }
1080
+ // 立即发 agent.meta.list 请求对端返回元数据
1081
+ const sent = await irohTransport.sendMessage(target, 'agent.meta.list', new TextEncoder().encode('{}'));
1082
+ console.log(`[v3] connect+list 发送结果: connect=ok, list=${sent}`);
1083
+ res.json({ ok: true, connected: true, sent, target });
1084
+ }
1085
+ catch (err) {
1086
+ console.error('[v3] /api/remote-channels/connect 失败:', err);
1087
+ res.status(500).json({ error: err.message });
1088
+ }
1089
+ });
772
1090
  app.post('/channels', async (req, res) => {
773
1091
  try {
774
1092
  const { name, agentId, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
@@ -954,7 +1272,7 @@ export async function createWebServer(port = 3000, options = {}) {
954
1272
  app.patch('/channels/:channelId', async (req, res) => {
955
1273
  try {
956
1274
  const { channelId } = req.params;
957
- const { name, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
1275
+ const { name, walletAddress, autoInvokeTools, bound_judgment_ids, shared_with_peers } = req.body;
958
1276
  const channels = await loadChannels();
959
1277
  const channel = channels.find(c => c.id === channelId);
960
1278
  if (!channel) {
@@ -994,6 +1312,25 @@ export async function createWebServer(port = 3000, options = {}) {
994
1312
  }
995
1313
  console.log(`[Channel ${channelId}] 绑定判断力: ${channel.bound_judgment_ids.length} 条`);
996
1314
  }
1315
+ // Phase 3: shared_with_peers (显式分享给指定 peerPublicKey 列表)
1316
+ if (shared_with_peers !== undefined) {
1317
+ if (shared_with_peers === null) {
1318
+ channel.shared_with_peers = [];
1319
+ }
1320
+ else if (Array.isArray(shared_with_peers)) {
1321
+ channel.shared_with_peers = shared_with_peers.filter((x) => typeof x === 'string' && x.length === 64 // iroh/hyperswarm pubkey 32 字节 = 64 hex
1322
+ );
1323
+ }
1324
+ else {
1325
+ return res.status(400).json({ error: 'shared_with_peers must be array of publicKey hex' });
1326
+ }
1327
+ console.log(`[Channel ${channelId}] 分享给 ${channel.shared_with_peers.length} 个 peer`);
1328
+ }
1329
+ // 首次保存时自动生成 share_id (短字符串, 方便粘贴)
1330
+ if (!channel.share_id) {
1331
+ channel.share_id = `shr_${channelId.slice(3, 12)}_${Math.random().toString(36).substring(2, 8)}`;
1332
+ console.log(`[Channel ${channelId}] 自动生成 share_id: ${channel.share_id}`);
1333
+ }
997
1334
  channel.updatedAt = new Date().toISOString();
998
1335
  await saveChannels(channels);
999
1336
  res.json(channel);
@@ -1534,6 +1871,139 @@ export async function createWebServer(port = 3000, options = {}) {
1534
1871
  res.status(500).json({ error: err.message });
1535
1872
  }
1536
1873
  });
1874
+ // v3 测试: 返回 iroh endpoint 完整地址 (含 relay URL), 这才是 connect() 真正需要的
1875
+ app.get('/api/iroh-addr', async (_req, res) => {
1876
+ try {
1877
+ const addr = irohTransport.getEndpointAddr
1878
+ ? irohTransport.getEndpointAddr()
1879
+ : irohTransport.getNodeId();
1880
+ res.json({ addr });
1881
+ }
1882
+ catch (err) {
1883
+ res.status(500).json({ error: err.message });
1884
+ }
1885
+ });
1886
+ // v3: 暴露 P2PDirect 自己的 publicKey, 对方可用它主动 connect
1887
+ app.get('/api/p2p-publickey', async (_req, res) => {
1888
+ try {
1889
+ if (!v3P2PRef) {
1890
+ return res.status(503).json({ error: 'P2PDirect not started' });
1891
+ }
1892
+ res.json({ publicKey: v3P2PRef.getPublicKey() });
1893
+ }
1894
+ catch (err) {
1895
+ res.status(500).json({ error: err.message });
1896
+ }
1897
+ });
1898
+ // v3: known peers CRUD (持久化到 ~/.bolloon/known_peers.json)
1899
+ // GET 列表, POST 加/更新, DELETE 删, PATCH 重命名
1900
+ app.get('/api/p2p-peers', async (_req, res) => {
1901
+ try {
1902
+ const { listPeers } = await import('../network/known-peers.js');
1903
+ const peers = await listPeers();
1904
+ res.json({ count: peers.length, peers });
1905
+ }
1906
+ catch (err) {
1907
+ res.status(500).json({ error: err.message });
1908
+ }
1909
+ });
1910
+ app.post('/api/p2p-peers', async (req, res) => {
1911
+ try {
1912
+ const { name, publicKey, notes } = req.body || {};
1913
+ if (!name || !publicKey)
1914
+ return res.status(400).json({ error: 'name and publicKey required' });
1915
+ if (typeof publicKey !== 'string' || publicKey.length !== 64) {
1916
+ return res.status(400).json({ error: 'publicKey must be 64-char hex (32 bytes)' });
1917
+ }
1918
+ const { addOrUpdatePeer } = await import('../network/known-peers.js');
1919
+ await addOrUpdatePeer(name, publicKey, notes);
1920
+ res.json({ ok: true });
1921
+ }
1922
+ catch (err) {
1923
+ res.status(500).json({ error: err.message });
1924
+ }
1925
+ });
1926
+ app.delete('/api/p2p-peers/:name', async (req, res) => {
1927
+ try {
1928
+ const { removePeer } = await import('../network/known-peers.js');
1929
+ await removePeer(req.params.name);
1930
+ res.json({ ok: true });
1931
+ }
1932
+ catch (err) {
1933
+ res.status(500).json({ error: err.message });
1934
+ }
1935
+ });
1936
+ // v3: 主动 connect 到对端的 P2PDirect publicKey
1937
+ // 用法: POST /api/remote-channels/p2p-connect { targetPublicKey: "<hex>" }
1938
+ app.post('/api/remote-channels/p2p-connect', async (req, res) => {
1939
+ try {
1940
+ if (!v3P2PRef) {
1941
+ return res.status(503).json({ error: 'P2PDirect not started' });
1942
+ }
1943
+ const { targetPublicKey, name, persist } = req.body || {};
1944
+ if (!targetPublicKey || typeof targetPublicKey !== 'string') {
1945
+ return res.status(400).json({ error: 'targetPublicKey required (hex)' });
1946
+ }
1947
+ // v3P2PRef 直接连到目标 publicKey (用 hyperswarm 的 joinPeer API)
1948
+ const swarm = v3P2PRef.swarm;
1949
+ if (!swarm)
1950
+ return res.status(503).json({ error: 'swarm not available' });
1951
+ const conn = await swarm.joinPeer(Buffer.from(targetPublicKey, 'hex'));
1952
+ console.log(`[v3] 已主动 joinPeer ${targetPublicKey.substring(0, 12)}...`);
1953
+ // 自动持久化 (默认开启) — 之后启动自动重连
1954
+ let persistedAs = null;
1955
+ if (persist !== false) {
1956
+ const { addOrUpdatePeer, findNameByPublicKey } = await import('../network/known-peers.js');
1957
+ // 优先用客户端传的 name, 否则用 publicKey 前 8 位
1958
+ const peerName = name || `peer-${targetPublicKey.substring(0, 8)}`;
1959
+ // 如果 publicKey 已被别的 name 占用, 用现有 name
1960
+ const existingName = await findNameByPublicKey(targetPublicKey);
1961
+ persistedAs = existingName ?? peerName ?? `peer-${targetPublicKey.substring(0, 8)}`;
1962
+ await addOrUpdatePeer(persistedAs, targetPublicKey);
1963
+ console.log(`[v3] 自动持久化 peer: ${persistedAs}`);
1964
+ }
1965
+ res.json({ ok: true, target: targetPublicKey, persistedAs });
1966
+ }
1967
+ catch (err) {
1968
+ console.error('[v3] p2p-connect 失败:', err);
1969
+ res.status(500).json({ error: err.message });
1970
+ }
1971
+ });
1972
+ // v3: 给远端 channel 发消息 (B 节点) - 通过 P2PDirect 转发到 A, A 跑 LLM, 回 B
1973
+ // 用法: POST /api/remote-channels/chat-send
1974
+ // { targetPublicKey, channelId, text }
1975
+ app.post('/api/remote-channels/chat-send', async (req, res) => {
1976
+ try {
1977
+ if (!v3P2PRef) {
1978
+ return res.status(503).json({ error: 'P2PDirect not started' });
1979
+ }
1980
+ const { targetPublicKey, channelId, text } = req.body || {};
1981
+ if (!targetPublicKey || !channelId || !text) {
1982
+ return res.status(400).json({ error: 'targetPublicKey, channelId, text required' });
1983
+ }
1984
+ if (typeof text !== 'string' || text.length === 0 || text.length > 8000) {
1985
+ return res.status(400).json({ error: 'text length must be 1-8000' });
1986
+ }
1987
+ const fromPk = v3P2PRef.getPublicKey();
1988
+ const msg = JSON.stringify({
1989
+ v: 3,
1990
+ op: 'agent.chat.send',
1991
+ payload: { channelId, text, fromPublicKey: fromPk }
1992
+ });
1993
+ const ok = v3P2PRef.sendTo(targetPublicKey, msg);
1994
+ if (!ok) {
1995
+ return res.status(502).json({
1996
+ error: 'peer not connected. POST /api/remote-channels/p2p-connect first.'
1997
+ });
1998
+ }
1999
+ console.log(`[v3] chat-send 转发到 ${targetPublicKey.substring(0, 12)}... (channelId=${channelId})`);
2000
+ res.json({ ok: true, sent: true });
2001
+ }
2002
+ catch (err) {
2003
+ console.error('[v3] chat-send 失败:', err);
2004
+ res.status(500).json({ error: err.message });
2005
+ }
2006
+ });
1537
2007
  // 获取已连接的节点
1538
2008
  app.get('/api/peers', async (_req, res) => {
1539
2009
  try {
@@ -1699,7 +2169,8 @@ export async function createWebServer(port = 3000, options = {}) {
1699
2169
  console.log(`[v3] 收到 agent.meta.list from ${msg.from.substring(0, 12)}...`);
1700
2170
  try {
1701
2171
  const channels = await loadChannels();
1702
- const publicMeta = channels.map(sanitizeChannelForPeer);
2172
+ // iroh 路径保留 (admin / debug 用, 不走分享过滤)
2173
+ const publicMeta = channels.map((ch) => sanitizeChannelForPeer(ch));
1703
2174
  const response = JSON.stringify({ ok: true, channels: publicMeta });
1704
2175
  const encoded = new TextEncoder().encode(response);
1705
2176
  // 沿用 msg.from 路由回去
@@ -2811,19 +3282,52 @@ export async function createWebServer(port = 3000, options = {}) {
2811
3282
  }
2812
3283
  // 安装自改总线 -> SSE 桥
2813
3284
  void installSelfImproveHook();
2814
- return new Promise((resolve) => {
2815
- server.listen(port, () => {
2816
- console.log(`Web 服务器启动完成: http://localhost:${port}`);
2817
- console.log('服务器已监听');
2818
- // 安装 chat bus -> SSE 桥 (供前端 inbox UI 实时刷新)
2819
- void installChatBusHook();
2820
- setInterval(() => {
2821
- for (const client of sseClients) {
2822
- client.res.write(': ping\n\n');
2823
- }
2824
- }, 30000);
2825
- resolve({ app, server });
2826
- });
3285
+ // 端口冲突时自动找下一个可用端口(最多 10 次),避免 EADDRINUSE 直接崩溃
3286
+ return new Promise((resolve, reject) => {
3287
+ const maxAttempts = 10;
3288
+ const startPort = port;
3289
+ let currentPort = startPort;
3290
+ let attempt = 0;
3291
+ // 局部可变 server 引用 — listen 失败后必须重新 createServer 再 listen
3292
+ let currentServer = server;
3293
+ const tryListen = () => {
3294
+ currentServer.removeAllListeners('error');
3295
+ currentServer.once('error', onError);
3296
+ currentServer.listen(currentPort, () => {
3297
+ if (currentPort !== startPort) {
3298
+ console.warn(`⚠ 端口 ${startPort} 被占用,已自动切换到 ${currentPort}`);
3299
+ }
3300
+ console.log(`Web 服务器启动完成: http://localhost:${currentPort}`);
3301
+ console.log('服务器已监听');
3302
+ // 安装 chat bus -> SSE 桥 (供前端 inbox UI 实时刷新)
3303
+ void installChatBusHook();
3304
+ setInterval(() => {
3305
+ for (const client of sseClients) {
3306
+ client.res.write(': ping\n\n');
3307
+ }
3308
+ }, 30000);
3309
+ resolve({ app, server: currentServer, port: currentPort });
3310
+ });
3311
+ };
3312
+ const onError = (err) => {
3313
+ if (err && err.code === 'EADDRINUSE' && attempt < maxAttempts - 1) {
3314
+ attempt += 1;
3315
+ const nextPort = currentPort + 1;
3316
+ console.log(`⚠ 端口 ${currentPort} 被占用,尝试 ${nextPort}...`);
3317
+ try {
3318
+ currentServer.close();
3319
+ }
3320
+ catch { /* ignore */ }
3321
+ // 重新创建 server 实例(listen 失败后原 server 无法再次 listen)
3322
+ currentServer = createServer(app);
3323
+ currentPort = nextPort;
3324
+ tryListen();
3325
+ }
3326
+ else {
3327
+ reject(err);
3328
+ }
3329
+ };
3330
+ tryListen();
2827
3331
  });
2828
3332
  }
2829
3333
  function broadcast(data, channelId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli.js",
package/src/index.ts CHANGED
@@ -1629,10 +1629,10 @@ async function main() {
1629
1629
  const { createWebServer, openBrowser } = await import('./web/server.js');
1630
1630
 
1631
1631
  s.info(`启动 Web 服务端口 ${port}...`);
1632
- await createWebServer(port);
1632
+ const { port: actualPort } = await createWebServer(port);
1633
1633
 
1634
- s.success(`浏览器已打开 → http://localhost:${port}`);
1635
- openBrowser(`http://localhost:${port}`);
1634
+ s.success(`浏览器已打开 → http://localhost:${actualPort}`);
1635
+ openBrowser(`http://localhost:${actualPort}`);
1636
1636
  } else if (isNonInteractive) {
1637
1637
  console.log = originalLog;
1638
1638
  console.info = originalInfo;