evolclaw 2.4.0 → 2.5.0

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,62 @@
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';
4
5
  import { logger, localTimestamp } from '../utils/logger.js';
5
- import { normalizeChannelInstances } from '../config.js';
6
+ import { normalizeChannelInstances, getChannelShowActivities } from '../config.js';
6
7
  import { resolvePaths } from '../paths.js';
8
+ import { saveToUploads, sanitizeFileName } from '../utils/media-cache.js';
9
+ function guessMime(filename) {
10
+ const ext = path.extname(filename).toLowerCase();
11
+ const map = {
12
+ '.txt': 'text/plain', '.md': 'text/markdown', '.json': 'application/json',
13
+ '.js': 'text/javascript', '.ts': 'text/typescript', '.py': 'text/x-python',
14
+ '.html': 'text/html', '.css': 'text/css', '.csv': 'text/csv',
15
+ '.pdf': 'application/pdf', '.zip': 'application/zip', '.gz': 'application/gzip',
16
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
17
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
18
+ '.xml': 'application/xml', '.yaml': 'application/x-yaml', '.yml': 'application/x-yaml',
19
+ };
20
+ return map[ext] || 'application/octet-stream';
21
+ }
22
+ function formatSize(bytes) {
23
+ if (bytes < 1024)
24
+ return `${bytes} B`;
25
+ if (bytes < 1048576)
26
+ return `${(bytes / 1024).toFixed(1)} KB`;
27
+ return `${(bytes / 1048576).toFixed(1)} MB`;
28
+ }
7
29
  export class AUNChannel {
8
30
  config;
9
31
  client = null;
32
+ projectPathProvider;
10
33
  messageHandler;
34
+ recallHandler;
11
35
  connected = false;
12
36
  traceStream = null;
37
+ traceDate = ''; // 当前 trace 文件对应的日期 (YYYYMMDD)
13
38
  trace(dir, event, data) {
39
+ if (!this.config.aunTrace)
40
+ return;
41
+ this.rotateTraceIfNeeded();
14
42
  if (!this.traceStream)
15
43
  return;
16
44
  const line = JSON.stringify({ ts: localTimestamp(), dir, event, data });
17
45
  this.traceStream.write(line + '\n');
18
46
  }
47
+ rotateTraceIfNeeded() {
48
+ const d = new Date();
49
+ const today = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
50
+ if (this.traceDate === today && this.traceStream)
51
+ return;
52
+ if (this.traceStream) {
53
+ this.traceStream.end();
54
+ this.traceStream = null;
55
+ }
56
+ this.traceDate = today;
57
+ const logPath = path.join(resolvePaths().logs, `aun-${today}.log`);
58
+ this.traceStream = fs.createWriteStream(logPath, { flags: 'a' });
59
+ }
19
60
  /** 判断 channelId 是否为群组 ID(g-xxx.agentid.pub 或 grp_ 前缀) */
20
61
  isGroupId(id) {
21
62
  return id.startsWith('grp_') || (id.startsWith('g-') && id.includes('.'));
@@ -43,14 +84,17 @@ export class AUNChannel {
43
84
  const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
44
85
  return new RegExp(`(^|\\s)@${escaped}(?=$|\\s|[.,!?;:,。!?;:]|[\\u4e00-\\u9fff])`).test(text);
45
86
  }
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();
87
+ stripSelfMentionIfOnly(text, selfAid) {
88
+ if (!selfAid)
89
+ return text;
90
+ const mentions = text.match(/@[\w.-]+/g) || [];
91
+ if (mentions.length !== 1)
92
+ return text;
93
+ const escapedAid = selfAid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
94
+ return text
95
+ .replace(new RegExp(`(^|\\s)@${escapedAid}(?=$|\\s|[.,!?;:,。!?;:]|[\\u4e00-\\u9fff])`, 'g'), '$1')
96
+ .replace(/[ \t]+/g, ' ')
97
+ .trim();
54
98
  }
55
99
  buildGroupReplyContext(taskId, senderAid) {
56
100
  const replyContext = {};
@@ -69,7 +113,9 @@ export class AUNChannel {
69
113
  this.messageSeqMap.delete(messageId);
70
114
  }
71
115
  _aid;
116
+ _chatId = ''; // aid:device_id:slot_id — 多实例回声过滤
72
117
  seenMessages = new Map();
118
+ peerInfoCache = new Map();
73
119
  messageSeqMap = new Map(); // messageId → seq (for ack)
74
120
  sentCount = new Map(); // channelId → 已发消息计数(用于判断最终回复)
75
121
  // Reconnect state (TS-layer fallback, on top of SDK auto_reconnect)
@@ -78,12 +124,17 @@ export class AUNChannel {
78
124
  reconnectTimer = null;
79
125
  static RECONNECT_DELAYS = [60, 120, 300, 600]; // seconds
80
126
  onChannelDown;
127
+ // SDK reconnect throttling — avoid log spam when SDK enters tight reconnect loop
128
+ lastReconnectLogTime = 0;
129
+ lastReconnectLogAttempt = 0;
130
+ static RECONNECT_LOG_INTERVAL = 60_000; // log at most every 60s
131
+ static RECONNECT_LOG_STEP = 100; // or every 100 attempts
132
+ static SDK_RECONNECT_GIVEUP = 50; // force TS-layer fallback after this many SDK attempts
81
133
  constructor(config) {
82
134
  this.config = config;
83
135
  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}`);
136
+ this.rotateTraceIfNeeded();
137
+ logger.info(`[AUN] Trace logging enabled (daily rotation): ${resolvePaths().logs}/aun-YYYYMMDD.log`);
87
138
  }
88
139
  }
89
140
  async connect() {
@@ -104,18 +155,22 @@ export class AUNChannel {
104
155
  const aunPath = this.config.keystorePath || `${process.env.HOME || '~'}/.aun`;
105
156
  const aidName = this.config.aid;
106
157
  const encryptionSeed = this.config.encryptionSeed || process.env.AUN_ENCRYPTION_SEED || undefined;
107
- // Gateway URL: 旧配置 gatewayUrl 优先,否则从 AID 推导
158
+ // Gateway URL 解析:优先用配置的 gatewayUrl,否则通过 well-known 自动发现
108
159
  let gateway = this.config.gatewayUrl || '';
109
160
  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`;
161
+ // AID 本身即域名(如 evolai.agentid.pub),用其查询 well-known,与 Python SDK 行为对齐
162
+ const wellKnownUrl = `https://${aidName}/.well-known/aun-gateway`;
163
+ try {
164
+ const discovery = new GatewayDiscovery({});
165
+ gateway = await discovery.discover(wellKnownUrl);
166
+ logger.info(`[AUN] Gateway discovered: ${gateway}`);
167
+ }
168
+ catch (e) {
169
+ logger.warn(`[AUN] Well-known discovery failed (${e}), no fallback available`);
115
170
  }
116
171
  }
117
172
  if (!gateway) {
118
- logger.error('[AUN] Cannot derive gateway URL from AID');
173
+ logger.error('[AUN] Cannot resolve gateway URL from AID');
119
174
  return;
120
175
  }
121
176
  logger.info(`[AUN] Initializing: aid=${aidName}, gateway=${gateway}, aun_path=${aunPath}`);
@@ -145,9 +200,23 @@ export class AUNChannel {
145
200
  this.handleIncomingGroupMessage(data);
146
201
  });
147
202
  this.client.on('connection.state', (data) => {
148
- this.trace('IN', 'connection.state', data);
203
+ // trace is handled inside handleConnectionState with throttling
149
204
  this.handleConnectionState(data);
150
205
  });
206
+ this.client.on('message.recalled', (data) => {
207
+ this.trace('IN', 'message.recalled', data);
208
+ if (data && typeof data === 'object') {
209
+ const ids = data.message_ids;
210
+ if (Array.isArray(ids)) {
211
+ for (const id of ids) {
212
+ if (typeof id === 'string') {
213
+ logger.info(`[AUN] Message recalled: ${id}`);
214
+ this.recallHandler?.(id);
215
+ }
216
+ }
217
+ }
218
+ }
219
+ });
151
220
  // Authenticate
152
221
  // Workaround: SDK 0.3.x _loadIdentityOrRaise doesn't set identity.aid from requested aid,
153
222
  // causing gateway "missing aid" error. Patch to backfill aid on loaded identity.
@@ -190,6 +259,8 @@ export class AUNChannel {
190
259
  try {
191
260
  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
261
  this._aid = this.client.aid ?? undefined;
262
+ const deviceId = this.client._device_id ?? '';
263
+ this._chatId = this._aid ? `${this._aid}:${deviceId}:` : '';
193
264
  this.connected = true;
194
265
  this.reconnectAttempt = 0;
195
266
  // Workaround: SDK e2ee uses _identity.cert for sender_cert_fingerprint;
@@ -212,6 +283,64 @@ export class AUNChannel {
212
283
  }
213
284
  }
214
285
  // ── Event handlers ──────────────────────────────────────────
286
+ async downloadAttachment(att, channelId) {
287
+ const ownerAid = att.owner_aid || this._aid || '';
288
+ const objectKey = att.object_key;
289
+ const filename = att.filename || objectKey.split('/').pop() || 'unknown';
290
+ if (!objectKey) {
291
+ logger.warn('[AUN] Attachment missing object_key, skipping');
292
+ return null;
293
+ }
294
+ let downloadUrl;
295
+ try {
296
+ const ticket = await this.client.call('storage.create_download_ticket', {
297
+ owner_aid: ownerAid,
298
+ object_key: objectKey,
299
+ });
300
+ downloadUrl = ticket.download_url || '';
301
+ if (!downloadUrl) {
302
+ logger.warn(`[AUN] No download_url for attachment: ${filename}`);
303
+ return null;
304
+ }
305
+ }
306
+ catch (e) {
307
+ logger.warn(`[AUN] create_download_ticket failed for ${filename}: ${e}`);
308
+ return null;
309
+ }
310
+ let buffer;
311
+ try {
312
+ const res = await fetch(downloadUrl);
313
+ if (!res.ok) {
314
+ logger.warn(`[AUN] Download failed for ${filename}: HTTP ${res.status}`);
315
+ return null;
316
+ }
317
+ buffer = Buffer.from(await res.arrayBuffer());
318
+ }
319
+ catch (e) {
320
+ logger.warn(`[AUN] Download error for ${filename}: ${e}`);
321
+ return null;
322
+ }
323
+ if (att.sha256) {
324
+ const { createHash } = await import('node:crypto');
325
+ const actual = createHash('sha256').update(buffer).digest('hex');
326
+ if (actual !== att.sha256) {
327
+ logger.warn(`[AUN] SHA256 mismatch for ${filename}: expected ${att.sha256.slice(0, 8)}… got ${actual.slice(0, 8)}…`);
328
+ return null;
329
+ }
330
+ }
331
+ const projectPath = this.projectPathProvider
332
+ ? await this.projectPathProvider(channelId)
333
+ : process.cwd();
334
+ try {
335
+ const result = saveToUploads(buffer, filename, projectPath);
336
+ logger.info(`[AUN] Saved attachment: ${result.filePath} (${result.size} bytes)`);
337
+ return result.filePath;
338
+ }
339
+ catch (e) {
340
+ logger.warn(`[AUN] saveToUploads failed for ${filename}: ${e}`);
341
+ return null;
342
+ }
343
+ }
215
344
  async handleIncomingPrivateMessage(data) {
216
345
  if (!data || typeof data !== 'object')
217
346
  return;
@@ -219,23 +348,62 @@ export class AUNChannel {
219
348
  const fromAid = msg.from ?? '';
220
349
  const payload = msg.payload ?? '';
221
350
  const text = this.extractTextPayload(payload);
222
- const taskId = msg.task_id;
351
+ const taskId = typeof payload === 'object' && payload !== null ? payload.thread_id : undefined;
223
352
  const messageId = msg.message_id ?? '';
224
353
  const seq = msg.seq;
354
+ // 回声过滤:自己发出的消息会被 gateway fanout 回来,
355
+ // 只有 from_aid == self 且 chat_id 不匹配时才丢弃(说明是其它实例发的)
356
+ const msgChatId = typeof payload === 'object' && payload !== null && payload.chat_id;
357
+ if (this._aid && fromAid === this._aid && (!msgChatId || !this._chatId || msgChatId !== this._chatId)) {
358
+ this.acknowledgeImmediately(messageId, seq);
359
+ return;
360
+ }
225
361
  // Detect @mentions
226
362
  const mentions = [];
227
363
  if (this._aid && text.includes(`@${this._aid}`)) {
228
364
  mentions.push(this._aid);
229
365
  }
366
+ // Process attachments
367
+ const rawAttachments = Array.isArray(payload?.attachments)
368
+ ? payload.attachments
369
+ : [];
370
+ let finalText = text;
371
+ if (rawAttachments.length > 0 && this.client) {
372
+ const fileParts = [];
373
+ for (const att of rawAttachments) {
374
+ const filePath = await this.downloadAttachment(att, fromAid);
375
+ if (filePath) {
376
+ const name = sanitizeFileName(att.filename || att.object_key?.split('/').pop() || 'file');
377
+ fileParts.push(`[文件: ${name} → ${filePath}]`);
378
+ }
379
+ }
380
+ if (fileParts.length > 0) {
381
+ const parts = [];
382
+ if (text)
383
+ parts.push(text);
384
+ parts.push(...fileParts);
385
+ parts.push('请使用 Read 工具读取文件内容。');
386
+ finalText = parts.join('\n\n');
387
+ }
388
+ }
389
+ // Extract chat_id from payload for multi-instance routing (falls back to fromAid)
390
+ const chatId = (typeof payload === 'object' && payload !== null && payload.chat_id)
391
+ ? String(payload.chat_id)
392
+ : fromAid;
393
+ const peerInfo = await this.fetchPeerInfo(fromAid);
394
+ const shortAid = this.getShortAid(fromAid);
395
+ const displayName = peerInfo.name || shortAid;
230
396
  this.dispatchMessage({
231
- channelId: fromAid,
397
+ channelId: chatId,
232
398
  userId: fromAid,
233
- text,
399
+ text: finalText,
234
400
  chatType: 'private',
235
401
  messageId,
236
402
  seq,
237
403
  taskId,
238
404
  mentions,
405
+ peerName: displayName || undefined,
406
+ peerType: peerInfo.type || 'unknown',
239
407
  });
240
408
  }
241
409
  async handleIncomingGroupMessage(data) {
@@ -243,10 +411,10 @@ export class AUNChannel {
243
411
  return;
244
412
  const msg = data;
245
413
  const groupId = msg.group_id ?? '';
246
- const senderAid = msg.sender_aid ?? msg.from ?? '';
414
+ const senderAid = msg.sender_aid ?? '';
247
415
  const payload = msg.payload ?? '';
248
416
  const text = this.extractTextPayload(payload);
249
- const taskId = msg.task_id;
417
+ const taskId = typeof payload === 'object' && payload !== null ? payload.thread_id : undefined;
250
418
  const messageId = msg.message_id ?? '';
251
419
  const seq = msg.seq;
252
420
  // Extract structured mentions from payload (e.g. payload.mentions: ["evolai.agentid.pub"])
@@ -270,17 +438,47 @@ export class AUNChannel {
270
438
  this.acknowledgeImmediately(messageId, seq);
271
439
  return;
272
440
  }
273
- const strippedText = this.stripTriggerMentions(text, this._aid);
274
- if (!strippedText) {
441
+ const strippedText = this.stripSelfMentionIfOnly(text, this._aid);
442
+ // Detect attachments before the empty-text guard
443
+ const rawAttachments = Array.isArray(payload?.attachments)
444
+ ? payload.attachments
445
+ : [];
446
+ const hasAttachments = rawAttachments.length > 0;
447
+ // Allow through if there's text OR attachments; both-empty messages are silently dropped
448
+ if (!strippedText && !hasAttachments) {
275
449
  this.acknowledgeImmediately(messageId, seq);
276
450
  return;
277
451
  }
278
452
  const mentions = mentionedAll ? ['all'] : (this._aid ? [this._aid] : []);
453
+ // Process attachments
454
+ let finalText = strippedText;
455
+ if (hasAttachments && this.client) {
456
+ const fileParts = [];
457
+ for (const att of rawAttachments) {
458
+ const filePath = await this.downloadAttachment(att, groupId);
459
+ if (filePath) {
460
+ const name = sanitizeFileName(att.filename || att.object_key?.split('/').pop() || 'file');
461
+ fileParts.push(`[文件: ${name} → ${filePath}]`);
462
+ }
463
+ }
464
+ if (fileParts.length > 0) {
465
+ const parts = [];
466
+ if (strippedText)
467
+ parts.push(strippedText);
468
+ parts.push(...fileParts);
469
+ parts.push('请使用 Read 工具读取文件内容。');
470
+ finalText = parts.join('\n\n');
471
+ }
472
+ }
473
+ const peerInfo = await this.fetchPeerInfo(senderAid);
474
+ const shortAid = this.getShortAid(senderAid);
475
+ const displayName = peerInfo.name || shortAid;
279
476
  this.dispatchMessage({
280
477
  channelId: groupId,
281
478
  userId: senderAid,
282
- peerName: this.getShortAid(senderAid),
283
- text: strippedText,
479
+ peerName: displayName || undefined,
480
+ peerType: peerInfo.type || 'unknown',
481
+ text: finalText,
284
482
  chatType: 'group',
285
483
  messageId,
286
484
  seq,
@@ -316,6 +514,7 @@ export class AUNChannel {
316
514
  chatType: event.chatType,
317
515
  peerId: event.userId || event.channelId || '',
318
516
  peerName: event.peerName,
517
+ peerType: event.peerType,
319
518
  messageId: event.messageId,
320
519
  threadId: event.taskId,
321
520
  mentions: mentionObjects,
@@ -331,6 +530,8 @@ export class AUNChannel {
331
530
  if (state === 'connected') {
332
531
  this.connected = true;
333
532
  this.reconnectAttempt = 0;
533
+ this.lastReconnectLogTime = 0;
534
+ this.lastReconnectLogAttempt = 0;
334
535
  logger.info('[AUN] Connected');
335
536
  }
336
537
  else if (state === 'disconnected') {
@@ -338,21 +539,50 @@ export class AUNChannel {
338
539
  logger.warn(`[AUN] Disconnected: ${data.error ?? 'unknown'}`);
339
540
  }
340
541
  else if (state === 'reconnecting') {
341
- logger.info(`[AUN] SDK reconnecting (attempt ${data.attempt})`);
542
+ const attempt = data.attempt ?? 0;
543
+ const now = Date.now();
544
+ // Throttled logging: first attempt, every N attempts, or every M seconds
545
+ const isFirst = attempt <= 1;
546
+ const isStep = attempt - this.lastReconnectLogAttempt >= AUNChannel.RECONNECT_LOG_STEP;
547
+ const isInterval = now - this.lastReconnectLogTime >= AUNChannel.RECONNECT_LOG_INTERVAL;
548
+ if (isFirst || isStep || isInterval) {
549
+ const suppressed = attempt - this.lastReconnectLogAttempt - 1;
550
+ const suffix = suppressed > 0 ? `, ${suppressed} suppressed since last log` : '';
551
+ logger.info(`[AUN] SDK reconnecting (attempt ${attempt}${suffix})`);
552
+ this.lastReconnectLogTime = now;
553
+ this.lastReconnectLogAttempt = attempt;
554
+ this.trace('IN', 'connection.state', data);
555
+ }
556
+ // Detect runaway SDK reconnect loop: force disconnect and use TS-layer backoff
557
+ if (attempt >= AUNChannel.SDK_RECONNECT_GIVEUP && !this.intentionalDisconnect) {
558
+ logger.warn(`[AUN] SDK reconnect stuck at attempt ${attempt}, forcing TS-layer reconnect with backoff`);
559
+ this.connected = false;
560
+ if (this.client) {
561
+ this.client.close().catch(() => { });
562
+ this.client = null;
563
+ }
564
+ this.scheduleReconnect();
565
+ }
342
566
  }
343
567
  else if (state === 'terminal_failed') {
344
568
  this.connected = false;
345
- logger.error(`[AUN] Terminal failure: ${data.error ?? 'unknown'}`);
346
- // SDK auto_reconnect exhausted; fall back to TS-layer reconnect
569
+ const reason = data.reason ?? '';
570
+ logger.error(`[AUN] Terminal failure: ${data.error ?? 'unknown'}${reason ? ` (${reason})` : ''}`);
347
571
  if (!this.intentionalDisconnect) {
348
572
  this.scheduleReconnect();
349
573
  }
350
574
  }
351
575
  }
352
576
  // ── Public API (same interface as before) ───────────────────
577
+ onProjectPathRequest(provider) {
578
+ this.projectPathProvider = provider;
579
+ }
353
580
  onMessage(handler) {
354
581
  this.messageHandler = handler;
355
582
  }
583
+ onRecall(handler) {
584
+ this.recallHandler = handler;
585
+ }
356
586
  async sendMessage(channelId, text, context) {
357
587
  if (!this.connected || !this.client) {
358
588
  logger.warn('[AUN] Cannot send: not connected');
@@ -374,9 +604,16 @@ export class AUNChannel {
374
604
  finalText = `@${context.peerId} ` + finalText;
375
605
  }
376
606
  }
377
- const params = { payload: { text: finalText }, encrypt: true };
607
+ const payload = { type: 'text', text: finalText };
378
608
  if (context?.threadId)
379
- params.task_id = context.threadId;
609
+ payload.thread_id = context.threadId;
610
+ const params = { payload, encrypt: true };
611
+ // Multi-instance routing: channelId may be "aid:device_id:slot_id"
612
+ const colonIdx = channelId.indexOf(':');
613
+ const targetAid = colonIdx > 0 ? channelId.substring(0, colonIdx) : channelId;
614
+ if (colonIdx > 0) {
615
+ params.payload.chat_id = channelId;
616
+ }
380
617
  try {
381
618
  if (this.isGroupId(channelId)) {
382
619
  params.group_id = channelId;
@@ -384,7 +621,7 @@ export class AUNChannel {
384
621
  await this.client.call('group.send', params);
385
622
  }
386
623
  else {
387
- params.to = channelId;
624
+ params.to = targetAid;
388
625
  this.trace('OUT', 'message.send', params);
389
626
  await this.client.call('message.send', params);
390
627
  }
@@ -394,6 +631,103 @@ export class AUNChannel {
394
631
  logger.error(`[AUN] Send failed to ${channelId}: ${e}`);
395
632
  }
396
633
  }
634
+ async sendFile(channelId, filePath, context) {
635
+ if (!this.connected || !this.client) {
636
+ logger.warn('[AUN] Cannot sendFile: not connected');
637
+ return;
638
+ }
639
+ const absPath = path.resolve(filePath);
640
+ if (!fs.existsSync(absPath)) {
641
+ logger.warn(`[AUN] sendFile: file not found: ${absPath}`);
642
+ return;
643
+ }
644
+ const stat = fs.statSync(absPath);
645
+ if (stat.size === 0) {
646
+ logger.warn('[AUN] sendFile: file is empty');
647
+ return;
648
+ }
649
+ if (stat.size > 10 * 1024 * 1024) {
650
+ logger.warn(`[AUN] sendFile: file too large (${formatSize(stat.size)}, max 10 MB)`);
651
+ return;
652
+ }
653
+ const filename = path.basename(absPath);
654
+ const fileData = fs.readFileSync(absPath);
655
+ const sha256 = crypto.createHash('sha256').update(fileData).digest('hex');
656
+ const contentType = guessMime(filename);
657
+ const objectKey = `shared/${crypto.randomUUID()}/${filename}`;
658
+ try {
659
+ // Upload to storage
660
+ if (stat.size <= 64 * 1024) {
661
+ // Inline upload for small files (≤64KB)
662
+ await this.client.call('storage.put_object', {
663
+ object_key: objectKey,
664
+ content: fileData.toString('base64'),
665
+ content_type: contentType,
666
+ is_private: false,
667
+ overwrite: true,
668
+ });
669
+ }
670
+ else {
671
+ // Ticket upload for large files
672
+ const session = await this.client.call('storage.create_upload_session', {
673
+ object_key: objectKey,
674
+ size_bytes: stat.size,
675
+ content_type: contentType,
676
+ });
677
+ const uploadUrl = session.upload_url;
678
+ if (!uploadUrl)
679
+ throw new Error('No upload_url in session response');
680
+ const uploadResp = await fetch(uploadUrl, { method: 'PUT', body: fileData });
681
+ if (!uploadResp.ok)
682
+ throw new Error(`HTTP upload failed: ${uploadResp.status}`);
683
+ await this.client.call('storage.complete_upload', {
684
+ object_key: objectKey,
685
+ sha256,
686
+ content_type: contentType,
687
+ is_private: false,
688
+ size_bytes: stat.size,
689
+ });
690
+ }
691
+ // Send message with attachment
692
+ const attachment = {
693
+ owner_aid: this._aid || '',
694
+ object_key: objectKey,
695
+ filename,
696
+ size_bytes: stat.size,
697
+ sha256,
698
+ content_type: contentType,
699
+ };
700
+ const filePayload = {
701
+ type: 'file',
702
+ text: `📎 ${filename} (${formatSize(stat.size)})`,
703
+ attachments: [attachment],
704
+ };
705
+ if (context?.threadId)
706
+ filePayload.thread_id = context.threadId;
707
+ const params = { payload: filePayload, encrypt: true };
708
+ // Multi-instance routing
709
+ const fileColonIdx = channelId.indexOf(':');
710
+ const fileTargetAid = fileColonIdx > 0 ? channelId.substring(0, fileColonIdx) : channelId;
711
+ if (fileColonIdx > 0) {
712
+ params.payload.chat_id = channelId;
713
+ }
714
+ if (this.isGroupId(channelId)) {
715
+ params.group_id = channelId;
716
+ this.trace('OUT', 'group.send.file', params);
717
+ await this.client.call('group.send', params);
718
+ }
719
+ else {
720
+ params.to = fileTargetAid;
721
+ this.trace('OUT', 'message.send.file', params);
722
+ await this.client.call('message.send', params);
723
+ }
724
+ logger.info(`[AUN] File sent: ${filename} (${formatSize(stat.size)}) → ${channelId}`);
725
+ }
726
+ catch (e) {
727
+ this.trace('OUT', 'sendFile.error', { channelId, filePath, error: String(e) });
728
+ logger.error(`[AUN] sendFile failed for ${channelId}: ${e}`);
729
+ }
730
+ }
397
731
  acknowledge(messageId) {
398
732
  // Gateway auto-delivery-ack is sufficient; skip explicit message.ack RPC
399
733
  // to avoid duplicate "已送达" at the sender CLI
@@ -404,18 +738,28 @@ export class AUNChannel {
404
738
  this.sentCount.delete(channelId); // 新任务开始,重置计数
405
739
  if (!this.client || !this.connected)
406
740
  return;
407
- const payload = {
408
- type: 'processing',
409
- status,
410
- sessionId,
411
- timestamp: Math.floor(Date.now() / 1000),
741
+ const eventMap = {
742
+ start: 'task.started',
743
+ done: 'task.completed',
744
+ interrupted: 'task.interrupted',
745
+ error: 'task.error',
746
+ timeout: 'task.timeout',
412
747
  };
413
- const params = {
414
- payload,
415
- encrypt: true,
748
+ const payload = {
749
+ type: 'event',
750
+ event: eventMap[status] ?? `task.${status}`,
751
+ data: { session_id: sessionId },
752
+ severity: status === 'error' || status === 'timeout' ? 'error' : 'info',
416
753
  };
417
754
  if (context?.threadId)
418
- params.task_id = context.threadId;
755
+ payload.thread_id = context.threadId;
756
+ const params = { payload, encrypt: true };
757
+ // Multi-instance routing
758
+ const statusColonIdx = channelId.indexOf(':');
759
+ const statusTargetAid = statusColonIdx > 0 ? channelId.substring(0, statusColonIdx) : channelId;
760
+ if (statusColonIdx > 0) {
761
+ payload.chat_id = channelId;
762
+ }
419
763
  if (this.isGroupId(channelId)) {
420
764
  params.group_id = channelId;
421
765
  this.trace('OUT', 'group.send.status', params);
@@ -424,7 +768,7 @@ export class AUNChannel {
424
768
  });
425
769
  }
426
770
  else {
427
- params.to = channelId;
771
+ params.to = statusTargetAid;
428
772
  this.trace('OUT', 'message.send.status', params);
429
773
  this.client.call('message.send', params).catch(e => {
430
774
  logger.debug(`[AUN] Processing status failed: ${e}`);
@@ -444,8 +788,14 @@ export class AUNChannel {
444
788
  catch {
445
789
  payloadObj = { text: payload };
446
790
  }
791
+ // Multi-instance routing
792
+ const customColonIdx = channelId.indexOf(':');
793
+ const customTargetAid = customColonIdx > 0 ? channelId.substring(0, customColonIdx) : channelId;
794
+ if (customColonIdx > 0) {
795
+ payloadObj.chat_id = channelId;
796
+ }
447
797
  const sendParams = {
448
- to: channelId, payload: payloadObj,
798
+ to: customTargetAid, payload: payloadObj,
449
799
  encrypt: true,
450
800
  };
451
801
  this.trace('OUT', 'message.send.custom', sendParams);
@@ -532,6 +882,37 @@ export class AUNChannel {
532
882
  maxAttempts: AUNChannel.RECONNECT_DELAYS.length,
533
883
  };
534
884
  }
885
+ async fetchPeerInfo(aid) {
886
+ const cached = this.peerInfoCache.get(aid);
887
+ if (cached !== undefined)
888
+ return cached;
889
+ if (!this.client)
890
+ return { type: null };
891
+ try {
892
+ const md = await this.client.auth.downloadAgentMd(aid);
893
+ const typeMatch = md.match(/^type:\s*["']?(\w+)["']?/m);
894
+ const nameMatch = md.match(/^name:\s*["']?(.+?)["']?\s*$/m);
895
+ const type = typeMatch?.[1] === 'human' ? 'human' : 'ai';
896
+ const name = nameMatch?.[1]?.trim() || undefined;
897
+ const info = { type, name };
898
+ this.peerInfoCache.set(aid, info);
899
+ setTimeout(() => this.peerInfoCache.delete(aid), 30 * 60 * 1000);
900
+ return info;
901
+ }
902
+ catch {
903
+ return { type: null }; // no agent.md → unknown
904
+ }
905
+ }
906
+ async uploadAgentMd(content) {
907
+ if (!this.client)
908
+ throw new Error('not connected');
909
+ await this.client.auth.uploadAgentMd(content);
910
+ }
911
+ async downloadAgentMd(aid) {
912
+ if (!this.client)
913
+ throw new Error('not connected');
914
+ return this.client.auth.downloadAgentMd(aid);
915
+ }
535
916
  }
536
917
  // Plugin implementation
537
918
  export class AUNChannelPlugin {
@@ -554,7 +935,6 @@ export class AUNChannelPlugin {
554
935
  const channel = new AUNChannel({
555
936
  aid: inst.aid,
556
937
  keystorePath: inst.keystorePath,
557
- gatewayPort: inst.gatewayPort,
558
938
  gatewayUrl: inst.gatewayUrl,
559
939
  accessToken: inst.accessToken,
560
940
  flushDelay: inst.flushDelay,
@@ -564,19 +944,23 @@ export class AUNChannelPlugin {
564
944
  const adapter = {
565
945
  channelName: inst.name,
566
946
  sendText: (id, text, context) => channel.sendMessage(id, text, context),
947
+ sendFile: (id, filePath, context) => channel.sendFile(id, filePath, context),
567
948
  acknowledge: (messageId) => { channel.acknowledge(messageId); return Promise.resolve(); },
568
949
  sendProcessingStatus: (id, status, sessionId, context) => channel.sendProcessingStatus(id, status, sessionId, context),
569
950
  sendCustomPayload: (id, payload) => channel.sendCustomPayload(id, payload),
951
+ uploadAgentMd: (content) => channel.uploadAgentMd(content),
952
+ downloadAgentMd: (aid) => channel.downloadAgentMd(aid),
953
+ _selfAid: () => channel.getStatus().aid,
570
954
  };
571
955
  const policy = {
572
- canSwitchProject: (chatType, identity) => identity === 'owner',
573
- canListProjects: (chatType, identity) => identity === 'owner',
956
+ canSwitchProject: (chatType, identity) => identity === 'owner' || identity === 'admin',
957
+ canListProjects: (chatType, identity) => identity === 'owner' || identity === 'admin',
574
958
  canCreateSession: (chatType, identity) => true,
575
959
  canDeleteSession: (chatType, identity) => true,
576
- canImportCliSession: (chatType, identity) => identity === 'owner',
960
+ canImportCliSession: (chatType, identity) => identity === 'owner' || identity === 'admin',
577
961
  messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
578
962
  showMiddleResult: (chatType, identity) => {
579
- const mode = inst.showActivities ?? config.showActivities ?? 'all';
963
+ const mode = getChannelShowActivities(config, inst.name);
580
964
  if (mode === 'none')
581
965
  return false;
582
966
  if (mode === 'dm-only')
@@ -586,7 +970,7 @@ export class AUNChannelPlugin {
586
970
  return true;
587
971
  },
588
972
  showIdleMonitor: (chatType, identity) => {
589
- const mode = inst.showActivities ?? config.showActivities ?? 'all';
973
+ const mode = getChannelShowActivities(config, inst.name);
590
974
  if (mode === 'none')
591
975
  return false;
592
976
  if (mode === 'dm-only')
@@ -609,6 +993,7 @@ export class AUNChannelPlugin {
609
993
  options,
610
994
  connect: () => channel.connect(),
611
995
  disconnect: () => channel.disconnect(),
996
+ onProjectPathRequest: (channelId) => Promise.resolve(config.projects?.defaultPath || process.cwd()),
612
997
  });
613
998
  }
614
999
  return result;