evolclaw 2.3.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,66 @@
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
+ }
60
+ /** 判断 channelId 是否为群组 ID(g-xxx.agentid.pub 或 grp_ 前缀) */
61
+ isGroupId(id) {
62
+ return id.startsWith('grp_') || (id.startsWith('g-') && id.includes('.'));
63
+ }
19
64
  getShortAid(aid) {
20
65
  if (!aid)
21
66
  return undefined;
@@ -39,25 +84,23 @@ export class AUNChannel {
39
84
  const escaped = target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
85
  return new RegExp(`(^|\\s)@${escaped}(?=$|\\s|[.,!?;:,。!?;:]|[\\u4e00-\\u9fff])`).test(text);
41
86
  }
42
- stripTriggerMentions(text, selfAid) {
43
- let result = text;
44
- if (selfAid) {
45
- const escapedAid = selfAid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
46
- result = result.replace(new RegExp(`(^|\\s)@${escapedAid}(?=$|\\s|[.,!?;:,。!?;:]|[\\u4e00-\\u9fff])`, 'g'), '$1');
47
- }
48
- result = result.replace(/(^|\s)@all(?=$|\s|[.,!?;:,。!?;:]|[\u4e00-\u9fff])/gi, '$1');
49
- 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();
50
98
  }
51
- buildGroupReplyContext(taskId, senderAid, text) {
99
+ buildGroupReplyContext(taskId, senderAid) {
52
100
  const replyContext = {};
53
101
  if (taskId)
54
102
  replyContext.threadId = taskId;
55
- if (this.hasExplicitMention(text, 'all')) {
56
- replyContext.mentionUserIds = ['all'];
57
- }
58
- else {
59
- replyContext.mentionUserIds = [senderAid];
60
- }
103
+ replyContext.peerId = senderAid;
61
104
  return replyContext;
62
105
  }
63
106
  acknowledgeImmediately(messageId, seq) {
@@ -70,7 +113,9 @@ export class AUNChannel {
70
113
  this.messageSeqMap.delete(messageId);
71
114
  }
72
115
  _aid;
116
+ _chatId = ''; // aid:device_id:slot_id — 多实例回声过滤
73
117
  seenMessages = new Map();
118
+ peerInfoCache = new Map();
74
119
  messageSeqMap = new Map(); // messageId → seq (for ack)
75
120
  sentCount = new Map(); // channelId → 已发消息计数(用于判断最终回复)
76
121
  // Reconnect state (TS-layer fallback, on top of SDK auto_reconnect)
@@ -79,12 +124,17 @@ export class AUNChannel {
79
124
  reconnectTimer = null;
80
125
  static RECONNECT_DELAYS = [60, 120, 300, 600]; // seconds
81
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
82
133
  constructor(config) {
83
134
  this.config = config;
84
135
  if (config.aunTrace) {
85
- const logPath = path.join(resolvePaths().logs, 'aun-trace.log');
86
- this.traceStream = fs.createWriteStream(logPath, { flags: 'a' });
87
- logger.info(`[AUN] Trace logging enabled: ${logPath}`);
136
+ this.rotateTraceIfNeeded();
137
+ logger.info(`[AUN] Trace logging enabled (daily rotation): ${resolvePaths().logs}/aun-YYYYMMDD.log`);
88
138
  }
89
139
  }
90
140
  async connect() {
@@ -105,18 +155,22 @@ export class AUNChannel {
105
155
  const aunPath = this.config.keystorePath || `${process.env.HOME || '~'}/.aun`;
106
156
  const aidName = this.config.aid;
107
157
  const encryptionSeed = this.config.encryptionSeed || process.env.AUN_ENCRYPTION_SEED || undefined;
108
- // Gateway URL: 旧配置 gatewayUrl 优先,否则从 AID 推导
158
+ // Gateway URL 解析:优先用配置的 gatewayUrl,否则通过 well-known 自动发现
109
159
  let gateway = this.config.gatewayUrl || '';
110
160
  if (!gateway) {
111
- const parts = aidName.split('.');
112
- if (parts.length >= 3) {
113
- const domain = parts.slice(1).join('.'); // alice.agentid.pub → agentid.pub
114
- const port = this.config.gatewayPort || 443;
115
- 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`);
116
170
  }
117
171
  }
118
172
  if (!gateway) {
119
- logger.error('[AUN] Cannot derive gateway URL from AID');
173
+ logger.error('[AUN] Cannot resolve gateway URL from AID');
120
174
  return;
121
175
  }
122
176
  logger.info(`[AUN] Initializing: aid=${aidName}, gateway=${gateway}, aun_path=${aunPath}`);
@@ -146,10 +200,36 @@ export class AUNChannel {
146
200
  this.handleIncomingGroupMessage(data);
147
201
  });
148
202
  this.client.on('connection.state', (data) => {
149
- this.trace('IN', 'connection.state', data);
203
+ // trace is handled inside handleConnectionState with throttling
150
204
  this.handleConnectionState(data);
151
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
+ });
152
220
  // Authenticate
221
+ // Workaround: SDK 0.3.x _loadIdentityOrRaise doesn't set identity.aid from requested aid,
222
+ // causing gateway "missing aid" error. Patch to backfill aid on loaded identity.
223
+ const authFlow = this.client._auth;
224
+ if (authFlow && typeof authFlow._loadIdentityOrRaise === 'function') {
225
+ const origLoad = authFlow._loadIdentityOrRaise.bind(authFlow);
226
+ authFlow._loadIdentityOrRaise = (aid) => {
227
+ const identity = origLoad(aid);
228
+ if (identity && !identity.aid)
229
+ identity.aid = aid ?? authFlow._aid;
230
+ return identity;
231
+ };
232
+ }
153
233
  let accessToken;
154
234
  try {
155
235
  logger.info(`[AUN] Authenticating as ${aidName}...`);
@@ -169,7 +249,8 @@ export class AUNChannel {
169
249
  // Fallback: try direct token from env/config (legacy)
170
250
  accessToken = this.config.accessToken || process.env.AUN_ACCESS_TOKEN || '';
171
251
  if (!accessToken) {
172
- logger.error(`[AUN] No accessToken fallback available, AUN channel disabled`);
252
+ logger.error(`[AUN] No accessToken fallback available, scheduling retry`);
253
+ this.scheduleReconnect();
173
254
  return;
174
255
  }
175
256
  logger.warn(`[AUN] Using accessToken fallback`);
@@ -178,16 +259,88 @@ export class AUNChannel {
178
259
  try {
179
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 } });
180
261
  this._aid = this.client.aid ?? undefined;
262
+ const deviceId = this.client._device_id ?? '';
263
+ this._chatId = this._aid ? `${this._aid}:${deviceId}:` : '';
181
264
  this.connected = true;
182
265
  this.reconnectAttempt = 0;
266
+ // Workaround: SDK e2ee uses _identity.cert for sender_cert_fingerprint;
267
+ // if cert is missing, it falls back to public key SPKI fingerprint which
268
+ // causes peer cert lookup failures. Backfill from keystore if needed.
269
+ const clientAny = this.client;
270
+ if (clientAny._identity && !clientAny._identity.cert) {
271
+ const cert = clientAny._keystore?.loadCert?.(aidName);
272
+ if (cert) {
273
+ clientAny._identity.cert = cert;
274
+ logger.info('[AUN] Backfilled identity.cert from keystore for e2ee fingerprint');
275
+ }
276
+ }
183
277
  logger.info(`[AUN] Connected as ${this._aid}`);
184
278
  }
185
279
  catch (e) {
186
280
  logger.error(`[AUN] Connection failed: ${e}`);
281
+ this.scheduleReconnect();
187
282
  return;
188
283
  }
189
284
  }
190
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
+ }
191
344
  async handleIncomingPrivateMessage(data) {
192
345
  if (!data || typeof data !== 'object')
193
346
  return;
@@ -195,23 +348,62 @@ export class AUNChannel {
195
348
  const fromAid = msg.from ?? '';
196
349
  const payload = msg.payload ?? '';
197
350
  const text = this.extractTextPayload(payload);
198
- const taskId = msg.task_id;
351
+ const taskId = typeof payload === 'object' && payload !== null ? payload.thread_id : undefined;
199
352
  const messageId = msg.message_id ?? '';
200
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
+ }
201
361
  // Detect @mentions
202
362
  const mentions = [];
203
363
  if (this._aid && text.includes(`@${this._aid}`)) {
204
364
  mentions.push(this._aid);
205
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;
206
396
  this.dispatchMessage({
207
- channelId: fromAid,
397
+ channelId: chatId,
208
398
  userId: fromAid,
209
- text,
399
+ text: finalText,
210
400
  chatType: 'private',
211
401
  messageId,
212
402
  seq,
213
403
  taskId,
214
404
  mentions,
405
+ peerName: displayName || undefined,
406
+ peerType: peerInfo.type || 'unknown',
215
407
  });
216
408
  }
217
409
  async handleIncomingGroupMessage(data) {
@@ -219,12 +411,16 @@ export class AUNChannel {
219
411
  return;
220
412
  const msg = data;
221
413
  const groupId = msg.group_id ?? '';
222
- const senderAid = msg.sender_aid ?? msg.from ?? '';
414
+ const senderAid = msg.sender_aid ?? '';
223
415
  const payload = msg.payload ?? '';
224
416
  const text = this.extractTextPayload(payload);
225
- const taskId = msg.task_id;
417
+ const taskId = typeof payload === 'object' && payload !== null ? payload.thread_id : undefined;
226
418
  const messageId = msg.message_id ?? '';
227
419
  const seq = msg.seq;
420
+ // Extract structured mentions from payload (e.g. payload.mentions: ["evolai.agentid.pub"])
421
+ const payloadMentions = Array.isArray(payload?.mentions)
422
+ ? payload.mentions.filter((m) => typeof m === 'string')
423
+ : [];
228
424
  logger.info(`[AUN][DIAG-GRP] full_msg=${JSON.stringify(msg).substring(0, 500)}`);
229
425
  if (!groupId || !senderAid) {
230
426
  this.acknowledgeImmediately(messageId, seq);
@@ -234,29 +430,61 @@ export class AUNChannel {
234
430
  this.acknowledgeImmediately(messageId, seq);
235
431
  return;
236
432
  }
237
- const mentionedSelf = this._aid ? this.hasExplicitMention(text, this._aid) : false;
238
- const mentionedAll = this.hasExplicitMention(text, 'all');
433
+ const mentionedSelf = this._aid
434
+ ? (this.hasExplicitMention(text, this._aid) || payloadMentions.includes(this._aid))
435
+ : false;
436
+ const mentionedAll = this.hasExplicitMention(text, 'all') || payloadMentions.includes('all');
239
437
  if (!mentionedSelf && !mentionedAll) {
240
438
  this.acknowledgeImmediately(messageId, seq);
241
439
  return;
242
440
  }
243
- const strippedText = this.stripTriggerMentions(text, this._aid);
244
- 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) {
245
449
  this.acknowledgeImmediately(messageId, seq);
246
450
  return;
247
451
  }
248
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;
249
476
  this.dispatchMessage({
250
477
  channelId: groupId,
251
478
  userId: senderAid,
252
- peerName: this.getShortAid(senderAid),
253
- text: strippedText,
479
+ peerName: displayName || undefined,
480
+ peerType: peerInfo.type || 'unknown',
481
+ text: finalText,
254
482
  chatType: 'group',
255
483
  messageId,
256
484
  seq,
257
485
  taskId,
258
486
  mentions,
259
- replyContext: this.buildGroupReplyContext(taskId, senderAid, text),
487
+ replyContext: this.buildGroupReplyContext(taskId, senderAid),
260
488
  });
261
489
  }
262
490
  dispatchMessage(event) {
@@ -286,6 +514,7 @@ export class AUNChannel {
286
514
  chatType: event.chatType,
287
515
  peerId: event.userId || event.channelId || '',
288
516
  peerName: event.peerName,
517
+ peerType: event.peerType,
289
518
  messageId: event.messageId,
290
519
  threadId: event.taskId,
291
520
  mentions: mentionObjects,
@@ -301,6 +530,8 @@ export class AUNChannel {
301
530
  if (state === 'connected') {
302
531
  this.connected = true;
303
532
  this.reconnectAttempt = 0;
533
+ this.lastReconnectLogTime = 0;
534
+ this.lastReconnectLogAttempt = 0;
304
535
  logger.info('[AUN] Connected');
305
536
  }
306
537
  else if (state === 'disconnected') {
@@ -308,21 +539,50 @@ export class AUNChannel {
308
539
  logger.warn(`[AUN] Disconnected: ${data.error ?? 'unknown'}`);
309
540
  }
310
541
  else if (state === 'reconnecting') {
311
- 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
+ }
312
566
  }
313
567
  else if (state === 'terminal_failed') {
314
568
  this.connected = false;
315
- logger.error(`[AUN] Terminal failure: ${data.error ?? 'unknown'}`);
316
- // 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})` : ''}`);
317
571
  if (!this.intentionalDisconnect) {
318
572
  this.scheduleReconnect();
319
573
  }
320
574
  }
321
575
  }
322
576
  // ── Public API (same interface as before) ───────────────────
577
+ onProjectPathRequest(provider) {
578
+ this.projectPathProvider = provider;
579
+ }
323
580
  onMessage(handler) {
324
581
  this.messageHandler = handler;
325
582
  }
583
+ onRecall(handler) {
584
+ this.recallHandler = handler;
585
+ }
326
586
  async sendMessage(channelId, text, context) {
327
587
  if (!this.connected || !this.client) {
328
588
  logger.warn('[AUN] Cannot send: not connected');
@@ -338,24 +598,30 @@ export class AUNChannel {
338
598
  finalText = '最终回复\n' + text;
339
599
  }
340
600
  this.sentCount.set(channelId, (this.sentCount.get(channelId) || 0) + 1);
341
- // Render outbound mentions for group sends
342
- if (channelId.startsWith('grp_') && context?.mentionUserIds?.length) {
343
- const mentionPrefix = context.mentionUserIds.includes('all')
344
- ? '@all '
345
- : context.mentionUserIds.map(id => `@${id}`).join(' ') + ' ';
346
- finalText = mentionPrefix + finalText;
347
- }
348
- const params = { payload: { text: finalText }, encrypt: true };
601
+ // 群聊 @ 兜底:提示词已告知 agent @,但如果 agent 没写,系统自动补上
602
+ if (this.isGroupId(channelId) && context?.peerId) {
603
+ if (!finalText.includes(`@${context.peerId}`)) {
604
+ finalText = `@${context.peerId} ` + finalText;
605
+ }
606
+ }
607
+ const payload = { type: 'text', text: finalText };
349
608
  if (context?.threadId)
350
- 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
+ }
351
617
  try {
352
- if (channelId.startsWith('grp_')) {
618
+ if (this.isGroupId(channelId)) {
353
619
  params.group_id = channelId;
354
620
  this.trace('OUT', 'group.send', params);
355
621
  await this.client.call('group.send', params);
356
622
  }
357
623
  else {
358
- params.to = channelId;
624
+ params.to = targetAid;
359
625
  this.trace('OUT', 'message.send', params);
360
626
  await this.client.call('message.send', params);
361
627
  }
@@ -365,33 +631,136 @@ export class AUNChannel {
365
631
  logger.error(`[AUN] Send failed to ${channelId}: ${e}`);
366
632
  }
367
633
  }
368
- acknowledge(messageId) {
369
- const seq = this.messageSeqMap.get(messageId);
370
- if (seq != null && this.client) {
371
- this.client.call('message.ack', { seq }).catch(e => {
372
- logger.debug(`[AUN] Ack failed: ${e}`);
373
- });
374
- this.messageSeqMap.delete(messageId);
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}`);
375
729
  }
376
730
  }
731
+ acknowledge(messageId) {
732
+ // Gateway auto-delivery-ack is sufficient; skip explicit message.ack RPC
733
+ // to avoid duplicate "已送达" at the sender CLI
734
+ this.messageSeqMap.delete(messageId);
735
+ }
377
736
  sendProcessingStatus(channelId, status, sessionId, context) {
378
737
  if (status === 'start')
379
738
  this.sentCount.delete(channelId); // 新任务开始,重置计数
380
739
  if (!this.client || !this.connected)
381
740
  return;
382
- const payload = {
383
- type: 'processing',
384
- status,
385
- sessionId,
386
- 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',
387
747
  };
388
- const params = {
389
- payload,
390
- encrypt: true, persist: false,
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',
391
753
  };
392
754
  if (context?.threadId)
393
- params.task_id = context.threadId;
394
- if (channelId.startsWith('grp_')) {
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
+ }
763
+ if (this.isGroupId(channelId)) {
395
764
  params.group_id = channelId;
396
765
  this.trace('OUT', 'group.send.status', params);
397
766
  this.client.call('group.send', params).catch(e => {
@@ -399,7 +768,7 @@ export class AUNChannel {
399
768
  });
400
769
  }
401
770
  else {
402
- params.to = channelId;
771
+ params.to = statusTargetAid;
403
772
  this.trace('OUT', 'message.send.status', params);
404
773
  this.client.call('message.send', params).catch(e => {
405
774
  logger.debug(`[AUN] Processing status failed: ${e}`);
@@ -419,9 +788,15 @@ export class AUNChannel {
419
788
  catch {
420
789
  payloadObj = { text: payload };
421
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
+ }
422
797
  const sendParams = {
423
- to: channelId, payload: payloadObj,
424
- encrypt: true, persist: false,
798
+ to: customTargetAid, payload: payloadObj,
799
+ encrypt: true,
425
800
  };
426
801
  this.trace('OUT', 'message.send.custom', sendParams);
427
802
  this.client.call('message.send', sendParams).catch(e => {
@@ -507,6 +882,37 @@ export class AUNChannel {
507
882
  maxAttempts: AUNChannel.RECONNECT_DELAYS.length,
508
883
  };
509
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
+ }
510
916
  }
511
917
  // Plugin implementation
512
918
  export class AUNChannelPlugin {
@@ -529,7 +935,6 @@ export class AUNChannelPlugin {
529
935
  const channel = new AUNChannel({
530
936
  aid: inst.aid,
531
937
  keystorePath: inst.keystorePath,
532
- gatewayPort: inst.gatewayPort,
533
938
  gatewayUrl: inst.gatewayUrl,
534
939
  accessToken: inst.accessToken,
535
940
  flushDelay: inst.flushDelay,
@@ -539,19 +944,23 @@ export class AUNChannelPlugin {
539
944
  const adapter = {
540
945
  channelName: inst.name,
541
946
  sendText: (id, text, context) => channel.sendMessage(id, text, context),
947
+ sendFile: (id, filePath, context) => channel.sendFile(id, filePath, context),
542
948
  acknowledge: (messageId) => { channel.acknowledge(messageId); return Promise.resolve(); },
543
949
  sendProcessingStatus: (id, status, sessionId, context) => channel.sendProcessingStatus(id, status, sessionId, context),
544
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,
545
954
  };
546
955
  const policy = {
547
- canSwitchProject: (chatType, identity) => identity === 'owner',
548
- canListProjects: (chatType, identity) => identity === 'owner',
956
+ canSwitchProject: (chatType, identity) => identity === 'owner' || identity === 'admin',
957
+ canListProjects: (chatType, identity) => identity === 'owner' || identity === 'admin',
549
958
  canCreateSession: (chatType, identity) => true,
550
959
  canDeleteSession: (chatType, identity) => true,
551
- canImportCliSession: (chatType, identity) => identity === 'owner',
552
- messagePrefix: () => '',
960
+ canImportCliSession: (chatType, identity) => identity === 'owner' || identity === 'admin',
961
+ messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
553
962
  showMiddleResult: (chatType, identity) => {
554
- const mode = inst.showActivities ?? config.showActivities ?? 'all';
963
+ const mode = getChannelShowActivities(config, inst.name);
555
964
  if (mode === 'none')
556
965
  return false;
557
966
  if (mode === 'dm-only')
@@ -561,7 +970,7 @@ export class AUNChannelPlugin {
561
970
  return true;
562
971
  },
563
972
  showIdleMonitor: (chatType, identity) => {
564
- const mode = inst.showActivities ?? config.showActivities ?? 'all';
973
+ const mode = getChannelShowActivities(config, inst.name);
565
974
  if (mode === 'none')
566
975
  return false;
567
976
  if (mode === 'dm-only')
@@ -584,6 +993,7 @@ export class AUNChannelPlugin {
584
993
  options,
585
994
  connect: () => channel.connect(),
586
995
  disconnect: () => channel.disconnect(),
996
+ onProjectPathRequest: (channelId) => Promise.resolve(config.projects?.defaultPath || process.cwd()),
587
997
  });
588
998
  }
589
999
  return result;