@bolloon/bolloon-agent 0.3.7 → 0.3.9

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.
@@ -34,6 +34,10 @@
34
34
  <span class="api-tab-icon">🎵</span>
35
35
  <span>音频生成</span>
36
36
  </button>
37
+ <button class="api-tab" data-tab="engines" onclick="switchTab('engines')">
38
+ <span class="api-tab-icon">🤖</span>
39
+ <span>外部智能体</span>
40
+ </button>
37
41
  </div>
38
42
 
39
43
  <!-- LLM 面板 -->
@@ -63,6 +67,16 @@
63
67
  </div>
64
68
  </div>
65
69
 
70
+ <!-- 外部智能体面板 -->
71
+ <div class="api-panel" id="panel-engines" data-panel="engines" style="display: none;">
72
+ <div class="video-intro">
73
+ <p>Bolloon 自动发现本机已安装的 AI 编码工具(Codex / Claude Code / OpenCode / OpenClaw / Hermes)与实验目录声明的 API。可在此筛选模型并把它们的 API 当作 Bolloon 的供应商一键导入,无需重复填 Key。委派执行请通过对话里的 <code>delegate_to_engine</code> 工具。</p>
74
+ </div>
75
+ <div class="provider-list" id="engineList">
76
+ <div class="loading-state">加载中...</div>
77
+ </div>
78
+ </div>
79
+
66
80
  <!-- 配置弹窗 -->
67
81
  <div class="modal-overlay" id="configModal" style="display: none;">
68
82
  <div class="modal-box">
@@ -186,6 +200,62 @@
186
200
  </div>
187
201
  </div>
188
202
 
203
+ <!-- 外部智能体 配置弹窗 -->
204
+ <div class="modal-overlay" id="engineModal" style="display: none;">
205
+ <div class="modal-box">
206
+ <div class="modal-header">
207
+ <div class="modal-title-group">
208
+ <div class="modal-icon" id="engineModalIcon">A</div>
209
+ <div>
210
+ <h2 id="engineModalTitle">配置</h2>
211
+ <p class="modal-subtitle" id="engineModalSubtitle">描述</p>
212
+ </div>
213
+ </div>
214
+ <button class="modal-close" onclick="closeEngineModal()">×</button>
215
+ </div>
216
+
217
+ <form class="modal-form" id="engineConfigForm">
218
+ <div class="form-group">
219
+ <label>映射供应商</label>
220
+ <select id="engineProviderInput"></select>
221
+ <p class="form-hint">导入时写进该 Bolloon 供应商 (可覆盖发现的映射)</p>
222
+ </div>
223
+
224
+ <div class="form-group">
225
+ <label>API Key</label>
226
+ <input type="password" id="engineApiKeyInput" placeholder="输入 API Key">
227
+ <p class="form-hint" id="engineApiKeyHint">已有 Key 会保留,输入新值以更新</p>
228
+ </div>
229
+
230
+ <div class="form-group">
231
+ <label>Base URL</label>
232
+ <input type="text" id="engineBaseUrlInput" placeholder="https://api.example.com/v1">
233
+ <p class="form-hint">留空使用默认地址</p>
234
+ </div>
235
+
236
+ <div class="form-group">
237
+ <label>模型(可筛选)</label>
238
+ <div class="combobox" id="engineModelCombobox">
239
+ <input type="text" id="engineModelInput" placeholder="输入关键字筛选模型…" autocomplete="off">
240
+ <div class="combobox-list" id="engineModelList" style="display: none;"></div>
241
+ </div>
242
+ <p class="form-hint" id="engineModelHint">从发现到的候选模型里筛选,或手动输入自定义模型名</p>
243
+ </div>
244
+
245
+ <div class="form-group">
246
+ <button type="button" class="test-btn" id="engineImportBtn" onclick="importEngine()">
247
+ ⚡ 导入为供应商
248
+ </button>
249
+ <div class="test-result" id="engineImportResult" style="display: none;"></div>
250
+ </div>
251
+
252
+ <div class="form-actions">
253
+ <button type="button" class="btn-cancel" onclick="closeEngineModal()">取消</button>
254
+ </div>
255
+ </form>
256
+ </div>
257
+ </div>
258
+
189
259
  <script>
190
260
  // 当前 tab
191
261
  let currentTab = 'llm';
@@ -194,7 +264,9 @@
194
264
  let llmConfigData = null;
195
265
  let videoConfigData = null;
196
266
  let audioConfigData = null;
267
+ let engineData = null;
197
268
  let currentProvider = null;
269
+ let currentEngine = null;
198
270
  let currentProviderType = 'llm'; // 'llm' | 'video' | 'audio'
199
271
 
200
272
  // ==================== Tab 切换 ====================
@@ -211,6 +283,13 @@
211
283
  }
212
284
 
213
285
  function updateCountBadge() {
286
+ if (currentTab === 'engines') {
287
+ const list = engineData && engineData.engines ? engineData.engines : [];
288
+ const total = list.length;
289
+ const available = list.filter(e => e.available).length;
290
+ document.getElementById('configCount').textContent = available + '/' + total + ' 智能体可用';
291
+ return;
292
+ }
214
293
  const data = currentTab === 'llm' ? llmConfigData :
215
294
  currentTab === 'video' ? videoConfigData : audioConfigData;
216
295
  if (!data) {
@@ -235,7 +314,19 @@
235
314
 
236
315
  // ==================== 加载 ====================
237
316
  async function loadAll() {
238
- await Promise.all([loadLLMConfig(), loadVideoConfig(), loadAudioConfig()]);
317
+ await Promise.all([loadLLMConfig(), loadVideoConfig(), loadAudioConfig(), loadEngines()]);
318
+ }
319
+
320
+ async function loadEngines() {
321
+ try {
322
+ const resp = await fetch('/api/external-engines');
323
+ engineData = await resp.json();
324
+ renderEngines();
325
+ if (currentTab === 'engines') updateCountBadge();
326
+ } catch (err) {
327
+ const el = document.getElementById('engineList');
328
+ if (el) el.innerHTML = '<div class="error-state">加载失败: ' + err.message + '</div>';
329
+ }
239
330
  }
240
331
 
241
332
  async function loadLLMConfig() {
@@ -518,6 +609,164 @@
518
609
  }
519
610
  };
520
611
 
612
+ // ==================== 外部智能体 渲染 ====================
613
+ function renderEngines() {
614
+ const listEl = document.getElementById('engineList');
615
+ if (!listEl) return;
616
+ const list = engineData && engineData.engines ? engineData.engines : [];
617
+ if (list.length === 0) {
618
+ listEl.innerHTML = '<div class="error-state">未发现本机已安装的外部编码智能体,也未找到实验目录 API(BOLLOON_EXPERIMENT_API_DIR)</div>';
619
+ return;
620
+ }
621
+ let html = '';
622
+ for (const e of list) {
623
+ const isExperiment = String(e.id).startsWith('experiment:');
624
+ let statusText, statusClass;
625
+ if (e.available) { statusText = '可用'; statusClass = 'status-green'; }
626
+ else if (e.installed) { statusText = '已装未配'; statusClass = 'status-yellow'; }
627
+ else if (e.configured) { statusText = '已配未装'; statusClass = 'status-yellow'; }
628
+ else { statusText = '未发现'; statusClass = 'status-muted'; }
629
+
630
+ html += `
631
+ <div class="provider-card ${e.available ? 'active' : ''}" onclick="openEngineModal('${e.id}')">
632
+ <div class="provider-header">
633
+ <div class="provider-icon">${isExperiment ? '🧪' : e.displayName.charAt(0).toUpperCase()}</div>
634
+ <div class="provider-info">
635
+ <h3 class="provider-name">${e.displayName}</h3>
636
+ <p class="provider-desc">${e.id}${e.provider ? ' → ' + e.provider : ''}${e.cliPath ? ' · 已安装' : ''}</p>
637
+ </div>
638
+ <div class="status-badge ${statusClass}">
639
+ <span class="status-dot"></span>
640
+ ${statusText}
641
+ </div>
642
+ </div>
643
+ <div class="provider-footer">
644
+ <div class="config-status">
645
+ <span class="status-icon ${e.configured ? 'configured' : ''}">${e.configured ? '✓' : '○'}</span>
646
+ ${e.configured ? '已配置 API' : '未配置 API'}
647
+ </div>
648
+ ${e.model ? '<div class="model-info">模型: ' + e.model + '</div>' : ''}
649
+ ${e.models && e.models.length ? '<div class="model-info">候选模型: ' + e.models.length + ' 个(可筛选)</div>' : ''}
650
+ <div class="arrow-indicator">›</div>
651
+ </div>
652
+ </div>
653
+ `;
654
+ }
655
+ listEl.innerHTML = html;
656
+ }
657
+
658
+ // ==================== 外部智能体 配置弹窗 ====================
659
+ const ENGINE_PROVIDER_FALLBACK = ['openai','anthropic','ollama','openrouter','gemini','minimax','deepseek','kimi','glm','qwen','mimo','local'];
660
+
661
+ function openEngineModal(id) {
662
+ const list = engineData && engineData.engines ? engineData.engines : [];
663
+ const e = list.find(x => x.id === id);
664
+ if (!e) return;
665
+ currentEngine = e;
666
+
667
+ document.getElementById('engineModalIcon').textContent = String(e.displayName).charAt(0).toUpperCase();
668
+ document.getElementById('engineModalTitle').textContent = '配置 ' + e.displayName;
669
+ document.getElementById('engineModalSubtitle').textContent = (e.notes || '') + (e.configPath ? ' · 配置: ' + e.configPath : '');
670
+
671
+ // 映射供应商 select
672
+ const provSel = document.getElementById('engineProviderInput');
673
+ const provKeys = (llmConfigData && llmConfigData.providers) ? Object.keys(llmConfigData.providers) : ENGINE_PROVIDER_FALLBACK;
674
+ const mapped = e.provider || 'openai';
675
+ provSel.innerHTML = provKeys.map(k => `<option value="${k}" ${k === mapped ? 'selected' : ''}>${k}</option>`).join('');
676
+
677
+ document.getElementById('engineApiKeyInput').value = '';
678
+ document.getElementById('engineApiKeyHint').textContent = e.apiKey
679
+ ? '当前已发现 (***' + (e.apiKey.slice(-4) || '') + '),输入新值以更新'
680
+ : '输入 API Key';
681
+ document.getElementById('engineBaseUrlInput').value = e.baseUrl || '';
682
+ document.getElementById('engineModelInput').value = e.model || '';
683
+ document.getElementById('engineModelHint').textContent = (e.models && e.models.length)
684
+ ? '从 ' + e.models.length + ' 个候选模型里筛选,或手动输入'
685
+ : '手动输入模型名';
686
+
687
+ document.getElementById('engineImportResult').style.display = 'none';
688
+ document.getElementById('engineModal').style.display = 'flex';
689
+ // 准备模型筛选下拉
690
+ renderEngineModelList('');
691
+ setTimeout(() => document.getElementById('engineModelInput').focus(), 50);
692
+ }
693
+
694
+ function closeEngineModal() {
695
+ document.getElementById('engineModal').style.display = 'none';
696
+ currentEngine = null;
697
+ document.getElementById('engineModelList').style.display = 'none';
698
+ }
699
+
700
+ // 模型筛选下拉
701
+ function renderEngineModelList(filter) {
702
+ const listEl = document.getElementById('engineModelList');
703
+ if (!currentEngine || !currentEngine.models) { listEl.style.display = 'none'; return; }
704
+ const f = (filter || '').toLowerCase();
705
+ const matched = currentEngine.models.filter(m => m.toLowerCase().includes(f));
706
+ if (matched.length === 0) { listEl.style.display = 'none'; return; }
707
+ listEl.innerHTML = matched.map(m => `<div class="combobox-option" onclick="selectEngineModel('${m.replace(/'/g, "\\'")}')">${m}</div>`).join('');
708
+ listEl.style.display = 'block';
709
+ }
710
+
711
+ function selectEngineModel(model) {
712
+ document.getElementById('engineModelInput').value = model;
713
+ document.getElementById('engineModelList').style.display = 'none';
714
+ }
715
+
716
+ document.getElementById('engineModelInput').addEventListener('input', (ev) => {
717
+ renderEngineModelList(ev.target.value);
718
+ });
719
+ document.getElementById('engineModelInput').addEventListener('focus', (ev) => {
720
+ renderEngineModelList(ev.target.value);
721
+ });
722
+ // 点击空白关闭下拉
723
+ document.addEventListener('click', (ev) => {
724
+ const cb = document.getElementById('engineModelCombobox');
725
+ if (cb && !cb.contains(ev.target)) {
726
+ document.getElementById('engineModelList').style.display = 'none';
727
+ }
728
+ });
729
+ document.getElementById('engineModal').addEventListener('click', (ev) => {
730
+ if (ev.target === document.getElementById('engineModal')) closeEngineModal();
731
+ });
732
+
733
+ async function importEngine() {
734
+ if (!currentEngine) return;
735
+ const btn = document.getElementById('engineImportBtn');
736
+ const result = document.getElementById('engineImportResult');
737
+ btn.disabled = true;
738
+ requestAnimationFrame(() => { btn.textContent = '⚡ 导入中...'; });
739
+ result.style.display = 'none';
740
+
741
+ const body = {
742
+ id: currentEngine.id,
743
+ model: document.getElementById('engineModelInput').value.trim(),
744
+ provider: document.getElementById('engineProviderInput').value,
745
+ };
746
+
747
+ try {
748
+ const resp = await fetch('/api/external-engines/import', {
749
+ method: 'POST',
750
+ headers: { 'Content-Type': 'application/json' },
751
+ body: JSON.stringify(body),
752
+ });
753
+ const data = await resp.json();
754
+ if (!resp.ok) throw new Error(data.error || '导入失败');
755
+ result.className = 'test-result test-success';
756
+ result.innerHTML = '<span>✓</span> 已导入为供应商: ' + (data.provider || '') +
757
+ (data.autoActivated ? '(已激活)' : '') + ',可在「LLM 对话」tab 选用';
758
+ result.style.display = 'flex';
759
+ // 刷新 LLM 配置 + 引擎列表
760
+ await Promise.all([loadLLMConfig(), loadEngines()]);
761
+ } catch (err) {
762
+ result.className = 'test-result test-error';
763
+ result.innerHTML = '<span>✗</span> ' + (err.message || '导入失败');
764
+ result.style.display = 'flex';
765
+ }
766
+ btn.disabled = false;
767
+ requestAnimationFrame(() => { btn.textContent = '⚡ 导入为供应商'; });
768
+ }
769
+
521
770
  // ==================== 关闭弹窗 ====================
522
771
  document.getElementById('configModal').onclick = function(e) {
523
772
  if (e.target === this) closeModal();
@@ -2588,7 +2588,12 @@ ${data.error || "channel not found"}`, "error");
2588
2588
  const allPreviews = container.querySelectorAll(".message-ai.preview");
2589
2589
  allPreviews.forEach((el) => el.remove());
2590
2590
  currentPreviewBubble = null;
2591
- addMessage2(data.content || "", "ai", true, container, lastUsedJudgmentIds || []);
2591
+ if (!MR_hasStreamingText()) {
2592
+ addMessage2(data.content || "", "ai", true, container, lastUsedJudgmentIds || []);
2593
+ } else {
2594
+ MR_replaceStreamingText?.(data.content || "");
2595
+ MR_finalizeTimelineAsMessage(getRendererCtx());
2596
+ }
2592
2597
  } else if (data.type === "reply-preview") {
2593
2598
  const previewContent = data.content || "";
2594
2599
  const oldPreviews = container.querySelectorAll(".message-ai.preview");
@@ -0,0 +1,111 @@
1
+ /**
2
+ * routes-external-engines.ts — 外部编码智能体 配置/委派 路由
3
+ *
4
+ * 三个能力:
5
+ * GET /api/external-engines 发现本机已装的引擎 (脱敏)
6
+ * POST /api/external-engines/import 把发现的引擎 API 写进 Bolloon provider 体系 (当供应商)
7
+ * POST /api/external-engines/run 委派编码任务给引擎 CLI (子智能体)
8
+ *
9
+ * 复用 routes-llm-config.ts 的 llmConfigStore + initMinimax 做激活.
10
+ */
11
+ import { discoverEngines, mapEngineToProviderConfig, resolveProvider } from '../external-engines/index.js';
12
+ import { llmConfigStore } from '../llm/config-store.js';
13
+ import { initMinimax } from '../constraints/index.js';
14
+ function maskKey(key) {
15
+ if (!key)
16
+ return '';
17
+ if (key.length <= 4)
18
+ return '***';
19
+ return '***' + key.slice(-4);
20
+ }
21
+ export function registerExternalEngineRoutes(app) {
22
+ // ==================== 发现 ====================
23
+ app.get('/api/external-engines', async (_req, res) => {
24
+ try {
25
+ const engines = await discoverEngines();
26
+ const safe = engines.map((e) => ({
27
+ ...e,
28
+ apiKey: maskKey(e.apiKey),
29
+ }));
30
+ res.json({ engines: safe });
31
+ }
32
+ catch (err) {
33
+ res.status(500).json({ error: err?.message || String(err) });
34
+ }
35
+ });
36
+ // ==================== 导入为供应商 ====================
37
+ app.post('/api/external-engines/import', async (req, res) => {
38
+ try {
39
+ const { id, model, provider: providerOverride } = req.body || {};
40
+ if (!id)
41
+ return res.status(400).json({ error: 'id 必填' });
42
+ const engines = await discoverEngines();
43
+ const engine = engines.find((e) => e.id === id);
44
+ if (!engine)
45
+ return res.status(404).json({ error: `未发现的引擎: ${id}` });
46
+ if (!engine.configured) {
47
+ return res.status(400).json({ error: `引擎 ${id} 未配置 API key / baseUrl, 无法导入为供应商` });
48
+ }
49
+ // 允许前端在导入时覆盖 model / provider (API 配置 UI 里用户筛选模型后传来)
50
+ if (model && typeof model === 'string' && model.trim()) {
51
+ engine.model = model.trim();
52
+ }
53
+ if (providerOverride && typeof providerOverride === 'string' && providerOverride.trim()) {
54
+ const resolved = resolveProvider(providerOverride.trim(), engine.provider || 'openai');
55
+ engine.provider = resolved;
56
+ }
57
+ let importPatch;
58
+ try {
59
+ importPatch = mapEngineToProviderConfig(engine);
60
+ }
61
+ catch (e) {
62
+ return res.status(400).json({ error: e.message });
63
+ }
64
+ const { provider, patch } = importPatch;
65
+ await llmConfigStore.updateProvider(provider, patch);
66
+ // 导入即激活 (若提供了有效 key, 或不需要 key 的 provider)
67
+ const shouldActivate = !!patch.apiKey || !engine.apiKey;
68
+ let autoActivated = false;
69
+ if (shouldActivate) {
70
+ try {
71
+ await llmConfigStore.setActiveProvider(provider);
72
+ initMinimax({
73
+ provider: provider,
74
+ apiKey: patch.apiKey || undefined,
75
+ baseUrl: patch.baseUrl || undefined,
76
+ model: patch.model || undefined,
77
+ });
78
+ autoActivated = true;
79
+ }
80
+ catch (e) {
81
+ // 激活失败不阻断导入 (可能该 provider 仍需别的字段)
82
+ console.warn('[external-engines] 激活失败:', e?.message);
83
+ }
84
+ }
85
+ res.json({ ok: true, provider, autoActivated, imported: { ...patch, apiKey: maskKey(patch.apiKey) } });
86
+ }
87
+ catch (err) {
88
+ res.status(500).json({ error: err?.message || String(err) });
89
+ }
90
+ });
91
+ // ==================== 委派执行 ====================
92
+ app.post('/api/external-engines/run', async (req, res) => {
93
+ try {
94
+ const { id, prompt, cwd, model } = req.body || {};
95
+ if (!id)
96
+ return res.status(400).json({ error: 'id 必填' });
97
+ if (!prompt)
98
+ return res.status(400).json({ error: 'prompt 必填' });
99
+ // 动态 import 避免顶层循环依赖 (delegate -> discovery, 这里反向)
100
+ const { delegateToEngine } = await import('../external-engines/delegate.js');
101
+ const result = await delegateToEngine(id, prompt, {
102
+ cwd,
103
+ ...(model ? { model: String(model) } : {}),
104
+ });
105
+ res.json(result);
106
+ }
107
+ catch (err) {
108
+ res.status(500).json({ error: err?.message || String(err) });
109
+ }
110
+ });
111
+ }
@@ -12,6 +12,7 @@ import { segmentChatReply } from '../agents/chat-segmenter.js';
12
12
  import { listTools } from '../llm/tool-manifest/index.js';
13
13
  import { registerJudgmentsRoutes } from './routes-judgments.js';
14
14
  import { registerLlmConfigRoutes } from './routes-llm-config.js';
15
+ import { registerExternalEngineRoutes } from './routes-external-engines.js';
15
16
  import { registerTaskRoutes } from './routes-tasks.js';
16
17
  import { registerHearthRoutes } from './routes-hearth.js';
17
18
  // 2026-07-06: 类型抽到 ./server-types.ts (channel / session / task / sse client / iroh info / paths)
@@ -217,6 +218,8 @@ async function persistRemoteChannelCache() {
217
218
  loadRemoteChannelCacheFromDisk();
218
219
  // v3: P2PDirect 引用 (Hyperswarm 薄包装) - 模块级, 因为 web server 闭包里不可用
219
220
  let v3P2PRef = null;
221
+ // 2026-07-21: 智能体社交心跳实例 (beacon + 自主决策发起对话), data 事件处理器会引用它
222
+ let agentHeartbeat = null;
220
223
  // 2026-06-10: watchdog 提升到 module-level, 让 broadcast() / 模块级业务函数能埋点喂活动
221
224
  // 之前在 createWebServer 闭包内, 闭包外的 broadcast() 拿不到 → 误判 30min 无活动 → 自杀.
222
225
  let watchdogRef = null;
@@ -1239,6 +1242,13 @@ function cleanupAndExit(signal) {
1239
1242
  return;
1240
1243
  cleanupDone = true;
1241
1244
  console.log(`[server] 收到 ${signal}, 开始清理...`);
1245
+ // 优雅停止社交心跳: 清理 beacon/social 定时器, 防止进程退出前仍一直社交
1246
+ try {
1247
+ agentHeartbeat?.stop();
1248
+ }
1249
+ catch (e) {
1250
+ console.warn('[heartbeat] 停止失败:', e?.message);
1251
+ }
1242
1252
  try {
1243
1253
  fsSync.unlinkSync(LOCK_PATH);
1244
1254
  }
@@ -1415,6 +1425,11 @@ export async function createWebServer(port = 3000, options = {}) {
1415
1425
  }, 'p2p-global');
1416
1426
  return;
1417
1427
  }
1428
+ // 2026-07-21: 社交心跳 beacon — 远端智能体宣告存活/能力, 更新本地 liveness
1429
+ if (parsed.op === 'agent.heartbeat') {
1430
+ agentHeartbeat?.handleIncoming('agent.heartbeat', parsed.payload, evt.fromPublicKey);
1431
+ return;
1432
+ }
1418
1433
  // v3 新增: B 端收到 A 的 thinking (开始 + 流式 token)
1419
1434
  if (parsed.op === 'agent.chat.thinking') {
1420
1435
  const phase = parsed.payload?.phase;
@@ -1621,6 +1636,158 @@ export async function createWebServer(port = 3000, options = {}) {
1621
1636
  console.error('[v3-P2PDirect] 解析/处理消息失败:', err.message);
1622
1637
  }
1623
1638
  });
1639
+ // === 2026-07-21: 智能体社交心跳 (beacon + 自主决策发起对话) ===
1640
+ // beacon 周期向已知 peer 宣告存活/能力; social 循环让本地 agent 自主决定跟哪个远端智能体发起对话.
1641
+ // 远端唤醒/回复链路已存在 (agent.chat.send → server.ts:529 跑 LLM → agent.chat.reply → SSE remote-chat-reply).
1642
+ try {
1643
+ const { AgentHeartbeat } = await import('../social/agent-heartbeat.js');
1644
+ const socialOn = process.env.BOLLOON_AGENT_HEARTBEAT_SOCIAL !== '0';
1645
+ const myName = await (async () => {
1646
+ let n = process.env.BOLLOON_USER_NAME || process.env.USER || 'node';
1647
+ try {
1648
+ const { readFileSync, existsSync } = await import('fs');
1649
+ const cfgPath = `${process.env.HOME || '/tmp'}/.bolloon/config.json`;
1650
+ if (existsSync(cfgPath)) {
1651
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8'));
1652
+ if (cfg.userName)
1653
+ n = cfg.userName;
1654
+ }
1655
+ }
1656
+ catch { }
1657
+ return n;
1658
+ })();
1659
+ agentHeartbeat = new AgentHeartbeat({
1660
+ enabled: true,
1661
+ socialEnabled: socialOn,
1662
+ beaconIntervalMs: Number(process.env.BOLLOON_HEARTBEAT_BEACON_MS) || 30_000,
1663
+ socialIntervalMs: Number(process.env.BOLLOON_HEARTBEAT_SOCIAL_MS) || 120_000,
1664
+ cooldownMs: Number(process.env.BOLLOON_HEARTBEAT_COOLDOWN_MS) || 10 * 60_000,
1665
+ self: async () => {
1666
+ const channels = await loadChannels();
1667
+ const myPk = v3P2PRef?.getPublicKey() || '';
1668
+ return {
1669
+ publicKey: myPk,
1670
+ agentId: channels[0]?.agentId,
1671
+ name: myName,
1672
+ channels: channels.map((c) => ({ id: c.id, name: c.name })),
1673
+ };
1674
+ },
1675
+ getPeers: async () => {
1676
+ const { listPeers } = await import('../network/known-peers.js');
1677
+ const kp = await listPeers();
1678
+ const myPk = v3P2PRef?.getPublicKey() || '';
1679
+ const peers = [];
1680
+ for (const p of kp) {
1681
+ if (p.publicKey === myPk)
1682
+ continue;
1683
+ const cached = remoteChannelCache.get(p.publicKey) || [];
1684
+ peers.push({
1685
+ publicKey: p.publicKey,
1686
+ name: p.name,
1687
+ channels: cached.map((c) => ({ id: c.id, name: c.name })),
1688
+ });
1689
+ }
1690
+ return peers;
1691
+ },
1692
+ transport: {
1693
+ send: async (pk, op, payload) => {
1694
+ const { sendOrQueue } = await import('../network/p2p-outbox.js');
1695
+ return sendOrQueue(pk, op, payload, v3P2PRef);
1696
+ },
1697
+ },
1698
+ decide: socialOn ? llmSocialDecide : undefined,
1699
+ // 目标: 社交服务于"与网络中的其他智能体建立并维持协作". 配额/效果阈值防止一直社交.
1700
+ // owner 可通过 env BOLLOON_AGENT_GOAL 覆盖描述; 也可经 RPC setGoal 运行时注入.
1701
+ getGoal: async () => ({
1702
+ id: 'owner-collab',
1703
+ description: process.env.BOLLOON_AGENT_GOAL || '与网络中的其他智能体建立并维持协作关系, 主动分享进展并获取所需信息',
1704
+ maxInitiations: Number(process.env.BOLLOON_HEARTBEAT_GOAL_MAX) || 8,
1705
+ effectThreshold: Number(process.env.BOLLOON_HEARTBEAT_GOAL_EFFECT) || 3,
1706
+ }),
1707
+ // 效果度量: 远端回了非空且有实质内容的消息, 视为推进了目标 (生产可换 LLM 判定 achievedGoal)
1708
+ assessEffect: ({ replyText }) => {
1709
+ const t = (replyText || '').trim();
1710
+ return { advanced: t.length > 0, achievedGoal: false };
1711
+ },
1712
+ onPeerAlive: (peer) => {
1713
+ broadcast({
1714
+ type: 'peer-heartbeat',
1715
+ fromPublicKey: peer.publicKey,
1716
+ name: peer.name,
1717
+ channels: peer.channels,
1718
+ ts: Date.now(),
1719
+ }, 'p2p-global');
1720
+ },
1721
+ // 每次社交 tick 喂给 24h 看门狗, 防止误判卡死重启
1722
+ onActivity: () => {
1723
+ try {
1724
+ watchdogRef?.recordActivity?.('agent-heartbeat');
1725
+ }
1726
+ catch { }
1727
+ },
1728
+ // 生命周期阶段变化 → 推 SSE 给前端展示
1729
+ onLifecycleChange: (phase, snap) => {
1730
+ broadcast({
1731
+ type: 'agent-lifecycle',
1732
+ phase,
1733
+ snapshot: snap,
1734
+ ts: Date.now(),
1735
+ }, 'p2p-global');
1736
+ },
1737
+ });
1738
+ agentHeartbeat.start();
1739
+ // 注册到全局, 让 24h HealthMonitor.checkHeartbeat 能观测到本智能体 (getDiscoveredAgents/isAntColonyEnabled)
1740
+ global.socialHeartbeat = agentHeartbeat;
1741
+ global.agentHeartbeat = agentHeartbeat;
1742
+ }
1743
+ catch (hbErr) {
1744
+ console.warn('[heartbeat] 启动失败 (non-fatal):', hbErr?.message);
1745
+ }
1746
+ // 社交决策: 让本地 agent (用第一个本地 channel 的身份) 判断是否主动联络某 peer
1747
+ // 目标感知: ctx.goal 是当前要达成的目标, 决策应服务于它, 达成后可声明 goalAchieved 进入 RESTING
1748
+ async function llmSocialDecide(ctx) {
1749
+ try {
1750
+ const channels = await loadChannels();
1751
+ const local = channels[0];
1752
+ if (!local)
1753
+ return { initiate: false };
1754
+ const agent = await getAgentForChannel(local.id, local.did || '', local.name, local.didDocRef);
1755
+ const peerLines = ctx.peers
1756
+ .map((p) => `- ${p.name || p.publicKey.slice(0, 8)} (pk=${p.publicKey.slice(0, 12)}…): 渠道[${p.channels.map((c) => c.name).join(', ') || '无'}]`)
1757
+ .join('\n');
1758
+ const goalDesc = ctx.goal ? `当前目标: ${ctx.goal.description} (已发起 ${ctx.goal.initiationsUsed}/${ctx.goal.maxInitiations}, 有效回复 ${ctx.goal.effectfulReplies}/${ctx.goal.effectThreshold})` : '当前无明确目标';
1759
+ const prompt = `你是智能体「${ctx.self.name || '本地智能体'}」。你通过 P2P 网络认识以下其他智能体:
1760
+ ${peerLines}
1761
+
1762
+ ${goalDesc}
1763
+
1764
+ 规则:
1765
+ 1. 社交是为了达成上述目标, 不是闲聊。只在你有真正有价值的信息要分享/询问、且能推进目标时才主动发起。
1766
+ 2. 不要重复最近已经聊过的话题, 不要每条心跳都发消息, 保持克制。
1767
+ 3. 如果目标已经通过已有交流达成 (或你认为无需再聊), 输出 {"initiate": false, "goalAchieved": true}。
1768
+ 4. 如果决定发起, 选一个最合适的目标渠道 (用对方渠道的真实 id)。
1769
+
1770
+ 现在是否要主动联系其中某个智能体? 只输出一个 JSON 对象, 不要任何其他文字:
1771
+ {"initiate": true 或 false, "goalAchieved": true 或 false, "targetPeerPublicKey": "对方 pk", "targetChannelId": "对方渠道 id", "message": "你要说的话"}
1772
+ 若不想发起, 输出 {"initiate": false}。`;
1773
+ const raw = await agent.promptStream(prompt, () => { }, undefined, local.id);
1774
+ const m = raw.match(/\{[\s\S]*\}/);
1775
+ if (!m)
1776
+ return { initiate: false };
1777
+ const obj = JSON.parse(m[0]);
1778
+ return {
1779
+ initiate: !!obj.initiate,
1780
+ goalAchieved: !!obj.goalAchieved,
1781
+ targetPeerPublicKey: obj.targetPeerPublicKey,
1782
+ targetChannelId: obj.targetChannelId,
1783
+ message: obj.message,
1784
+ };
1785
+ }
1786
+ catch (err) {
1787
+ console.warn('[heartbeat] 社交决策 LLM 失败 (跳过本次发起):', err?.message);
1788
+ return { initiate: false };
1789
+ }
1790
+ }
1624
1791
  // 新连接进来 → 主动发我分享给 ta 的 channel 列表
1625
1792
  v3P2PRef.on('connection', (evt) => {
1626
1793
  // 2026-06-10: 喂 watchdog —— 新连接到来是真实业务活动
@@ -3763,6 +3930,9 @@ export async function createWebServer(port = 3000, options = {}) {
3763
3930
  registerTaskRoutes(app, { broadcast, getAgentForChannel });
3764
3931
  // 2026-07-06: LLM/Video/Audio 配置路由抽到 ./routes-llm-config.ts
3765
3932
  registerLlmConfigRoutes(app);
3933
+ // 2026-07-22: 外部编码智能体 (codex/claude-code/opencode/openclaw/hermes/实验 API)
3934
+ // 发现 + 配置为供应商 + 委派
3935
+ registerExternalEngineRoutes(app);
3766
3936
  // ==================== P2P Network API ====================
3767
3937
  // 获取当前身份
3768
3938
  app.get('/api/identity', async (_req, res) => {