@bolloon/bolloon-agent 0.1.7 → 0.1.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.
@@ -114,10 +114,17 @@ async function startCLI(additionalArgs) {
114
114
  }
115
115
 
116
116
  async function main() {
117
+ // Get version from package.json
118
+ const binPath = require.main.filename;
119
+ const binDir = path.dirname(binPath);
120
+ const packageDir = path.dirname(binDir);
121
+ const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf-8"));
122
+ const version = packageJson.version;
123
+
117
124
  const { mode, args } = parseArgs();
118
125
  switch (mode) {
119
126
  case "version":
120
- console.log("Bolloon Agent v0.1.2");
127
+ console.log("Bolloon Agent v" + version);
121
128
  break;
122
129
  case "help":
123
130
  printBanner();
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env node
2
+ const path = require("path");
3
+ const { spawn } = require("child_process");
4
+ const fs = require("fs");
5
+
6
+ const RESET = "\x1b[0m";
7
+ const BOLD = "\x1b[1m";
8
+ const CYAN = "\x1b[36m";
9
+ const GREEN = "\x1b[32m";
10
+ const MAGENTA = "\x1b[35m";
11
+
12
+ function log(msg, color) {
13
+ console.log((color || RESET) + msg + RESET);
14
+ }
15
+
16
+ function printBanner() {
17
+ console.log("\n" + CYAN + BOLD + [
18
+ " ╔═══════════════════════════════════════════╗",
19
+ " ║ 🤖 Bolloon Agent ║",
20
+ " ║ P2P AI Document Processor ║",
21
+ " ╚═══════════════════════════════════════════╝"
22
+ ].join("\n") + RESET + "\n");
23
+ }
24
+
25
+ function getMainEntry() {
26
+ const distDir = path.dirname(require.main.filename);
27
+ const distIndex = path.join(distDir, "index.js");
28
+ if (fs.existsSync(distIndex)) {
29
+ return distIndex;
30
+ }
31
+ return path.join(process.cwd(), "src", "index.ts");
32
+ }
33
+
34
+ function parseArgs() {
35
+ const args = process.argv.slice(2);
36
+ if (args.length === 0) {
37
+ return { mode: "gui", args: [] };
38
+ }
39
+ const first = args[0];
40
+ switch (first) {
41
+ case "-v":
42
+ case "--version":
43
+ return { mode: "version", args: [] };
44
+ case "-h":
45
+ case "--help":
46
+ return { mode: "help", args: [] };
47
+ case "-g":
48
+ case "--gui":
49
+ return { mode: "gui", args: args.slice(1) };
50
+ case "-w":
51
+ case "--web":
52
+ return { mode: "web", args: args.slice(1) };
53
+ case "-c":
54
+ case "--cli":
55
+ return { mode: "cli", args: args.slice(1) };
56
+ default:
57
+ return { mode: "passthrough", args };
58
+ }
59
+ }
60
+
61
+ async function startElectron(additionalArgs) {
62
+ try {
63
+ const electron = require("electron");
64
+ const distDir = path.dirname(require.main.filename);
65
+ let mainPath = path.join(distDir, "electron.js");
66
+ if (!fs.existsSync(mainPath)) {
67
+ mainPath = path.join(process.cwd(), "src", "electron.ts");
68
+ }
69
+ log("启动 Electron...", CYAN);
70
+ const child = spawn(electron, [mainPath, ...additionalArgs], {
71
+ stdio: "inherit",
72
+ env: { ...process.env, NODE_ENV: "development" }
73
+ });
74
+ child.on("error", (err) => {
75
+ log("Electron 启动失败: " + err.message, MAGENTA);
76
+ process.exit(1);
77
+ });
78
+ child.on("exit", (code) => process.exit(code || 0));
79
+ } catch (err) {
80
+ log("Electron 不可用,切换到 Web 模式...", CYAN);
81
+ await startWebServer(additionalArgs);
82
+ }
83
+ }
84
+
85
+ async function startWebServer(additionalArgs) {
86
+ const mainPath = getMainEntry();
87
+ const webArgs = ["--web", ...additionalArgs];
88
+ log("启动 Web 服务...", CYAN);
89
+ const child = spawn(process.execPath, [mainPath, ...webArgs], { stdio: "inherit" });
90
+ child.on("error", (err) => {
91
+ log("Web 服务启动失败: " + err.message, MAGENTA);
92
+ process.exit(1);
93
+ });
94
+ child.on("exit", (code) => process.exit(code || 0));
95
+ }
96
+
97
+ async function startCLI(additionalArgs) {
98
+ const mainPath = getMainEntry();
99
+ log("启动命令行界面...", CYAN);
100
+ const child = spawn(process.execPath, [mainPath, ...additionalArgs], { stdio: "inherit" });
101
+ child.on("error", (err) => {
102
+ log("CLI 启动失败: " + err.message, MAGENTA);
103
+ process.exit(1);
104
+ });
105
+ child.on("exit", (code) => process.exit(code || 0));
106
+ }
107
+
108
+ async function main() {
109
+ const { mode, args } = parseArgs();
110
+ switch (mode) {
111
+ case "version":
112
+ console.log("Bolloon Agent v0.1.1");
113
+ break;
114
+ case "help":
115
+ printBanner();
116
+ console.log(BOLD + "用法:" + RESET + " bolloon [选项] [命令] [参数]");
117
+ console.log(BOLD + "选项:" + RESET + " --gui, -g 启动图形界面");
118
+ console.log(" --web, -w 启动 Web UI");
119
+ console.log(" --cli, -c 启动命令行界面");
120
+ console.log(" --version, -v 显示版本");
121
+ console.log(" --help, -h 显示帮助");
122
+ console.log(BOLD + "示例:" + RESET + " bolloon # 启动图形界面");
123
+ console.log(" bolloon --web # 启动 Web UI");
124
+ console.log(" bolloon --read file # 读取文档");
125
+ break;
126
+ case "gui":
127
+ printBanner();
128
+ await startElectron(args);
129
+ break;
130
+ case "web":
131
+ printBanner();
132
+ await startWebServer(args);
133
+ break;
134
+ case "cli":
135
+ await startCLI(args);
136
+ break;
137
+ case "passthrough":
138
+ const mainPath = getMainEntry();
139
+ const child = spawn(process.execPath, [mainPath, ...args], { stdio: "inherit" });
140
+ child.on("error", (err) => {
141
+ log("执行失败: " + err.message, MAGENTA);
142
+ process.exit(1);
143
+ });
144
+ child.on("exit", (code) => process.exit(code || 0));
145
+ break;
146
+ default:
147
+ log("未知模式: " + mode, MAGENTA);
148
+ printBanner();
149
+ console.log("输入 --help 查看帮助");
150
+ process.exit(1);
151
+ }
152
+ }
153
+
154
+ main().catch((err) => {
155
+ console.error("Fatal error:", err);
156
+ process.exit(1);
157
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * P2P Document Tools - Tools for sending/receiving documents over iroh P2P
3
+ */
4
+ import * as crypto from 'crypto';
5
+ import * as fs from 'fs/promises';
6
+ import { irohTransport } from '../network/iroh-transport.js';
7
+ import { documentReader } from '../documents/reader.js';
8
+ import { documentStore } from '../documents/store.js';
9
+ const CHUNK_SIZE = 60 * 1024; // 60KB per chunk
10
+ function generateDocId() {
11
+ return crypto.randomUUID();
12
+ }
13
+ function getMimeType(filename) {
14
+ const ext = filename.toLowerCase().split('.').pop();
15
+ const mimeTypes = {
16
+ 'txt': 'text/plain',
17
+ 'md': 'text/markdown',
18
+ 'pdf': 'application/pdf',
19
+ 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
20
+ };
21
+ return mimeTypes[ext || ''] || 'application/octet-stream';
22
+ }
23
+ export async function initDocumentReceiver() {
24
+ await documentStore.initialize();
25
+ documentStore.onDocumentReceived((doc) => {
26
+ console.log(`[DocumentReceiver] Document received: ${doc.fileName} from ${doc.fromNodeIdShort}`);
27
+ });
28
+ irohTransport.onMessage('document_chunk', async (msg) => {
29
+ try {
30
+ const chunk = JSON.parse(new TextDecoder().decode(msg.payload));
31
+ const result = await documentStore.receiveChunk(chunk);
32
+ if (result) {
33
+ console.log(`[DocumentReceiver] Document complete: ${result.fileName}`);
34
+ }
35
+ }
36
+ catch (e) {
37
+ console.error('[DocumentReceiver] Failed to process chunk:', e);
38
+ }
39
+ });
40
+ console.log('[DocumentReceiver] Initialized and listening for document chunks');
41
+ }
42
+ export const p2pDocumentTools = [
43
+ {
44
+ name: 'list_online_peers',
45
+ description: '列出当前通过 iroh P2P 网络在线的对等节点',
46
+ parameters: {},
47
+ execute: async () => {
48
+ try {
49
+ const peers = irohTransport.getConnectedPeers();
50
+ if (peers.length === 0) {
51
+ return { success: true, output: '当前无在线的对等节点' };
52
+ }
53
+ return {
54
+ success: true,
55
+ output: `在线节点 (${peers.length}):\n${peers.map(p => ` - ${p.substring(0, 16)}...`).join('\n')}`
56
+ };
57
+ }
58
+ catch (e) {
59
+ return { success: false, error: String(e) };
60
+ }
61
+ }
62
+ },
63
+ {
64
+ name: 'send_document',
65
+ description: '读取本地文档并发送给指定的对等节点,支持 .txt, .md, .pdf, .docx 格式',
66
+ parameters: {
67
+ target_peer_id: '目标对等节点的完整 nodeId',
68
+ file_path: '要发送的本地文件路径',
69
+ message: '可选的附言消息'
70
+ },
71
+ execute: async (args) => {
72
+ try {
73
+ const { target_peer_id, file_path, message } = args;
74
+ // 读取文档
75
+ let content;
76
+ try {
77
+ content = await documentReader.read(file_path);
78
+ }
79
+ catch (e) {
80
+ return { success: false, error: `无法读取文件: ${e}` };
81
+ }
82
+ // 分块
83
+ const mimeType = getMimeType(file_path);
84
+ const fileData = await fs.readFile(file_path);
85
+ const contentBase64 = fileData.toString('base64');
86
+ const totalChunks = Math.ceil(contentBase64.length / CHUNK_SIZE);
87
+ const docId = generateDocId();
88
+ // 逐块发送
89
+ for (let i = 0; i < totalChunks; i++) {
90
+ const chunk = {
91
+ docId,
92
+ fileName: content.metadata.filename,
93
+ fileSize: content.metadata.size,
94
+ mimeType,
95
+ chunkIndex: i,
96
+ totalChunks,
97
+ content: contentBase64.substring(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE),
98
+ fromNodeId: irohTransport.getNodeId() || '',
99
+ message: i === 0 ? message : undefined,
100
+ timestamp: Date.now(),
101
+ };
102
+ const sent = await irohTransport.sendMessage(target_peer_id, 'document_chunk', new TextEncoder().encode(JSON.stringify(chunk)));
103
+ if (!sent) {
104
+ return { success: false, error: `发送第 ${i + 1}/${totalChunks} 块失败` };
105
+ }
106
+ }
107
+ return {
108
+ success: true,
109
+ output: `📤 文档已发送: ${content.metadata.filename}\n大小: ${content.metadata.size} 字节\n分块: ${totalChunks}\n目标: ${target_peer_id.substring(0, 16)}...`
110
+ };
111
+ }
112
+ catch (e) {
113
+ return { success: false, error: String(e) };
114
+ }
115
+ }
116
+ },
117
+ {
118
+ name: 'receive_documents',
119
+ description: '列出已接收到的文档,可按发送者筛选',
120
+ parameters: {
121
+ limit: '返回数量上限,默认 50',
122
+ sender_peer_id: '可选,按发送者 nodeId 筛选'
123
+ },
124
+ execute: async (args) => {
125
+ try {
126
+ const limit = parseInt(args.limit) || 50;
127
+ const docs = await documentStore.getReceivedDocuments(limit, args.sender_peer_id);
128
+ if (docs.length === 0) {
129
+ return { success: true, output: '暂无已接收的文档' };
130
+ }
131
+ const list = docs.map(d => `📄 ${d.fileName}\n ID: ${d.id}\n 大小: ${d.fileSize} 字节\n 来自: ${d.fromNodeIdShort}\n 时间: ${new Date(d.receivedAt).toLocaleString()}`).join('\n\n');
132
+ return {
133
+ success: true,
134
+ output: `已接收文档 (${docs.length}):\n\n${list}`
135
+ };
136
+ }
137
+ catch (e) {
138
+ return { success: false, error: String(e) };
139
+ }
140
+ }
141
+ },
142
+ {
143
+ name: 'read_received_document',
144
+ description: '读取已接收文档的内容,返回文档ID可查询文档详情',
145
+ parameters: {
146
+ document_id: '文档ID (从 receive_documents 获取)'
147
+ },
148
+ execute: async (args) => {
149
+ try {
150
+ const { document_id } = args;
151
+ if (!document_id) {
152
+ return { success: false, error: '缺少 document_id 参数' };
153
+ }
154
+ const result = await documentStore.readDocument(document_id);
155
+ if (!result) {
156
+ return { success: false, error: `未找到文档: ${document_id}` };
157
+ }
158
+ const { content, metadata } = result;
159
+ return {
160
+ success: true,
161
+ output: `📄 ${metadata.fileName}\nID: ${metadata.id}\n大小: ${metadata.fileSize} 字节\n来自: ${metadata.fromNodeIdShort}\n\n${content.substring(0, 2000)}${content.length > 2000 ? '...\n(内容已截断)' : ''}`
162
+ };
163
+ }
164
+ catch (e) {
165
+ return { success: false, error: String(e) };
166
+ }
167
+ }
168
+ }
169
+ ];
@@ -12,6 +12,7 @@ import { ConstraintLayer } from './constraint-layer.js';
12
12
  import { WorkflowEngine } from './workflow-engine.js';
13
13
  import { DeepThinkingEngine, AgentCoordinator } from '@bolloon/constraint-runtime';
14
14
  import { WorkflowPivotLoop, createDefaultPivotConfig } from './workflow-pivot-loop.js';
15
+ import { p2pDocumentTools, initDocumentReceiver } from './p2p-document-tools.js';
15
16
  import { DiscoveredAgentsManager, createSocialHeartbeat } from '../social/heartbeat.js';
16
17
  import { getGlobalSharedContext } from '../social/global-shared-context.js';
17
18
  import { Session, SkillRegistry, saveSession, loadSession } from '@bolloon/constraint-runtime';
@@ -350,6 +351,7 @@ class PiAgentSession {
350
351
  this.usePivotLoop = config.usePivotLoop ?? false;
351
352
  this.pivotLoopConfig = config.pivotLoopConfig;
352
353
  this.initSession();
354
+ initDocumentReceiver();
353
355
  this.registerTools();
354
356
  this.initHarness();
355
357
  }
@@ -577,6 +579,13 @@ class PiAgentSession {
577
579
  }
578
580
  }
579
581
  });
582
+ // P2P Document Tools
583
+ for (const tool of p2pDocumentTools) {
584
+ this.tools.set(tool.name, tool);
585
+ }
586
+ }
587
+ async registerP2PDocumentReceiver() {
588
+ await initDocumentReceiver();
580
589
  }
581
590
  getToolDefinitions() {
582
591
  const defs = ['可用工具:'];
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Document Store - P2P Document Receiving and Storage
3
+ * 接收并存储来自其他 iroh 节点的文档
4
+ */
5
+ import * as fs from 'fs/promises';
6
+ import * as path from 'path';
7
+ export class DocumentStore {
8
+ baseDir;
9
+ pendingDocs = new Map();
10
+ receivedCallback = null;
11
+ constructor(baseDir) {
12
+ this.baseDir = baseDir || path.join(process.env.HOME || '/tmp', '.bolloon', 'documents', 'received');
13
+ }
14
+ async initialize() {
15
+ await fs.mkdir(this.baseDir, { recursive: true });
16
+ await fs.mkdir(path.join(this.baseDir, 'chunks'), { recursive: true });
17
+ console.log('[DocumentStore] Initialized at', this.baseDir);
18
+ }
19
+ onDocumentReceived(callback) {
20
+ this.receivedCallback = callback;
21
+ }
22
+ async receiveChunk(chunk) {
23
+ const { docId, chunkIndex, totalChunks, fileName, fileSize, mimeType, content, fromNodeId, message, timestamp } = chunk;
24
+ // 获取或创建 pending document
25
+ let pending = this.pendingDocs.get(docId);
26
+ if (!pending) {
27
+ pending = {
28
+ docId,
29
+ fileName,
30
+ fileSize,
31
+ mimeType,
32
+ totalChunks,
33
+ receivedChunks: new Map(),
34
+ fromNodeId,
35
+ message,
36
+ timestamp,
37
+ };
38
+ this.pendingDocs.set(docId, pending);
39
+ }
40
+ // 存储分块
41
+ pending.receivedChunks.set(chunkIndex, content);
42
+ // 检查是否收齐
43
+ if (pending.receivedChunks.size >= totalChunks) {
44
+ return this.assembleDocument(docId);
45
+ }
46
+ return null;
47
+ }
48
+ async assembleDocument(docId) {
49
+ const pending = this.pendingDocs.get(docId);
50
+ if (!pending)
51
+ return null;
52
+ try {
53
+ // 按顺序合并所有分块
54
+ const allContent = [];
55
+ for (let i = 0; i < pending.totalChunks; i++) {
56
+ const chunkContent = pending.receivedChunks.get(i);
57
+ if (!chunkContent) {
58
+ console.warn(`[DocumentStore] Missing chunk ${i} for doc ${docId}`);
59
+ return null;
60
+ }
61
+ const bytes = Uint8Array.from(atob(chunkContent), c => c.charCodeAt(0));
62
+ allContent.push(...Array.from(bytes));
63
+ }
64
+ // 创建文档目录
65
+ const docDir = path.join(this.baseDir, docId);
66
+ await fs.mkdir(docDir, { recursive: true });
67
+ // 保存文件
68
+ const filePath = path.join(docDir, pending.fileName);
69
+ await fs.writeFile(filePath, Buffer.from(allContent));
70
+ // 保存 manifest
71
+ const manifest = {
72
+ id: docId,
73
+ fileName: pending.fileName,
74
+ fileSize: pending.fileSize,
75
+ mimeType: pending.mimeType,
76
+ fromNodeId: pending.fromNodeId,
77
+ receivedAt: Date.now(),
78
+ message: pending.message,
79
+ };
80
+ await fs.writeFile(path.join(docDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
81
+ // 更新索引
82
+ await this.updateIndex(manifest);
83
+ // 清理 pending
84
+ this.pendingDocs.delete(docId);
85
+ const receivedDoc = {
86
+ ...manifest,
87
+ fromNodeIdShort: pending.fromNodeId.substring(0, 16) + '...',
88
+ path: filePath,
89
+ };
90
+ // 触发回调
91
+ if (this.receivedCallback) {
92
+ this.receivedCallback(receivedDoc);
93
+ }
94
+ console.log(`[DocumentStore] Document assembled: ${pending.fileName} (${pending.fileSize} bytes)`);
95
+ return receivedDoc;
96
+ }
97
+ catch (e) {
98
+ console.error('[DocumentStore] Failed to assemble document:', e);
99
+ this.pendingDocs.delete(docId);
100
+ return null;
101
+ }
102
+ }
103
+ async updateIndex(manifest) {
104
+ const indexPath = path.join(this.baseDir, 'index.json');
105
+ let index = [];
106
+ try {
107
+ const existing = await fs.readFile(indexPath, 'utf-8');
108
+ index = JSON.parse(existing);
109
+ }
110
+ catch {
111
+ // Index doesn't exist yet
112
+ }
113
+ // 添加新文档
114
+ index.unshift(manifest); // newest first
115
+ // 只保留最近 100 条
116
+ if (index.length > 100) {
117
+ index = index.slice(0, 100);
118
+ }
119
+ await fs.writeFile(indexPath, JSON.stringify(index, null, 2));
120
+ }
121
+ async getReceivedDocuments(limit = 50, senderPeerId) {
122
+ const indexPath = path.join(this.baseDir, 'index.json');
123
+ try {
124
+ const data = await fs.readFile(indexPath, 'utf-8');
125
+ let docs = JSON.parse(data);
126
+ if (senderPeerId) {
127
+ docs = docs.filter(d => d.fromNodeId === senderPeerId);
128
+ }
129
+ return docs.slice(0, limit).map(doc => ({
130
+ ...doc,
131
+ fromNodeIdShort: doc.fromNodeId.substring(0, 16) + '...',
132
+ path: path.join(this.baseDir, doc.id, doc.fileName),
133
+ }));
134
+ }
135
+ catch {
136
+ return [];
137
+ }
138
+ }
139
+ async readDocument(docId) {
140
+ const docDir = path.join(this.baseDir, docId);
141
+ const manifestPath = path.join(docDir, 'manifest.json');
142
+ const filePath = path.join(docDir);
143
+ try {
144
+ const manifestData = await fs.readFile(manifestPath, 'utf-8');
145
+ const manifest = JSON.parse(manifestData);
146
+ const fileContent = await fs.readFile(filePath, 'utf-8');
147
+ return {
148
+ content: fileContent,
149
+ metadata: {
150
+ ...manifest,
151
+ fromNodeIdShort: manifest.fromNodeId.substring(0, 16) + '...',
152
+ path: filePath,
153
+ },
154
+ };
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ }
160
+ async deleteDocument(docId) {
161
+ const docDir = path.join(this.baseDir, docId);
162
+ try {
163
+ await fs.rm(docDir, { recursive: true });
164
+ // 更新索引
165
+ const indexPath = path.join(this.baseDir, 'index.json');
166
+ try {
167
+ const data = await fs.readFile(indexPath, 'utf-8');
168
+ let index = JSON.parse(data);
169
+ index = index.filter(d => d.id !== docId);
170
+ await fs.writeFile(indexPath, JSON.stringify(index, null, 2));
171
+ }
172
+ catch {
173
+ // Index doesn't exist
174
+ }
175
+ return true;
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ }
181
+ }
182
+ export const documentStore = new DocumentStore();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli.js",
@@ -32,7 +32,7 @@
32
32
  "src/constraint-runtime"
33
33
  ],
34
34
  "dependencies": {
35
- "@bolloon/bolloon-agent": "^0.1.5",
35
+ "@bolloon/bolloon-agent": "^0.1.8",
36
36
  "@bolloon/constraint-runtime": "0.1.0",
37
37
  "@chainsafe/libp2p-noise": "^17.0.0",
38
38
  "@chainsafe/libp2p-yamux": "^8.0.1",
@@ -0,0 +1,197 @@
1
+ /**
2
+ * P2P Document Tools - Tools for sending/receiving documents over iroh P2P
3
+ */
4
+
5
+ import * as crypto from 'crypto';
6
+ import * as fs from 'fs/promises';
7
+ import { irohTransport } from '../network/iroh-transport.js';
8
+ import { documentReader, type DocumentContent } from '../documents/reader.js';
9
+ import { documentStore, type DocumentChunk, type ReceivedDocument } from '../documents/store.js';
10
+ import type { Tool } from './pi-sdk.js';
11
+
12
+ const CHUNK_SIZE = 60 * 1024; // 60KB per chunk
13
+
14
+ function generateDocId(): string {
15
+ return crypto.randomUUID();
16
+ }
17
+
18
+ function getMimeType(filename: string): string {
19
+ const ext = filename.toLowerCase().split('.').pop();
20
+ const mimeTypes: Record<string, string> = {
21
+ 'txt': 'text/plain',
22
+ 'md': 'text/markdown',
23
+ 'pdf': 'application/pdf',
24
+ 'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
25
+ };
26
+ return mimeTypes[ext || ''] || 'application/octet-stream';
27
+ }
28
+
29
+ export async function initDocumentReceiver(): Promise<void> {
30
+ await documentStore.initialize();
31
+
32
+ documentStore.onDocumentReceived((doc) => {
33
+ console.log(`[DocumentReceiver] Document received: ${doc.fileName} from ${doc.fromNodeIdShort}`);
34
+ });
35
+
36
+ irohTransport.onMessage('document_chunk', async (msg) => {
37
+ try {
38
+ const chunk: DocumentChunk = JSON.parse(new TextDecoder().decode(msg.payload));
39
+ const result = await documentStore.receiveChunk(chunk);
40
+ if (result) {
41
+ console.log(`[DocumentReceiver] Document complete: ${result.fileName}`);
42
+ }
43
+ } catch (e) {
44
+ console.error('[DocumentReceiver] Failed to process chunk:', e);
45
+ }
46
+ });
47
+
48
+ console.log('[DocumentReceiver] Initialized and listening for document chunks');
49
+ }
50
+
51
+ export const p2pDocumentTools: Tool[] = [
52
+ {
53
+ name: 'list_online_peers',
54
+ description: '列出当前通过 iroh P2P 网络在线的对等节点',
55
+ parameters: {},
56
+ execute: async () => {
57
+ try {
58
+ const peers = irohTransport.getConnectedPeers();
59
+ if (peers.length === 0) {
60
+ return { success: true, output: '当前无在线的对等节点' };
61
+ }
62
+ return {
63
+ success: true,
64
+ output: `在线节点 (${peers.length}):\n${peers.map(p => ` - ${p.substring(0, 16)}...`).join('\n')}`
65
+ };
66
+ } catch (e) {
67
+ return { success: false, error: String(e) };
68
+ }
69
+ }
70
+ },
71
+
72
+ {
73
+ name: 'send_document',
74
+ description: '读取本地文档并发送给指定的对等节点,支持 .txt, .md, .pdf, .docx 格式',
75
+ parameters: {
76
+ target_peer_id: '目标对等节点的完整 nodeId',
77
+ file_path: '要发送的本地文件路径',
78
+ message: '可选的附言消息'
79
+ },
80
+ execute: async (args) => {
81
+ try {
82
+ const { target_peer_id, file_path, message } = args;
83
+
84
+ // 读取文档
85
+ let content: DocumentContent;
86
+ try {
87
+ content = await documentReader.read(file_path);
88
+ } catch (e) {
89
+ return { success: false, error: `无法读取文件: ${e}` };
90
+ }
91
+
92
+ // 分块
93
+ const mimeType = getMimeType(file_path);
94
+ const fileData = await fs.readFile(file_path);
95
+ const contentBase64 = fileData.toString('base64');
96
+ const totalChunks = Math.ceil(contentBase64.length / CHUNK_SIZE);
97
+
98
+ const docId = generateDocId();
99
+
100
+ // 逐块发送
101
+ for (let i = 0; i < totalChunks; i++) {
102
+ const chunk: DocumentChunk = {
103
+ docId,
104
+ fileName: content.metadata.filename,
105
+ fileSize: content.metadata.size,
106
+ mimeType,
107
+ chunkIndex: i,
108
+ totalChunks,
109
+ content: contentBase64.substring(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE),
110
+ fromNodeId: irohTransport.getNodeId() || '',
111
+ message: i === 0 ? message : undefined,
112
+ timestamp: Date.now(),
113
+ };
114
+
115
+ const sent = await irohTransport.sendMessage(
116
+ target_peer_id,
117
+ 'document_chunk',
118
+ new TextEncoder().encode(JSON.stringify(chunk))
119
+ );
120
+
121
+ if (!sent) {
122
+ return { success: false, error: `发送第 ${i + 1}/${totalChunks} 块失败` };
123
+ }
124
+ }
125
+
126
+ return {
127
+ success: true,
128
+ output: `📤 文档已发送: ${content.metadata.filename}\n大小: ${content.metadata.size} 字节\n分块: ${totalChunks}\n目标: ${target_peer_id.substring(0, 16)}...`
129
+ };
130
+ } catch (e) {
131
+ return { success: false, error: String(e) };
132
+ }
133
+ }
134
+ },
135
+
136
+ {
137
+ name: 'receive_documents',
138
+ description: '列出已接收到的文档,可按发送者筛选',
139
+ parameters: {
140
+ limit: '返回数量上限,默认 50',
141
+ sender_peer_id: '可选,按发送者 nodeId 筛选'
142
+ },
143
+ execute: async (args) => {
144
+ try {
145
+ const limit = parseInt(args.limit) || 50;
146
+ const docs = await documentStore.getReceivedDocuments(limit, args.sender_peer_id);
147
+
148
+ if (docs.length === 0) {
149
+ return { success: true, output: '暂无已接收的文档' };
150
+ }
151
+
152
+ const list = docs.map(d =>
153
+ `📄 ${d.fileName}\n ID: ${d.id}\n 大小: ${d.fileSize} 字节\n 来自: ${d.fromNodeIdShort}\n 时间: ${new Date(d.receivedAt).toLocaleString()}`
154
+ ).join('\n\n');
155
+
156
+ return {
157
+ success: true,
158
+ output: `已接收文档 (${docs.length}):\n\n${list}`
159
+ };
160
+ } catch (e) {
161
+ return { success: false, error: String(e) };
162
+ }
163
+ }
164
+ },
165
+
166
+ {
167
+ name: 'read_received_document',
168
+ description: '读取已接收文档的内容,返回文档ID可查询文档详情',
169
+ parameters: {
170
+ document_id: '文档ID (从 receive_documents 获取)'
171
+ },
172
+ execute: async (args) => {
173
+ try {
174
+ const { document_id } = args;
175
+
176
+ if (!document_id) {
177
+ return { success: false, error: '缺少 document_id 参数' };
178
+ }
179
+
180
+ const result = await documentStore.readDocument(document_id);
181
+
182
+ if (!result) {
183
+ return { success: false, error: `未找到文档: ${document_id}` };
184
+ }
185
+
186
+ const { content, metadata } = result;
187
+
188
+ return {
189
+ success: true,
190
+ output: `📄 ${metadata.fileName}\nID: ${metadata.id}\n大小: ${metadata.fileSize} 字节\n来自: ${metadata.fromNodeIdShort}\n\n${content.substring(0, 2000)}${content.length > 2000 ? '...\n(内容已截断)' : ''}`
191
+ };
192
+ } catch (e) {
193
+ return { success: false, error: String(e) };
194
+ }
195
+ }
196
+ }
197
+ ];
@@ -13,6 +13,7 @@ import { ConstraintLayer, WorkflowContext } from './constraint-layer.js';
13
13
  import { WorkflowEngine, WorkflowStep, StepResult, Workflow } from './workflow-engine.js';
14
14
  import { DeepThinkingEngine, AgentCoordinator, type ThinkResult, type AgentResult } from '@bolloon/constraint-runtime';
15
15
  import { WorkflowPivotLoop, createDefaultPivotConfig, type PivotLoopConfig, type LoopResult } from './workflow-pivot-loop.js';
16
+ import { p2pDocumentTools, initDocumentReceiver } from './p2p-document-tools.js';
16
17
  import {
17
18
  DiscoveredAgentsManager,
18
19
  SocialHeartbeat,
@@ -572,6 +573,7 @@ class PiAgentSession implements AgentSession {
572
573
  this.usePivotLoop = config.usePivotLoop ?? false;
573
574
  this.pivotLoopConfig = config.pivotLoopConfig;
574
575
  this.initSession();
576
+ initDocumentReceiver();
575
577
  this.registerTools();
576
578
  this.initHarness();
577
579
  }
@@ -801,6 +803,15 @@ class PiAgentSession implements AgentSession {
801
803
  }
802
804
  }
803
805
  });
806
+
807
+ // P2P Document Tools
808
+ for (const tool of p2pDocumentTools) {
809
+ this.tools.set(tool.name, tool);
810
+ }
811
+ }
812
+
813
+ private async registerP2PDocumentReceiver(): Promise<void> {
814
+ await initDocumentReceiver();
804
815
  }
805
816
 
806
817
  private getToolDefinitions(): string {
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Document Store - P2P Document Receiving and Storage
3
+ * 接收并存储来自其他 iroh 节点的文档
4
+ */
5
+
6
+ import * as fs from 'fs/promises';
7
+ import * as path from 'path';
8
+ import * as crypto from 'crypto';
9
+
10
+ export interface DocumentChunk {
11
+ docId: string;
12
+ fileName: string;
13
+ fileSize: number;
14
+ mimeType: string;
15
+ chunkIndex: number;
16
+ totalChunks: number;
17
+ content: string; // Base64 encoded
18
+ fromNodeId: string;
19
+ message?: string;
20
+ timestamp: number;
21
+ }
22
+
23
+ export interface ReceivedDocument {
24
+ id: string;
25
+ fileName: string;
26
+ fileSize: number;
27
+ mimeType: string;
28
+ fromNodeId: string;
29
+ fromNodeIdShort: string;
30
+ receivedAt: number;
31
+ message?: string;
32
+ path: string;
33
+ }
34
+
35
+ export type DocumentReceivedCallback = (doc: ReceivedDocument) => void;
36
+
37
+ interface PendingDocument {
38
+ docId: string;
39
+ fileName: string;
40
+ fileSize: number;
41
+ mimeType: string;
42
+ totalChunks: number;
43
+ receivedChunks: Map<number, string>; // chunkIndex -> Base64 content
44
+ fromNodeId: string;
45
+ message?: string;
46
+ timestamp: number;
47
+ }
48
+
49
+ export class DocumentStore {
50
+ private baseDir: string;
51
+ private pendingDocs: Map<string, PendingDocument> = new Map();
52
+ private receivedCallback: DocumentReceivedCallback | null = null;
53
+
54
+ constructor(baseDir?: string) {
55
+ this.baseDir = baseDir || path.join(process.env.HOME || '/tmp', '.bolloon', 'documents', 'received');
56
+ }
57
+
58
+ async initialize(): Promise<void> {
59
+ await fs.mkdir(this.baseDir, { recursive: true });
60
+ await fs.mkdir(path.join(this.baseDir, 'chunks'), { recursive: true });
61
+ console.log('[DocumentStore] Initialized at', this.baseDir);
62
+ }
63
+
64
+ onDocumentReceived(callback: DocumentReceivedCallback): void {
65
+ this.receivedCallback = callback;
66
+ }
67
+
68
+ async receiveChunk(chunk: DocumentChunk): Promise<ReceivedDocument | null> {
69
+ const { docId, chunkIndex, totalChunks, fileName, fileSize, mimeType, content, fromNodeId, message, timestamp } = chunk;
70
+
71
+ // 获取或创建 pending document
72
+ let pending = this.pendingDocs.get(docId);
73
+ if (!pending) {
74
+ pending = {
75
+ docId,
76
+ fileName,
77
+ fileSize,
78
+ mimeType,
79
+ totalChunks,
80
+ receivedChunks: new Map(),
81
+ fromNodeId,
82
+ message,
83
+ timestamp,
84
+ };
85
+ this.pendingDocs.set(docId, pending);
86
+ }
87
+
88
+ // 存储分块
89
+ pending.receivedChunks.set(chunkIndex, content);
90
+
91
+ // 检查是否收齐
92
+ if (pending.receivedChunks.size >= totalChunks) {
93
+ return this.assembleDocument(docId);
94
+ }
95
+
96
+ return null;
97
+ }
98
+
99
+ private async assembleDocument(docId: string): Promise<ReceivedDocument | null> {
100
+ const pending = this.pendingDocs.get(docId);
101
+ if (!pending) return null;
102
+
103
+ try {
104
+ // 按顺序合并所有分块
105
+ const allContent: number[] = [];
106
+ for (let i = 0; i < pending.totalChunks; i++) {
107
+ const chunkContent = pending.receivedChunks.get(i);
108
+ if (!chunkContent) {
109
+ console.warn(`[DocumentStore] Missing chunk ${i} for doc ${docId}`);
110
+ return null;
111
+ }
112
+ const bytes = Uint8Array.from(atob(chunkContent), c => c.charCodeAt(0));
113
+ allContent.push(...Array.from(bytes));
114
+ }
115
+
116
+ // 创建文档目录
117
+ const docDir = path.join(this.baseDir, docId);
118
+ await fs.mkdir(docDir, { recursive: true });
119
+
120
+ // 保存文件
121
+ const filePath = path.join(docDir, pending.fileName);
122
+ await fs.writeFile(filePath, Buffer.from(allContent));
123
+
124
+ // 保存 manifest
125
+ const manifest = {
126
+ id: docId,
127
+ fileName: pending.fileName,
128
+ fileSize: pending.fileSize,
129
+ mimeType: pending.mimeType,
130
+ fromNodeId: pending.fromNodeId,
131
+ receivedAt: Date.now(),
132
+ message: pending.message,
133
+ };
134
+ await fs.writeFile(path.join(docDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
135
+
136
+ // 更新索引
137
+ await this.updateIndex(manifest);
138
+
139
+ // 清理 pending
140
+ this.pendingDocs.delete(docId);
141
+
142
+ const receivedDoc: ReceivedDocument = {
143
+ ...manifest,
144
+ fromNodeIdShort: pending.fromNodeId.substring(0, 16) + '...',
145
+ path: filePath,
146
+ };
147
+
148
+ // 触发回调
149
+ if (this.receivedCallback) {
150
+ this.receivedCallback(receivedDoc);
151
+ }
152
+
153
+ console.log(`[DocumentStore] Document assembled: ${pending.fileName} (${pending.fileSize} bytes)`);
154
+ return receivedDoc;
155
+ } catch (e) {
156
+ console.error('[DocumentStore] Failed to assemble document:', e);
157
+ this.pendingDocs.delete(docId);
158
+ return null;
159
+ }
160
+ }
161
+
162
+ private async updateIndex(manifest: any): Promise<void> {
163
+ const indexPath = path.join(this.baseDir, 'index.json');
164
+ let index: any[] = [];
165
+
166
+ try {
167
+ const existing = await fs.readFile(indexPath, 'utf-8');
168
+ index = JSON.parse(existing);
169
+ } catch {
170
+ // Index doesn't exist yet
171
+ }
172
+
173
+ // 添加新文档
174
+ index.unshift(manifest); // newest first
175
+
176
+ // 只保留最近 100 条
177
+ if (index.length > 100) {
178
+ index = index.slice(0, 100);
179
+ }
180
+
181
+ await fs.writeFile(indexPath, JSON.stringify(index, null, 2));
182
+ }
183
+
184
+ async getReceivedDocuments(limit: number = 50, senderPeerId?: string): Promise<ReceivedDocument[]> {
185
+ const indexPath = path.join(this.baseDir, 'index.json');
186
+
187
+ try {
188
+ const data = await fs.readFile(indexPath, 'utf-8');
189
+ let docs: any[] = JSON.parse(data);
190
+
191
+ if (senderPeerId) {
192
+ docs = docs.filter(d => d.fromNodeId === senderPeerId);
193
+ }
194
+
195
+ return docs.slice(0, limit).map(doc => ({
196
+ ...doc,
197
+ fromNodeIdShort: doc.fromNodeId.substring(0, 16) + '...',
198
+ path: path.join(this.baseDir, doc.id, doc.fileName),
199
+ }));
200
+ } catch {
201
+ return [];
202
+ }
203
+ }
204
+
205
+ async readDocument(docId: string): Promise<{ content: string; metadata: ReceivedDocument } | null> {
206
+ const docDir = path.join(this.baseDir, docId);
207
+ const manifestPath = path.join(docDir, 'manifest.json');
208
+ const filePath = path.join(docDir);
209
+
210
+ try {
211
+ const manifestData = await fs.readFile(manifestPath, 'utf-8');
212
+ const manifest = JSON.parse(manifestData);
213
+ const fileContent = await fs.readFile(filePath, 'utf-8');
214
+
215
+ return {
216
+ content: fileContent,
217
+ metadata: {
218
+ ...manifest,
219
+ fromNodeIdShort: manifest.fromNodeId.substring(0, 16) + '...',
220
+ path: filePath,
221
+ },
222
+ };
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ async deleteDocument(docId: string): Promise<boolean> {
229
+ const docDir = path.join(this.baseDir, docId);
230
+
231
+ try {
232
+ await fs.rm(docDir, { recursive: true });
233
+
234
+ // 更新索引
235
+ const indexPath = path.join(this.baseDir, 'index.json');
236
+ try {
237
+ const data = await fs.readFile(indexPath, 'utf-8');
238
+ let index: any[] = JSON.parse(data);
239
+ index = index.filter(d => d.id !== docId);
240
+ await fs.writeFile(indexPath, JSON.stringify(index, null, 2));
241
+ } catch {
242
+ // Index doesn't exist
243
+ }
244
+
245
+ return true;
246
+ } catch {
247
+ return false;
248
+ }
249
+ }
250
+ }
251
+
252
+ export const documentStore = new DocumentStore();
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -4,6 +4,18 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Bolloon Agent</title>
7
+
8
+ <!-- Favicon -->
9
+ <link rel="icon" type="image/x-icon" href="/icons/favicon.ico">
10
+ <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png">
11
+ <link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png">
12
+
13
+ <!-- Apple Touch Icon -->
14
+ <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
15
+
16
+ <!-- PWA Manifest -->
17
+ <link rel="manifest" href="/manifest.json">
18
+
7
19
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
20
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
21
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;600&display=swap" rel="stylesheet">
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "Bolloon Agent",
3
+ "short_name": "Bolloon",
4
+ "description": "AI Agent with Claude API integration",
5
+ "start_url": "/",
6
+ "display": "standalone",
7
+ "background_color": "#1a1a2e",
8
+ "theme_color": "#1a1a2e",
9
+ "icons": [
10
+ {
11
+ "src": "/icons/favicon-192x192.png",
12
+ "sizes": "192x192",
13
+ "type": "image/png"
14
+ },
15
+ {
16
+ "src": "/icons/favicon-512x512.png",
17
+ "sizes": "512x512",
18
+ "type": "image/png"
19
+ }
20
+ ]
21
+ }