@bolloon/bolloon-agent 0.1.11 → 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 (42) 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/utils/auto-update.js +15 -1
  16. package/dist/web/components/p2p/index.js +226 -264
  17. package/dist/web/index.html +12 -0
  18. package/package.json +3 -1
  19. package/scripts/build-web.ts +1 -1
  20. package/scripts/postinstall.js +1 -1
  21. package/src/agents/p2p-chat-tools.ts +383 -0
  22. package/src/agents/p2p-document-tools.ts +151 -1
  23. package/src/agents/workflow-pivot-loop.ts +13 -12
  24. package/src/bollharness-integration/channel-judgment-engine.ts +1 -1
  25. package/src/cli-entry.ts +1 -1
  26. package/src/documents/reader.ts +5 -0
  27. package/src/documents/store.ts +1 -1
  28. package/src/llm/pi-ai.ts +6 -5
  29. package/src/network/iroh-discovery.ts +2 -1
  30. package/src/network/iroh-transport.ts +15 -2
  31. package/src/network/p2p.ts +9 -8
  32. package/src/network/storage/adapters/json-adapter.ts +17 -2
  33. package/src/network/storage/index.ts +19 -3
  34. package/src/social/channels/channel-heartbeat-agent.ts +1 -1
  35. package/src/utils/auto-update.ts +17 -1
  36. package/src/web/server.ts +149 -0
  37. package/tsconfig.electron.json +1 -1
  38. package/tsconfig.json +1 -1
  39. package/dist/web/components/p2p/P2PModal.js +0 -188
  40. package/dist/web/components/p2p/p2p-modal.js +0 -657
  41. package/dist/web/components/p2p/p2p-tools.js +0 -248
  42. package/dist/web/server.js +0 -1890
@@ -0,0 +1,383 @@
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
+
21
+ import * as crypto from 'crypto';
22
+ import * as fs from 'fs/promises';
23
+ import * as path from 'path';
24
+ import * as os from 'os';
25
+ import { EventEmitter } from 'events';
26
+ import { irohTransport as defaultIrohTransport, type IrohTransport } from '../network/iroh-transport.js';
27
+ import { getRelevantValues, getValueProfile, loadAllJudgments } from '../pi-ecosystem-judgment/human-value-store.js';
28
+
29
+ function homeDir(): string {
30
+ return process.env.HOME || process.env.USERPROFILE || os.homedir();
31
+ }
32
+
33
+ // ============================================================================
34
+ // 事件总线 — 任何状态变化都 emit, web SSE/UI 可订阅
35
+ // ============================================================================
36
+ export type ChatEvent =
37
+ | { kind: 'inbox_new'; peerDID: string; entry: ChatMessage }
38
+ | { kind: 'draft_ready'; peerDID: string; entry: ChatMessage }
39
+ | { kind: 'outbox_updated'; peerDID: string; entry: ChatMessage }
40
+ | { kind: 'outbox_draft_in'; peerDID: string; originalId: string; draft: string }
41
+ | { kind: 'inbox_sent'; peerDID: string; entry: ChatMessage }
42
+ | { kind: 'inbox_dismissed'; peerDID: string; entry: ChatMessage };
43
+
44
+ class ChatEventBus extends EventEmitter {}
45
+ export const chatEventBus = new ChatEventBus();
46
+
47
+ // ============================================================================
48
+ // 类型
49
+ // ============================================================================
50
+
51
+ export type ChatMessageStatus = 'received' | 'drafted' | 'sent' | 'dismissed';
52
+
53
+ export interface ChatMessage {
54
+ id: string;
55
+ peerDID: string;
56
+ fromNodeId: string;
57
+ text: string;
58
+ timestamp: number;
59
+ receivedAt: number;
60
+ status: ChatMessageStatus;
61
+ draft?: string;
62
+ draftConfidence?: number;
63
+ draftReasoning?: string;
64
+ draftAt?: number;
65
+ sentText?: string;
66
+ sentAt?: number;
67
+ inboundDraft?: string;
68
+ inboundDraftAt?: number;
69
+ }
70
+
71
+ export type ChatMessageHandler = (msg: ChatMessage, from: string) => void;
72
+
73
+ // ============================================================================
74
+ // 每 transport 一份状态 (支持多 IrohTransport 实例)
75
+ // ============================================================================
76
+
77
+ interface ChatModuleState {
78
+ handlers: Set<ChatMessageHandler>;
79
+ listenerInstalled: boolean;
80
+ }
81
+
82
+ const states = new WeakMap<IrohTransport, ChatModuleState>();
83
+
84
+ function getState(transport: IrohTransport): ChatModuleState {
85
+ let s = states.get(transport);
86
+ if (!s) {
87
+ s = { handlers: new Set(), listenerInstalled: false };
88
+ states.set(transport, s);
89
+ }
90
+ return s;
91
+ }
92
+
93
+ // ============================================================================
94
+ // Inbox 存储
95
+ // ============================================================================
96
+
97
+ function inboxDir(): string {
98
+ return path.join(homeDir(), '.bolloon', 'inbox');
99
+ }
100
+
101
+ function inboxPath(peerDID: string): string {
102
+ const safe = peerDID.replace(/[^a-zA-Z0-9_-]/g, '_');
103
+ return path.join(inboxDir(), `${safe}.jsonl`);
104
+ }
105
+
106
+ function outboxPath(): string {
107
+ return path.join(inboxDir(), '_outbox.jsonl');
108
+ }
109
+
110
+ async function appendInbox(peerDID: string, entry: ChatMessage): Promise<void> {
111
+ await fs.mkdir(inboxDir(), { recursive: true });
112
+ await fs.appendFile(inboxPath(peerDID), JSON.stringify(entry) + '\n', 'utf-8');
113
+ }
114
+
115
+ async function readInbox(peerDID: string): Promise<ChatMessage[]> {
116
+ try {
117
+ const content = await fs.readFile(inboxPath(peerDID), 'utf-8');
118
+ return content.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l) as ChatMessage);
119
+ } catch { return []; }
120
+ }
121
+
122
+ async function rewriteInbox(peerDID: string, entries: ChatMessage[]): Promise<void> {
123
+ await fs.mkdir(inboxDir(), { recursive: true });
124
+ await fs.writeFile(inboxPath(peerDID), entries.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
125
+ }
126
+
127
+ async function updateInboxEntry(peerDID: string, id: string, patch: Partial<ChatMessage>): Promise<ChatMessage | null> {
128
+ const entries = await readInbox(peerDID);
129
+ const idx = entries.findIndex((e) => e.id === id);
130
+ if (idx < 0) return null;
131
+ entries[idx] = { ...entries[idx], ...patch };
132
+ await rewriteInbox(peerDID, entries);
133
+ return entries[idx];
134
+ }
135
+
136
+ async function markOutboundDraft(originalId: string, draft: string, at: number): Promise<void> {
137
+ try {
138
+ const content = await fs.readFile(outboxPath(), 'utf-8');
139
+ const entries: ChatMessage[] = content.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
140
+ const idx = entries.findIndex((e) => e.id === originalId);
141
+ if (idx >= 0) {
142
+ entries[idx].inboundDraft = draft;
143
+ entries[idx].inboundDraftAt = at;
144
+ await fs.writeFile(outboxPath(), entries.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
145
+ }
146
+ } catch { /* no outbox */ }
147
+ }
148
+
149
+ // ============================================================================
150
+ // Listener 安装 (每个 transport 各装一次)
151
+ // ============================================================================
152
+
153
+ function ensureListener(transport: IrohTransport): void {
154
+ const s = getState(transport);
155
+ if (s.listenerInstalled) return;
156
+ s.listenerInstalled = true;
157
+ transport.onMessage('agent_chat', (msg) => {
158
+ try {
159
+ const data = JSON.parse(new TextDecoder().decode(msg.payload)) as { kind: string; id: string; text: string; originalId?: string };
160
+ void handleIncomingChat(transport, msg.from, data);
161
+ } catch (e) {
162
+ console.error('[ChatReceiver] parse err:', (e as Error).message);
163
+ }
164
+ });
165
+ }
166
+
167
+ async function handleIncomingChat(transport: IrohTransport, fromNodeId: string, data: { kind: string; id: string; text: string; originalId?: string }): Promise<void> {
168
+ const peerDID = fromNodeId;
169
+ if (data.kind === 'user') {
170
+ const entry: ChatMessage = {
171
+ id: data.id, peerDID, fromNodeId, text: data.text,
172
+ timestamp: Date.now(), receivedAt: Date.now(), status: 'received',
173
+ };
174
+ await appendInbox(peerDID, entry);
175
+ const preview = data.text.slice(0, 60);
176
+ const peerShort = peerDID.slice(0, 12);
177
+ console.log('[ChatReceiver] received from ' + peerShort + ': ' + preview);
178
+ chatEventBus.emit('chat', { kind: 'inbox_new', peerDID, entry } satisfies ChatEvent);
179
+ for (const h of getState(transport).handlers) {
180
+ try { h(entry, fromNodeId); } catch {}
181
+ }
182
+ } else if (data.kind === 'draft' && data.originalId) {
183
+ await markOutboundDraft(data.originalId, data.text, Date.now());
184
+ console.log('[ChatReceiver] inbound draft for ' + data.originalId);
185
+ chatEventBus.emit('chat', { kind: 'outbox_draft_in', peerDID, originalId: data.originalId, draft: data.text } satisfies ChatEvent);
186
+ }
187
+ }
188
+
189
+ // ============================================================================
190
+ // 公共 API (可注入 transport)
191
+ // ============================================================================
192
+
193
+ export function onChatMessage(handler: ChatMessageHandler, transport: IrohTransport = defaultIrohTransport): void {
194
+ getState(transport).handlers.add(handler);
195
+ ensureListener(transport);
196
+ }
197
+
198
+ export async function initChatReceiver(transport: IrohTransport = defaultIrohTransport): Promise<void> {
199
+ ensureListener(transport);
200
+ await fs.mkdir(inboxDir(), { recursive: true });
201
+ console.log('[ChatReceiver] Initialized at', inboxDir(), 'transport=', (transport as any) === defaultIrohTransport ? 'singleton' : 'instance');
202
+ }
203
+
204
+ export async function sendChat(peerDID: string, text: string, transport: IrohTransport = defaultIrohTransport): Promise<string> {
205
+ const id = crypto.randomUUID();
206
+ const ok = await transport.sendMessage(
207
+ peerDID, 'agent_chat',
208
+ new TextEncoder().encode(JSON.stringify({ kind: 'user', id, text })),
209
+ );
210
+ if (ok) {
211
+ const entry: ChatMessage = {
212
+ id, peerDID,
213
+ fromNodeId: transport.getNodeId() || '',
214
+ text, timestamp: Date.now(), receivedAt: Date.now(),
215
+ status: 'sent', sentText: text, sentAt: Date.now(),
216
+ };
217
+ await fs.mkdir(inboxDir(), { recursive: true });
218
+ await fs.appendFile(outboxPath(), JSON.stringify(entry) + '\n', 'utf-8');
219
+ chatEventBus.emit('chat', { kind: 'outbox_updated', peerDID, entry } satisfies ChatEvent);
220
+ } else {
221
+ console.warn(`[ChatReceiver] send to ${peerDID.slice(0, 12)}... failed`);
222
+ }
223
+ return id;
224
+ }
225
+
226
+ export async function getInbox(peerDID?: string): Promise<ChatMessage[]> {
227
+ if (peerDID) return readInbox(peerDID);
228
+ try {
229
+ const files = await fs.readdir(inboxDir());
230
+ const all: ChatMessage[] = [];
231
+ for (const f of files) {
232
+ if (!f.endsWith('.jsonl') || f.startsWith('_')) continue;
233
+ const peerDID = f.replace('.jsonl', '').replace(/_/g, ':');
234
+ all.push(...await readInbox(peerDID));
235
+ }
236
+ return all.sort((a, b) => b.receivedAt - a.receivedAt);
237
+ } catch { return []; }
238
+ }
239
+
240
+ // ============================================================================
241
+ // Draft 引擎
242
+ // ============================================================================
243
+
244
+ async function buildValueHint(text: string): Promise<string> {
245
+ try {
246
+ const allJudgments = await loadAllJudgments();
247
+ const relevant = await getRelevantValues(text, undefined);
248
+ const profile = await getValueProfile('me');
249
+ const profileHint = [
250
+ `quality_focus=${profile.quality_focus.toFixed(2)}`,
251
+ `efficiency_focus=${profile.efficiency_focus.toFixed(2)}`,
252
+ `safety_focus=${profile.safety_focus.toFixed(2)}`,
253
+ `collaboration_focus=${profile.collaboration_focus.toFixed(2)}`,
254
+ `learning_focus=${profile.learning_focus.toFixed(2)}`,
255
+ ].join(', ');
256
+ const reasons = allJudgments
257
+ .filter((j) => (j.reasons || []).length > 0)
258
+ .slice(-10)
259
+ .flatMap((j) => j.reasons || [])
260
+ .slice(0, 20);
261
+ return `\n[主人历史判断 (style 参考, 不可外泄)]\n关注维度: ${profileHint}\n关键理由示例: ${reasons.join(' | ').slice(0, 400) || '(无)'}\n`;
262
+ } catch (e) {
263
+ return '';
264
+ }
265
+ }
266
+
267
+ export async function generateDraft(
268
+ messageId: string,
269
+ peerDID: string,
270
+ transport: IrohTransport = defaultIrohTransport,
271
+ ): Promise<ChatMessage | null> {
272
+ const entries = await readInbox(peerDID);
273
+ const entry = entries.find((e) => e.id === messageId);
274
+ if (!entry) return null;
275
+ if (entry.status !== 'received') return entry;
276
+
277
+ const valueHint = await buildValueHint(entry.text);
278
+ const promptForDraft = `你是主人的代理. 对方发来这条消息: "${entry.text.slice(0, 1500)}"\n${valueHint}\n请基于主人的历史判断, 用 1-2 句话代主人拟一个回复草案. 草案要保留主人的语气和立场, 开头标注 [DRAFT]. 直接给草案文本, 不要解释.`;
279
+
280
+ let draftText = '';
281
+ let confidence = 0.5;
282
+ try {
283
+ const openaiKey = process.env.OPENAI_API_KEY;
284
+ if (!openaiKey) throw new Error('OPENAI_API_KEY not set');
285
+ const openaiBase = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
286
+ const openaiModel = process.env.OPENAI_MODEL || 'gpt-4';
287
+ const r = await fetch(`${openaiBase}/chat/completions`, {
288
+ method: 'POST',
289
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${openaiKey}` },
290
+ body: JSON.stringify({
291
+ model: openaiModel,
292
+ messages: [
293
+ { role: 'system', content: '你是主人的代理. 你的输出会被主人审阅后才发出. 请谨慎.' },
294
+ { role: 'user', content: promptForDraft },
295
+ ],
296
+ temperature: 0.4,
297
+ max_tokens: 300,
298
+ }),
299
+ signal: AbortSignal.timeout(15000),
300
+ });
301
+ if (r.ok) {
302
+ const data = await r.json() as any;
303
+ draftText = (data.choices?.[0]?.message?.content || '').trim();
304
+ confidence = 0.7;
305
+ } else {
306
+ console.warn(`[DraftEngine] LLM ${r.status}`);
307
+ }
308
+ } catch (e) {
309
+ console.warn(`[DraftEngine] LLM call failed: ${(e as Error).message}`);
310
+ }
311
+
312
+ if (!draftText) {
313
+ draftText = `[DRAFT] 已收到. (本地 draft 引擎未配置 LLM, 主人上线后请手写回复)`;
314
+ confidence = 0.1;
315
+ }
316
+
317
+ const updated = await updateInboxEntry(peerDID, messageId, {
318
+ status: 'drafted', draft: draftText, draftConfidence: confidence,
319
+ draftReasoning: valueHint.slice(0, 200), draftAt: Date.now(),
320
+ });
321
+
322
+ if (updated) {
323
+ const sent = await transport.sendMessage(
324
+ peerDID, 'agent_chat',
325
+ new TextEncoder().encode(JSON.stringify({ kind: 'draft', id: crypto.randomUUID(), text: draftText, originalId: messageId })),
326
+ );
327
+ if (sent) console.log('[DraftEngine] draft sent to ' + peerDID.slice(0, 12) + ' for ' + messageId.slice(0, 8));
328
+ else console.warn('[DraftEngine] failed to send draft');
329
+ chatEventBus.emit('chat', { kind: 'draft_ready', peerDID, entry: updated } satisfies ChatEvent);
330
+ chatEventBus.emit('chat', { kind: 'outbox_updated', peerDID, entry: updated } satisfies ChatEvent);
331
+ }
332
+ return updated;
333
+ }
334
+
335
+ export async function processPendingInbox(transport: IrohTransport = defaultIrohTransport): Promise<{ processed: number; skipped: number }> {
336
+ await fs.mkdir(inboxDir(), { recursive: true });
337
+ const files = await fs.readdir(inboxDir());
338
+ let processed = 0, skipped = 0;
339
+ for (const f of files) {
340
+ if (!f.endsWith('.jsonl') || f.startsWith('_')) continue;
341
+ const peerDID = f.replace('.jsonl', '').replace(/_/g, ':');
342
+ const entries = await readInbox(peerDID);
343
+ for (const e of entries) {
344
+ if (e.status === 'received') {
345
+ const r = await generateDraft(e.id, peerDID, transport);
346
+ if (r) processed++; else skipped++;
347
+ }
348
+ }
349
+ }
350
+ return { processed, skipped };
351
+ }
352
+
353
+ export async function approveAndSend(
354
+ messageId: string, peerDID: string, finalText?: string,
355
+ transport: IrohTransport = defaultIrohTransport,
356
+ ): Promise<boolean> {
357
+ const entries = await readInbox(peerDID);
358
+ const entry = entries.find((e) => e.id === messageId);
359
+ if (!entry || entry.status !== 'drafted') return false;
360
+ const text = finalText || entry.draft || '';
361
+ const ok = await transport.sendMessage(
362
+ peerDID, 'agent_chat',
363
+ new TextEncoder().encode(JSON.stringify({ kind: 'user', id: crypto.randomUUID(), text })),
364
+ );
365
+ if (ok) {
366
+ const updated = await updateInboxEntry(peerDID, messageId, { status: 'sent', sentText: text, sentAt: Date.now() });
367
+ if (updated) chatEventBus.emit('chat', { kind: 'inbox_sent', peerDID, entry: updated } satisfies ChatEvent);
368
+ }
369
+ return ok;
370
+ }
371
+
372
+ export async function dismissDraft(messageId: string, peerDID: string): Promise<boolean> {
373
+ const entries = await readInbox(peerDID);
374
+ const entry = entries.find((e) => e.id === messageId);
375
+ if (!entry) return false;
376
+ const updated = await updateInboxEntry(peerDID, messageId, { status: 'dismissed' });
377
+ if (updated) chatEventBus.emit('chat', { kind: 'inbox_dismissed', peerDID, entry: updated } satisfies ChatEvent);
378
+ return true;
379
+ }
380
+
381
+ export const p2pChatTools = {
382
+ sendChat, getInbox, processPendingInbox, generateDraft, approveAndSend, dismissDraft, onChatMessage, initChatReceiver,
383
+ };
@@ -4,6 +4,7 @@
4
4
 
5
5
  import * as crypto from 'crypto';
6
6
  import * as fs from 'fs/promises';
7
+ import * as path from 'path';
7
8
  import { irohTransport } from '../network/iroh-transport.js';
8
9
  import { documentReader, type DocumentContent } from '../documents/reader.js';
9
10
  import { documentStore, type DocumentChunk, type ReceivedDocument } from '../documents/store.js';
@@ -20,6 +21,11 @@ function getMimeType(filename: string): string {
20
21
  const mimeTypes: Record<string, string> = {
21
22
  'txt': 'text/plain',
22
23
  'md': 'text/markdown',
24
+ 'html': 'text/html',
25
+ 'htm': 'text/html',
26
+ 'yaml': 'application/yaml',
27
+ 'yml': 'application/yaml',
28
+ 'json': 'application/json',
23
29
  'pdf': 'application/pdf',
24
30
  'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
25
31
  };
@@ -29,8 +35,10 @@ function getMimeType(filename: string): string {
29
35
  export async function initDocumentReceiver(): Promise<void> {
30
36
  await documentStore.initialize();
31
37
 
32
- documentStore.onDocumentReceived((doc) => {
38
+ documentStore.onDocumentReceived(async (doc) => {
33
39
  console.log(`[DocumentReceiver] Document received: ${doc.fileName} from ${doc.fromNodeIdShort}`);
40
+ // 异步调用 LLM 解析(不阻塞接收流程)
41
+ void parseDocumentWithLLM(doc);
34
42
  });
35
43
 
36
44
  irohTransport.onMessage('document_chunk', async (msg) => {
@@ -48,6 +56,148 @@ export async function initDocumentReceiver(): Promise<void> {
48
56
  console.log('[DocumentReceiver] Initialized and listening for document chunks');
49
57
  }
50
58
 
59
+ // ============================================================================
60
+ // AI 解析反馈:接收方解析完文档后把摘要回送给发送方
61
+ // ============================================================================
62
+
63
+ export interface AIFeedbackMessage {
64
+ docId: string;
65
+ fileName: string;
66
+ mimeType: string;
67
+ summary: string;
68
+ qualityScore: number;
69
+ feedbackAt: number;
70
+ fromNodeId: string;
71
+ }
72
+
73
+ export type AIFeedbackHandler = (feedback: AIFeedbackMessage, fromNodeId: string) => void;
74
+ const feedbackHandlers: Set<AIFeedbackHandler> = new Set();
75
+
76
+ /** 注册 AI 解析反馈监听器(发送方调用) */
77
+ export function onAIFeedback(handler: AIFeedbackHandler): void {
78
+ feedbackHandlers.add(handler);
79
+ // iroh listener 只挂一次
80
+ ensureFeedbackListenerInstalled();
81
+ }
82
+
83
+ let feedbackListenerInstalled = false;
84
+ function ensureFeedbackListenerInstalled(): void {
85
+ if (feedbackListenerInstalled) return;
86
+ feedbackListenerInstalled = true;
87
+ irohTransport.onMessage('ai_feedback', (msg) => {
88
+ try {
89
+ const fb: AIFeedbackMessage = JSON.parse(new TextDecoder().decode(msg.payload));
90
+ for (const h of feedbackHandlers) {
91
+ try {
92
+ h(fb, msg.from);
93
+ } catch (e) {
94
+ console.error('[AIFeedback] handler error:', e);
95
+ }
96
+ }
97
+ } catch (e) {
98
+ console.error('[AIFeedback] failed to parse message:', e);
99
+ }
100
+ });
101
+ }
102
+
103
+ /** 文档解析服务:优先走 web 统一入口 (含 judgment + harness), fallback 到本地 LLM */
104
+ async function callAIParseService(text: string, fileName: string, mimeType: string, fromNodeId?: string): Promise<{ summary: string; qualityScore: number; source: 'web' | 'local'; judgmentId?: string; gateArtifact?: string }> {
105
+ // 1) 优先: 调 web 端 POST /api/ai-parse (统一入口, 含 judgment + harness)
106
+ const webBase = process.env.BOLLOON_WEB_URL || process.env.PORTAL_URL || 'http://127.0.0.1:54188';
107
+ try {
108
+ const r = await fetch(`${webBase}/api/ai-parse`, {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify({ text, mimeType, fileName, fromNodeId, source: 'p2p-document' }),
112
+ // 短超时, fallback 友好
113
+ signal: AbortSignal.timeout(8000),
114
+ });
115
+ if (r.ok) {
116
+ const data = await r.json() as any;
117
+ return {
118
+ summary: data.summary,
119
+ qualityScore: data.qualityScore,
120
+ source: 'web',
121
+ judgmentId: data.judgmentId,
122
+ gateArtifact: data.gateArtifact,
123
+ };
124
+ }
125
+ console.warn(`[AIParse] web returned ${r.status}, fallback to local`);
126
+ } catch (e) {
127
+ console.warn(`[AIParse] web ${webBase} unreachable (${(e as Error).message}), fallback to local`);
128
+ }
129
+
130
+ // 2) Fallback: 直接调本地 LLM (不调 judgment/harness, 仅在 web 不可用时使用)
131
+ 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;
132
+ if (!apiKey) {
133
+ throw new Error('No web endpoint and no LLM API key configured. Set BOLLOON_WEB_URL or OPENAI_API_KEY.');
134
+ }
135
+ const { getMinimax } = await import('../constraints/index.js');
136
+ const llm = getMinimax();
137
+ const truncated = text.length > 6000 ? text.substring(0, 6000) + '...[截断]' : text;
138
+ const prompt = `请分析以下 ${mimeType} 文档,并给出 (1) 一句话中文摘要 (2) 关键要点列表 (3) 文档质量评分(0-1)。\n\n文件名: ${fileName}\n\n内容:\n${truncated}`;
139
+ const r = await llm.summarize(prompt);
140
+ return { summary: r.summary, qualityScore: r.qualityScore, source: 'local' };
141
+ }
142
+
143
+ async function parseDocumentWithLLM(doc: ReceivedDocument): Promise<void> {
144
+ try {
145
+ const filePath = path.join(
146
+ process.env.HOME || '/tmp',
147
+ '.bolloon', 'documents', 'received',
148
+ doc.id, doc.fileName
149
+ );
150
+ const content = await fs.readFile(filePath, 'utf-8');
151
+
152
+ const r = await callAIParseService(content, doc.fileName, doc.mimeType, doc.fromNodeId);
153
+
154
+ const sidecar = {
155
+ filename: doc.fileName,
156
+ mimeType: doc.mimeType,
157
+ fromNodeId: doc.fromNodeId,
158
+ receivedAt: doc.receivedAt,
159
+ summary: r.summary,
160
+ qualityScore: r.qualityScore,
161
+ parseSource: r.source,
162
+ judgmentId: r.judgmentId,
163
+ gateArtifact: r.gateArtifact,
164
+ analyzedAt: Date.now(),
165
+ };
166
+ const sidecarPath = path.join(
167
+ process.env.HOME || '/tmp',
168
+ '.bolloon', 'documents', 'received',
169
+ doc.id, 'ai-analysis.json'
170
+ );
171
+ await fs.writeFile(sidecarPath, JSON.stringify(sidecar, null, 2));
172
+ 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}` : ''}`);
173
+
174
+ // 把 AI 解析结果回送给发送方 (doc.fromNodeId),形成 "接收 → 解析 → 反馈" 闭环
175
+ if (doc.fromNodeId) {
176
+ const feedback: AIFeedbackMessage = {
177
+ docId: doc.id,
178
+ fileName: doc.fileName,
179
+ mimeType: doc.mimeType,
180
+ summary: r.summary,
181
+ qualityScore: r.qualityScore,
182
+ feedbackAt: Date.now(),
183
+ fromNodeId: irohTransport.getNodeId() || '',
184
+ };
185
+ const sent = await irohTransport.sendMessage(
186
+ doc.fromNodeId,
187
+ 'ai_feedback',
188
+ new TextEncoder().encode(JSON.stringify(feedback))
189
+ );
190
+ if (sent) {
191
+ console.log(`[DocumentReceiver] Feedback sent to ${doc.fromNodeIdShort}`);
192
+ } else {
193
+ console.warn(`[DocumentReceiver] Failed to send feedback to ${doc.fromNodeIdShort}`);
194
+ }
195
+ }
196
+ } catch (e) {
197
+ console.error(`[DocumentReceiver] AI parse failed for ${doc.fileName}:`, e);
198
+ }
199
+ }
200
+
51
201
  export const p2pDocumentTools: Tool[] = [
52
202
  {
53
203
  name: 'list_online_peers',
@@ -38,6 +38,7 @@ export interface ToolDefinition {
38
38
  name: string;
39
39
  description: string;
40
40
  parameters: Record<string, string>;
41
+ args?: Record<string, string>;
41
42
  }
42
43
 
43
44
  export interface LoopResult {
@@ -137,7 +138,7 @@ export class WorkflowPivotLoop {
137
138
  private config: Required<PivotLoopConfig>;
138
139
  private state: PivotLoopState;
139
140
  private tools: Map<string, Tool>;
140
- private messageHistory: Array<{ role: string; content: string; toolCall?: { name: string; args: Record<string, string> }; toolResult?: ToolResult }>;
141
+ private messageHistory: Array<{ role: string; content: string; toolCall?: ToolDefinition; toolResult?: ToolResult }>;
141
142
  private streamCallback?: StreamCallback;
142
143
 
143
144
  constructor(config: PivotLoopConfig) {
@@ -318,7 +319,7 @@ export class WorkflowPivotLoop {
318
319
  });
319
320
 
320
321
  try {
321
- const result = await tool.execute(toolCall.args);
322
+ const result = await tool.execute(toolCall.args ?? {});
322
323
 
323
324
  this.emit({
324
325
  type: result.success ? 'status' : 'error',
@@ -416,9 +417,9 @@ export class WorkflowPivotLoop {
416
417
  /**
417
418
  * Extract pending tool uses from LLM response
418
419
  */
419
- private extractPendingToolUses(content: string): Array<{ name: string; args: Record<string, string> }> {
420
- const pending: Array<{ name: string; args: Record<string, string> }> = [];
421
-
420
+ private extractPendingToolUses(content: string): ToolDefinition[] {
421
+ const pending: ToolDefinition[] = [];
422
+
422
423
  // Pattern 1: Chinese format "调用工具: tool_name(args)"
423
424
  const pattern1 = /调用工具[::]\s*(\w+)\s*\(([^)]*)\)/g;
424
425
  let match;
@@ -427,10 +428,10 @@ export class WorkflowPivotLoop {
427
428
  const argsStr = match[2];
428
429
  const args = this.parseArgs(argsStr);
429
430
  if (this.tools.has(name)) {
430
- pending.push({ name, args });
431
+ pending.push({ name, args, description: '', parameters: {} });
431
432
  }
432
433
  }
433
-
434
+
434
435
  // Pattern 2: tool_name(args) format
435
436
  const pattern2 = /(\w+)\s*\(\s*([^)]*)\s*\)/g;
436
437
  while ((match = pattern2.exec(content)) !== null) {
@@ -439,11 +440,11 @@ export class WorkflowPivotLoop {
439
440
  // Skip if already matched or doesn't look like a tool call
440
441
  if (pending.some(p => p.name === name)) continue;
441
442
  if (!this.tools.has(name)) continue;
442
-
443
+
443
444
  const args = this.parseArgs(argsStr);
444
- pending.push({ name, args });
445
+ pending.push({ name, args, description: '', parameters: {} });
445
446
  }
446
-
447
+
447
448
  // Pattern 3: JSON format tool calls
448
449
  try {
449
450
  const jsonMatch = content.match(/\{[\s\S]*"tool_calls"[\s\S]*\}/);
@@ -452,7 +453,7 @@ export class WorkflowPivotLoop {
452
453
  if (Array.isArray(parsed.tool_calls)) {
453
454
  for (const tc of parsed.tool_calls) {
454
455
  if (this.tools.has(tc.name)) {
455
- pending.push({ name: tc.name, args: tc.args || {} });
456
+ pending.push({ name: tc.name, args: tc.args || {}, description: '', parameters: {} });
456
457
  }
457
458
  }
458
459
  }
@@ -460,7 +461,7 @@ export class WorkflowPivotLoop {
460
461
  } catch {
461
462
  // JSON parsing failed, ignore
462
463
  }
463
-
464
+
464
465
  return pending;
465
466
  }
466
467
 
@@ -505,7 +505,7 @@ export class ChannelJudgmentEngine {
505
505
  * 确定 Skills(基于 Gate 和上下文)
506
506
  */
507
507
  private determineSkills(gate: Gate, context: JudgmentContext): string[] {
508
- const baseSkills = GATE_PROMPTS[gate]?.skills || ['arch'];
508
+ const baseSkills = (GATE_PROMPTS as Record<number, { skills: string[] }>)[gate]?.skills || ['arch'];
509
509
 
510
510
  // 根据上下文调整 Skills
511
511
  const { currentMessage } = context;
package/src/cli-entry.ts CHANGED
@@ -26,7 +26,7 @@ const GREEN = '\x1b[32m';
26
26
  const MAGENTA = '\x1b[35m';
27
27
 
28
28
  // 版本信息
29
- const VERSION = '0.1.11';
29
+ const VERSION = '0.1.12';
30
30
 
31
31
  function log(msg: string, color: string = RESET) {
32
32
  console.log(`${color}${msg}${RESET}`);
@@ -23,6 +23,11 @@ export class DocumentReader {
23
23
  switch (ext) {
24
24
  case '.txt':
25
25
  case '.md':
26
+ case '.html':
27
+ case '.htm':
28
+ case '.yaml':
29
+ case '.yml':
30
+ case '.json':
26
31
  text = await fs.readFile(filePath, 'utf-8');
27
32
  break;
28
33
  case '.pdf':