@bolloon/bolloon-agent 0.1.12 → 0.1.13

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.
Files changed (38) hide show
  1. package/dist/agents/p2p-chat-tools.js +321 -0
  2. package/dist/agents/p2p-document-tools.js +121 -1
  3. package/dist/agents/workflow-pivot-loop.js +4 -4
  4. package/dist/cli-entry.js +1 -1
  5. package/dist/documents/reader.js +5 -0
  6. package/dist/documents/store.js +1 -1
  7. package/dist/llm/pi-ai.js +6 -5
  8. package/dist/network/iroh-discovery.js +2 -1
  9. package/dist/network/iroh-transport.js +15 -2
  10. package/dist/network/p2p.js +9 -8
  11. package/dist/network/storage/adapters/json-adapter.js +16 -1
  12. package/dist/network/storage/index.js +2 -1
  13. package/dist/pi-ecosystem-judgment/index.js +43 -115
  14. package/dist/social/channels/channel-heartbeat-agent.js +1 -1
  15. package/dist/web/components/p2p/index.js +226 -264
  16. package/dist/web/index.html +12 -0
  17. package/package.json +1 -1
  18. package/scripts/build-web.ts +1 -1
  19. package/src/agents/p2p-chat-tools.ts +383 -0
  20. package/src/agents/p2p-document-tools.ts +151 -1
  21. package/src/agents/workflow-pivot-loop.ts +13 -12
  22. package/src/bollharness-integration/channel-judgment-engine.ts +1 -1
  23. package/src/documents/reader.ts +5 -0
  24. package/src/documents/store.ts +1 -1
  25. package/src/llm/pi-ai.ts +6 -5
  26. package/src/network/iroh-discovery.ts +2 -1
  27. package/src/network/iroh-transport.ts +15 -2
  28. package/src/network/p2p.ts +9 -8
  29. package/src/network/storage/adapters/json-adapter.ts +17 -2
  30. package/src/network/storage/index.ts +19 -3
  31. package/src/social/channels/channel-heartbeat-agent.ts +1 -1
  32. package/src/web/server.ts +149 -0
  33. package/tsconfig.electron.json +1 -1
  34. package/tsconfig.json +1 -1
  35. package/dist/web/components/p2p/P2PModal.js +0 -188
  36. package/dist/web/components/p2p/p2p-modal.js +0 -657
  37. package/dist/web/components/p2p/p2p-tools.js +0 -248
  38. package/dist/web/server.js +0 -1890
@@ -0,0 +1,321 @@
1
+ /**
2
+ * p2p-chat-tools — 异步 chat 通道 + 持久化 inbox + 判断力外包 (draft)
3
+ *
4
+ * 解决痛点: 两个节点的智能体代替各自人类主人做异步判断
5
+ *
6
+ * 流程:
7
+ * 1. A 节点 (人类在线) 通过 sendChat(peerDID, text) 发消息 → iroh 'agent_chat'
8
+ * 2. B 节点 (人类可能离线):
9
+ * - onMessage('agent_chat') → 落 ~/.bolloon/inbox/<peerDID>.jsonl
10
+ * - 状态 = 'received' (未处理)
11
+ * 3. B 节点 wake-up (processPendingInbox) → 扫描 status='received' 的消息
12
+ * - 调 LLM 生成 draft (注入主人历史判断 + ValueProfile)
13
+ * - 写 status='drafted', draft 落盘
14
+ * - 通过 'agent_chat' 消息类型回送 draft (前缀 [DRAFT] 表明是代回)
15
+ * 4. A 节点收到 draft → 写到 A 的 outbox (inbox 中 from=B 的那条)
16
+ * 5. B 人类上线 → getInbox() 看到 'drafted' 状态的条目, approveAndSend / dismissDraft
17
+ *
18
+ * 镜像 p2p-document-tools.ts 风格: iroh 消息 + 可注入 transport (支持多实例测试)
19
+ */
20
+ import * as crypto from 'crypto';
21
+ import * as fs from 'fs/promises';
22
+ import * as path from 'path';
23
+ import * as os from 'os';
24
+ import { EventEmitter } from 'events';
25
+ import { irohTransport as defaultIrohTransport } from '../network/iroh-transport.js';
26
+ import { getRelevantValues, getValueProfile, loadAllJudgments } from '../pi-ecosystem-judgment/human-value-store.js';
27
+ function homeDir() {
28
+ return process.env.HOME || process.env.USERPROFILE || os.homedir();
29
+ }
30
+ class ChatEventBus extends EventEmitter {
31
+ }
32
+ export const chatEventBus = new ChatEventBus();
33
+ const states = new WeakMap();
34
+ function getState(transport) {
35
+ let s = states.get(transport);
36
+ if (!s) {
37
+ s = { handlers: new Set(), listenerInstalled: false };
38
+ states.set(transport, s);
39
+ }
40
+ return s;
41
+ }
42
+ // ============================================================================
43
+ // Inbox 存储
44
+ // ============================================================================
45
+ function inboxDir() {
46
+ return path.join(homeDir(), '.bolloon', 'inbox');
47
+ }
48
+ function inboxPath(peerDID) {
49
+ const safe = peerDID.replace(/[^a-zA-Z0-9_-]/g, '_');
50
+ return path.join(inboxDir(), `${safe}.jsonl`);
51
+ }
52
+ function outboxPath() {
53
+ return path.join(inboxDir(), '_outbox.jsonl');
54
+ }
55
+ async function appendInbox(peerDID, entry) {
56
+ await fs.mkdir(inboxDir(), { recursive: true });
57
+ await fs.appendFile(inboxPath(peerDID), JSON.stringify(entry) + '\n', 'utf-8');
58
+ }
59
+ async function readInbox(peerDID) {
60
+ try {
61
+ const content = await fs.readFile(inboxPath(peerDID), 'utf-8');
62
+ return content.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
63
+ }
64
+ catch {
65
+ return [];
66
+ }
67
+ }
68
+ async function rewriteInbox(peerDID, entries) {
69
+ await fs.mkdir(inboxDir(), { recursive: true });
70
+ await fs.writeFile(inboxPath(peerDID), entries.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
71
+ }
72
+ async function updateInboxEntry(peerDID, id, patch) {
73
+ const entries = await readInbox(peerDID);
74
+ const idx = entries.findIndex((e) => e.id === id);
75
+ if (idx < 0)
76
+ return null;
77
+ entries[idx] = { ...entries[idx], ...patch };
78
+ await rewriteInbox(peerDID, entries);
79
+ return entries[idx];
80
+ }
81
+ async function markOutboundDraft(originalId, draft, at) {
82
+ try {
83
+ const content = await fs.readFile(outboxPath(), 'utf-8');
84
+ const entries = content.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
85
+ const idx = entries.findIndex((e) => e.id === originalId);
86
+ if (idx >= 0) {
87
+ entries[idx].inboundDraft = draft;
88
+ entries[idx].inboundDraftAt = at;
89
+ await fs.writeFile(outboxPath(), entries.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
90
+ }
91
+ }
92
+ catch { /* no outbox */ }
93
+ }
94
+ // ============================================================================
95
+ // Listener 安装 (每个 transport 各装一次)
96
+ // ============================================================================
97
+ function ensureListener(transport) {
98
+ const s = getState(transport);
99
+ if (s.listenerInstalled)
100
+ return;
101
+ s.listenerInstalled = true;
102
+ transport.onMessage('agent_chat', (msg) => {
103
+ try {
104
+ const data = JSON.parse(new TextDecoder().decode(msg.payload));
105
+ void handleIncomingChat(transport, msg.from, data);
106
+ }
107
+ catch (e) {
108
+ console.error('[ChatReceiver] parse err:', e.message);
109
+ }
110
+ });
111
+ }
112
+ async function handleIncomingChat(transport, fromNodeId, data) {
113
+ const peerDID = fromNodeId;
114
+ if (data.kind === 'user') {
115
+ const entry = {
116
+ id: data.id, peerDID, fromNodeId, text: data.text,
117
+ timestamp: Date.now(), receivedAt: Date.now(), status: 'received',
118
+ };
119
+ await appendInbox(peerDID, entry);
120
+ const preview = data.text.slice(0, 60);
121
+ const peerShort = peerDID.slice(0, 12);
122
+ console.log('[ChatReceiver] received from ' + peerShort + ': ' + preview);
123
+ chatEventBus.emit('chat', { kind: 'inbox_new', peerDID, entry });
124
+ for (const h of getState(transport).handlers) {
125
+ try {
126
+ h(entry, fromNodeId);
127
+ }
128
+ catch { }
129
+ }
130
+ }
131
+ else if (data.kind === 'draft' && data.originalId) {
132
+ await markOutboundDraft(data.originalId, data.text, Date.now());
133
+ console.log('[ChatReceiver] inbound draft for ' + data.originalId);
134
+ chatEventBus.emit('chat', { kind: 'outbox_draft_in', peerDID, originalId: data.originalId, draft: data.text });
135
+ }
136
+ }
137
+ // ============================================================================
138
+ // 公共 API (可注入 transport)
139
+ // ============================================================================
140
+ export function onChatMessage(handler, transport = defaultIrohTransport) {
141
+ getState(transport).handlers.add(handler);
142
+ ensureListener(transport);
143
+ }
144
+ export async function initChatReceiver(transport = defaultIrohTransport) {
145
+ ensureListener(transport);
146
+ await fs.mkdir(inboxDir(), { recursive: true });
147
+ console.log('[ChatReceiver] Initialized at', inboxDir(), 'transport=', transport === defaultIrohTransport ? 'singleton' : 'instance');
148
+ }
149
+ export async function sendChat(peerDID, text, transport = defaultIrohTransport) {
150
+ const id = crypto.randomUUID();
151
+ const ok = await transport.sendMessage(peerDID, 'agent_chat', new TextEncoder().encode(JSON.stringify({ kind: 'user', id, text })));
152
+ if (ok) {
153
+ const entry = {
154
+ id, peerDID,
155
+ fromNodeId: transport.getNodeId() || '',
156
+ text, timestamp: Date.now(), receivedAt: Date.now(),
157
+ status: 'sent', sentText: text, sentAt: Date.now(),
158
+ };
159
+ await fs.mkdir(inboxDir(), { recursive: true });
160
+ await fs.appendFile(outboxPath(), JSON.stringify(entry) + '\n', 'utf-8');
161
+ chatEventBus.emit('chat', { kind: 'outbox_updated', peerDID, entry });
162
+ }
163
+ else {
164
+ console.warn(`[ChatReceiver] send to ${peerDID.slice(0, 12)}... failed`);
165
+ }
166
+ return id;
167
+ }
168
+ export async function getInbox(peerDID) {
169
+ if (peerDID)
170
+ return readInbox(peerDID);
171
+ try {
172
+ const files = await fs.readdir(inboxDir());
173
+ const all = [];
174
+ for (const f of files) {
175
+ if (!f.endsWith('.jsonl') || f.startsWith('_'))
176
+ continue;
177
+ const peerDID = f.replace('.jsonl', '').replace(/_/g, ':');
178
+ all.push(...await readInbox(peerDID));
179
+ }
180
+ return all.sort((a, b) => b.receivedAt - a.receivedAt);
181
+ }
182
+ catch {
183
+ return [];
184
+ }
185
+ }
186
+ // ============================================================================
187
+ // Draft 引擎
188
+ // ============================================================================
189
+ async function buildValueHint(text) {
190
+ try {
191
+ const allJudgments = await loadAllJudgments();
192
+ const relevant = await getRelevantValues(text, undefined);
193
+ const profile = await getValueProfile('me');
194
+ const profileHint = [
195
+ `quality_focus=${profile.quality_focus.toFixed(2)}`,
196
+ `efficiency_focus=${profile.efficiency_focus.toFixed(2)}`,
197
+ `safety_focus=${profile.safety_focus.toFixed(2)}`,
198
+ `collaboration_focus=${profile.collaboration_focus.toFixed(2)}`,
199
+ `learning_focus=${profile.learning_focus.toFixed(2)}`,
200
+ ].join(', ');
201
+ const reasons = allJudgments
202
+ .filter((j) => (j.reasons || []).length > 0)
203
+ .slice(-10)
204
+ .flatMap((j) => j.reasons || [])
205
+ .slice(0, 20);
206
+ return `\n[主人历史判断 (style 参考, 不可外泄)]\n关注维度: ${profileHint}\n关键理由示例: ${reasons.join(' | ').slice(0, 400) || '(无)'}\n`;
207
+ }
208
+ catch (e) {
209
+ return '';
210
+ }
211
+ }
212
+ export async function generateDraft(messageId, peerDID, transport = defaultIrohTransport) {
213
+ const entries = await readInbox(peerDID);
214
+ const entry = entries.find((e) => e.id === messageId);
215
+ if (!entry)
216
+ return null;
217
+ if (entry.status !== 'received')
218
+ return entry;
219
+ const valueHint = await buildValueHint(entry.text);
220
+ const promptForDraft = `你是主人的代理. 对方发来这条消息: "${entry.text.slice(0, 1500)}"\n${valueHint}\n请基于主人的历史判断, 用 1-2 句话代主人拟一个回复草案. 草案要保留主人的语气和立场, 开头标注 [DRAFT]. 直接给草案文本, 不要解释.`;
221
+ let draftText = '';
222
+ let confidence = 0.5;
223
+ try {
224
+ const openaiKey = process.env.OPENAI_API_KEY;
225
+ if (!openaiKey)
226
+ throw new Error('OPENAI_API_KEY not set');
227
+ const openaiBase = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
228
+ const openaiModel = process.env.OPENAI_MODEL || 'gpt-4';
229
+ const r = await fetch(`${openaiBase}/chat/completions`, {
230
+ method: 'POST',
231
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${openaiKey}` },
232
+ body: JSON.stringify({
233
+ model: openaiModel,
234
+ messages: [
235
+ { role: 'system', content: '你是主人的代理. 你的输出会被主人审阅后才发出. 请谨慎.' },
236
+ { role: 'user', content: promptForDraft },
237
+ ],
238
+ temperature: 0.4,
239
+ max_tokens: 300,
240
+ }),
241
+ signal: AbortSignal.timeout(15000),
242
+ });
243
+ if (r.ok) {
244
+ const data = await r.json();
245
+ draftText = (data.choices?.[0]?.message?.content || '').trim();
246
+ confidence = 0.7;
247
+ }
248
+ else {
249
+ console.warn(`[DraftEngine] LLM ${r.status}`);
250
+ }
251
+ }
252
+ catch (e) {
253
+ console.warn(`[DraftEngine] LLM call failed: ${e.message}`);
254
+ }
255
+ if (!draftText) {
256
+ draftText = `[DRAFT] 已收到. (本地 draft 引擎未配置 LLM, 主人上线后请手写回复)`;
257
+ confidence = 0.1;
258
+ }
259
+ const updated = await updateInboxEntry(peerDID, messageId, {
260
+ status: 'drafted', draft: draftText, draftConfidence: confidence,
261
+ draftReasoning: valueHint.slice(0, 200), draftAt: Date.now(),
262
+ });
263
+ if (updated) {
264
+ const sent = await transport.sendMessage(peerDID, 'agent_chat', new TextEncoder().encode(JSON.stringify({ kind: 'draft', id: crypto.randomUUID(), text: draftText, originalId: messageId })));
265
+ if (sent)
266
+ console.log('[DraftEngine] draft sent to ' + peerDID.slice(0, 12) + ' for ' + messageId.slice(0, 8));
267
+ else
268
+ console.warn('[DraftEngine] failed to send draft');
269
+ chatEventBus.emit('chat', { kind: 'draft_ready', peerDID, entry: updated });
270
+ chatEventBus.emit('chat', { kind: 'outbox_updated', peerDID, entry: updated });
271
+ }
272
+ return updated;
273
+ }
274
+ export async function processPendingInbox(transport = defaultIrohTransport) {
275
+ await fs.mkdir(inboxDir(), { recursive: true });
276
+ const files = await fs.readdir(inboxDir());
277
+ let processed = 0, skipped = 0;
278
+ for (const f of files) {
279
+ if (!f.endsWith('.jsonl') || f.startsWith('_'))
280
+ continue;
281
+ const peerDID = f.replace('.jsonl', '').replace(/_/g, ':');
282
+ const entries = await readInbox(peerDID);
283
+ for (const e of entries) {
284
+ if (e.status === 'received') {
285
+ const r = await generateDraft(e.id, peerDID, transport);
286
+ if (r)
287
+ processed++;
288
+ else
289
+ skipped++;
290
+ }
291
+ }
292
+ }
293
+ return { processed, skipped };
294
+ }
295
+ export async function approveAndSend(messageId, peerDID, finalText, transport = defaultIrohTransport) {
296
+ const entries = await readInbox(peerDID);
297
+ const entry = entries.find((e) => e.id === messageId);
298
+ if (!entry || entry.status !== 'drafted')
299
+ return false;
300
+ const text = finalText || entry.draft || '';
301
+ const ok = await transport.sendMessage(peerDID, 'agent_chat', new TextEncoder().encode(JSON.stringify({ kind: 'user', id: crypto.randomUUID(), text })));
302
+ if (ok) {
303
+ const updated = await updateInboxEntry(peerDID, messageId, { status: 'sent', sentText: text, sentAt: Date.now() });
304
+ if (updated)
305
+ chatEventBus.emit('chat', { kind: 'inbox_sent', peerDID, entry: updated });
306
+ }
307
+ return ok;
308
+ }
309
+ export async function dismissDraft(messageId, peerDID) {
310
+ const entries = await readInbox(peerDID);
311
+ const entry = entries.find((e) => e.id === messageId);
312
+ if (!entry)
313
+ return false;
314
+ const updated = await updateInboxEntry(peerDID, messageId, { status: 'dismissed' });
315
+ if (updated)
316
+ chatEventBus.emit('chat', { kind: 'inbox_dismissed', peerDID, entry: updated });
317
+ return true;
318
+ }
319
+ export const p2pChatTools = {
320
+ sendChat, getInbox, processPendingInbox, generateDraft, approveAndSend, dismissDraft, onChatMessage, initChatReceiver,
321
+ };
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import * as crypto from 'crypto';
5
5
  import * as fs from 'fs/promises';
6
+ import * as path from 'path';
6
7
  import { irohTransport } from '../network/iroh-transport.js';
7
8
  import { documentReader } from '../documents/reader.js';
8
9
  import { documentStore } from '../documents/store.js';
@@ -15,6 +16,11 @@ function getMimeType(filename) {
15
16
  const mimeTypes = {
16
17
  'txt': 'text/plain',
17
18
  'md': 'text/markdown',
19
+ 'html': 'text/html',
20
+ 'htm': 'text/html',
21
+ 'yaml': 'application/yaml',
22
+ 'yml': 'application/yaml',
23
+ 'json': 'application/json',
18
24
  'pdf': 'application/pdf',
19
25
  'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
20
26
  };
@@ -22,8 +28,10 @@ function getMimeType(filename) {
22
28
  }
23
29
  export async function initDocumentReceiver() {
24
30
  await documentStore.initialize();
25
- documentStore.onDocumentReceived((doc) => {
31
+ documentStore.onDocumentReceived(async (doc) => {
26
32
  console.log(`[DocumentReceiver] Document received: ${doc.fileName} from ${doc.fromNodeIdShort}`);
33
+ // 异步调用 LLM 解析(不阻塞接收流程)
34
+ void parseDocumentWithLLM(doc);
27
35
  });
28
36
  irohTransport.onMessage('document_chunk', async (msg) => {
29
37
  try {
@@ -39,6 +47,118 @@ export async function initDocumentReceiver() {
39
47
  });
40
48
  console.log('[DocumentReceiver] Initialized and listening for document chunks');
41
49
  }
50
+ const feedbackHandlers = new Set();
51
+ /** 注册 AI 解析反馈监听器(发送方调用) */
52
+ export function onAIFeedback(handler) {
53
+ feedbackHandlers.add(handler);
54
+ // iroh listener 只挂一次
55
+ ensureFeedbackListenerInstalled();
56
+ }
57
+ let feedbackListenerInstalled = false;
58
+ function ensureFeedbackListenerInstalled() {
59
+ if (feedbackListenerInstalled)
60
+ return;
61
+ feedbackListenerInstalled = true;
62
+ irohTransport.onMessage('ai_feedback', (msg) => {
63
+ try {
64
+ const fb = JSON.parse(new TextDecoder().decode(msg.payload));
65
+ for (const h of feedbackHandlers) {
66
+ try {
67
+ h(fb, msg.from);
68
+ }
69
+ catch (e) {
70
+ console.error('[AIFeedback] handler error:', e);
71
+ }
72
+ }
73
+ }
74
+ catch (e) {
75
+ console.error('[AIFeedback] failed to parse message:', e);
76
+ }
77
+ });
78
+ }
79
+ /** 文档解析服务:优先走 web 统一入口 (含 judgment + harness), fallback 到本地 LLM */
80
+ async function callAIParseService(text, fileName, mimeType, fromNodeId) {
81
+ // 1) 优先: 调 web 端 POST /api/ai-parse (统一入口, 含 judgment + harness)
82
+ const webBase = process.env.BOLLOON_WEB_URL || process.env.PORTAL_URL || 'http://127.0.0.1:54188';
83
+ try {
84
+ const r = await fetch(`${webBase}/api/ai-parse`, {
85
+ method: 'POST',
86
+ headers: { 'Content-Type': 'application/json' },
87
+ body: JSON.stringify({ text, mimeType, fileName, fromNodeId, source: 'p2p-document' }),
88
+ // 短超时, fallback 友好
89
+ signal: AbortSignal.timeout(8000),
90
+ });
91
+ if (r.ok) {
92
+ const data = await r.json();
93
+ return {
94
+ summary: data.summary,
95
+ qualityScore: data.qualityScore,
96
+ source: 'web',
97
+ judgmentId: data.judgmentId,
98
+ gateArtifact: data.gateArtifact,
99
+ };
100
+ }
101
+ console.warn(`[AIParse] web returned ${r.status}, fallback to local`);
102
+ }
103
+ catch (e) {
104
+ console.warn(`[AIParse] web ${webBase} unreachable (${e.message}), fallback to local`);
105
+ }
106
+ // 2) Fallback: 直接调本地 LLM (不调 judgment/harness, 仅在 web 不可用时使用)
107
+ const apiKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY || process.env.MINIMAX_API_KEY || process.env.OPENROUTER_API_KEY || process.env.GEMINI_API_KEY;
108
+ if (!apiKey) {
109
+ throw new Error('No web endpoint and no LLM API key configured. Set BOLLOON_WEB_URL or OPENAI_API_KEY.');
110
+ }
111
+ const { getMinimax } = await import('../constraints/index.js');
112
+ const llm = getMinimax();
113
+ const truncated = text.length > 6000 ? text.substring(0, 6000) + '...[截断]' : text;
114
+ const prompt = `请分析以下 ${mimeType} 文档,并给出 (1) 一句话中文摘要 (2) 关键要点列表 (3) 文档质量评分(0-1)。\n\n文件名: ${fileName}\n\n内容:\n${truncated}`;
115
+ const r = await llm.summarize(prompt);
116
+ return { summary: r.summary, qualityScore: r.qualityScore, source: 'local' };
117
+ }
118
+ async function parseDocumentWithLLM(doc) {
119
+ try {
120
+ const filePath = path.join(process.env.HOME || '/tmp', '.bolloon', 'documents', 'received', doc.id, doc.fileName);
121
+ const content = await fs.readFile(filePath, 'utf-8');
122
+ const r = await callAIParseService(content, doc.fileName, doc.mimeType, doc.fromNodeId);
123
+ const sidecar = {
124
+ filename: doc.fileName,
125
+ mimeType: doc.mimeType,
126
+ fromNodeId: doc.fromNodeId,
127
+ receivedAt: doc.receivedAt,
128
+ summary: r.summary,
129
+ qualityScore: r.qualityScore,
130
+ parseSource: r.source,
131
+ judgmentId: r.judgmentId,
132
+ gateArtifact: r.gateArtifact,
133
+ analyzedAt: Date.now(),
134
+ };
135
+ const sidecarPath = path.join(process.env.HOME || '/tmp', '.bolloon', 'documents', 'received', doc.id, 'ai-analysis.json');
136
+ await fs.writeFile(sidecarPath, JSON.stringify(sidecar, null, 2));
137
+ console.log(`[DocumentReceiver] AI parsed ${doc.fileName} via ${r.source} (score=${r.qualityScore.toFixed(2)})${r.judgmentId ? ` judgment=${r.judgmentId}` : ''}${r.gateArtifact ? ` gate=${r.gateArtifact}` : ''}`);
138
+ // 把 AI 解析结果回送给发送方 (doc.fromNodeId),形成 "接收 → 解析 → 反馈" 闭环
139
+ if (doc.fromNodeId) {
140
+ const feedback = {
141
+ docId: doc.id,
142
+ fileName: doc.fileName,
143
+ mimeType: doc.mimeType,
144
+ summary: r.summary,
145
+ qualityScore: r.qualityScore,
146
+ feedbackAt: Date.now(),
147
+ fromNodeId: irohTransport.getNodeId() || '',
148
+ };
149
+ const sent = await irohTransport.sendMessage(doc.fromNodeId, 'ai_feedback', new TextEncoder().encode(JSON.stringify(feedback)));
150
+ if (sent) {
151
+ console.log(`[DocumentReceiver] Feedback sent to ${doc.fromNodeIdShort}`);
152
+ }
153
+ else {
154
+ console.warn(`[DocumentReceiver] Failed to send feedback to ${doc.fromNodeIdShort}`);
155
+ }
156
+ }
157
+ }
158
+ catch (e) {
159
+ console.error(`[DocumentReceiver] AI parse failed for ${doc.fileName}:`, e);
160
+ }
161
+ }
42
162
  export const p2pDocumentTools = [
43
163
  {
44
164
  name: 'list_online_peers',
@@ -220,7 +220,7 @@ export class WorkflowPivotLoop {
220
220
  tool: toolCall.name
221
221
  });
222
222
  try {
223
- const result = await tool.execute(toolCall.args);
223
+ const result = await tool.execute(toolCall.args ?? {});
224
224
  this.emit({
225
225
  type: result.success ? 'status' : 'error',
226
226
  content: result.success
@@ -313,7 +313,7 @@ export class WorkflowPivotLoop {
313
313
  const argsStr = match[2];
314
314
  const args = this.parseArgs(argsStr);
315
315
  if (this.tools.has(name)) {
316
- pending.push({ name, args });
316
+ pending.push({ name, args, description: '', parameters: {} });
317
317
  }
318
318
  }
319
319
  // Pattern 2: tool_name(args) format
@@ -327,7 +327,7 @@ export class WorkflowPivotLoop {
327
327
  if (!this.tools.has(name))
328
328
  continue;
329
329
  const args = this.parseArgs(argsStr);
330
- pending.push({ name, args });
330
+ pending.push({ name, args, description: '', parameters: {} });
331
331
  }
332
332
  // Pattern 3: JSON format tool calls
333
333
  try {
@@ -337,7 +337,7 @@ export class WorkflowPivotLoop {
337
337
  if (Array.isArray(parsed.tool_calls)) {
338
338
  for (const tc of parsed.tool_calls) {
339
339
  if (this.tools.has(tc.name)) {
340
- pending.push({ name: tc.name, args: tc.args || {} });
340
+ pending.push({ name: tc.name, args: tc.args || {}, description: '', parameters: {} });
341
341
  }
342
342
  }
343
343
  }
package/dist/cli-entry.js CHANGED
@@ -22,7 +22,7 @@ const YELLOW = '\x1b[33m';
22
22
  const GREEN = '\x1b[32m';
23
23
  const MAGENTA = '\x1b[35m';
24
24
  // 版本信息
25
- const VERSION = '0.1.11';
25
+ const VERSION = '0.1.12';
26
26
  function log(msg, color = RESET) {
27
27
  console.log(`${color}${msg}${RESET}`);
28
28
  }
@@ -11,6 +11,11 @@ export class DocumentReader {
11
11
  switch (ext) {
12
12
  case '.txt':
13
13
  case '.md':
14
+ case '.html':
15
+ case '.htm':
16
+ case '.yaml':
17
+ case '.yml':
18
+ case '.json':
14
19
  text = await fs.readFile(filePath, 'utf-8');
15
20
  break;
16
21
  case '.pdf':
@@ -139,10 +139,10 @@ export class DocumentStore {
139
139
  async readDocument(docId) {
140
140
  const docDir = path.join(this.baseDir, docId);
141
141
  const manifestPath = path.join(docDir, 'manifest.json');
142
- const filePath = path.join(docDir);
143
142
  try {
144
143
  const manifestData = await fs.readFile(manifestPath, 'utf-8');
145
144
  const manifest = JSON.parse(manifestData);
145
+ const filePath = path.join(docDir, manifest.fileName);
146
146
  const fileContent = await fs.readFile(filePath, 'utf-8');
147
147
  return {
148
148
  content: fileContent,
package/dist/llm/pi-ai.js CHANGED
@@ -102,25 +102,26 @@ export class PiAIModel {
102
102
  if (this.config.baseUrl) {
103
103
  return this.config.baseUrl;
104
104
  }
105
+ // 允许通过 OPENAI_BASE_URL 等环境变量覆盖默认 base URL
105
106
  const baseUrls = {
106
- openai: 'https://api.openai.com/v1',
107
+ openai: process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
107
108
  anthropic: 'https://api.anthropic.com/v1',
108
109
  ollama: process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
109
- openrouter: 'https://openrouter.ai/api/v1',
110
+ openrouter: process.env.OPENROUTER_BASE_URL || 'https://openrouter.ai/api/v1',
110
111
  gemini: 'https://generativelanguage.googleapis.com/v1beta',
111
- minimax: 'https://api.minimaxi.com/v1',
112
+ minimax: process.env.MINIMAX_BASE_URL || 'https://api.minimaxi.com/v1',
112
113
  local: 'http://localhost:11434'
113
114
  };
114
115
  return baseUrls[this.provider];
115
116
  }
116
117
  mapModel() {
117
118
  const modelMap = {
118
- openai: this.config.model || 'gpt-4',
119
+ openai: this.config.model || process.env.OPENAI_MODEL || 'gpt-4',
119
120
  anthropic: this.config.model || 'claude-3-5-sonnet-20241022',
120
121
  ollama: this.config.model || 'llama3.2',
121
122
  openrouter: this.config.model || 'anthropic/claude-3.5-sonnet',
122
123
  gemini: this.config.model || 'gemini-2.0-flash',
123
- minimax: this.config.model || 'MiniMax-M2.7',
124
+ minimax: this.config.model || process.env.MINIMAX_MODEL || 'MiniMax-M2.7',
124
125
  local: this.config.model || 'llama3.2'
125
126
  };
126
127
  return modelMap[this.provider];
@@ -54,9 +54,10 @@ export class IrohDiscoveryService {
54
54
  }, this.config.refreshIntervalMs);
55
55
  }
56
56
  startDiscoveryLoop() {
57
+ const interval = this.config.discoveryIntervalMs ?? 30000;
57
58
  this.discoveryTimer = setInterval(async () => {
58
59
  await this.discoverPeers();
59
- }, this.discoveryIntervalMs);
60
+ }, interval);
60
61
  setTimeout(() => this.discoverPeers(), 2000);
61
62
  }
62
63
  async discoverPeers() {
@@ -100,6 +100,9 @@ export class IrohTransport {
100
100
  count += queue.length;
101
101
  return count;
102
102
  },
103
+ async getAllOfflineTargets() {
104
+ return Array.from(offlineQueues.keys());
105
+ },
103
106
  async savePendingResponse(req) {
104
107
  const id = crypto.randomUUID();
105
108
  const pending = { ...req, id };
@@ -123,8 +126,13 @@ export class IrohTransport {
123
126
  if (!this.messageStore)
124
127
  return;
125
128
  this.offlineDeliveryInterval = setInterval(async () => {
129
+ // 遍历所有有离线消息的目标节点(不仅是"已连接"的)
130
+ // 这样目标节点一旦在线(accept 连接)就能拿到离线消息
131
+ const allTargets = await this.messageStore.getAllOfflineTargets();
126
132
  const connectedPeers = this.getConnectedPeers();
127
- for (const peerId of connectedPeers) {
133
+ // 合并:已连接 + 有离线消息但未连接(也会去尝试 connect)
134
+ const targets = new Set([...connectedPeers, ...allTargets]);
135
+ for (const peerId of targets) {
128
136
  const offlineMsgs = await this.messageStore.getOfflineMessages(peerId);
129
137
  for (const msg of offlineMsgs) {
130
138
  if (msg.retryCount >= 10) {
@@ -140,6 +148,9 @@ export class IrohTransport {
140
148
  await this.messageStore.dequeueOfflineMessage(msg.id);
141
149
  console.log(`[IrohTransport] Delivered offline message to ${peerId.substring(0, 12)}...`);
142
150
  }
151
+ else {
152
+ await this.messageStore.incrementOfflineRetry(msg.id);
153
+ }
143
154
  }
144
155
  catch {
145
156
  await this.messageStore.incrementOfflineRetry(msg.id);
@@ -356,8 +367,10 @@ export class IrohTransport {
356
367
  await send.writeAll(Buffer.from(requestMsg));
357
368
  await send.finish();
358
369
  // 等待响应,带超时
370
+ // 注意: server sendResponse 后会关闭连接,导致 readToEnd 以 "connection lost" 错误 reject
371
+ // 这里我们把 readToEnd 的错误吞掉(视为流结束),只有超时才视为失败
359
372
  const response = await Promise.race([
360
- recv.readToEnd(64 * 1024),
373
+ recv.readToEnd(64 * 1024).catch(() => new Uint8Array(0)),
361
374
  new Promise((_, rejectTimeout) => setTimeout(() => rejectTimeout(new Error('timeout')), timeout)),
362
375
  ]);
363
376
  conn.close();
@@ -437,8 +437,8 @@ export class P2PNetwork {
437
437
  const colonIdx = messageStr.indexOf(':');
438
438
  const didMarker = 'DID:';
439
439
  let did;
440
- let type;
441
- let payload;
440
+ let type = 'message';
441
+ let payload = '';
442
442
  let requestId = undefined;
443
443
  if (messageStr.startsWith(didMarker)) {
444
444
  const didEndIdx = messageStr.indexOf('|');
@@ -651,7 +651,11 @@ export class P2PNetwork {
651
651
  * Register a handler for responses (used by the receiving side)
652
652
  */
653
653
  onResponse(type, handler) {
654
- this.messageHandlers.set(type, handler);
654
+ // Store as pendingResponseHandlers-shaped wrapper. Extra args (did, requestId) are not
655
+ // available in pendingResponseHandlers signature, so ignore them when invoked.
656
+ this.pendingResponseHandlers.set(type, (responseData, from) => {
657
+ handler(responseData, from, undefined, undefined);
658
+ });
655
659
  }
656
660
  /**
657
661
  * Send a response back to a peer
@@ -675,11 +679,8 @@ export class P2PNetwork {
675
679
  handleRequest(type, payload, requestId, fromPeerId, did) {
676
680
  const handler = this.messageHandlers.get(type);
677
681
  if (handler) {
678
- // Create a wrapper that sends the response
679
- const originalHandler = handler;
680
- handler = (msg, from, didParam) => {
681
- originalHandler(msg, from, didParam);
682
- };
682
+ // Forward raw payload; callers register with onMessage() and adapt as needed.
683
+ handler(new TextEncoder().encode(payload), fromPeerId, did);
683
684
  }
684
685
  // Check if there's a response handler registered
685
686
  const responseHandler = this.pendingResponseHandlers.get(type);