evolclaw 2.4.0 → 2.5.1

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.
@@ -1,21 +1,63 @@
1
- import { AUNClient } from '@eleans/aun-core-node';
1
+ import { AUNClient, GatewayDiscovery } from '@eleans/aun-core-sdk';
2
+ import crypto from 'crypto';
2
3
  import fs from 'fs';
3
4
  import path from 'path';
5
+ import os from 'os';
4
6
  import { logger, localTimestamp } from '../utils/logger.js';
5
- import { normalizeChannelInstances } from '../config.js';
7
+ import { normalizeChannelInstances, getChannelShowActivities } from '../config.js';
6
8
  import { resolvePaths } from '../paths.js';
9
+ import { saveToUploads, sanitizeFileName } from '../utils/media-cache.js';
10
+ function guessMime(filename) {
11
+ const ext = path.extname(filename).toLowerCase();
12
+ const map = {
13
+ '.txt': 'text/plain', '.md': 'text/markdown', '.json': 'application/json',
14
+ '.js': 'text/javascript', '.ts': 'text/typescript', '.py': 'text/x-python',
15
+ '.html': 'text/html', '.css': 'text/css', '.csv': 'text/csv',
16
+ '.pdf': 'application/pdf', '.zip': 'application/zip', '.gz': 'application/gzip',
17
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
18
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
19
+ '.xml': 'application/xml', '.yaml': 'application/x-yaml', '.yml': 'application/x-yaml',
20
+ };
21
+ return map[ext] || 'application/octet-stream';
22
+ }
23
+ function formatSize(bytes) {
24
+ if (bytes < 1024)
25
+ return `${bytes} B`;
26
+ if (bytes < 1048576)
27
+ return `${(bytes / 1024).toFixed(1)} KB`;
28
+ return `${(bytes / 1048576).toFixed(1)} MB`;
29
+ }
7
30
  export class AUNChannel {
8
31
  config;
9
32
  client = null;
33
+ projectPathProvider;
10
34
  messageHandler;
35
+ recallHandler;
11
36
  connected = false;
12
37
  traceStream = null;
38
+ traceDate = ''; // 当前 trace 文件对应的日期 (YYYYMMDD)
13
39
  trace(dir, event, data) {
40
+ if (!this.config.aunTrace)
41
+ return;
42
+ this.rotateTraceIfNeeded();
14
43
  if (!this.traceStream)
15
44
  return;
16
45
  const line = JSON.stringify({ ts: localTimestamp(), dir, event, data });
17
46
  this.traceStream.write(line + '\n');
18
47
  }
48
+ rotateTraceIfNeeded() {
49
+ const d = new Date();
50
+ const today = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
51
+ if (this.traceDate === today && this.traceStream)
52
+ return;
53
+ if (this.traceStream) {
54
+ this.traceStream.end();
55
+ this.traceStream = null;
56
+ }
57
+ this.traceDate = today;
58
+ const logPath = path.join(resolvePaths().logs, `aun-${today}.log`);
59
+ this.traceStream = fs.createWriteStream(logPath, { flags: 'a' });
60
+ }
19
61
  /** 判断 channelId 是否为群组 ID(g-xxx.agentid.pub 或 grp_ 前缀) */
20
62
  isGroupId(id) {
21
63
  return id.startsWith('grp_') || (id.startsWith('g-') && id.includes('.'));
@@ -43,14 +85,17 @@ export class AUNChannel {
43
85
  const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
44
86
  return new RegExp(`(^|\\s)@${escaped}(?=$|\\s|[.,!?;:,。!?;:]|[\\u4e00-\\u9fff])`).test(text);
45
87
  }
46
- stripTriggerMentions(text, selfAid) {
47
- let result = text;
48
- if (selfAid) {
49
- const escapedAid = selfAid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
50
- result = result.replace(new RegExp(`(^|\\s)@${escapedAid}(?=$|\\s|[.,!?;:,。!?;:]|[\\u4e00-\\u9fff])`, 'g'), '$1');
51
- }
52
- result = result.replace(/(^|\s)@all(?=$|\s|[.,!?;:,。!?;:]|[\u4e00-\u9fff])/gi, '$1');
53
- return result.replace(/[ \t]+/g, ' ').trim();
88
+ stripSelfMentionIfOnly(text, selfAid) {
89
+ if (!selfAid)
90
+ return text;
91
+ const mentions = text.match(/@[\w.-]+/g) || [];
92
+ if (mentions.length !== 1)
93
+ return text;
94
+ const escapedAid = selfAid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
95
+ return text
96
+ .replace(new RegExp(`(^|\\s)@${escapedAid}(?=$|\\s|[.,!?;:,。!?;:]|[\\u4e00-\\u9fff])`, 'g'), '$1')
97
+ .replace(/[ \t]+/g, ' ')
98
+ .trim();
54
99
  }
55
100
  buildGroupReplyContext(taskId, senderAid) {
56
101
  const replyContext = {};
@@ -69,7 +114,9 @@ export class AUNChannel {
69
114
  this.messageSeqMap.delete(messageId);
70
115
  }
71
116
  _aid;
117
+ _chatId = ''; // aid:device_id:slot_id — 多实例回声过滤
72
118
  seenMessages = new Map();
119
+ peerInfoCache = new Map();
73
120
  messageSeqMap = new Map(); // messageId → seq (for ack)
74
121
  sentCount = new Map(); // channelId → 已发消息计数(用于判断最终回复)
75
122
  // Reconnect state (TS-layer fallback, on top of SDK auto_reconnect)
@@ -78,12 +125,17 @@ export class AUNChannel {
78
125
  reconnectTimer = null;
79
126
  static RECONNECT_DELAYS = [60, 120, 300, 600]; // seconds
80
127
  onChannelDown;
128
+ // SDK reconnect throttling — avoid log spam when SDK enters tight reconnect loop
129
+ lastReconnectLogTime = 0;
130
+ lastReconnectLogAttempt = 0;
131
+ static RECONNECT_LOG_INTERVAL = 60_000; // log at most every 60s
132
+ static RECONNECT_LOG_STEP = 100; // or every 100 attempts
133
+ static SDK_RECONNECT_GIVEUP = 50; // force TS-layer fallback after this many SDK attempts
81
134
  constructor(config) {
82
135
  this.config = config;
83
136
  if (config.aunTrace) {
84
- const logPath = path.join(resolvePaths().logs, 'aun-trace.log');
85
- this.traceStream = fs.createWriteStream(logPath, { flags: 'a' });
86
- logger.info(`[AUN] Trace logging enabled: ${logPath}`);
137
+ this.rotateTraceIfNeeded();
138
+ logger.info(`[AUN] Trace logging enabled (daily rotation): ${resolvePaths().logs}/aun-YYYYMMDD.log`);
87
139
  }
88
140
  }
89
141
  async connect() {
@@ -104,18 +156,22 @@ export class AUNChannel {
104
156
  const aunPath = this.config.keystorePath || `${process.env.HOME || '~'}/.aun`;
105
157
  const aidName = this.config.aid;
106
158
  const encryptionSeed = this.config.encryptionSeed || process.env.AUN_ENCRYPTION_SEED || undefined;
107
- // Gateway URL: 旧配置 gatewayUrl 优先,否则从 AID 推导
159
+ // Gateway URL 解析:优先用配置的 gatewayUrl,否则通过 well-known 自动发现
108
160
  let gateway = this.config.gatewayUrl || '';
109
161
  if (!gateway) {
110
- const parts = aidName.split('.');
111
- if (parts.length >= 3) {
112
- const domain = parts.slice(1).join('.'); // alice.agentid.pub → agentid.pub
113
- const port = this.config.gatewayPort || 443;
114
- gateway = `wss://gateway.${domain}:${port}/aun`;
162
+ // AID 本身即域名(如 evolai.agentid.pub),用其查询 well-known,与 Python SDK 行为对齐
163
+ const wellKnownUrl = `https://${aidName}/.well-known/aun-gateway`;
164
+ try {
165
+ const discovery = new GatewayDiscovery({});
166
+ gateway = await discovery.discover(wellKnownUrl);
167
+ logger.info(`[AUN] Gateway discovered: ${gateway}`);
168
+ }
169
+ catch (e) {
170
+ logger.warn(`[AUN] Well-known discovery failed (${e}), no fallback available`);
115
171
  }
116
172
  }
117
173
  if (!gateway) {
118
- logger.error('[AUN] Cannot derive gateway URL from AID');
174
+ logger.error('[AUN] Cannot resolve gateway URL from AID');
119
175
  return;
120
176
  }
121
177
  logger.info(`[AUN] Initializing: aid=${aidName}, gateway=${gateway}, aun_path=${aunPath}`);
@@ -145,9 +201,23 @@ export class AUNChannel {
145
201
  this.handleIncomingGroupMessage(data);
146
202
  });
147
203
  this.client.on('connection.state', (data) => {
148
- this.trace('IN', 'connection.state', data);
204
+ // trace is handled inside handleConnectionState with throttling
149
205
  this.handleConnectionState(data);
150
206
  });
207
+ this.client.on('message.recalled', (data) => {
208
+ this.trace('IN', 'message.recalled', data);
209
+ if (data && typeof data === 'object') {
210
+ const ids = data.message_ids;
211
+ if (Array.isArray(ids)) {
212
+ for (const id of ids) {
213
+ if (typeof id === 'string') {
214
+ logger.info(`[AUN] Message recalled: ${id}`);
215
+ this.recallHandler?.(id);
216
+ }
217
+ }
218
+ }
219
+ }
220
+ });
151
221
  // Authenticate
152
222
  // Workaround: SDK 0.3.x _loadIdentityOrRaise doesn't set identity.aid from requested aid,
153
223
  // causing gateway "missing aid" error. Patch to backfill aid on loaded identity.
@@ -190,6 +260,8 @@ export class AUNChannel {
190
260
  try {
191
261
  await this.client.connect({ access_token: accessToken, gateway: this.client._gatewayUrl }, { auto_reconnect: true, retry: { max_attempts: 5, initial_delay: 1.0, max_delay: 30.0 } });
192
262
  this._aid = this.client.aid ?? undefined;
263
+ const deviceId = this.client._device_id ?? '';
264
+ this._chatId = this._aid ? `${this._aid}:${deviceId}:` : '';
193
265
  this.connected = true;
194
266
  this.reconnectAttempt = 0;
195
267
  // Workaround: SDK e2ee uses _identity.cert for sender_cert_fingerprint;
@@ -204,6 +276,8 @@ export class AUNChannel {
204
276
  }
205
277
  }
206
278
  logger.info(`[AUN] Connected as ${this._aid}`);
279
+ // Send welcome message to owner after first connection
280
+ await this.sendWelcomeMessage();
207
281
  }
208
282
  catch (e) {
209
283
  logger.error(`[AUN] Connection failed: ${e}`);
@@ -211,7 +285,149 @@ export class AUNChannel {
211
285
  return;
212
286
  }
213
287
  }
288
+ async sendWelcomeMessage() {
289
+ try {
290
+ const owner = this.config.owner;
291
+ if (!owner) {
292
+ logger.info('[AUN] No owner configured, skipping welcome message');
293
+ return;
294
+ }
295
+ // Check agent.md initialized field
296
+ const aid = this.config.aid;
297
+ const aidName = aid.startsWith('@') ? aid.slice(1) : aid;
298
+ const agentMdPath = path.join(os.homedir(), '.aun', 'AIDs', aidName, 'agent.md');
299
+ if (!fs.existsSync(agentMdPath)) {
300
+ logger.warn('[AUN] agent.md not found, skipping welcome message');
301
+ return;
302
+ }
303
+ const agentMdContent = fs.readFileSync(agentMdPath, 'utf-8');
304
+ const match = agentMdContent.match(/^---\n([\s\S]*?)\n---/);
305
+ if (!match) {
306
+ logger.warn('[AUN] agent.md frontmatter not found');
307
+ return;
308
+ }
309
+ const frontmatter = match[1];
310
+ const initializedMatch = frontmatter.match(/^initialized:\s*(true|false)/m);
311
+ if (!initializedMatch || initializedMatch[1] === 'true') {
312
+ logger.info('[AUN] Agent already initialized, skipping welcome message');
313
+ return;
314
+ }
315
+ // Generate new agent.md with proper fields
316
+ const ownerShortId = owner.split('@')[0].slice(0, 8);
317
+ const newAgentMd = `---
318
+ aid: "${aid}"
319
+ name: "${ownerShortId}的Evol助手"
320
+ type: "codeagent"
321
+ version: "1.0.0"
322
+ description: "EvolClaw AI Agent Gateway - 连接 Claude/Codex 到消息通道"
323
+ tags:
324
+ - evolclaw
325
+ - ai-agent
326
+ - gateway
327
+ initialized: true
328
+ ---
329
+
330
+ # ${ownerShortId}的Evol助手
331
+
332
+ EvolClaw AI Agent 网关,支持多项目会话管理和多 AI 后端切换。
333
+ `;
334
+ // Write locally
335
+ fs.writeFileSync(agentMdPath, newAgentMd, 'utf-8');
336
+ logger.info('[AUN] Updated agent.md with initialized=true');
337
+ // Publish to AUN network via auth.uploadAgentMd
338
+ try {
339
+ await this.client.auth.uploadAgentMd(newAgentMd);
340
+ logger.info('[AUN] Published agent.md to AUN network');
341
+ }
342
+ catch (e) {
343
+ logger.warn(`[AUN] Failed to publish agent.md: ${e}`);
344
+ }
345
+ // Send welcome message
346
+ const welcomeText = `🎉 欢迎使用 EvolClaw!
347
+
348
+ 我是您的 AI Agent 网关,已成功连接到 AUN 网络。
349
+
350
+ 📋 **日常使用方法**:
351
+
352
+ 1. **绑定项目**:发送 \`/bind <项目路径>\` 绑定工作目录
353
+ 2. **查看帮助**:发送 \`/help\` 查看所有可用命令
354
+ 3. **切换项目**:发送 \`/project <项目名>\` 切换到其他项目
355
+ 4. **查看状态**:发送 \`/status\` 查看当前会话状态
356
+ 5. **查看 Agent 信息**:发送 \`/agentmd\` 查看 agent.md 内容
357
+ 6. **会话管理**:发送 \`/session\` 查看和切换会话
358
+
359
+ 💡 **提示**:
360
+ - 直接发送消息即可与 Claude/Codex 对话
361
+ - 支持多项目会话管理,每个项目独立会话
362
+ - 所有命令以 \`/\` 开头
363
+
364
+ 现在,请先使用 \`/bind\` 命令绑定您的项目目录,然后就可以开始工作了!`;
365
+ await this.sendMessage(owner, welcomeText);
366
+ logger.info(`[AUN] Welcome message sent to owner: ${owner}`);
367
+ }
368
+ catch (e) {
369
+ logger.warn(`[AUN] Failed to send welcome message: ${e}`);
370
+ }
371
+ }
214
372
  // ── Event handlers ──────────────────────────────────────────
373
+ async downloadAttachment(att, channelId) {
374
+ const ownerAid = att.owner_aid || this._aid || '';
375
+ const objectKey = att.object_key;
376
+ const filename = att.filename || objectKey.split('/').pop() || 'unknown';
377
+ if (!objectKey) {
378
+ logger.warn('[AUN] Attachment missing object_key, skipping');
379
+ return null;
380
+ }
381
+ let downloadUrl;
382
+ try {
383
+ const ticket = await this.client.call('storage.create_download_ticket', {
384
+ owner_aid: ownerAid,
385
+ object_key: objectKey,
386
+ });
387
+ downloadUrl = ticket.download_url || '';
388
+ if (!downloadUrl) {
389
+ logger.warn(`[AUN] No download_url for attachment: ${filename}`);
390
+ return null;
391
+ }
392
+ }
393
+ catch (e) {
394
+ logger.warn(`[AUN] create_download_ticket failed for ${filename}: ${e}`);
395
+ return null;
396
+ }
397
+ let buffer;
398
+ try {
399
+ const res = await fetch(downloadUrl);
400
+ if (!res.ok) {
401
+ logger.warn(`[AUN] Download failed for ${filename}: HTTP ${res.status}`);
402
+ return null;
403
+ }
404
+ buffer = Buffer.from(await res.arrayBuffer());
405
+ }
406
+ catch (e) {
407
+ logger.warn(`[AUN] Download error for ${filename}: ${e}`);
408
+ return null;
409
+ }
410
+ if (att.sha256) {
411
+ const { createHash } = await import('node:crypto');
412
+ const actual = createHash('sha256').update(buffer).digest('hex');
413
+ if (actual !== att.sha256) {
414
+ logger.warn(`[AUN] SHA256 mismatch for ${filename}: expected ${att.sha256.slice(0, 8)}… got ${actual.slice(0, 8)}…`);
415
+ return null;
416
+ }
417
+ }
418
+ const projectPath = this.projectPathProvider
419
+ ? await this.projectPathProvider(channelId)
420
+ : process.cwd();
421
+ try {
422
+ const result = saveToUploads(buffer, filename, projectPath);
423
+ logger.info(`[AUN] Saved attachment: ${result.filePath} (${result.size} bytes)`);
424
+ return result.filePath;
425
+ }
426
+ catch (e) {
427
+ logger.warn(`[AUN] saveToUploads failed for ${filename}: ${e}`);
428
+ return null;
429
+ }
430
+ }
215
431
  async handleIncomingPrivateMessage(data) {
216
432
  if (!data || typeof data !== 'object')
217
433
  return;
@@ -219,23 +435,62 @@ export class AUNChannel {
219
435
  const fromAid = msg.from ?? '';
220
436
  const payload = msg.payload ?? '';
221
437
  const text = this.extractTextPayload(payload);
222
- const taskId = msg.task_id;
438
+ const taskId = typeof payload === 'object' && payload !== null ? payload.thread_id : undefined;
223
439
  const messageId = msg.message_id ?? '';
224
440
  const seq = msg.seq;
441
+ // 回声过滤:自己发出的消息会被 gateway fanout 回来,
442
+ // 只有 from_aid == self 且 chat_id 不匹配时才丢弃(说明是其它实例发的)
443
+ const msgChatId = typeof payload === 'object' && payload !== null && payload.chat_id;
444
+ if (this._aid && fromAid === this._aid && (!msgChatId || !this._chatId || msgChatId !== this._chatId)) {
445
+ this.acknowledgeImmediately(messageId, seq);
446
+ return;
447
+ }
225
448
  // Detect @mentions
226
449
  const mentions = [];
227
450
  if (this._aid && text.includes(`@${this._aid}`)) {
228
451
  mentions.push(this._aid);
229
452
  }
453
+ // Process attachments
454
+ const rawAttachments = Array.isArray(payload?.attachments)
455
+ ? payload.attachments
456
+ : [];
457
+ let finalText = text;
458
+ if (rawAttachments.length > 0 && this.client) {
459
+ const fileParts = [];
460
+ for (const att of rawAttachments) {
461
+ const filePath = await this.downloadAttachment(att, fromAid);
462
+ if (filePath) {
463
+ const name = sanitizeFileName(att.filename || att.object_key?.split('/').pop() || 'file');
464
+ fileParts.push(`[文件: ${name} → ${filePath}]`);
465
+ }
466
+ }
467
+ if (fileParts.length > 0) {
468
+ const parts = [];
469
+ if (text)
470
+ parts.push(text);
471
+ parts.push(...fileParts);
472
+ parts.push('请使用 Read 工具读取文件内容。');
473
+ finalText = parts.join('\n\n');
474
+ }
475
+ }
476
+ // Extract chat_id from payload for multi-instance routing (falls back to fromAid)
477
+ const chatId = (typeof payload === 'object' && payload !== null && payload.chat_id)
478
+ ? String(payload.chat_id)
479
+ : fromAid;
480
+ const peerInfo = await this.fetchPeerInfo(fromAid);
481
+ const shortAid = this.getShortAid(fromAid);
482
+ const displayName = peerInfo.name || shortAid;
230
483
  this.dispatchMessage({
231
- channelId: fromAid,
484
+ channelId: chatId,
232
485
  userId: fromAid,
233
- text,
486
+ text: finalText,
234
487
  chatType: 'private',
235
488
  messageId,
236
489
  seq,
237
490
  taskId,
238
491
  mentions,
492
+ peerName: displayName || undefined,
493
+ peerType: peerInfo.type || 'unknown',
239
494
  });
240
495
  }
241
496
  async handleIncomingGroupMessage(data) {
@@ -243,10 +498,10 @@ export class AUNChannel {
243
498
  return;
244
499
  const msg = data;
245
500
  const groupId = msg.group_id ?? '';
246
- const senderAid = msg.sender_aid ?? msg.from ?? '';
501
+ const senderAid = msg.sender_aid ?? '';
247
502
  const payload = msg.payload ?? '';
248
503
  const text = this.extractTextPayload(payload);
249
- const taskId = msg.task_id;
504
+ const taskId = typeof payload === 'object' && payload !== null ? payload.thread_id : undefined;
250
505
  const messageId = msg.message_id ?? '';
251
506
  const seq = msg.seq;
252
507
  // Extract structured mentions from payload (e.g. payload.mentions: ["evolai.agentid.pub"])
@@ -270,17 +525,47 @@ export class AUNChannel {
270
525
  this.acknowledgeImmediately(messageId, seq);
271
526
  return;
272
527
  }
273
- const strippedText = this.stripTriggerMentions(text, this._aid);
274
- if (!strippedText) {
528
+ const strippedText = this.stripSelfMentionIfOnly(text, this._aid);
529
+ // Detect attachments before the empty-text guard
530
+ const rawAttachments = Array.isArray(payload?.attachments)
531
+ ? payload.attachments
532
+ : [];
533
+ const hasAttachments = rawAttachments.length > 0;
534
+ // Allow through if there's text OR attachments; both-empty messages are silently dropped
535
+ if (!strippedText && !hasAttachments) {
275
536
  this.acknowledgeImmediately(messageId, seq);
276
537
  return;
277
538
  }
278
539
  const mentions = mentionedAll ? ['all'] : (this._aid ? [this._aid] : []);
540
+ // Process attachments
541
+ let finalText = strippedText;
542
+ if (hasAttachments && this.client) {
543
+ const fileParts = [];
544
+ for (const att of rawAttachments) {
545
+ const filePath = await this.downloadAttachment(att, groupId);
546
+ if (filePath) {
547
+ const name = sanitizeFileName(att.filename || att.object_key?.split('/').pop() || 'file');
548
+ fileParts.push(`[文件: ${name} → ${filePath}]`);
549
+ }
550
+ }
551
+ if (fileParts.length > 0) {
552
+ const parts = [];
553
+ if (strippedText)
554
+ parts.push(strippedText);
555
+ parts.push(...fileParts);
556
+ parts.push('请使用 Read 工具读取文件内容。');
557
+ finalText = parts.join('\n\n');
558
+ }
559
+ }
560
+ const peerInfo = await this.fetchPeerInfo(senderAid);
561
+ const shortAid = this.getShortAid(senderAid);
562
+ const displayName = peerInfo.name || shortAid;
279
563
  this.dispatchMessage({
280
564
  channelId: groupId,
281
565
  userId: senderAid,
282
- peerName: this.getShortAid(senderAid),
283
- text: strippedText,
566
+ peerName: displayName || undefined,
567
+ peerType: peerInfo.type || 'unknown',
568
+ text: finalText,
284
569
  chatType: 'group',
285
570
  messageId,
286
571
  seq,
@@ -316,6 +601,7 @@ export class AUNChannel {
316
601
  chatType: event.chatType,
317
602
  peerId: event.userId || event.channelId || '',
318
603
  peerName: event.peerName,
604
+ peerType: event.peerType,
319
605
  messageId: event.messageId,
320
606
  threadId: event.taskId,
321
607
  mentions: mentionObjects,
@@ -331,6 +617,8 @@ export class AUNChannel {
331
617
  if (state === 'connected') {
332
618
  this.connected = true;
333
619
  this.reconnectAttempt = 0;
620
+ this.lastReconnectLogTime = 0;
621
+ this.lastReconnectLogAttempt = 0;
334
622
  logger.info('[AUN] Connected');
335
623
  }
336
624
  else if (state === 'disconnected') {
@@ -338,21 +626,50 @@ export class AUNChannel {
338
626
  logger.warn(`[AUN] Disconnected: ${data.error ?? 'unknown'}`);
339
627
  }
340
628
  else if (state === 'reconnecting') {
341
- logger.info(`[AUN] SDK reconnecting (attempt ${data.attempt})`);
629
+ const attempt = data.attempt ?? 0;
630
+ const now = Date.now();
631
+ // Throttled logging: first attempt, every N attempts, or every M seconds
632
+ const isFirst = attempt <= 1;
633
+ const isStep = attempt - this.lastReconnectLogAttempt >= AUNChannel.RECONNECT_LOG_STEP;
634
+ const isInterval = now - this.lastReconnectLogTime >= AUNChannel.RECONNECT_LOG_INTERVAL;
635
+ if (isFirst || isStep || isInterval) {
636
+ const suppressed = attempt - this.lastReconnectLogAttempt - 1;
637
+ const suffix = suppressed > 0 ? `, ${suppressed} suppressed since last log` : '';
638
+ logger.info(`[AUN] SDK reconnecting (attempt ${attempt}${suffix})`);
639
+ this.lastReconnectLogTime = now;
640
+ this.lastReconnectLogAttempt = attempt;
641
+ this.trace('IN', 'connection.state', data);
642
+ }
643
+ // Detect runaway SDK reconnect loop: force disconnect and use TS-layer backoff
644
+ if (attempt >= AUNChannel.SDK_RECONNECT_GIVEUP && !this.intentionalDisconnect) {
645
+ logger.warn(`[AUN] SDK reconnect stuck at attempt ${attempt}, forcing TS-layer reconnect with backoff`);
646
+ this.connected = false;
647
+ if (this.client) {
648
+ this.client.close().catch(() => { });
649
+ this.client = null;
650
+ }
651
+ this.scheduleReconnect();
652
+ }
342
653
  }
343
654
  else if (state === 'terminal_failed') {
344
655
  this.connected = false;
345
- logger.error(`[AUN] Terminal failure: ${data.error ?? 'unknown'}`);
346
- // SDK auto_reconnect exhausted; fall back to TS-layer reconnect
656
+ const reason = data.reason ?? '';
657
+ logger.error(`[AUN] Terminal failure: ${data.error ?? 'unknown'}${reason ? ` (${reason})` : ''}`);
347
658
  if (!this.intentionalDisconnect) {
348
659
  this.scheduleReconnect();
349
660
  }
350
661
  }
351
662
  }
352
663
  // ── Public API (same interface as before) ───────────────────
664
+ onProjectPathRequest(provider) {
665
+ this.projectPathProvider = provider;
666
+ }
353
667
  onMessage(handler) {
354
668
  this.messageHandler = handler;
355
669
  }
670
+ onRecall(handler) {
671
+ this.recallHandler = handler;
672
+ }
356
673
  async sendMessage(channelId, text, context) {
357
674
  if (!this.connected || !this.client) {
358
675
  logger.warn('[AUN] Cannot send: not connected');
@@ -374,9 +691,16 @@ export class AUNChannel {
374
691
  finalText = `@${context.peerId} ` + finalText;
375
692
  }
376
693
  }
377
- const params = { payload: { text: finalText }, encrypt: true };
694
+ const payload = { type: 'text', text: finalText };
378
695
  if (context?.threadId)
379
- params.task_id = context.threadId;
696
+ payload.thread_id = context.threadId;
697
+ const params = { payload, encrypt: true };
698
+ // Multi-instance routing: channelId may be "aid:device_id:slot_id"
699
+ const colonIdx = channelId.indexOf(':');
700
+ const targetAid = colonIdx > 0 ? channelId.substring(0, colonIdx) : channelId;
701
+ if (colonIdx > 0) {
702
+ params.payload.chat_id = channelId;
703
+ }
380
704
  try {
381
705
  if (this.isGroupId(channelId)) {
382
706
  params.group_id = channelId;
@@ -384,7 +708,7 @@ export class AUNChannel {
384
708
  await this.client.call('group.send', params);
385
709
  }
386
710
  else {
387
- params.to = channelId;
711
+ params.to = targetAid;
388
712
  this.trace('OUT', 'message.send', params);
389
713
  await this.client.call('message.send', params);
390
714
  }
@@ -394,6 +718,103 @@ export class AUNChannel {
394
718
  logger.error(`[AUN] Send failed to ${channelId}: ${e}`);
395
719
  }
396
720
  }
721
+ async sendFile(channelId, filePath, context) {
722
+ if (!this.connected || !this.client) {
723
+ logger.warn('[AUN] Cannot sendFile: not connected');
724
+ return;
725
+ }
726
+ const absPath = path.resolve(filePath);
727
+ if (!fs.existsSync(absPath)) {
728
+ logger.warn(`[AUN] sendFile: file not found: ${absPath}`);
729
+ return;
730
+ }
731
+ const stat = fs.statSync(absPath);
732
+ if (stat.size === 0) {
733
+ logger.warn('[AUN] sendFile: file is empty');
734
+ return;
735
+ }
736
+ if (stat.size > 10 * 1024 * 1024) {
737
+ logger.warn(`[AUN] sendFile: file too large (${formatSize(stat.size)}, max 10 MB)`);
738
+ return;
739
+ }
740
+ const filename = path.basename(absPath);
741
+ const fileData = fs.readFileSync(absPath);
742
+ const sha256 = crypto.createHash('sha256').update(fileData).digest('hex');
743
+ const contentType = guessMime(filename);
744
+ const objectKey = `shared/${crypto.randomUUID()}/${filename}`;
745
+ try {
746
+ // Upload to storage
747
+ if (stat.size <= 64 * 1024) {
748
+ // Inline upload for small files (≤64KB)
749
+ await this.client.call('storage.put_object', {
750
+ object_key: objectKey,
751
+ content: fileData.toString('base64'),
752
+ content_type: contentType,
753
+ is_private: false,
754
+ overwrite: true,
755
+ });
756
+ }
757
+ else {
758
+ // Ticket upload for large files
759
+ const session = await this.client.call('storage.create_upload_session', {
760
+ object_key: objectKey,
761
+ size_bytes: stat.size,
762
+ content_type: contentType,
763
+ });
764
+ const uploadUrl = session.upload_url;
765
+ if (!uploadUrl)
766
+ throw new Error('No upload_url in session response');
767
+ const uploadResp = await fetch(uploadUrl, { method: 'PUT', body: fileData });
768
+ if (!uploadResp.ok)
769
+ throw new Error(`HTTP upload failed: ${uploadResp.status}`);
770
+ await this.client.call('storage.complete_upload', {
771
+ object_key: objectKey,
772
+ sha256,
773
+ content_type: contentType,
774
+ is_private: false,
775
+ size_bytes: stat.size,
776
+ });
777
+ }
778
+ // Send message with attachment
779
+ const attachment = {
780
+ owner_aid: this._aid || '',
781
+ object_key: objectKey,
782
+ filename,
783
+ size_bytes: stat.size,
784
+ sha256,
785
+ content_type: contentType,
786
+ };
787
+ const filePayload = {
788
+ type: 'file',
789
+ text: `📎 ${filename} (${formatSize(stat.size)})`,
790
+ attachments: [attachment],
791
+ };
792
+ if (context?.threadId)
793
+ filePayload.thread_id = context.threadId;
794
+ const params = { payload: filePayload, encrypt: true };
795
+ // Multi-instance routing
796
+ const fileColonIdx = channelId.indexOf(':');
797
+ const fileTargetAid = fileColonIdx > 0 ? channelId.substring(0, fileColonIdx) : channelId;
798
+ if (fileColonIdx > 0) {
799
+ params.payload.chat_id = channelId;
800
+ }
801
+ if (this.isGroupId(channelId)) {
802
+ params.group_id = channelId;
803
+ this.trace('OUT', 'group.send.file', params);
804
+ await this.client.call('group.send', params);
805
+ }
806
+ else {
807
+ params.to = fileTargetAid;
808
+ this.trace('OUT', 'message.send.file', params);
809
+ await this.client.call('message.send', params);
810
+ }
811
+ logger.info(`[AUN] File sent: ${filename} (${formatSize(stat.size)}) → ${channelId}`);
812
+ }
813
+ catch (e) {
814
+ this.trace('OUT', 'sendFile.error', { channelId, filePath, error: String(e) });
815
+ logger.error(`[AUN] sendFile failed for ${channelId}: ${e}`);
816
+ }
817
+ }
397
818
  acknowledge(messageId) {
398
819
  // Gateway auto-delivery-ack is sufficient; skip explicit message.ack RPC
399
820
  // to avoid duplicate "已送达" at the sender CLI
@@ -404,18 +825,28 @@ export class AUNChannel {
404
825
  this.sentCount.delete(channelId); // 新任务开始,重置计数
405
826
  if (!this.client || !this.connected)
406
827
  return;
407
- const payload = {
408
- type: 'processing',
409
- status,
410
- sessionId,
411
- timestamp: Math.floor(Date.now() / 1000),
828
+ const eventMap = {
829
+ start: 'task.started',
830
+ done: 'task.completed',
831
+ interrupted: 'task.interrupted',
832
+ error: 'task.error',
833
+ timeout: 'task.timeout',
412
834
  };
413
- const params = {
414
- payload,
415
- encrypt: true,
835
+ const payload = {
836
+ type: 'event',
837
+ event: eventMap[status] ?? `task.${status}`,
838
+ data: { session_id: sessionId },
839
+ severity: status === 'error' || status === 'timeout' ? 'error' : 'info',
416
840
  };
417
841
  if (context?.threadId)
418
- params.task_id = context.threadId;
842
+ payload.thread_id = context.threadId;
843
+ const params = { payload, encrypt: true };
844
+ // Multi-instance routing
845
+ const statusColonIdx = channelId.indexOf(':');
846
+ const statusTargetAid = statusColonIdx > 0 ? channelId.substring(0, statusColonIdx) : channelId;
847
+ if (statusColonIdx > 0) {
848
+ payload.chat_id = channelId;
849
+ }
419
850
  if (this.isGroupId(channelId)) {
420
851
  params.group_id = channelId;
421
852
  this.trace('OUT', 'group.send.status', params);
@@ -424,7 +855,7 @@ export class AUNChannel {
424
855
  });
425
856
  }
426
857
  else {
427
- params.to = channelId;
858
+ params.to = statusTargetAid;
428
859
  this.trace('OUT', 'message.send.status', params);
429
860
  this.client.call('message.send', params).catch(e => {
430
861
  logger.debug(`[AUN] Processing status failed: ${e}`);
@@ -444,8 +875,14 @@ export class AUNChannel {
444
875
  catch {
445
876
  payloadObj = { text: payload };
446
877
  }
878
+ // Multi-instance routing
879
+ const customColonIdx = channelId.indexOf(':');
880
+ const customTargetAid = customColonIdx > 0 ? channelId.substring(0, customColonIdx) : channelId;
881
+ if (customColonIdx > 0) {
882
+ payloadObj.chat_id = channelId;
883
+ }
447
884
  const sendParams = {
448
- to: channelId, payload: payloadObj,
885
+ to: customTargetAid, payload: payloadObj,
449
886
  encrypt: true,
450
887
  };
451
888
  this.trace('OUT', 'message.send.custom', sendParams);
@@ -532,6 +969,37 @@ export class AUNChannel {
532
969
  maxAttempts: AUNChannel.RECONNECT_DELAYS.length,
533
970
  };
534
971
  }
972
+ async fetchPeerInfo(aid) {
973
+ const cached = this.peerInfoCache.get(aid);
974
+ if (cached !== undefined)
975
+ return cached;
976
+ if (!this.client)
977
+ return { type: null };
978
+ try {
979
+ const md = await this.client.auth.downloadAgentMd(aid);
980
+ const typeMatch = md.match(/^type:\s*["']?(\w+)["']?/m);
981
+ const nameMatch = md.match(/^name:\s*["']?(.+?)["']?\s*$/m);
982
+ const type = typeMatch?.[1] === 'human' ? 'human' : 'ai';
983
+ const name = nameMatch?.[1]?.trim() || undefined;
984
+ const info = { type, name };
985
+ this.peerInfoCache.set(aid, info);
986
+ setTimeout(() => this.peerInfoCache.delete(aid), 30 * 60 * 1000);
987
+ return info;
988
+ }
989
+ catch {
990
+ return { type: null }; // no agent.md → unknown
991
+ }
992
+ }
993
+ async uploadAgentMd(content) {
994
+ if (!this.client)
995
+ throw new Error('not connected');
996
+ await this.client.auth.uploadAgentMd(content);
997
+ }
998
+ async downloadAgentMd(aid) {
999
+ if (!this.client)
1000
+ throw new Error('not connected');
1001
+ return this.client.auth.downloadAgentMd(aid);
1002
+ }
535
1003
  }
536
1004
  // Plugin implementation
537
1005
  export class AUNChannelPlugin {
@@ -554,7 +1022,6 @@ export class AUNChannelPlugin {
554
1022
  const channel = new AUNChannel({
555
1023
  aid: inst.aid,
556
1024
  keystorePath: inst.keystorePath,
557
- gatewayPort: inst.gatewayPort,
558
1025
  gatewayUrl: inst.gatewayUrl,
559
1026
  accessToken: inst.accessToken,
560
1027
  flushDelay: inst.flushDelay,
@@ -564,19 +1031,23 @@ export class AUNChannelPlugin {
564
1031
  const adapter = {
565
1032
  channelName: inst.name,
566
1033
  sendText: (id, text, context) => channel.sendMessage(id, text, context),
1034
+ sendFile: (id, filePath, context) => channel.sendFile(id, filePath, context),
567
1035
  acknowledge: (messageId) => { channel.acknowledge(messageId); return Promise.resolve(); },
568
1036
  sendProcessingStatus: (id, status, sessionId, context) => channel.sendProcessingStatus(id, status, sessionId, context),
569
1037
  sendCustomPayload: (id, payload) => channel.sendCustomPayload(id, payload),
1038
+ uploadAgentMd: (content) => channel.uploadAgentMd(content),
1039
+ downloadAgentMd: (aid) => channel.downloadAgentMd(aid),
1040
+ _selfAid: () => channel.getStatus().aid,
570
1041
  };
571
1042
  const policy = {
572
- canSwitchProject: (chatType, identity) => identity === 'owner',
573
- canListProjects: (chatType, identity) => identity === 'owner',
1043
+ canSwitchProject: (chatType, identity) => identity === 'owner' || identity === 'admin',
1044
+ canListProjects: (chatType, identity) => identity === 'owner' || identity === 'admin',
574
1045
  canCreateSession: (chatType, identity) => true,
575
1046
  canDeleteSession: (chatType, identity) => true,
576
- canImportCliSession: (chatType, identity) => identity === 'owner',
1047
+ canImportCliSession: (chatType, identity) => identity === 'owner' || identity === 'admin',
577
1048
  messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
578
1049
  showMiddleResult: (chatType, identity) => {
579
- const mode = inst.showActivities ?? config.showActivities ?? 'all';
1050
+ const mode = getChannelShowActivities(config, inst.name);
580
1051
  if (mode === 'none')
581
1052
  return false;
582
1053
  if (mode === 'dm-only')
@@ -586,7 +1057,7 @@ export class AUNChannelPlugin {
586
1057
  return true;
587
1058
  },
588
1059
  showIdleMonitor: (chatType, identity) => {
589
- const mode = inst.showActivities ?? config.showActivities ?? 'all';
1060
+ const mode = getChannelShowActivities(config, inst.name);
590
1061
  if (mode === 'none')
591
1062
  return false;
592
1063
  if (mode === 'dm-only')
@@ -609,6 +1080,7 @@ export class AUNChannelPlugin {
609
1080
  options,
610
1081
  connect: () => channel.connect(),
611
1082
  disconnect: () => channel.disconnect(),
1083
+ onProjectPathRequest: (channelId) => Promise.resolve(config.projects?.defaultPath || process.cwd()),
612
1084
  });
613
1085
  }
614
1086
  return result;