@bolloon/bolloon-agent 0.3.26 → 0.3.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.
- package/dist/agents/decision-store.js +247 -0
- package/dist/agents/pi-sdk-tools.js +419 -0
- package/dist/bootstrap/context-os.js +213 -0
- package/dist/bootstrap/lifecycle-hooks.js +8 -2
- package/dist/bootstrap/memory-compressor.js +126 -1
- package/dist/bootstrap/persona-loader.js +88 -5
- package/dist/pi-ecosystem-mcp/index.js +153 -37
- package/dist/security/tool-gate.js +10 -0
- package/dist/web/client.js +119 -9
- package/dist/web/server-storage.js +9 -2
- package/dist/web/server.js +570 -36
- package/package.json +1 -1
package/dist/web/server.js
CHANGED
|
@@ -151,6 +151,9 @@ let sseClients = new Set();
|
|
|
151
151
|
// v3: 远端 channel UI 元数据缓存 — key: peerId, value: sanitize 过的 channel 列表
|
|
152
152
|
// in-memory only, 进程重启清空 (judgment 内容永远不在这里)
|
|
153
153
|
let remoteChannelCache = new Map();
|
|
154
|
+
// 2026-08-02: channel 运行状态 (running/queue/abort/续看) — 模块级提升,
|
|
155
|
+
// triggerRemoteFollowup (模块级) 也要访问. createWebServer 启动时 clear.
|
|
156
|
+
let channelRunState = new Map();
|
|
154
157
|
// 2026-08-02: 待处理好友申请 (pending friend requests) — 收到 agent.friend.request 时暂存,
|
|
155
158
|
// 人类通过 UI 处理, 智能体通过工具 list_pending_friend_requests / accept_friend_request 处理。
|
|
156
159
|
// key: requestId, value: 申请详情 (含 fromPublicKey / name / message / 备注)
|
|
@@ -274,6 +277,49 @@ let watchdogRef = null;
|
|
|
274
277
|
const v3PendingHistoryGets = new Map();
|
|
275
278
|
let channelSessions = new Map(); // key: channelId
|
|
276
279
|
let sessionMessages = new Map(); // key: channelId + sessionId
|
|
280
|
+
// ============ 2026-08-02: 远端对话本地镜像 (替代 localStorage 缓存) ============
|
|
281
|
+
// 问题: localStorage 5MB 上限 + 同步阻塞 + 每浏览器独立; 且对方离线时 chat-history RPC 拉不到.
|
|
282
|
+
// 方案: 服务端磁盘镜像 ~/.bolloon/remote-chat-logs/<peerPk>__<channelId>.json —
|
|
283
|
+
// · 本地 @ 发出 (remote-chat-sent) → 写镜像 (source: local-sent)
|
|
284
|
+
// · 收到回复 (chat.reply) → 写镜像 (source: remote-reply)
|
|
285
|
+
// · chat-history API 先读镜像 (立即返回, 离线可读), 后台 RPC 增量合并对端历史
|
|
286
|
+
const REMOTE_CHAT_LOG_DIR = `${process.env.HOME || '/tmp'}/.bolloon/remote-chat-logs`;
|
|
287
|
+
async function readRemoteChatLog(peerPk, channelId) {
|
|
288
|
+
try {
|
|
289
|
+
const { readFile } = await import('fs/promises');
|
|
290
|
+
const p = `${REMOTE_CHAT_LOG_DIR}/${peerPk}__${channelId}.json`;
|
|
291
|
+
const raw = await readFile(p, 'utf-8');
|
|
292
|
+
const arr = JSON.parse(raw);
|
|
293
|
+
return Array.isArray(arr) ? arr : [];
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return [];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async function appendRemoteChatLog(peerPk, channelId, entry) {
|
|
300
|
+
try {
|
|
301
|
+
const { mkdir, readFile, writeFile } = await import('fs/promises');
|
|
302
|
+
await mkdir(REMOTE_CHAT_LOG_DIR, { recursive: true });
|
|
303
|
+
const p = `${REMOTE_CHAT_LOG_DIR}/${peerPk}__${channelId}.json`;
|
|
304
|
+
let arr = [];
|
|
305
|
+
try {
|
|
306
|
+
arr = JSON.parse(await readFile(p, 'utf-8'));
|
|
307
|
+
}
|
|
308
|
+
catch { }
|
|
309
|
+
if (!Array.isArray(arr))
|
|
310
|
+
arr = [];
|
|
311
|
+
// 去重: 同 source + content + timestamp 跳过
|
|
312
|
+
const dup = arr.some(m => m.source === entry.source && m.content === entry.content && m.timestamp === entry.timestamp);
|
|
313
|
+
if (!dup) {
|
|
314
|
+
arr.push(entry);
|
|
315
|
+
// 防无限增长: 保留最近 500 条
|
|
316
|
+
if (arr.length > 500)
|
|
317
|
+
arr = arr.slice(-500);
|
|
318
|
+
await writeFile(p, JSON.stringify(arr, null, 2), 'utf-8');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
catch { /* 镜像失败不阻塞 */ }
|
|
322
|
+
}
|
|
277
323
|
/**
|
|
278
324
|
* v3 重做: 构造 channel 的两路 judgment prompt 片段
|
|
279
325
|
* 路 1: 用户在盾牌里手动绑定的 judgment (channel.bound_judgment_ids)
|
|
@@ -325,9 +371,12 @@ function isSharedWith(ch, peerPublicKey) {
|
|
|
325
371
|
*/
|
|
326
372
|
async function routeMentionsInReply(originChannelId, replyText, localChannels, remoteChannels) {
|
|
327
373
|
const results = [];
|
|
328
|
-
// 解析: 匹配 @渠道名 后面跟一段文字 (
|
|
374
|
+
// 解析: 匹配 @渠道名 后面跟一段文字 (到行尾 / 下一个 @ / 结束)
|
|
329
375
|
// 渠道名: 中文/英文/数字/下划线/连字符, 1-30 字符
|
|
330
|
-
|
|
376
|
+
// 2026-08-02 fix: 文字部分 [^\n]+? 不跨行 + lookahead 支持 \n 边界 —
|
|
377
|
+
// 原来 lookahead 只认 @ 或 $, AI 回复里 "@渠道名 消息\n\n(解释...)" 尾随说明行
|
|
378
|
+
// 会导致匹配失败, @ 转发静默失效
|
|
379
|
+
const regex = /@([一-龥A-Za-z0-9_\-]{1,30})\s+([^\n]+?)(?=\n|\s*@[一-龥A-Za-z0-9_\-]{1,30}\s|$)/g;
|
|
331
380
|
const matches = [...replyText.matchAll(regex)];
|
|
332
381
|
if (matches.length === 0)
|
|
333
382
|
return results;
|
|
@@ -404,10 +453,56 @@ async function routeMentionsInReply(originChannelId, replyText, localChannels, r
|
|
|
404
453
|
const r = await sendOrQueue(ownerPk, 'agent.cross.post', rpcPayload, v3P2PRef);
|
|
405
454
|
if (r === 'SENT') {
|
|
406
455
|
console.log(`[v3-cross] (${originChannelName}) @${targetName} → 远端 peer ${ownerPk.substring(0, 12)}... (channelId=${remoteTarget.id})`);
|
|
456
|
+
// 2026-08-02: 激活远端协作续看 — 本地智能体 @ 远端后, 收到对方回复时多看一次
|
|
457
|
+
// (Validator: 判断完成 or 继续), remoteChannelId 用于 reply 事件匹配, maxRounds=3 防死循环
|
|
458
|
+
const rs = channelRunState.get(originChannelId);
|
|
459
|
+
if (rs && !rs.remoteFollowup) {
|
|
460
|
+
rs.remoteFollowup = { rounds: 0, maxRounds: 3, remoteChannelId: remoteTarget.id };
|
|
461
|
+
console.log(`[v3-followup] ${originChannelId} 激活远端协作续看 → ${remoteTarget.id} (maxRounds=3)`);
|
|
462
|
+
}
|
|
463
|
+
// 2026-08-02: @ 消息也要显示在 P2P 对话框 — broadcast remote-chat-sent,
|
|
464
|
+
// 前端 rcm-log 打开且匹配 channelId 时显示"我 → 远端"这条消息
|
|
465
|
+
// 注意: 不能传 'p2p-global' 第二参 — broadcast 会用第二参覆盖 payload.channelId
|
|
466
|
+
broadcast({
|
|
467
|
+
type: 'remote-chat-sent',
|
|
468
|
+
channelId: remoteTarget.id,
|
|
469
|
+
fromPublicKey: ownerPk,
|
|
470
|
+
text,
|
|
471
|
+
originChannelId,
|
|
472
|
+
originChannelName,
|
|
473
|
+
peerName: remoteTarget.name,
|
|
474
|
+
sent: true,
|
|
475
|
+
});
|
|
476
|
+
// 写本地镜像 (替代 localStorage) — 对方离线时对话记录也可读
|
|
477
|
+
appendRemoteChatLog(ownerPk, remoteTarget.id, {
|
|
478
|
+
type: 'user', content: text, timestamp: new Date().toISOString(), source: 'local-sent',
|
|
479
|
+
}).catch(() => { });
|
|
407
480
|
results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'sent' });
|
|
408
481
|
}
|
|
409
482
|
else if (r === 'QUEUED') {
|
|
410
483
|
console.log(`[v3-cross] (${originChannelName}) @${targetName} → 远端 peer ${ownerPk.substring(0, 12)}... 已入队 (对方不在线)`);
|
|
484
|
+
// 入队也激活 — 对方上线后会回复, 同样续看
|
|
485
|
+
const rs = channelRunState.get(originChannelId);
|
|
486
|
+
if (rs && !rs.remoteFollowup) {
|
|
487
|
+
rs.remoteFollowup = { rounds: 0, maxRounds: 3, remoteChannelId: remoteTarget.id };
|
|
488
|
+
console.log(`[v3-followup] ${originChannelId} 激活远端协作续看 (入队 → ${remoteTarget.id})`);
|
|
489
|
+
}
|
|
490
|
+
// 入队也广播 (对方不在线, 显示"已入队")
|
|
491
|
+
broadcast({
|
|
492
|
+
type: 'remote-chat-sent',
|
|
493
|
+
channelId: remoteTarget.id,
|
|
494
|
+
fromPublicKey: ownerPk,
|
|
495
|
+
text,
|
|
496
|
+
originChannelId,
|
|
497
|
+
originChannelName,
|
|
498
|
+
peerName: remoteTarget.name,
|
|
499
|
+
sent: false,
|
|
500
|
+
queued: true,
|
|
501
|
+
});
|
|
502
|
+
// 写本地镜像 (入队也记录)
|
|
503
|
+
appendRemoteChatLog(ownerPk, remoteTarget.id, {
|
|
504
|
+
type: 'user', content: text, timestamp: new Date().toISOString(), source: 'local-sent',
|
|
505
|
+
}).catch(() => { });
|
|
411
506
|
results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'queued' });
|
|
412
507
|
}
|
|
413
508
|
else {
|
|
@@ -425,6 +520,121 @@ async function routeMentionsInReply(originChannelId, replyText, localChannels, r
|
|
|
425
520
|
}
|
|
426
521
|
return results;
|
|
427
522
|
}
|
|
523
|
+
/**
|
|
524
|
+
* 2026-08-02: 远端协作续看 — 收到远端回复后, 本地智能体"多看一次回复".
|
|
525
|
+
* 流程 (与 /message 一致): 构建 context (注入远端回复 + 渠道目录) → promptStream →
|
|
526
|
+
* routeMentionsInReply (LLM 若 @ 则继续转发) → broadcast 显示.
|
|
527
|
+
* LLM 判断: 任务未完成 → 回复里 @ 继续 (下次回复再续看); 完成 → 总结, 协作结束.
|
|
528
|
+
* 防死循环: roundsLeft 由调用方控制 (triggerRemoteFollowup 结束时若还有轮次, 保留
|
|
529
|
+
* remoteFollowup; 否则清除).
|
|
530
|
+
*/
|
|
531
|
+
async function triggerRemoteFollowup(channelId, remoteReply, fromPublicKey, roundsLeft) {
|
|
532
|
+
try {
|
|
533
|
+
const channels = await loadChannels();
|
|
534
|
+
const ch = channels.find((c) => c.id === channelId);
|
|
535
|
+
if (!ch)
|
|
536
|
+
return;
|
|
537
|
+
const agent = await getAgentForChannel(channelId, ch.did || '', ch.name, ch.didDocRef);
|
|
538
|
+
if (!agent)
|
|
539
|
+
return;
|
|
540
|
+
// 构建 context: 远端回复 + 渠道目录 (让 LLM 知道可以 @ 谁继续)
|
|
541
|
+
let contextHint = `[系统上下文] 当前频道名称: ${ch.name}\n`;
|
|
542
|
+
contextHint += `[系统上下文] 你通过 P2P 给远端智能体发了消息, 对方回复如下. 请判断协作是否可以继续:\n`;
|
|
543
|
+
contextHint += ` 对方回复: ${remoteReply.slice(0, 1500)}\n\n`;
|
|
544
|
+
contextHint += `[系统上下文] 协作规则 (本轮为自动续看, 非用户直接消息):\n`;
|
|
545
|
+
contextHint += ` 1. 如果对方回复解决了问题/任务完成 → 给用户总结结果, 不要继续发消息.\n`;
|
|
546
|
+
contextHint += ` 2. 如果对方回复不完整/需要进一步协作 → 在回复中写 "@渠道名 继续的内容" 继续协作 (剩余续看轮次: ${Math.max(0, roundsLeft)}).\n`;
|
|
547
|
+
contextHint += ` 3. 最多再继续 ${Math.max(0, roundsLeft)} 轮, 之后必须总结收尾.\n\n`;
|
|
548
|
+
// 渠道目录 (本地跳过自己 + 远端)
|
|
549
|
+
try {
|
|
550
|
+
const localChs = await loadChannels();
|
|
551
|
+
const remoteForDir = [];
|
|
552
|
+
for (const [peerPk, list] of remoteChannelCache.entries()) {
|
|
553
|
+
for (const rc of list)
|
|
554
|
+
remoteForDir.push({ ...rc, _ownerPublicKey: peerPk });
|
|
555
|
+
}
|
|
556
|
+
if (localChs.length > 0 || remoteForDir.length > 0) {
|
|
557
|
+
contextHint += '[系统上下文] 可用渠道 (回复中写 "@渠道名 消息内容" 可给它们发消息):\n';
|
|
558
|
+
for (const c of localChs) {
|
|
559
|
+
if (c.id === channelId)
|
|
560
|
+
continue;
|
|
561
|
+
contextHint += ` - [本地] @${c.name} (id=${c.id})\n`;
|
|
562
|
+
}
|
|
563
|
+
for (const c of remoteForDir) {
|
|
564
|
+
contextHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0, 8)}…] @${c.name} (id=${c.id})\n`;
|
|
565
|
+
}
|
|
566
|
+
contextHint += '\n';
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
catch { /* 目录失败不阻塞 */ }
|
|
570
|
+
const markedPrompt = `【自动续看】远端智能体的回复需要你判断是否继续协作.\n【远端回复】\n${remoteReply.slice(0, 2000)}\n【回复结束】\n\n${contextHint}`;
|
|
571
|
+
// streamCallback: 广播 thinking/step (让用户看到续看过程), 不广播 token 流
|
|
572
|
+
const streamCallback = (event) => {
|
|
573
|
+
if (event?.type === 'used_judgments')
|
|
574
|
+
return;
|
|
575
|
+
if (event.type === 'step_start' || event.type === 'step_done' || event.type === 'step_error') {
|
|
576
|
+
broadcast({ type: 'followup-step', ...event, channelId }, channelId);
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
const fullResponse = await agent.promptStream(markedPrompt, streamCallback, undefined, channelId);
|
|
580
|
+
if (!fullResponse.trim())
|
|
581
|
+
return;
|
|
582
|
+
// 广播续看结果给 UI
|
|
583
|
+
broadcast({
|
|
584
|
+
type: 'ai',
|
|
585
|
+
content: `🔄 远端智能体回复 (来自 ${fromPublicKey.substring(0, 10)}…):\n${remoteReply.slice(0, 600)}\n\n---\n\n${fullResponse}`,
|
|
586
|
+
followup: true,
|
|
587
|
+
}, channelId);
|
|
588
|
+
// 存 session (作为 ai 消息)
|
|
589
|
+
try {
|
|
590
|
+
const existing = await loadSession(channelId, ch.currentSessionId || 'default');
|
|
591
|
+
const session = existing || { channelId, sessionId: 'default', messages: [], lastUpdated: '' };
|
|
592
|
+
session.messages.push({
|
|
593
|
+
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
594
|
+
type: 'ai',
|
|
595
|
+
content: fullResponse,
|
|
596
|
+
timestamp: new Date().toISOString(),
|
|
597
|
+
source: 'followup',
|
|
598
|
+
});
|
|
599
|
+
session.lastUpdated = new Date().toISOString();
|
|
600
|
+
await saveSession(session);
|
|
601
|
+
}
|
|
602
|
+
catch { /* 存失败不阻塞 */ }
|
|
603
|
+
// 路由 @ 转发 (LLM 若决定继续, 回复里会有 @渠道名)
|
|
604
|
+
try {
|
|
605
|
+
const localChs = await loadChannels();
|
|
606
|
+
const remoteForRoute = [];
|
|
607
|
+
for (const [peerPk, list] of remoteChannelCache.entries()) {
|
|
608
|
+
for (const rc of list)
|
|
609
|
+
remoteForRoute.push({ ...rc, _ownerPublicKey: peerPk });
|
|
610
|
+
}
|
|
611
|
+
const mentions = await routeMentionsInReply(channelId, fullResponse, localChs, remoteForRoute);
|
|
612
|
+
const didContinue = mentions.some((m) => m.status === 'sent' || m.status === 'queued');
|
|
613
|
+
if (!didContinue) {
|
|
614
|
+
// LLM 决定结束 → 清除续看状态
|
|
615
|
+
const rs = channelRunState.get(channelId);
|
|
616
|
+
if (rs)
|
|
617
|
+
rs.remoteFollowup = undefined;
|
|
618
|
+
console.log(`[v3-followup] ${channelId} 本地智能体决定结束协作 (无继续 @)`);
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
console.log(`[v3-followup] ${channelId} 本地智能体继续协作 (${mentions.length} 个 @ 转发)`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
catch (routeErr) {
|
|
625
|
+
console.warn('[v3-followup] 路由 @ 失败:', routeErr?.message?.slice(0, 100));
|
|
626
|
+
const rs = channelRunState.get(channelId);
|
|
627
|
+
if (rs)
|
|
628
|
+
rs.remoteFollowup = undefined;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
console.error(`[v3-followup] ${channelId} 续看失败:`, err?.message?.slice(0, 200));
|
|
633
|
+
const rs = channelRunState.get(channelId);
|
|
634
|
+
if (rs)
|
|
635
|
+
rs.remoteFollowup = undefined;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
428
638
|
/**
|
|
429
639
|
* v3: 处理 Hyperswarm 通道收到的 v3 RPC 消息
|
|
430
640
|
* 设计: 用 HyperswarmCommunicator (DHT topic 自动发现) 取代 iroh 直接 connect
|
|
@@ -1383,6 +1593,8 @@ function checkStaleLock(startPort) {
|
|
|
1383
1593
|
}
|
|
1384
1594
|
export async function createWebServer(port = 3000, options = {}) {
|
|
1385
1595
|
selfImproveEnabled = options.selfImprove ?? false;
|
|
1596
|
+
// 2026-08-02: channelRunState 是模块级 (triggerRemoteFollowup 访问), 每次启动清空避免残留
|
|
1597
|
+
channelRunState.clear();
|
|
1386
1598
|
// 防止 P2P DHT 超时等错误导致进程崩溃
|
|
1387
1599
|
process.on('unhandledRejection', (reason, promise) => {
|
|
1388
1600
|
console.error('[警告] 未处理的 Promise 拒绝:', reason);
|
|
@@ -1402,11 +1614,112 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1402
1614
|
catch (err) {
|
|
1403
1615
|
console.warn('[createWebServer] bootstrap 失败 (非致命):', err);
|
|
1404
1616
|
}
|
|
1617
|
+
// 2026-08-03 (Context OS P5): 初始化资产层 12+3 目录 (幂等, 每层 README 声明职责边界)
|
|
1618
|
+
try {
|
|
1619
|
+
const { ensureContextOsDirs } = await import('../bootstrap/context-os.js');
|
|
1620
|
+
await ensureContextOsDirs();
|
|
1621
|
+
console.log('[createWebServer] Context OS 资产层就绪 (~/.bolloon/context-os/, 12+3 层)');
|
|
1622
|
+
}
|
|
1623
|
+
catch (err) {
|
|
1624
|
+
console.warn('[createWebServer] Context OS 资产层初始化失败 (非致命):', err);
|
|
1625
|
+
}
|
|
1626
|
+
// 2026-08-03: 后台自动安装/启动本地 Kubo (IPFS+IPNS 发布用, 全自动零手动).
|
|
1627
|
+
// fire-and-forget, 不阻塞 server 启动; 首次下载 ~100MB 需 1-2 分钟.
|
|
1628
|
+
// 装好后 publish_did / DID 发布自动走 IPFS+IPNS; 没装好则降级本地模式.
|
|
1629
|
+
(async () => {
|
|
1630
|
+
try {
|
|
1631
|
+
const sdk = await import('@diap/sdk');
|
|
1632
|
+
const checkKuboSetup = sdk.checkKuboSetup;
|
|
1633
|
+
if (typeof checkKuboSetup !== 'function')
|
|
1634
|
+
return;
|
|
1635
|
+
const setup = await checkKuboSetup(true, true);
|
|
1636
|
+
if (setup?.ready && setup?.daemonRunning) {
|
|
1637
|
+
console.log('[ipfs] 本地 Kubo 就绪 (自动安装/启动) → DID 发布支持 IPFS+IPNS');
|
|
1638
|
+
}
|
|
1639
|
+
else {
|
|
1640
|
+
console.log('[ipfs] Kubo 不可用, DID 发布降级本地模式 (可用 publish_did 工具重试)');
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
catch (e) {
|
|
1644
|
+
console.warn('[ipfs] Kubo 自动安装失败 (非致命):', e?.message?.slice(0, 120));
|
|
1645
|
+
}
|
|
1646
|
+
})();
|
|
1647
|
+
// 2026-08-03: 初始化 MCP 适配器 (读 ~/.mcp.json, 自动握手发现工具).
|
|
1648
|
+
// 后台异步: 不阻塞启动, 失败静默 (agent 调 mcp_list_tools 时再触发).
|
|
1649
|
+
(async () => {
|
|
1650
|
+
try {
|
|
1651
|
+
const { initializeMcpAdapter } = await import('../pi-ecosystem-mcp/index.js');
|
|
1652
|
+
await initializeMcpAdapter();
|
|
1653
|
+
}
|
|
1654
|
+
catch (e) {
|
|
1655
|
+
console.warn('[mcp] 初始化失败 (非致命):', e?.message?.slice(0, 120));
|
|
1656
|
+
}
|
|
1657
|
+
})();
|
|
1405
1658
|
// 重置旧的 agent session,确保使用新的 LLM 配置
|
|
1406
1659
|
const { resetAgentSession } = await import('../agents/pi-sdk.js');
|
|
1407
1660
|
resetAgentSession();
|
|
1408
1661
|
// 初始化 LLM(从配置文件读取 MiniMax 配置)
|
|
1409
1662
|
initMinimax();
|
|
1663
|
+
// 2026-08-02 fix: 启动自愈 — 从 agents.json 恢复 channels.json 缺失的 channel.
|
|
1664
|
+
// 背景: UI 创建 channel 偶发不落盘 / 历史并发覆盖丢 channel, 但 agents.json 里
|
|
1665
|
+
// agent 的 channelId + name 还在 (session 文件也在) → 启动时自动恢复, 智能体不再"消失".
|
|
1666
|
+
// 2026-08-02 v2: 抽成函数 healMissingChannels() — 启动 + GET /channels 时都调用.
|
|
1667
|
+
// 之前只启动时跑一次: 启动时 channel 还在 → 跳过, 之后运行中丢失 → 永远不恢复
|
|
1668
|
+
// (用户报告"每次刷新和 build 都会消失" — 刷新后 GET /channels 触发恢复即可自愈).
|
|
1669
|
+
async function healMissingChannels() {
|
|
1670
|
+
try {
|
|
1671
|
+
const { existsSync } = await import('fs');
|
|
1672
|
+
const { readFile } = await import('fs/promises');
|
|
1673
|
+
const agentsFile = `${process.env.HOME || '/tmp'}/.bolloon/agents/agents.json`;
|
|
1674
|
+
const sessionsDir = `${process.env.HOME || '/tmp'}/.bolloon/sessions/cache`;
|
|
1675
|
+
if (!existsSync(agentsFile))
|
|
1676
|
+
return 0;
|
|
1677
|
+
const agentsRaw = await readFile(agentsFile, 'utf-8');
|
|
1678
|
+
const agentsArr = JSON.parse(agentsRaw);
|
|
1679
|
+
const arr = Array.isArray(agentsArr) ? agentsArr : [];
|
|
1680
|
+
const chs = await loadChannels();
|
|
1681
|
+
const knownIds = new Set(chs.map((c) => c.id));
|
|
1682
|
+
let healed = 0;
|
|
1683
|
+
for (const a of arr) {
|
|
1684
|
+
const cid = a && a.channelId;
|
|
1685
|
+
if (!cid || knownIds.has(cid))
|
|
1686
|
+
continue;
|
|
1687
|
+
// 有 session 文件才算可恢复 (说明确实创建过)
|
|
1688
|
+
const hasSession = existsSync(`${sessionsDir}/${cid}:default.json`) || existsSync(`${sessionsDir}/${cid}.json`);
|
|
1689
|
+
if (!hasSession)
|
|
1690
|
+
continue;
|
|
1691
|
+
const restored = {
|
|
1692
|
+
id: cid,
|
|
1693
|
+
name: a.name || `Agent-${String(cid).slice(-6)}`,
|
|
1694
|
+
agentId: a.id,
|
|
1695
|
+
createdAt: a.createdAt || new Date().toISOString(),
|
|
1696
|
+
updatedAt: new Date().toISOString(),
|
|
1697
|
+
currentSessionId: 'default',
|
|
1698
|
+
sessions: [{ id: 'default', name: 'Default', createdAt: new Date().toISOString(), messageCount: 0, preview: '' }],
|
|
1699
|
+
did: a.did || undefined,
|
|
1700
|
+
};
|
|
1701
|
+
await updateChannels((all) => {
|
|
1702
|
+
if (!all.some((c) => c.id === cid))
|
|
1703
|
+
all.push(restored);
|
|
1704
|
+
return all;
|
|
1705
|
+
});
|
|
1706
|
+
knownIds.add(cid);
|
|
1707
|
+
healed++;
|
|
1708
|
+
console.log(`[自愈] 恢复 channel: ${cid} (${restored.name}, agent=${a.id})`);
|
|
1709
|
+
}
|
|
1710
|
+
if (healed > 0)
|
|
1711
|
+
console.log(`[自愈] 共恢复 ${healed} 个丢失的 channel`);
|
|
1712
|
+
return healed;
|
|
1713
|
+
}
|
|
1714
|
+
catch (healErr) {
|
|
1715
|
+
console.warn('[自愈] channel 恢复失败 (非致命):', healErr?.message?.slice(0, 120));
|
|
1716
|
+
return 0;
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
// 启动时自愈一次
|
|
1720
|
+
await healMissingChannels().catch(() => { });
|
|
1721
|
+
// 2026-08-02: GET /channels 运行中自愈的节流时间戳
|
|
1722
|
+
let lastHealAt = 0;
|
|
1410
1723
|
// ==================== P2P DIAP 身份初始化 ====================
|
|
1411
1724
|
let p2pIdentity = {
|
|
1412
1725
|
did: '',
|
|
@@ -1559,12 +1872,54 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1559
1872
|
session.lastUpdated = new Date().toISOString();
|
|
1560
1873
|
await saveSession(session);
|
|
1561
1874
|
console.log(`[v3] chat.reply 已持久化到 session (${replyChannelId}): ${replyText.substring(0, 40)}...`);
|
|
1875
|
+
// 2026-08-02: 写本地镜像 (替代 localStorage) — 离线也能看到对方回复
|
|
1876
|
+
appendRemoteChatLog(evt.fromPublicKey, replyChannelId, {
|
|
1877
|
+
type: 'ai', content: replyText, timestamp: new Date().toISOString(), source: 'remote-reply',
|
|
1878
|
+
}).catch(() => { });
|
|
1562
1879
|
}
|
|
1563
1880
|
catch (e) {
|
|
1564
1881
|
console.warn('[v3] chat.reply 持久化失败:', e?.message?.substring(0, 100));
|
|
1565
1882
|
}
|
|
1566
1883
|
}).catch(() => { });
|
|
1567
1884
|
}
|
|
1885
|
+
// 2026-08-02: 远端协作续看 — 用户 @ 过远端 / 智能体发过 send_to_remote 的 channel,
|
|
1886
|
+
// 收到对方回复时本地智能体"多看一次回复": LLM 判断任务是否完成, 未完成可 @ 继续.
|
|
1887
|
+
// 防死循环: maxRounds 上限 (默认 3), 达到后结束协作.
|
|
1888
|
+
// replyChannelId 是远端 channel id, 需遍历 channelRunState 匹配 remoteFollowup.remoteChannelId
|
|
1889
|
+
if (replyChannelId && replyText) {
|
|
1890
|
+
try {
|
|
1891
|
+
let matchedLocalId = null;
|
|
1892
|
+
let matchedRs = null;
|
|
1893
|
+
for (const [cid, rs] of channelRunState.entries()) {
|
|
1894
|
+
if (rs?.remoteFollowup?.remoteChannelId === replyChannelId) {
|
|
1895
|
+
matchedLocalId = cid;
|
|
1896
|
+
matchedRs = rs;
|
|
1897
|
+
break;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
// 兜底: 若 replyChannelId 本身是本地 channel (旧协议), 直接用它
|
|
1901
|
+
if (!matchedRs && channelRunState.has(replyChannelId) && channelRunState.get(replyChannelId)?.remoteFollowup) {
|
|
1902
|
+
matchedLocalId = replyChannelId;
|
|
1903
|
+
matchedRs = channelRunState.get(replyChannelId);
|
|
1904
|
+
}
|
|
1905
|
+
if (matchedRs && matchedRs.remoteFollowup && !matchedRs.running && matchedLocalId) {
|
|
1906
|
+
const fu = matchedRs.remoteFollowup;
|
|
1907
|
+
fu.rounds += 1;
|
|
1908
|
+
if (fu.rounds <= fu.maxRounds) {
|
|
1909
|
+
console.log(`[v3-followup] ${matchedLocalId} 收到远端回复 (round ${fu.rounds}/${fu.maxRounds}), 本地智能体续看...`);
|
|
1910
|
+
// 异步触发本地智能体处理回复 (不阻塞 data 事件循环)
|
|
1911
|
+
void triggerRemoteFollowup(matchedLocalId, replyText, evt.fromPublicKey, fu.maxRounds - fu.rounds);
|
|
1912
|
+
}
|
|
1913
|
+
else {
|
|
1914
|
+
console.log(`[v3-followup] ${matchedLocalId} 达到续看上限 (${fu.maxRounds}), 结束协作`);
|
|
1915
|
+
matchedRs.remoteFollowup = undefined;
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
catch (fuErr) {
|
|
1920
|
+
console.warn('[v3-followup] 续看调度失败 (非致命):', fuErr?.message?.slice(0, 100));
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1568
1923
|
return;
|
|
1569
1924
|
}
|
|
1570
1925
|
// 2026-07-21: 社交心跳 beacon — 远端智能体宣告存活/能力, 更新本地 liveness
|
|
@@ -2725,6 +3080,23 @@ ${goalDesc}
|
|
|
2725
3080
|
broadcast({ type: 'status', tool: event.tool, content: event.content }, channelId);
|
|
2726
3081
|
broadcast({ type: 'workflow_step', step: event.tool || '系统', content: event.content }, channelId);
|
|
2727
3082
|
console.log(`[SSE 广播] workflow_step: step=${event.tool}, content="${event.content?.substring(0, 80)}..."`);
|
|
3083
|
+
// 2026-08-02: 本地 @ 远端时, 工作流步骤也推给 P2P 对话框 (rcm-log)
|
|
3084
|
+
// — 用户报告"进程看不到": 本地智能体执行过程 (即使没调工具, 只有 status/tool 事件)
|
|
3085
|
+
// 也要在 P2P 对话框显示, 让用户看到"进程"
|
|
3086
|
+
try {
|
|
3087
|
+
const rs = channelRunState.get(channelId);
|
|
3088
|
+
if (rs?.remoteFollowup?.remoteChannelId) {
|
|
3089
|
+
broadcast({
|
|
3090
|
+
type: 'remote-chat-step',
|
|
3091
|
+
channelId: rs.remoteFollowup.remoteChannelId,
|
|
3092
|
+
stepType: 'step_start',
|
|
3093
|
+
tool: event.tool || '工作流',
|
|
3094
|
+
content: event.content,
|
|
3095
|
+
localStep: true,
|
|
3096
|
+
});
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
catch { /* 非致命 */ }
|
|
2728
3100
|
}
|
|
2729
3101
|
else if (event.type === 'step_start' || event.type === 'step_done' || event.type === 'step_error') {
|
|
2730
3102
|
// 2026-06-15: 步骤状态机事件 — 原样转发 (前端 step-timeline 组件订阅)
|
|
@@ -2737,6 +3109,26 @@ ${goalDesc}
|
|
|
2737
3109
|
error: event.error,
|
|
2738
3110
|
args: event.args,
|
|
2739
3111
|
}, channelId);
|
|
3112
|
+
// 2026-08-02: 对称显示 — 本地智能体 @ 远端时, 本地工具调用过程也推给 P2P 对话框
|
|
3113
|
+
// (rcm-log): remote-chat-step 事件带 remoteChannelId, 前端按对话框匹配显示
|
|
3114
|
+
try {
|
|
3115
|
+
const rs = channelRunState.get(channelId);
|
|
3116
|
+
if (rs?.remoteFollowup?.remoteChannelId) {
|
|
3117
|
+
broadcast({
|
|
3118
|
+
type: 'remote-chat-step',
|
|
3119
|
+
channelId: rs.remoteFollowup.remoteChannelId,
|
|
3120
|
+
stepType: event.type,
|
|
3121
|
+
tool: event.tool,
|
|
3122
|
+
content: event.content,
|
|
3123
|
+
success: event.success,
|
|
3124
|
+
output: event.output,
|
|
3125
|
+
error: event.error,
|
|
3126
|
+
args: event.args,
|
|
3127
|
+
localStep: true, // 标记: 这是本地智能体的工具过程 (区别于对方转发的)
|
|
3128
|
+
});
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
catch { /* 非致命 */ }
|
|
2740
3132
|
// 2026-06-16: 累积 step 到 runState, 供 /api/loop/inspect 读取
|
|
2741
3133
|
try {
|
|
2742
3134
|
if (event.type === 'step_done' || event.type === 'step_error') {
|
|
@@ -2760,6 +3152,32 @@ ${goalDesc}
|
|
|
2760
3152
|
}
|
|
2761
3153
|
};
|
|
2762
3154
|
console.log(`[消息处理] 开始处理用户消息, channelId: ${channelId}, sessionId: ${currentSessionId}`);
|
|
3155
|
+
// 2026-08-02 fix: 预激活远端协作续看 — 用户消息含 @远端 时立即激活 remoteFollowup.
|
|
3156
|
+
// 之前只在 routeMentionsInReply (AI 回复后) 激活 → 首次 @ 时本地智能体的工具调用
|
|
3157
|
+
// (step 事件) 发生在激活前, P2P 对话框看不到本地工具过程 (用户报告"进程看不到").
|
|
3158
|
+
// 现在收到消息即激活 → promptStream 期间的工具 step 也能 broadcast remote-chat-step.
|
|
3159
|
+
try {
|
|
3160
|
+
const mentionMatch = /@([一-龥A-Za-z0-9_\-]{1,30})/.exec(text);
|
|
3161
|
+
if (mentionMatch && remoteChannelCache.size > 0) {
|
|
3162
|
+
const targetName = mentionMatch[1];
|
|
3163
|
+
let hitRemoteId = null;
|
|
3164
|
+
for (const list of remoteChannelCache.values()) {
|
|
3165
|
+
const rc = list.find((c) => c.name === targetName);
|
|
3166
|
+
if (rc) {
|
|
3167
|
+
hitRemoteId = String(rc.id);
|
|
3168
|
+
break;
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
if (hitRemoteId) {
|
|
3172
|
+
const rs = channelRunState.get(channelId);
|
|
3173
|
+
if (rs && !rs.remoteFollowup) {
|
|
3174
|
+
rs.remoteFollowup = { rounds: 0, maxRounds: 3, remoteChannelId: hitRemoteId };
|
|
3175
|
+
console.log(`[v3-followup] ${channelId} 预激活远端协作续看 → ${hitRemoteId} (消息含 @${targetName})`);
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
catch { /* 预激活失败不阻塞 */ }
|
|
2763
3181
|
// 将真实 DID 作为上下文前缀,让 AI 使用真实的 DID 而不是自己编造的
|
|
2764
3182
|
let contextHint = '';
|
|
2765
3183
|
// 2026-08-02: slash 命令提示 (在 /message 开头解析, 这里注入)
|
|
@@ -2778,6 +3196,33 @@ ${goalDesc}
|
|
|
2778
3196
|
else {
|
|
2779
3197
|
contextHint += `[系统上下文] 自动工具调用已关闭: 每次执行工具前必须先与用户确认。\n`;
|
|
2780
3198
|
}
|
|
3199
|
+
// 2026-08-02 fix: 本地路径注入远端 channel 目录 (dirHint) — 之前只有远端路径 (agent.chat.send)
|
|
3200
|
+
// 有 dirHint, 本地智能体对话时看不到远端 channel 列表 → 无法 @ 远程智能体交流.
|
|
3201
|
+
// 现在注入: 可用渠道列表 (本地 + 远端), 让 LLM 知道 @ 谁、发什么.
|
|
3202
|
+
try {
|
|
3203
|
+
const localChs = await loadChannels();
|
|
3204
|
+
const remoteForDir = [];
|
|
3205
|
+
for (const [peerPk, list] of remoteChannelCache.entries()) {
|
|
3206
|
+
for (const ch of list) {
|
|
3207
|
+
remoteForDir.push({ ...ch, _ownerPublicKey: peerPk });
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
if (localChs.length > 0 || remoteForDir.length > 0) {
|
|
3211
|
+
contextHint += '[系统上下文] 可用渠道 (你可以在回复中写 "@渠道名 消息内容" 给它们发消息, 消息会持久化到目标 channel 的 session):\n';
|
|
3212
|
+
for (const c of localChs) {
|
|
3213
|
+
if (c.id === channelId)
|
|
3214
|
+
continue; // 跳过自己
|
|
3215
|
+
contextHint += ` - [本地] @${c.name} (id=${c.id})\n`;
|
|
3216
|
+
}
|
|
3217
|
+
for (const c of remoteForDir) {
|
|
3218
|
+
contextHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0, 8)}…] @${c.name} (id=${c.id})\n`;
|
|
3219
|
+
}
|
|
3220
|
+
contextHint += '语法: 在回复中写 "@渠道名 我要说的话" 即可, 系统会自动转发。\n\n';
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3223
|
+
catch (dirErr) {
|
|
3224
|
+
// 静默 — dirHint 不是核心
|
|
3225
|
+
}
|
|
2781
3226
|
// v3: 注入 channel 绑定的判断力 (judgment_ids)
|
|
2782
3227
|
// 这是 v3 的核心 — channel 跑 LLM 时, 它的判断力 = 绑定的 judgment 列表
|
|
2783
3228
|
const judgmentHint = await buildJudgmentHint(channelForJudgment, channelId);
|
|
@@ -2899,7 +3344,7 @@ ${goalDesc}
|
|
|
2899
3344
|
}
|
|
2900
3345
|
if (summariesToRead.length > 0) {
|
|
2901
3346
|
const memBlock = summariesToRead.map(s => s.trim().slice(-1500)).join('\n\n---\n\n');
|
|
2902
|
-
contextHint += `[系统上下文] 本 channel
|
|
3347
|
+
contextHint += `[系统上下文] 动态状态层 · chat-worksite (上次做到哪 — 本 channel 历史记忆, 来自 memory-compressor 摘要, 引用而非复述):\n${memBlock.slice(-2500)}\n\n`;
|
|
2903
3348
|
}
|
|
2904
3349
|
}
|
|
2905
3350
|
}
|
|
@@ -2914,12 +3359,25 @@ ${goalDesc}
|
|
|
2914
3359
|
const plans = await listActivePlans();
|
|
2915
3360
|
if (plans.length > 0) {
|
|
2916
3361
|
const plansBlock = plans.slice(0, 3).map(p => planToContext(p)).join('\n\n');
|
|
2917
|
-
contextHint += `[系统上下文]
|
|
3362
|
+
contextHint += `[系统上下文] 动态状态层 · focus (此刻优先级与时间边界 — 进行中的计划, 来自 plan-store, 执行中每完成一步调 update_plan 勾选):\n${plansBlock}\n\n`;
|
|
2918
3363
|
}
|
|
2919
3364
|
}
|
|
2920
3365
|
catch (planErr) {
|
|
2921
3366
|
// 静默失败
|
|
2922
3367
|
}
|
|
3368
|
+
// 2026-08-03 (Context OS P5): 资产层目录注入 — LLM 知道有 12+3 层资产,
|
|
3369
|
+
// 需要细节时用 read_context_assets 按层路由读取, 不全仓扫描 (Context OS §4).
|
|
3370
|
+
try {
|
|
3371
|
+
const { readContextAssets, formatLayerListing } = await import('../bootstrap/context-os.js');
|
|
3372
|
+
const listings = await readContextAssets();
|
|
3373
|
+
const total = listings.reduce((s, l) => s + l.fileCount, 0);
|
|
3374
|
+
if (total > 0) {
|
|
3375
|
+
contextHint += `[系统上下文] 资产层 (Context OS 12+3 层, ${total} 篇资产 — 任务需要细节时用 read_context_assets 按层读取, 不要假装知道没读过的内容):\n${formatLayerListing(listings)}`;
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
catch (ctxErr) {
|
|
3379
|
+
// 静默失败
|
|
3380
|
+
}
|
|
2923
3381
|
const linkedIds = channelForJudgment?.linkedDocumentIds;
|
|
2924
3382
|
if (Array.isArray(linkedIds) && linkedIds.length > 0) {
|
|
2925
3383
|
try {
|
|
@@ -3317,7 +3775,7 @@ ${goalDesc}
|
|
|
3317
3775
|
const didFixQueue = new Set(); // 待修复的 channelId
|
|
3318
3776
|
let didFixRunning = false;
|
|
3319
3777
|
let didFixTimer = null;
|
|
3320
|
-
|
|
3778
|
+
// 2026-08-02: channelRunState 已提升为模块级 (triggerRemoteFollowup 也访问), 这里复用
|
|
3321
3779
|
function getOrCreateRunState(channelId) {
|
|
3322
3780
|
let s = channelRunState.get(channelId);
|
|
3323
3781
|
if (!s) {
|
|
@@ -3557,6 +4015,14 @@ ${goalDesc}
|
|
|
3557
4015
|
}
|
|
3558
4016
|
app.get('/channels', async (_req, res) => {
|
|
3559
4017
|
try {
|
|
4018
|
+
// 2026-08-02 fix: 运行中自愈 — 每次拉取前检查丢失的 channel (从 agents.json 恢复).
|
|
4019
|
+
// 解决"刷新/build 后 channel 消失": 刷新即触发 GET /channels → 丢失的自动回来
|
|
4020
|
+
// 节流: 5s 内只跑一次 (heal 内部有文件 IO)
|
|
4021
|
+
const nowMs = Date.now();
|
|
4022
|
+
if (nowMs - (lastHealAt || 0) > 5000) {
|
|
4023
|
+
lastHealAt = nowMs;
|
|
4024
|
+
await healMissingChannels().catch(() => { });
|
|
4025
|
+
}
|
|
3560
4026
|
// 2026-06-17: 缓存命中 → 0 行;未命中 → 1 行 summary (上面 console.log proxy 已吃掉 [API] /channels 等旧日志)
|
|
3561
4027
|
const t0 = Date.now();
|
|
3562
4028
|
const now = t0;
|
|
@@ -3810,6 +4276,29 @@ ${goalDesc}
|
|
|
3810
4276
|
await fs.writeFile(agentsPath, JSON.stringify(arr, null, 2), 'utf-8');
|
|
3811
4277
|
console.log(`[创建频道] agent 写进 agents.json: name=${name} id=${agentId}`);
|
|
3812
4278
|
}
|
|
4279
|
+
else {
|
|
4280
|
+
// 2026-08-02 fix: agent 已存在时更新 channelId + name — 之前 exists 直接跳过,
|
|
4281
|
+
// 用户复用同一 agentId 新建 channel 时, agents.json 的 channelId 仍指向旧 channel
|
|
4282
|
+
// (可能已删除), P2P manifest / 恢复逻辑拿到的关联是错的 → "智能体消失"
|
|
4283
|
+
const existing = arr.find(a => a && a.id === agentId);
|
|
4284
|
+
const oldCid = existing?.channelId;
|
|
4285
|
+
let changed = false;
|
|
4286
|
+
if (existing) {
|
|
4287
|
+
if (existing.channelId !== id) {
|
|
4288
|
+
existing.channelId = id;
|
|
4289
|
+
changed = true;
|
|
4290
|
+
}
|
|
4291
|
+
if (existing.name !== name) {
|
|
4292
|
+
existing.name = name;
|
|
4293
|
+
changed = true;
|
|
4294
|
+
}
|
|
4295
|
+
existing.lastActive = new Date().toISOString();
|
|
4296
|
+
}
|
|
4297
|
+
if (changed) {
|
|
4298
|
+
await fs.writeFile(agentsPath, JSON.stringify(arr, null, 2), 'utf-8');
|
|
4299
|
+
console.log(`[创建频道] agent ${agentId} channelId 更新: ${oldCid} → ${id} (name=${name})`);
|
|
4300
|
+
}
|
|
4301
|
+
}
|
|
3813
4302
|
}
|
|
3814
4303
|
catch (e) {
|
|
3815
4304
|
console.warn('[创建频道] 写 agents.json 失败 (非致命):', e?.message?.slice(0, 120));
|
|
@@ -3993,6 +4482,9 @@ ${goalDesc}
|
|
|
3993
4482
|
// 之前 v0.3.6 (Bug 6) 创建频道时同步往 agents.json append, 删频道却没删回来
|
|
3994
4483
|
// → agents.json 里残留孤儿 agent, 重启后 loadLocalSubAgents 还能读到这些 — 看起来像"删不掉"
|
|
3995
4484
|
// 修法: 用 channel.agentId 找, 同步从 agents.json 删一条
|
|
4485
|
+
// 2026-08-02 二次修: 只在没有其他 channel 复用该 agentId 时才删 agent —
|
|
4486
|
+
// 之前无条件 filter(a.id === channel.agentId), 多个 channel 共享 agentId (用户复用"智能体" id)
|
|
4487
|
+
// 时, 删一个 channel 把 agent 也清了 → 其他 channel 变孤儿 → "智能体消失"
|
|
3996
4488
|
try {
|
|
3997
4489
|
const agentsPath = path.join(process.env.HOME || '/tmp', '.bolloon', 'agents', 'agents.json');
|
|
3998
4490
|
const raw = await fs.readFile(agentsPath, 'utf-8').catch(() => '');
|
|
@@ -4004,11 +4496,25 @@ ${goalDesc}
|
|
|
4004
4496
|
catch { }
|
|
4005
4497
|
if (!Array.isArray(arr))
|
|
4006
4498
|
arr = [];
|
|
4499
|
+
// 检查是否有其他 channel 还引用这个 agentId (含刚删的这条: 用删除后的 channels 判断)
|
|
4500
|
+
const remainingChannels = await loadChannels();
|
|
4501
|
+
const agentIdStillUsed = remainingChannels.some((c) => c.id !== channelId && c.agentId === channel.agentId);
|
|
4007
4502
|
const before = arr.length;
|
|
4008
|
-
|
|
4503
|
+
// 只删: ① channelId 精确匹配的 agent 条目 ② 该 agentId 无其他 channel 使用时才按 agentId 删
|
|
4504
|
+
arr = arr.filter(a => {
|
|
4505
|
+
if (!a)
|
|
4506
|
+
return false;
|
|
4507
|
+
if (a.channelId === channelId)
|
|
4508
|
+
return false; // 精确指向被删 channel
|
|
4509
|
+
if (a.id === channel.agentId && agentIdStillUsed)
|
|
4510
|
+
return true; // 共享中, 保留
|
|
4511
|
+
if (a.id === channel.agentId && !agentIdStillUsed)
|
|
4512
|
+
return false; // 无引用, 删
|
|
4513
|
+
return true;
|
|
4514
|
+
});
|
|
4009
4515
|
if (arr.length !== before) {
|
|
4010
4516
|
await fs.writeFile(agentsPath, JSON.stringify(arr, null, 2), 'utf-8');
|
|
4011
|
-
console.log(`[删除频道] agents.json 清掉 ${before - arr.length} 条
|
|
4517
|
+
console.log(`[删除频道] agents.json 清掉 ${before - arr.length} 条 agent (channel=${channelId}, agentId=${channel.agentId}, 仍被使用=${agentIdStillUsed})`);
|
|
4012
4518
|
}
|
|
4013
4519
|
}
|
|
4014
4520
|
}
|
|
@@ -4911,43 +5417,67 @@ ${goalDesc}
|
|
|
4911
5417
|
// 实现: B → POST 给 A 一个 agent.history.get RPC → A 把 session 返回 → B 渲染
|
|
4912
5418
|
app.get('/api/remote-channels/chat-history', async (req, res) => {
|
|
4913
5419
|
try {
|
|
4914
|
-
if (!v3P2PRef) {
|
|
4915
|
-
return res.status(503).json({ error: 'P2PDirect not started' });
|
|
4916
|
-
}
|
|
4917
5420
|
const targetPublicKey = String(req.query.targetPublicKey || '');
|
|
4918
5421
|
const channelId = String(req.query.channelId || '');
|
|
4919
5422
|
if (!targetPublicKey || !channelId) {
|
|
4920
5423
|
return res.status(400).json({ error: 'targetPublicKey, channelId required' });
|
|
4921
5424
|
}
|
|
4922
|
-
//
|
|
4923
|
-
const
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
|
|
4928
|
-
|
|
4929
|
-
|
|
4930
|
-
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
const result = await new Promise((resolve, reject) => {
|
|
4936
|
-
const timer = setTimeout(() => {
|
|
4937
|
-
v3PendingHistoryGets.delete(rpcId);
|
|
4938
|
-
reject(new Error('A 端 15s 内未回复, 可能未分享该 channel'));
|
|
4939
|
-
}, 15000);
|
|
4940
|
-
v3PendingHistoryGets.set(rpcId, {
|
|
4941
|
-
resolve: (data) => { clearTimeout(timer); resolve(data); },
|
|
4942
|
-
reject: (err) => { clearTimeout(timer); reject(err); }
|
|
5425
|
+
// 2026-08-02: 本地镜像优先 — 立即返回本地记录 (对方离线也有), 不用等 RPC
|
|
5426
|
+
const mirror = await readRemoteChatLog(targetPublicKey, channelId);
|
|
5427
|
+
if (mirror.length > 0 && !v3P2PRef) {
|
|
5428
|
+
return res.json({ messages: mirror, source: 'mirror', judgments: { bound: [], candidates: [] } });
|
|
5429
|
+
}
|
|
5430
|
+
// 有 P2P: 后台 RPC 拉对端合并 (不阻塞响应 — 镜像先返回, RPC 结果下次刷新拿到)
|
|
5431
|
+
if (v3P2PRef) {
|
|
5432
|
+
const fromPk = v3P2PRef.getPublicKey();
|
|
5433
|
+
const rpcId = `hist-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
5434
|
+
const msg = JSON.stringify({
|
|
5435
|
+
v: 3,
|
|
5436
|
+
op: 'agent.history.get',
|
|
5437
|
+
payload: { rpcId, channelId, fromPublicKey: fromPk }
|
|
4943
5438
|
});
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
5439
|
+
const ok = v3P2PRef.sendTo(targetPublicKey, msg);
|
|
5440
|
+
if (!ok && mirror.length === 0) {
|
|
5441
|
+
return res.status(502).json({ error: 'peer not connected (本地也无缓存)' });
|
|
5442
|
+
}
|
|
5443
|
+
if (ok && mirror.length === 0) {
|
|
5444
|
+
// 无本地镜像 → 等 RPC (15s)
|
|
5445
|
+
try {
|
|
5446
|
+
const result = await new Promise((resolve, reject) => {
|
|
5447
|
+
const timer = setTimeout(() => {
|
|
5448
|
+
v3PendingHistoryGets.delete(rpcId);
|
|
5449
|
+
reject(new Error('A 端 15s 内未回复'));
|
|
5450
|
+
}, 15000);
|
|
5451
|
+
v3PendingHistoryGets.set(rpcId, {
|
|
5452
|
+
resolve: (data) => { clearTimeout(timer); resolve(data); },
|
|
5453
|
+
reject: (err) => { clearTimeout(timer); reject(err); }
|
|
5454
|
+
});
|
|
5455
|
+
});
|
|
5456
|
+
// 把对端历史写进本地镜像 (增量缓存)
|
|
5457
|
+
const remoteMsgs = result.messages || [];
|
|
5458
|
+
for (const m of remoteMsgs) {
|
|
5459
|
+
await appendRemoteChatLog(targetPublicKey, channelId, {
|
|
5460
|
+
type: m.type === 'user' ? 'user' : 'ai',
|
|
5461
|
+
content: m.content || '',
|
|
5462
|
+
timestamp: m.timestamp || new Date().toISOString(),
|
|
5463
|
+
source: m.source || 'remote',
|
|
5464
|
+
});
|
|
5465
|
+
}
|
|
5466
|
+
return res.json({ ...result, messages: remoteMsgs, source: 'rpc' });
|
|
5467
|
+
}
|
|
5468
|
+
catch (err) {
|
|
5469
|
+
return res.status(504).json({ error: err.message });
|
|
5470
|
+
}
|
|
5471
|
+
}
|
|
5472
|
+
// 有本地镜像 + 有 P2P: 返回镜像 (RPC 结果由前端 15s 刷新轮询拿)
|
|
5473
|
+
return res.json({ messages: mirror, source: 'mirror', judgments: { bound: [], candidates: [] } });
|
|
5474
|
+
}
|
|
5475
|
+
// 无 P2P
|
|
5476
|
+
return res.json({ messages: mirror, source: 'mirror', judgments: { bound: [], candidates: [] } });
|
|
4947
5477
|
}
|
|
4948
5478
|
catch (err) {
|
|
4949
5479
|
console.error('[v3] chat-history 失败:', err.message);
|
|
4950
|
-
res.status(
|
|
5480
|
+
res.status(500).json({ error: err.message });
|
|
4951
5481
|
}
|
|
4952
5482
|
});
|
|
4953
5483
|
// 获取已连接的节点
|
|
@@ -6312,7 +6842,11 @@ function broadcast(data, channelId) {
|
|
|
6312
6842
|
const msgId = (data.type === 'ai' || data.type === 'user')
|
|
6313
6843
|
? nextMsgId(channelId)
|
|
6314
6844
|
: `evt_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
6315
|
-
|
|
6845
|
+
// 2026-08-02 fix: 第二参 channelId 为 undefined 时, 不要用 undefined 覆盖 data.channelId —
|
|
6846
|
+
// 否则 payload 自带的 channelId (如 remote-chat-sent 的远端 channel id) 会丢, 前端无法匹配对话框
|
|
6847
|
+
const envelope = channelId !== undefined
|
|
6848
|
+
? { ...data, channelId, seq, msgId }
|
|
6849
|
+
: { ...data, seq, msgId };
|
|
6316
6850
|
const message = `data: ${JSON.stringify(envelope)}\n\n`;
|
|
6317
6851
|
console.log(`[broadcast] type=${data.type}, channelId=${channelId}, seq=${seq}, msgId=${msgId}, clients=${sseClients.size}`);
|
|
6318
6852
|
for (const client of sseClients) {
|