@bolloon/bolloon-agent 0.3.24 → 0.3.25
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/README.md +83 -474
- package/dist/agents/pi-sdk-tools.js +337 -15
- package/dist/agents/plan-store.js +167 -0
- package/dist/agents/skill-writer.js +193 -0
- package/dist/agents/workflow-pivot-loop.js +86 -25
- package/dist/constraint-runtime/src/tools/PolymarketSDK/cancelOrder.js +1 -1
- package/dist/constraint-runtime/src/tools/PolymarketSDK/createOrder.js +1 -1
- package/dist/constraint-runtime/src/tools/PolymarketSDK/getOrders.js +1 -1
- package/dist/network/known-peers.js +52 -8
- package/dist/security/tool-gate.js +4 -0
- package/dist/web/client.js +520 -17
- package/dist/web/index.html +6 -2
- package/dist/web/server.js +481 -29
- package/dist/web/style.css +14 -10
- package/dist/web/ui/message-renderer.js +42 -3
- package/dist/web/ui/step-timeline.js +5 -0
- package/package.json +6 -2
- package/bin/ipfs +0 -0
package/dist/web/server.js
CHANGED
|
@@ -138,7 +138,7 @@ function isValidWalletAddress(addr) {
|
|
|
138
138
|
// 2026-07-06: loadChannels / saveChannels / loadSession / saveSession / loadTheme / saveTheme
|
|
139
139
|
// 已抽到 ./server-storage.ts — 这里只保留旧引用供 createWebServer 内部闭包使用
|
|
140
140
|
// TODO: 后续让所有调用走 import, 然后删除这段冗余定义
|
|
141
|
-
import { loadChannels as _loadChannels, saveChannels as _saveChannels, loadSession as _loadSession, saveSession as _saveSession, loadTheme as _loadTheme, saveTheme as _saveTheme, getLastChannelsWriteAt, } from './server-storage.js';
|
|
141
|
+
import { loadChannels as _loadChannels, saveChannels as _saveChannels, loadSession as _loadSession, saveSession as _saveSession, loadTheme as _loadTheme, saveTheme as _saveTheme, getLastChannelsWriteAt, updateChannels, } from './server-storage.js';
|
|
142
142
|
// 包装成闭包内可用的形式, 保持 createWebServer 内代码不用改
|
|
143
143
|
const loadChannels = _loadChannels;
|
|
144
144
|
const saveChannels = _saveChannels;
|
|
@@ -151,6 +151,10 @@ 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: 待处理好友申请 (pending friend requests) — 收到 agent.friend.request 时暂存,
|
|
155
|
+
// 人类通过 UI 处理, 智能体通过工具 list_pending_friend_requests / accept_friend_request 处理。
|
|
156
|
+
// key: requestId, value: 申请详情 (含 fromPublicKey / name / message / 备注)
|
|
157
|
+
const pendingFriendRequests = new Map();
|
|
154
158
|
// 2026-07-05: 一次性 prompt 附加块 — key: channelId, value: 下一次 LLM prompt 时 prepend 的内容
|
|
155
159
|
// 用于 manifest-loader 加载对方能力后, 仅影响本次对话, 不污染主 prompt
|
|
156
160
|
const nextPromptHints = new Map();
|
|
@@ -217,6 +221,48 @@ async function persistRemoteChannelCache() {
|
|
|
217
221
|
}
|
|
218
222
|
// 启动时立即同步读一次 (异步, 不阻塞)
|
|
219
223
|
loadRemoteChannelCacheFromDisk();
|
|
224
|
+
// 2026-08-02: pending 好友申请持久化 — 重启后智能体/人类仍能看到未处理的申请
|
|
225
|
+
const PENDING_FRIEND_REQ_FILE = `${process.env.HOME || '/tmp'}/.bolloon/pending-friend-requests.json`;
|
|
226
|
+
async function loadPendingFriendRequestsFromDisk() {
|
|
227
|
+
try {
|
|
228
|
+
const { readFile } = await import('fs/promises');
|
|
229
|
+
const { existsSync } = await import('fs');
|
|
230
|
+
if (!existsSync(PENDING_FRIEND_REQ_FILE))
|
|
231
|
+
return;
|
|
232
|
+
const raw = await readFile(PENDING_FRIEND_REQ_FILE, 'utf-8');
|
|
233
|
+
const obj = JSON.parse(raw);
|
|
234
|
+
if (obj && typeof obj === 'object') {
|
|
235
|
+
for (const [reqId, r] of Object.entries(obj)) {
|
|
236
|
+
if (r && typeof r === 'object' && r.fromPublicKey) {
|
|
237
|
+
pendingFriendRequests.set(reqId, r);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
console.log(`[v3-friend] 从磁盘恢复 ${pendingFriendRequests.size} 个待处理好友申请`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
console.warn('[v3-friend] 恢复 pending 好友申请失败 (非致命):', err.message);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async function persistPendingFriendRequests() {
|
|
248
|
+
try {
|
|
249
|
+
const { writeFile, mkdir } = await import('fs/promises');
|
|
250
|
+
const { existsSync } = await import('fs');
|
|
251
|
+
if (!existsSync(`${process.env.HOME || '/tmp'}/.bolloon`)) {
|
|
252
|
+
await mkdir(`${process.env.HOME || '/tmp'}/.bolloon`, { recursive: true });
|
|
253
|
+
}
|
|
254
|
+
const obj = {};
|
|
255
|
+
for (const [reqId, r] of pendingFriendRequests.entries()) {
|
|
256
|
+
obj[reqId] = r;
|
|
257
|
+
}
|
|
258
|
+
await writeFile(PENDING_FRIEND_REQ_FILE, JSON.stringify(obj, null, 2), 'utf-8');
|
|
259
|
+
}
|
|
260
|
+
catch (err) {
|
|
261
|
+
console.warn('[v3-friend] 持久化 pending 好友申请失败 (非致命):', err.message);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// 启动时恢复 (异步, 不阻塞主流程)
|
|
265
|
+
loadPendingFriendRequestsFromDisk();
|
|
220
266
|
// v3: P2PDirect 引用 (Hyperswarm 薄包装) - 模块级, 因为 web server 闭包里不可用
|
|
221
267
|
let v3P2PRef = null;
|
|
222
268
|
// 2026-07-21: 智能体社交心跳实例 (beacon + 自主决策发起对话), data 事件处理器会引用它
|
|
@@ -459,12 +505,14 @@ async function handleV3P2PMessage(parsed, conn, comm) {
|
|
|
459
505
|
if (op === 'agent.chat.send') {
|
|
460
506
|
// B 端发来: 在 A 节点上对 channelId 跑 LLM, 结果回 B
|
|
461
507
|
// judgment 永远在 A 节点 (buildJudgmentHint 已经用 bound_judgment_ids)
|
|
462
|
-
const { channelId, text, fromPublicKey } = parsed.payload || {};
|
|
508
|
+
const { channelId, text, fromPublicKey, autoInvokeTools } = parsed.payload || {};
|
|
463
509
|
if (!channelId || !text) {
|
|
464
510
|
console.warn(`[v3] agent.chat.send 缺少 channelId/text`);
|
|
465
511
|
return;
|
|
466
512
|
}
|
|
467
513
|
const senderKey = fromPublicKey || peerKey;
|
|
514
|
+
// 2026-08-02: 发送方工具开关 (P2P 对话栏的 🔧 toggle) — 显式 false 时远端消息不调工具
|
|
515
|
+
const remoteToolsOverride = typeof autoInvokeTools === 'boolean' ? autoInvokeTools : null;
|
|
468
516
|
// 2026-07-29: Dunbar Tier 检查 — 远端 agent 不能触发禁区工具
|
|
469
517
|
const tierState = await loadPeerTier(senderKey);
|
|
470
518
|
if (tierState.tier === 'blocked') {
|
|
@@ -588,7 +636,11 @@ async function handleV3P2PMessage(parsed, conn, comm) {
|
|
|
588
636
|
// 2026-06-15: P2P 远端访客路径也用显式 marker 包裹 text
|
|
589
637
|
// 2026-06-15 二次修: 把 text 放在最前 (与主路径 server.ts:1868 对齐),
|
|
590
638
|
// 避免 LLM 被 judgmentHint 末尾的 "..." 误判为整个 input 被截断
|
|
591
|
-
|
|
639
|
+
// 2026-08-02: 发送方工具开关 (P2P 🔧 toggle) — false 时禁止本轮工具调用
|
|
640
|
+
const toolsGateHint = remoteToolsOverride === false
|
|
641
|
+
? '\n[系统指令] 本轮对话**禁止调用任何工具**, 直接基于已有知识回答即可.\n\n'
|
|
642
|
+
: '';
|
|
643
|
+
const fullPrompt = `【本轮用户请求】\n${text}\n【请求结束】\n\n${visitorHint}${dirHint}${toolsGateHint}${judgmentHint}\n`;
|
|
592
644
|
let fullResponse = '';
|
|
593
645
|
// v3 新增: 流式 token 节流推给 B — 让 B 看到过程
|
|
594
646
|
let lastFlushAt = 0;
|
|
@@ -610,6 +662,26 @@ async function handleV3P2PMessage(parsed, conn, comm) {
|
|
|
610
662
|
comm.sendToConnection(conn.id, msg).catch(() => { });
|
|
611
663
|
}
|
|
612
664
|
}
|
|
665
|
+
// 2026-08-02 fix: 远端路径转发 step 事件 (工具调用过程) — 之前只转 token,
|
|
666
|
+
// B 端 P2P 对话看不到工具调用步骤. 复用 agent.chat.thinking 的 phase=step 通道.
|
|
667
|
+
else if (event.type === 'step_start' || event.type === 'step_done' || event.type === 'step_error') {
|
|
668
|
+
const msg = JSON.stringify({
|
|
669
|
+
v: 3, op: 'agent.chat.thinking',
|
|
670
|
+
payload: {
|
|
671
|
+
channelId,
|
|
672
|
+
phase: 'step',
|
|
673
|
+
stepType: event.type,
|
|
674
|
+
tool: event.tool,
|
|
675
|
+
content: event.content,
|
|
676
|
+
success: event.success,
|
|
677
|
+
output: event.output,
|
|
678
|
+
error: event.error,
|
|
679
|
+
args: event.args,
|
|
680
|
+
fromPublicKey: v3P2PRef?.getPublicKey() || ''
|
|
681
|
+
}
|
|
682
|
+
});
|
|
683
|
+
comm.sendToConnection(conn.id, msg).catch(() => { });
|
|
684
|
+
}
|
|
613
685
|
};
|
|
614
686
|
const agent = await getAgentForChannel(channelId, ch.did || '', ch.name, ch.didDocRef);
|
|
615
687
|
fullResponse = await agent.promptStream(fullPrompt, streamCallback, undefined, channelId);
|
|
@@ -1499,7 +1571,11 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1499
1571
|
if (parsed.op === 'agent.heartbeat') {
|
|
1500
1572
|
agentHeartbeat?.handleIncoming('agent.heartbeat', parsed.payload, evt.fromPublicKey);
|
|
1501
1573
|
// 2026-07-29: 收到心跳也记录交互 (Dunbar 自动归类)
|
|
1502
|
-
|
|
1574
|
+
// 2026-08-02 fix: 之前不传 text → inferOpponentMove('') = defect,
|
|
1575
|
+
// heartbeat 被误判为背叛 → trustScore 持续下跌 → peer 跌入 blocked,
|
|
1576
|
+
// 导致对端消息被拒 (❌ 您已被本地系统加入通信黑名单)。
|
|
1577
|
+
// 改为传存活信号文本, 让机器协议消息判为 cooperate (在线维持连接 = 合作)。
|
|
1578
|
+
recordInteraction(evt.fromPublicKey, 'heartbeat 存活信号(自动)').catch(() => { });
|
|
1503
1579
|
return;
|
|
1504
1580
|
}
|
|
1505
1581
|
// v3 新增: B 端收到 A 的 thinking (开始 + 流式 token)
|
|
@@ -1578,11 +1654,27 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1578
1654
|
// v3 新增: 好友申请 RPC — 任何对端可以发, 推到前端 UI 让用户接受
|
|
1579
1655
|
if (parsed.op === 'agent.friend.request') {
|
|
1580
1656
|
console.log(`[v3-friend] 收到 ${evt.fromPublicKey.substring(0, 12)}... 的好友申请: ${parsed.payload?.name || '(无名字)'}`);
|
|
1657
|
+
const reqFromName = parsed.payload?.name || ('peer-' + evt.fromPublicKey.substring(0, 8));
|
|
1658
|
+
const reqMessage = parsed.payload?.message || '想加你为 P2P 好友';
|
|
1659
|
+
// 2026-08-02: 备注 = 申请消息或显式 note 字段 (自我介绍 / 来源), 存 pending 供智能体工具查询
|
|
1660
|
+
const reqNote = parsed.payload?.note || reqMessage;
|
|
1661
|
+
if (parsed.payload?.requestId) {
|
|
1662
|
+
pendingFriendRequests.set(parsed.payload.requestId, {
|
|
1663
|
+
requestId: parsed.payload.requestId,
|
|
1664
|
+
fromPublicKey: evt.fromPublicKey,
|
|
1665
|
+
fromName: reqFromName,
|
|
1666
|
+
message: reqMessage,
|
|
1667
|
+
note: reqNote,
|
|
1668
|
+
receivedAt: Date.now(),
|
|
1669
|
+
});
|
|
1670
|
+
persistPendingFriendRequests(); // 2026-08-02: 落盘, 重启不丢
|
|
1671
|
+
}
|
|
1581
1672
|
broadcast({
|
|
1582
1673
|
type: 'friend-request',
|
|
1583
1674
|
fromPublicKey: evt.fromPublicKey,
|
|
1584
|
-
fromName:
|
|
1585
|
-
message:
|
|
1675
|
+
fromName: reqFromName,
|
|
1676
|
+
message: reqMessage,
|
|
1677
|
+
note: reqNote, // 2026-08-02: 透传备注给 UI
|
|
1586
1678
|
requestId: parsed.payload?.requestId, // 2026-06-10: 透传 requestId 给前端
|
|
1587
1679
|
timestamp: Date.now()
|
|
1588
1680
|
}, 'p2p-global');
|
|
@@ -2278,6 +2370,26 @@ ${goalDesc}
|
|
|
2278
2370
|
const identity = await loadOrCreateUserIdentity();
|
|
2279
2371
|
res.json(identity);
|
|
2280
2372
|
});
|
|
2373
|
+
// 2026-08-02: 修改左下角用户名 (写回 ~/.bolloon/identity/user.json)
|
|
2374
|
+
app.put('/api/user/identity', async (req, res) => {
|
|
2375
|
+
try {
|
|
2376
|
+
const { name } = req.body || {};
|
|
2377
|
+
const newName = String(name || '').trim().slice(0, 40);
|
|
2378
|
+
if (!newName)
|
|
2379
|
+
return res.status(400).json({ error: 'name 必填' });
|
|
2380
|
+
const identity = await loadOrCreateUserIdentity();
|
|
2381
|
+
identity.name = newName;
|
|
2382
|
+
userIdentityCache = identity;
|
|
2383
|
+
const { mkdirSync, writeFileSync } = await import('fs');
|
|
2384
|
+
mkdirSync(IDENTITY_DIR, { recursive: true });
|
|
2385
|
+
writeFileSync(`${IDENTITY_DIR}/user.json`, JSON.stringify(identity, null, 2), { mode: 0o600 });
|
|
2386
|
+
console.log(`[user-identity] 名字已更新: ${newName}`);
|
|
2387
|
+
res.json(identity);
|
|
2388
|
+
}
|
|
2389
|
+
catch (err) {
|
|
2390
|
+
res.status(500).json({ error: err.message });
|
|
2391
|
+
}
|
|
2392
|
+
});
|
|
2281
2393
|
// 2026-07-01 (v0.2.6): 前后端分离核心 — 后端切 LLM 输出为结构化 segments
|
|
2282
2394
|
// - POST /api/segment-reply { reply, knownTools }
|
|
2283
2395
|
// - 返回 ChatSegment[] (think / text / env_details / tool_call / final)
|
|
@@ -2457,6 +2569,33 @@ ${goalDesc}
|
|
|
2457
2569
|
if (!text) {
|
|
2458
2570
|
return res.status(400).json({ error: 'No text provided' });
|
|
2459
2571
|
}
|
|
2572
|
+
// 2026-08-02: / 斜杠命令路由 — 用户输入 /plan /todo /task 等快捷命令时,
|
|
2573
|
+
// 直接把命令转成结构化指令注入 contextHint, 引导 LLM 调用对应工具.
|
|
2574
|
+
// 前端 / 菜单插入的是 "/plan 目标; 步骤" 这类文本, 这里解析成提示.
|
|
2575
|
+
let slashCommandHint = '';
|
|
2576
|
+
try {
|
|
2577
|
+
const slashMatch = String(text).match(/^\/([a-z-]+)\s*(.*)$/is);
|
|
2578
|
+
if (slashMatch) {
|
|
2579
|
+
const [, cmdRaw, cmdArgs] = slashMatch;
|
|
2580
|
+
const cmd = cmdRaw.toLowerCase();
|
|
2581
|
+
const argText = String(cmdArgs || '').trim();
|
|
2582
|
+
const hints = {
|
|
2583
|
+
plan: `用户输入了 /plan 命令, 参数: "${argText}". 请调用 create_plan 工具: goal=参数中 ";" 前的部分 (或整句), steps=";" 后的步骤列表 (逗号分隔). 若参数为空, 先向用户确认目标和步骤.`,
|
|
2584
|
+
todo: `用户输入了 /todo 命令, 参数: "${argText}". 请调用 update_plan 勾选步骤状态: 格式 "计划ID; 步骤ID; done|blocked". 参数不完整就向用户要.`,
|
|
2585
|
+
review: `用户输入了 /review 命令, 参数: "${argText}". 请调用 review_plan: 格式 "计划ID; 审查总结". 参数不完整就向用户要.`,
|
|
2586
|
+
task: `用户输入了 /task 命令, 参数: "${argText}". 请调用 create_task 创建任务.`,
|
|
2587
|
+
goal: `用户输入了 /goal 命令, 参数: "${argText}". 请调用 park_goal 暂停目标.`,
|
|
2588
|
+
skill: `用户输入了 /skill 命令, 参数: "${argText}". 请调用 create_skill 沉淀技能.`,
|
|
2589
|
+
'add-friend': `用户输入了 /add-friend 命令, 参数: "${argText}". 请调用 add_friend_by_id 添加好友 (参数: 64位 hex 公钥 + 可选备注).`,
|
|
2590
|
+
help: `用户输入了 /help. 请简要列出可用命令: /plan /todo /review /task /goal /skill /add-friend, 各一句话说明.`,
|
|
2591
|
+
};
|
|
2592
|
+
if (hints[cmd]) {
|
|
2593
|
+
slashCommandHint = `[系统上下文] 快捷命令路由:\n${hints[cmd]}\n\n`;
|
|
2594
|
+
console.log(`[slash] 命令 /${cmd} 参数="${argText.slice(0, 80)}"`);
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
catch { /* 命令解析失败忽略 */ }
|
|
2460
2599
|
// 2026-07-15 修 Bug 3: 拖拽附件 — LLM 在 contextHint 里看到文件清单, 用户文本保持可读
|
|
2461
2600
|
// 替代方案: 把 [attachment:id] 标记塞 text 里, 这里解析回 attachments 数组
|
|
2462
2601
|
let parsedAttachments = [];
|
|
@@ -2501,7 +2640,11 @@ ${goalDesc}
|
|
|
2501
2640
|
// 提前捕获 wallet/autoTools 到本地变量, 避免下面 try 块内的 inner const channel
|
|
2502
2641
|
// (line ~638) 与这里外层的 const channel 形成 shadowing 让 TS 误报"使用前未声明"
|
|
2503
2642
|
const boundWalletAddress = channel?.walletAddress;
|
|
2504
|
-
|
|
2643
|
+
// 2026-08-02: 支持 per-message 覆盖 — 前端"发送默认配置"toggle 传 autoInvokeTools,
|
|
2644
|
+
// 有则用消息级设置, 没有则回落到 channel 配置 (默认开启)
|
|
2645
|
+
const autoToolsEnabled = typeof req.body?.autoInvokeTools === 'boolean'
|
|
2646
|
+
? req.body.autoInvokeTools
|
|
2647
|
+
: channel?.autoInvokeTools !== false;
|
|
2505
2648
|
// 捕获外层 channel 到独立变量, 避免被 try 块内 (line 740+) 的 const channel 遮蔽
|
|
2506
2649
|
const channelForJudgment = channel;
|
|
2507
2650
|
// per-channel queue 检查: 已在跑就入队, 等当前跑完自动接上
|
|
@@ -2619,6 +2762,9 @@ ${goalDesc}
|
|
|
2619
2762
|
console.log(`[消息处理] 开始处理用户消息, channelId: ${channelId}, sessionId: ${currentSessionId}`);
|
|
2620
2763
|
// 将真实 DID 作为上下文前缀,让 AI 使用真实的 DID 而不是自己编造的
|
|
2621
2764
|
let contextHint = '';
|
|
2765
|
+
// 2026-08-02: slash 命令提示 (在 /message 开头解析, 这里注入)
|
|
2766
|
+
if (slashCommandHint)
|
|
2767
|
+
contextHint += slashCommandHint;
|
|
2622
2768
|
if (realChannelDid)
|
|
2623
2769
|
contextHint += `[系统上下文] 当前频道名称: ${realChannelName}, 你的真实 DID: ${realChannelDid}\n`;
|
|
2624
2770
|
// v3 新增: 标识发送方 — 让 AI 分清内部 owner vs 远端访客
|
|
@@ -2712,6 +2858,68 @@ ${goalDesc}
|
|
|
2712
2858
|
}
|
|
2713
2859
|
contextHint += '回复时应自然体现这个角色 (不要硬搬原文, 像这个角色说话即可).\n\n';
|
|
2714
2860
|
}
|
|
2861
|
+
// 2026-08-02: memory 回读 — 把本 channel 的历史记忆摘要注入 contextHint.
|
|
2862
|
+
// memory-compressor 每次 /message 后把增量摘要 append 到
|
|
2863
|
+
// ~/.bolloon/memory/<agentId>/sessions/<safe-channel>__<safe-session>.summary.md,
|
|
2864
|
+
// 但之前只写不读, 对话完全无记忆. 这里取尾部最近 3 段增量摘要回灌给 LLM.
|
|
2865
|
+
try {
|
|
2866
|
+
const { getSessionSummaryPath, getMemoryDir, sanitizeAgentId } = await import('../bootstrap/memory-compressor.js');
|
|
2867
|
+
const { readFile, readdir } = await import('fs/promises');
|
|
2868
|
+
const { join } = await import('path');
|
|
2869
|
+
const agentId = channelForJudgment?.agentId;
|
|
2870
|
+
if (agentId) {
|
|
2871
|
+
// 优先读当前 channel 的 summary (如果有)
|
|
2872
|
+
const currentSummary = getSessionSummaryPath(agentId, channelId, currentSessionId);
|
|
2873
|
+
const summariesToRead = [];
|
|
2874
|
+
try {
|
|
2875
|
+
const raw = await readFile(currentSummary, 'utf-8');
|
|
2876
|
+
if (raw.trim())
|
|
2877
|
+
summariesToRead.push(raw);
|
|
2878
|
+
}
|
|
2879
|
+
catch { /* 当前 channel 还没有摘要 */ }
|
|
2880
|
+
// 再兜底读同 agent 其他 channel 的最近摘要 (跨 channel 记忆)
|
|
2881
|
+
if (summariesToRead.length === 0) {
|
|
2882
|
+
try {
|
|
2883
|
+
const sessionsDir = join(getMemoryDir(agentId), 'sessions');
|
|
2884
|
+
const files = (await readdir(sessionsDir)).filter(f => f.endsWith('.summary.md'));
|
|
2885
|
+
// 按文件名排序取最近 2 个 (文件名含时间戳, 近似排序)
|
|
2886
|
+
const recent = files.sort().slice(-2);
|
|
2887
|
+
for (const f of recent) {
|
|
2888
|
+
if (f.includes(sanitizeAgentId(channelId)))
|
|
2889
|
+
continue; // 跳过已读的当前 channel
|
|
2890
|
+
try {
|
|
2891
|
+
const raw = await readFile(join(sessionsDir, f), 'utf-8');
|
|
2892
|
+
if (raw.trim())
|
|
2893
|
+
summariesToRead.push(raw);
|
|
2894
|
+
}
|
|
2895
|
+
catch { /* 单个摘要读失败跳过 */ }
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
catch { /* 无 sessions 目录 */ }
|
|
2899
|
+
}
|
|
2900
|
+
if (summariesToRead.length > 0) {
|
|
2901
|
+
const memBlock = summariesToRead.map(s => s.trim().slice(-1500)).join('\n\n---\n\n');
|
|
2902
|
+
contextHint += `[系统上下文] 本 channel 的历史记忆 (来自 memory-compressor 摘要, 帮助你延续之前的对话, 引用而非复述):\n${memBlock.slice(-2500)}\n\n`;
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
catch (memErr) {
|
|
2907
|
+
// 静默失败 — memory 回读不是核心, 失败不阻塞
|
|
2908
|
+
}
|
|
2909
|
+
// 2026-08-02: plan 回读 — 把进行中的计划注入 contextHint (在 memory 之后)
|
|
2910
|
+
// 配合 create_plan / update_plan / review_plan 工具, 让 LLM 每次对话都能看到
|
|
2911
|
+
// 未完成的计划并继续推进.
|
|
2912
|
+
try {
|
|
2913
|
+
const { listActivePlans, planToContext } = await import('../agents/plan-store.js');
|
|
2914
|
+
const plans = await listActivePlans();
|
|
2915
|
+
if (plans.length > 0) {
|
|
2916
|
+
const plansBlock = plans.slice(0, 3).map(p => planToContext(p)).join('\n\n');
|
|
2917
|
+
contextHint += `[系统上下文] 进行中的计划 (来自 plan-store, 执行中每完成一步调 update_plan 勾选):\n${plansBlock}\n\n`;
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
catch (planErr) {
|
|
2921
|
+
// 静默失败
|
|
2922
|
+
}
|
|
2715
2923
|
const linkedIds = channelForJudgment?.linkedDocumentIds;
|
|
2716
2924
|
if (Array.isArray(linkedIds) && linkedIds.length > 0) {
|
|
2717
2925
|
try {
|
|
@@ -2974,7 +3182,13 @@ ${goalDesc}
|
|
|
2974
3182
|
// 现在改名逻辑挪到 /api/agent-rename 端点, 用户主动触发才跑
|
|
2975
3183
|
if (channel) {
|
|
2976
3184
|
channel.updatedAt = new Date().toISOString();
|
|
2977
|
-
|
|
3185
|
+
// 2026-08-02 fix: 原子写入, 防并发覆盖丢 channel
|
|
3186
|
+
await updateChannels((chs) => {
|
|
3187
|
+
const c = chs.find(x => x.id === channelId);
|
|
3188
|
+
if (c)
|
|
3189
|
+
c.updatedAt = channel.updatedAt;
|
|
3190
|
+
return chs;
|
|
3191
|
+
});
|
|
2978
3192
|
}
|
|
2979
3193
|
broadcast({ type: 'done' }, channelId);
|
|
2980
3194
|
// D 触发: AI 被动捕获判断力 (后台异步, 不阻塞主对话)
|
|
@@ -3065,6 +3279,35 @@ ${goalDesc}
|
|
|
3065
3279
|
console.warn(`[web] saveCurrentSession failed (non-fatal): ${saveErr.message?.slice(0, 100)}`);
|
|
3066
3280
|
}
|
|
3067
3281
|
}
|
|
3282
|
+
// 2026-08-02: run-end skill 候选扫描 (fire-and-forget, 不阻塞 finally)
|
|
3283
|
+
// 从本轮 lastSteps 提取连续成功的工具调用模式, 写入 ~/.bolloon/skill-candidates/.
|
|
3284
|
+
// agent 之后可调 list_skill_candidates / promote_skill 决定是否转正.
|
|
3285
|
+
try {
|
|
3286
|
+
const steps = runState.lastSteps || [];
|
|
3287
|
+
const okTools = steps.filter(s => s.status === 'ok' && s.name && s.name !== 'system');
|
|
3288
|
+
if (okTools.length >= 2) {
|
|
3289
|
+
setImmediate(async () => {
|
|
3290
|
+
try {
|
|
3291
|
+
const { writeSkillCandidate } = await import('../agents/skill-writer.js');
|
|
3292
|
+
const toolNames = okTools.map(s => s.name).slice(0, 5).join(', ');
|
|
3293
|
+
const body = `## 背景\n本轮对话连续成功调用了 ${okTools.length} 个工具: ${toolNames}.\n\n## 流程\n${okTools.map(s => `1. 调用 ${s.name}${s.output ? ': ' + String(s.output).slice(0, 120) : ''}`).join('\n')}\n\n## 注意事项\n- 工具名以 list_skills / get_operation_logs 的实际注册名为准\n- 沉淀为正式 skill 前请人工确认流程可复用\n`;
|
|
3294
|
+
const candName = `auto-${okTools[0].name}-${Date.now().toString(36)}`;
|
|
3295
|
+
const file = await writeSkillCandidate({
|
|
3296
|
+
name: candName,
|
|
3297
|
+
description: `自动候选: ${okTools.length} 个工具连续成功 (${toolNames})`,
|
|
3298
|
+
body,
|
|
3299
|
+
source: `channel:${channelId}`,
|
|
3300
|
+
timestamp: new Date().toISOString(),
|
|
3301
|
+
});
|
|
3302
|
+
console.log(`[skill-candidates] 写入候选 ${file} (${okTools.length} tools)`);
|
|
3303
|
+
}
|
|
3304
|
+
catch (candErr) {
|
|
3305
|
+
console.warn('[skill-candidates] 写入失败 (non-fatal):', candErr?.message?.slice(0, 150));
|
|
3306
|
+
}
|
|
3307
|
+
});
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
catch { /* 非致命 */ }
|
|
3068
3311
|
}
|
|
3069
3312
|
});
|
|
3070
3313
|
// ---------- 频道元数据后台修复队列 ----------
|
|
@@ -3271,7 +3514,19 @@ ${goalDesc}
|
|
|
3271
3514
|
catch {
|
|
3272
3515
|
// IPFS 不可用, 跳过 — 下次再试
|
|
3273
3516
|
}
|
|
3274
|
-
|
|
3517
|
+
// 2026-08-02 fix: 原子写入 — DID 修复是异步队列, 与创建/改名的裸 saveChannels
|
|
3518
|
+
// 并发时用旧数组覆盖 (lost update), 这是"重启后 channel 消失"的根因之一.
|
|
3519
|
+
await updateChannels((chs) => {
|
|
3520
|
+
const c = chs.find(x => x.id === channelId);
|
|
3521
|
+
if (c) {
|
|
3522
|
+
c.did = channel.did;
|
|
3523
|
+
c.publicKey = channel.publicKey;
|
|
3524
|
+
c.cid = channel.cid;
|
|
3525
|
+
c.didDocRef = channel.didDocRef;
|
|
3526
|
+
c.updatedAt = new Date().toISOString();
|
|
3527
|
+
}
|
|
3528
|
+
return chs;
|
|
3529
|
+
});
|
|
3275
3530
|
}
|
|
3276
3531
|
// 频道列表响应缓存: 短时间内重复请求走缓存, 避免每次重读 + 重序列化 channels.json
|
|
3277
3532
|
// 跨作用域 (saveChannels 在模块顶层, 本函数在 createWebServer 内) 用 lastChannelsWriteAt 协调失效
|
|
@@ -3508,8 +3763,13 @@ ${goalDesc}
|
|
|
3508
3763
|
}]
|
|
3509
3764
|
};
|
|
3510
3765
|
console.log(`[创建频道] 先保存频道 ID: ${id}`);
|
|
3511
|
-
channels.push(
|
|
3512
|
-
|
|
3766
|
+
// 2026-08-02 fix: 用 updateChannels 原子写入 — 之前 channels.push + saveChannels(channels)
|
|
3767
|
+
// 是裸 read-modify-write, 与 /message updatedAt 保存等并发时会互相覆盖 (lost update),
|
|
3768
|
+
// 新 channel 可能被旧数组冲掉 → 重启后只剩一个 channel 的 bug.
|
|
3769
|
+
await updateChannels((chs) => {
|
|
3770
|
+
chs.push(channel);
|
|
3771
|
+
return chs;
|
|
3772
|
+
});
|
|
3513
3773
|
await saveSession({ channelId: id, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString() });
|
|
3514
3774
|
// 2026-07-15 修 Bug 6: 同步把 agent 定义写进 ~/.bolloon/agents/agents.json
|
|
3515
3775
|
// 之前 channel 只落 channels.json, 不会出现在 agents.json 里.
|
|
@@ -3578,7 +3838,18 @@ ${goalDesc}
|
|
|
3578
3838
|
channel.sessions.push(session);
|
|
3579
3839
|
channel.currentSessionId = sessionId;
|
|
3580
3840
|
channel.updatedAt = new Date().toISOString();
|
|
3581
|
-
|
|
3841
|
+
// 2026-08-02 fix: 原子写入
|
|
3842
|
+
await updateChannels((chs) => {
|
|
3843
|
+
const c = chs.find(x => x.id === channelId);
|
|
3844
|
+
if (c) {
|
|
3845
|
+
if (!c.sessions)
|
|
3846
|
+
c.sessions = [];
|
|
3847
|
+
c.sessions.push(session);
|
|
3848
|
+
c.currentSessionId = sessionId;
|
|
3849
|
+
c.updatedAt = new Date().toISOString();
|
|
3850
|
+
}
|
|
3851
|
+
return chs;
|
|
3852
|
+
});
|
|
3582
3853
|
await saveSession({ channelId, sessionId, messages: [], lastUpdated: new Date().toISOString() });
|
|
3583
3854
|
res.json({ session, currentSessionId: sessionId });
|
|
3584
3855
|
}
|
|
@@ -3617,7 +3888,15 @@ ${goalDesc}
|
|
|
3617
3888
|
}
|
|
3618
3889
|
channel.currentSessionId = sessionId;
|
|
3619
3890
|
channel.updatedAt = new Date().toISOString();
|
|
3620
|
-
|
|
3891
|
+
// 2026-08-02 fix: 原子写入
|
|
3892
|
+
await updateChannels((chs) => {
|
|
3893
|
+
const c = chs.find(x => x.id === channelId);
|
|
3894
|
+
if (c) {
|
|
3895
|
+
c.currentSessionId = sessionId;
|
|
3896
|
+
c.updatedAt = new Date().toISOString();
|
|
3897
|
+
}
|
|
3898
|
+
return chs;
|
|
3899
|
+
});
|
|
3621
3900
|
res.json({ ok: true, currentSessionId: sessionId });
|
|
3622
3901
|
}
|
|
3623
3902
|
catch (err) {
|
|
@@ -3649,7 +3928,20 @@ ${goalDesc}
|
|
|
3649
3928
|
channel.currentSessionId = nextSession.id;
|
|
3650
3929
|
}
|
|
3651
3930
|
channel.updatedAt = new Date().toISOString();
|
|
3652
|
-
|
|
3931
|
+
// 2026-08-02 fix: 原子写入
|
|
3932
|
+
await updateChannels((chs) => {
|
|
3933
|
+
const c = chs.find(x => x.id === channelId);
|
|
3934
|
+
if (c && c.sessions) {
|
|
3935
|
+
const idx = c.sessions.findIndex(s => s.id === sessionId);
|
|
3936
|
+
if (idx >= 0)
|
|
3937
|
+
c.sessions.splice(idx, 1);
|
|
3938
|
+
if (c.currentSessionId === sessionId && c.sessions[0]) {
|
|
3939
|
+
c.currentSessionId = c.sessions[0].id;
|
|
3940
|
+
}
|
|
3941
|
+
c.updatedAt = new Date().toISOString();
|
|
3942
|
+
}
|
|
3943
|
+
return chs;
|
|
3944
|
+
});
|
|
3653
3945
|
// 删除 session 文件
|
|
3654
3946
|
try {
|
|
3655
3947
|
await fs.unlink(path.join(SESSION_CACHE_PATH, `${channelId}:${sessionId}.json`));
|
|
@@ -3671,8 +3963,13 @@ ${goalDesc}
|
|
|
3671
3963
|
return res.status(404).json({ error: 'Channel not found' });
|
|
3672
3964
|
}
|
|
3673
3965
|
const channel = channels[index];
|
|
3674
|
-
|
|
3675
|
-
await
|
|
3966
|
+
// 2026-08-02 fix: 原子写入
|
|
3967
|
+
await updateChannels((chs) => {
|
|
3968
|
+
const idx = chs.findIndex(c => c.id === channelId);
|
|
3969
|
+
if (idx >= 0)
|
|
3970
|
+
chs.splice(idx, 1);
|
|
3971
|
+
return chs;
|
|
3972
|
+
});
|
|
3676
3973
|
// 清理该 channel 名下所有的 session 文件 + 默认 session 文件
|
|
3677
3974
|
const candidates = new Set([`${channelId}.json`]);
|
|
3678
3975
|
if (channel.sessions) {
|
|
@@ -3799,7 +4096,18 @@ ${goalDesc}
|
|
|
3799
4096
|
console.log(`[Channel ${channelId}] 自动生成 share_id: ${channel.share_id}`);
|
|
3800
4097
|
}
|
|
3801
4098
|
channel.updatedAt = new Date().toISOString();
|
|
3802
|
-
|
|
4099
|
+
// 2026-08-02 fix: 原子写入
|
|
4100
|
+
await updateChannels((chs) => {
|
|
4101
|
+
const c = chs.find(x => x.id === channelId);
|
|
4102
|
+
if (c) {
|
|
4103
|
+
if (shared_with_peers !== undefined)
|
|
4104
|
+
c.shared_with_peers = shared_with_peers;
|
|
4105
|
+
if (!c.share_id)
|
|
4106
|
+
c.share_id = channel.share_id;
|
|
4107
|
+
c.updatedAt = new Date().toISOString();
|
|
4108
|
+
}
|
|
4109
|
+
return chs;
|
|
4110
|
+
});
|
|
3803
4111
|
// v3 修复: 分享变更后立即广播给所有 peer, 不用等对方手动刷新
|
|
3804
4112
|
if (shared_with_peers !== undefined) {
|
|
3805
4113
|
v3BroadcastOwn().catch(err => console.error('[v3] broadcast after share update failed:', err));
|
|
@@ -3862,7 +4170,18 @@ ${goalDesc}
|
|
|
3862
4170
|
channel.autoInvokeTools = autoInvokeTools;
|
|
3863
4171
|
}
|
|
3864
4172
|
channel.updatedAt = new Date().toISOString();
|
|
3865
|
-
|
|
4173
|
+
// 2026-08-02 fix: 原子写入
|
|
4174
|
+
await updateChannels((chs) => {
|
|
4175
|
+
const c = chs.find(x => x.id === channelId);
|
|
4176
|
+
if (c) {
|
|
4177
|
+
c.walletAddress = walletAddress;
|
|
4178
|
+
c.walletRegisteredAt = new Date().toISOString();
|
|
4179
|
+
if (typeof autoInvokeTools === 'boolean')
|
|
4180
|
+
c.autoInvokeTools = autoInvokeTools;
|
|
4181
|
+
c.updatedAt = new Date().toISOString();
|
|
4182
|
+
}
|
|
4183
|
+
return chs;
|
|
4184
|
+
});
|
|
3866
4185
|
console.log(`[Wallet] channel ${channelId} 绑定钱包 ${channel.walletAddress} 到 DID ${did}`);
|
|
3867
4186
|
res.json(channel);
|
|
3868
4187
|
}
|
|
@@ -3891,7 +4210,18 @@ ${goalDesc}
|
|
|
3891
4210
|
channel.autoPayEnabled = autoPayEnabled;
|
|
3892
4211
|
}
|
|
3893
4212
|
channel.updatedAt = new Date().toISOString();
|
|
3894
|
-
|
|
4213
|
+
// 2026-08-02 fix: 原子写入
|
|
4214
|
+
await updateChannels((chs) => {
|
|
4215
|
+
const c = chs.find(x => x.id === channelId);
|
|
4216
|
+
if (c) {
|
|
4217
|
+
c.encryptedPrivateKey = encryptedPrivateKey;
|
|
4218
|
+
c.encryptedPrivateKeyIv = encryptedPrivateKeyIv;
|
|
4219
|
+
if (typeof autoPayEnabled === 'boolean')
|
|
4220
|
+
c.autoPayEnabled = autoPayEnabled;
|
|
4221
|
+
c.updatedAt = new Date().toISOString();
|
|
4222
|
+
}
|
|
4223
|
+
return chs;
|
|
4224
|
+
});
|
|
3895
4225
|
console.log(`[Wallet] channel ${channelId} 已存储加密私钥 (autoPay=${channel.autoPayEnabled})`);
|
|
3896
4226
|
res.json(channel);
|
|
3897
4227
|
}
|
|
@@ -3911,7 +4241,17 @@ ${goalDesc}
|
|
|
3911
4241
|
channel.encryptedPrivateKeyIv = undefined;
|
|
3912
4242
|
channel.autoPayEnabled = false;
|
|
3913
4243
|
channel.updatedAt = new Date().toISOString();
|
|
3914
|
-
|
|
4244
|
+
// 2026-08-02 fix: 原子写入
|
|
4245
|
+
await updateChannels((chs) => {
|
|
4246
|
+
const c = chs.find(x => x.id === channelId);
|
|
4247
|
+
if (c) {
|
|
4248
|
+
c.encryptedPrivateKey = undefined;
|
|
4249
|
+
c.encryptedPrivateKeyIv = undefined;
|
|
4250
|
+
c.autoPayEnabled = false;
|
|
4251
|
+
c.updatedAt = new Date().toISOString();
|
|
4252
|
+
}
|
|
4253
|
+
return chs;
|
|
4254
|
+
});
|
|
3915
4255
|
console.log(`[Wallet] channel ${channelId} 已清除加密私钥`);
|
|
3916
4256
|
res.json(channel);
|
|
3917
4257
|
}
|
|
@@ -3933,7 +4273,15 @@ ${goalDesc}
|
|
|
3933
4273
|
return res.status(404).json({ error: 'Channel not found' });
|
|
3934
4274
|
channel.autoPayEnabled = autoPayEnabled;
|
|
3935
4275
|
channel.updatedAt = new Date().toISOString();
|
|
3936
|
-
|
|
4276
|
+
// 2026-08-02 fix: 原子写入
|
|
4277
|
+
await updateChannels((chs) => {
|
|
4278
|
+
const c = chs.find(x => x.id === channelId);
|
|
4279
|
+
if (c) {
|
|
4280
|
+
c.autoPayEnabled = autoPayEnabled;
|
|
4281
|
+
c.updatedAt = new Date().toISOString();
|
|
4282
|
+
}
|
|
4283
|
+
return chs;
|
|
4284
|
+
});
|
|
3937
4285
|
console.log(`[Wallet] channel ${channelId} autoPay → ${autoPayEnabled}`);
|
|
3938
4286
|
res.json(channel);
|
|
3939
4287
|
}
|
|
@@ -4230,8 +4578,65 @@ ${goalDesc}
|
|
|
4230
4578
|
});
|
|
4231
4579
|
app.delete('/api/p2p-peers/:name', async (req, res) => {
|
|
4232
4580
|
try {
|
|
4233
|
-
const { removePeer } = await import('../network/known-peers.js');
|
|
4234
|
-
|
|
4581
|
+
const { removePeer, listPeers } = await import('../network/known-peers.js');
|
|
4582
|
+
// 2026-08-02: 删除好友时同时撤回分享 — 从所有 channel 的 shared_with_peers 移除该 peer
|
|
4583
|
+
// 参数支持 name 或 publicKey (陌生 peer 只有 publicKey, 不在 known_peers)
|
|
4584
|
+
const peers = await listPeers();
|
|
4585
|
+
const param = req.params.name;
|
|
4586
|
+
const target = peers.find(p => p.name === param) || peers.find(p => p.publicKey === param);
|
|
4587
|
+
if (target) {
|
|
4588
|
+
const { readFile, writeFile } = await import('fs/promises');
|
|
4589
|
+
const channelsPath = `${process.env.HOME || '/tmp'}/.bolloon/sessions/channels.json`;
|
|
4590
|
+
try {
|
|
4591
|
+
const raw = await readFile(channelsPath, 'utf-8');
|
|
4592
|
+
const chData = JSON.parse(raw);
|
|
4593
|
+
const chs = Array.isArray(chData) ? chData : (chData.channels || []);
|
|
4594
|
+
let changed = 0;
|
|
4595
|
+
for (const ch of chs) {
|
|
4596
|
+
if (Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(target.publicKey)) {
|
|
4597
|
+
ch.shared_with_peers = ch.shared_with_peers.filter((pk) => pk !== target.publicKey);
|
|
4598
|
+
changed++;
|
|
4599
|
+
}
|
|
4600
|
+
}
|
|
4601
|
+
if (changed > 0) {
|
|
4602
|
+
if (Array.isArray(chData))
|
|
4603
|
+
await writeFile(channelsPath, JSON.stringify(chs, null, 2), 'utf-8');
|
|
4604
|
+
else {
|
|
4605
|
+
chData.channels = chs;
|
|
4606
|
+
await writeFile(channelsPath, JSON.stringify(chData, null, 2), 'utf-8');
|
|
4607
|
+
}
|
|
4608
|
+
console.log(`[p2p-peers] 删除好友 ${param}: 撤回 ${changed} 个 channel 的分享`);
|
|
4609
|
+
}
|
|
4610
|
+
}
|
|
4611
|
+
catch (e) {
|
|
4612
|
+
console.warn('[p2p-peers] 撤回 channel 分享失败 (non-fatal):', e?.message?.slice(0, 120));
|
|
4613
|
+
}
|
|
4614
|
+
}
|
|
4615
|
+
// 2026-08-02: 同时清掉 remote channel cache (内存 + 磁盘) — 否则 topic 在线节点
|
|
4616
|
+
// 删除后仍以 "陌生 peer" (peer-xxx) 出现在 UI, 无法真正删除
|
|
4617
|
+
try {
|
|
4618
|
+
const targetPk = target?.publicKey || (param.length === 64 ? param : null);
|
|
4619
|
+
if (targetPk)
|
|
4620
|
+
remoteChannelCache.delete(targetPk);
|
|
4621
|
+
const { existsSync } = await import('fs');
|
|
4622
|
+
if (existsSync(REMOTE_CACHE_FILE)) {
|
|
4623
|
+
const { readFile, writeFile } = await import('fs/promises');
|
|
4624
|
+
const cacheObj = JSON.parse(await readFile(REMOTE_CACHE_FILE, 'utf-8'));
|
|
4625
|
+
if (cacheObj && typeof cacheObj === 'object') {
|
|
4626
|
+
const pkToDel = targetPk || Object.keys(cacheObj).find((k) => k === param);
|
|
4627
|
+
if (pkToDel && cacheObj[pkToDel]) {
|
|
4628
|
+
delete cacheObj[pkToDel];
|
|
4629
|
+
await writeFile(REMOTE_CACHE_FILE, JSON.stringify(cacheObj, null, 2), 'utf-8');
|
|
4630
|
+
console.log(`[p2p-peers] 已清 remote cache: ${pkToDel.substring(0, 12)}...`);
|
|
4631
|
+
}
|
|
4632
|
+
}
|
|
4633
|
+
}
|
|
4634
|
+
}
|
|
4635
|
+
catch (e) {
|
|
4636
|
+
console.warn('[p2p-peers] 清 remote cache 失败 (non-fatal):', e?.message?.slice(0, 120));
|
|
4637
|
+
}
|
|
4638
|
+
// 陌生 peer (只有 publicKey) 不在 known_peers, removePeer 无操作也 ok
|
|
4639
|
+
await removePeer(target?.name || param);
|
|
4235
4640
|
res.json({ ok: true });
|
|
4236
4641
|
}
|
|
4237
4642
|
catch (err) {
|
|
@@ -4316,7 +4721,7 @@ ${goalDesc}
|
|
|
4316
4721
|
if (!v3P2PRef) {
|
|
4317
4722
|
return res.status(503).json({ ok: false, code: 'P2P_NOT_STARTED', error: 'P2PDirect not started' });
|
|
4318
4723
|
}
|
|
4319
|
-
const { targetPublicKey, name, message } = req.body || {};
|
|
4724
|
+
const { targetPublicKey, name, message, note } = req.body || {};
|
|
4320
4725
|
if (!targetPublicKey || typeof targetPublicKey !== 'string' || targetPublicKey.length !== 64) {
|
|
4321
4726
|
return res.status(400).json({ ok: false, code: 'BAD_REQUEST', error: 'targetPublicKey (64 hex) required' });
|
|
4322
4727
|
}
|
|
@@ -4343,7 +4748,8 @@ ${goalDesc}
|
|
|
4343
4748
|
requestId, // 2026-06-10: 加 requestId, ack 时回带
|
|
4344
4749
|
fromPublicKey: myPk,
|
|
4345
4750
|
name: peerName,
|
|
4346
|
-
message: message || '想加你为 P2P 好友, 共享 channel 协作'
|
|
4751
|
+
message: message || '想加你为 P2P 好友, 共享 channel 协作',
|
|
4752
|
+
note: note || message || undefined, // 2026-08-02: 备注 (自我介绍/来源), 优先显式 note
|
|
4347
4753
|
}
|
|
4348
4754
|
});
|
|
4349
4755
|
// 2026-06-10: 用 sendToWithWait, 等 conn 真就绪后再发, 默认 5s 超时
|
|
@@ -4366,17 +4772,53 @@ ${goalDesc}
|
|
|
4366
4772
|
res.status(500).json({ ok: false, code: 'EXCEPTION', error: err.message });
|
|
4367
4773
|
}
|
|
4368
4774
|
});
|
|
4775
|
+
// v3: 待处理好友申请查询 (智能体工具 list_pending_friend_requests 用)
|
|
4776
|
+
// 用法: GET /api/friend-requests
|
|
4777
|
+
app.get('/api/friend-requests', async (_req, res) => {
|
|
4778
|
+
const list = Array.from(pendingFriendRequests.values()).map(r => ({
|
|
4779
|
+
requestId: r.requestId,
|
|
4780
|
+
fromPublicKey: r.fromPublicKey,
|
|
4781
|
+
fromName: r.fromName,
|
|
4782
|
+
message: r.message,
|
|
4783
|
+
note: r.note || '',
|
|
4784
|
+
receivedAt: r.receivedAt,
|
|
4785
|
+
}));
|
|
4786
|
+
res.json({ count: list.length, requests: list });
|
|
4787
|
+
});
|
|
4788
|
+
// v3: 忽略/拒绝一个待处理好友申请 (智能体工具 ignore_friend_request 用)
|
|
4789
|
+
// 用法: POST /api/friend-requests/ignore { requestId }
|
|
4790
|
+
app.post('/api/friend-requests/ignore', async (req, res) => {
|
|
4791
|
+
try {
|
|
4792
|
+
const { requestId } = req.body || {};
|
|
4793
|
+
if (!requestId || !pendingFriendRequests.has(requestId)) {
|
|
4794
|
+
return res.status(404).json({ error: `未找到 requestId=${requestId} 的申请` });
|
|
4795
|
+
}
|
|
4796
|
+
const r = pendingFriendRequests.get(requestId);
|
|
4797
|
+
pendingFriendRequests.delete(requestId);
|
|
4798
|
+
persistPendingFriendRequests(); // 2026-08-02: 落盘
|
|
4799
|
+
console.log(`[v3-friend] 忽略好友申请: ${r.fromName} (${r.fromPublicKey.substring(0, 12)}...)`);
|
|
4800
|
+
res.json({ ok: true, ignored: r.fromName });
|
|
4801
|
+
}
|
|
4802
|
+
catch (err) {
|
|
4803
|
+
res.status(500).json({ error: err.message });
|
|
4804
|
+
}
|
|
4805
|
+
});
|
|
4369
4806
|
// v3: 接受对方的好友申请 — 把对方加为 known_peers, 立即推我的 channel 列表给 ta
|
|
4370
|
-
// 用法: POST /api/friend-accept { fromPublicKey, name }
|
|
4807
|
+
// 用法: POST /api/friend-accept { fromPublicKey, name, requestId? }
|
|
4371
4808
|
app.post('/api/friend-accept', async (req, res) => {
|
|
4372
4809
|
try {
|
|
4373
4810
|
if (!v3P2PRef) {
|
|
4374
4811
|
return res.status(503).json({ error: 'P2PDirect not started' });
|
|
4375
4812
|
}
|
|
4376
|
-
const { fromPublicKey, name } = req.body || {};
|
|
4813
|
+
const { fromPublicKey, name, requestId } = req.body || {};
|
|
4377
4814
|
if (!fromPublicKey || typeof fromPublicKey !== 'string' || fromPublicKey.length !== 64) {
|
|
4378
4815
|
return res.status(400).json({ error: 'fromPublicKey (64 hex) required' });
|
|
4379
4816
|
}
|
|
4817
|
+
// 2026-08-02: 接受后清掉 pending 条目 (若带了 requestId)
|
|
4818
|
+
if (requestId) {
|
|
4819
|
+
pendingFriendRequests.delete(requestId);
|
|
4820
|
+
persistPendingFriendRequests();
|
|
4821
|
+
}
|
|
4380
4822
|
// 持久化
|
|
4381
4823
|
const { addOrUpdatePeer, findNameByPublicKey } = await import('../network/known-peers.js');
|
|
4382
4824
|
const existing = await findNameByPublicKey(fromPublicKey);
|
|
@@ -4409,7 +4851,7 @@ ${goalDesc}
|
|
|
4409
4851
|
if (!v3P2PRef) {
|
|
4410
4852
|
return res.status(503).json({ error: 'P2PDirect not started' });
|
|
4411
4853
|
}
|
|
4412
|
-
const { targetPublicKey, channelId, text } = req.body || {};
|
|
4854
|
+
const { targetPublicKey, channelId, text, autoInvokeTools } = req.body || {};
|
|
4413
4855
|
if (!targetPublicKey || !channelId || !text) {
|
|
4414
4856
|
return res.status(400).json({ error: 'targetPublicKey, channelId, text required' });
|
|
4415
4857
|
}
|
|
@@ -4418,8 +4860,12 @@ ${goalDesc}
|
|
|
4418
4860
|
}
|
|
4419
4861
|
const fromPk = v3P2PRef.getPublicKey();
|
|
4420
4862
|
// 2026-07-27: 改用 sendOrQueue (先尝试直发, 失败则入队, 不断线不丢)
|
|
4863
|
+
// 2026-08-02: 透传 autoInvokeTools (发送方工具开关设置, 只对本次远端消息生效)
|
|
4421
4864
|
const { sendOrQueue } = await import('../network/p2p-outbox.js');
|
|
4422
|
-
const r = await sendOrQueue(targetPublicKey, 'agent.chat.send', {
|
|
4865
|
+
const r = await sendOrQueue(targetPublicKey, 'agent.chat.send', {
|
|
4866
|
+
channelId, text, fromPublicKey: fromPk,
|
|
4867
|
+
...(typeof autoInvokeTools === 'boolean' ? { autoInvokeTools } : {}),
|
|
4868
|
+
}, v3P2PRef);
|
|
4423
4869
|
if (r === 'FAILED') {
|
|
4424
4870
|
return res.status(502).json({ error: 'send failed: peer not reachable' });
|
|
4425
4871
|
}
|
|
@@ -5043,6 +5489,12 @@ ${goalDesc}
|
|
|
5043
5489
|
if (s?.abortController) {
|
|
5044
5490
|
s.abortController.abort();
|
|
5045
5491
|
console.log(`[abort] user aborted channel=${channelId}`);
|
|
5492
|
+
// 2026-08-02 fix: abort 后立即广播 done — 之前前端靠 1.5s 兜底 setTimeout 切回 idle,
|
|
5493
|
+
// 视觉上"点了没反应". 这里主动推 done, 前端 finalizeTimelineAsMessage + setSendMode('idle') 立刻生效.
|
|
5494
|
+
try {
|
|
5495
|
+
broadcast({ type: 'done' }, channelId);
|
|
5496
|
+
}
|
|
5497
|
+
catch { /* 广播失败不影响 abort 主流程 */ }
|
|
5046
5498
|
return res.json({ ok: true, aborted: true });
|
|
5047
5499
|
}
|
|
5048
5500
|
res.json({ ok: true, aborted: false });
|