evolclaw 2.8.3 → 3.1.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.
Files changed (142) hide show
  1. package/README.md +21 -12
  2. package/bin/ec.js +29 -0
  3. package/dist/agents/baseagent-normalize.js +19 -0
  4. package/dist/agents/claude-runner.js +108 -46
  5. package/dist/agents/codex-runner.js +13 -14
  6. package/dist/agents/gemini-runner.js +15 -17
  7. package/dist/agents/kit-renderer.js +281 -0
  8. package/dist/agents/resolve.js +134 -0
  9. package/dist/aun/aid/agentmd.js +186 -0
  10. package/dist/aun/aid/client.js +134 -0
  11. package/dist/aun/aid/identity.js +159 -0
  12. package/dist/aun/aid/index.js +3 -0
  13. package/dist/aun/aid/lifecycle-log.js +33 -0
  14. package/dist/aun/aid/types.js +1 -0
  15. package/dist/aun/aid/validation.js +21 -0
  16. package/dist/aun/msg/group.js +293 -0
  17. package/dist/aun/msg/index.js +4 -0
  18. package/dist/aun/msg/p2p.js +147 -0
  19. package/dist/aun/msg/payload-type.js +27 -0
  20. package/dist/aun/msg/upload.js +98 -0
  21. package/dist/aun/outbox.js +138 -0
  22. package/dist/aun/rpc/caller.js +42 -0
  23. package/dist/aun/rpc/connection.js +34 -0
  24. package/dist/aun/rpc/index.js +2 -0
  25. package/dist/aun/storage/download.js +29 -0
  26. package/dist/aun/storage/index.js +3 -0
  27. package/dist/aun/storage/manage.js +10 -0
  28. package/dist/aun/storage/upload.js +35 -0
  29. package/dist/channels/aun.js +1340 -349
  30. package/dist/channels/dingtalk.js +59 -5
  31. package/dist/channels/feishu.js +381 -32
  32. package/dist/channels/qqbot.js +68 -12
  33. package/dist/channels/wechat.js +63 -4
  34. package/dist/channels/wecom.js +59 -5
  35. package/dist/cli/agent.js +800 -0
  36. package/dist/cli/bench.js +1219 -0
  37. package/dist/cli/index.js +4513 -0
  38. package/dist/{utils → cli}/init-channel.js +211 -621
  39. package/dist/cli/init.js +178 -0
  40. package/dist/cli/link-rules.js +245 -0
  41. package/dist/cli/net-check.js +640 -0
  42. package/dist/cli/watch-msg.js +589 -0
  43. package/dist/config-store.js +645 -0
  44. package/dist/core/{agent-loader.js → baseagent-loader.js} +6 -12
  45. package/dist/core/channel-loader.js +176 -12
  46. package/dist/core/command-handler.js +883 -848
  47. package/dist/core/evolagent-registry.js +191 -371
  48. package/dist/core/evolagent.js +202 -238
  49. package/dist/core/interaction-router.js +52 -5
  50. package/dist/core/message/im-renderer.js +486 -0
  51. package/dist/core/message/items-formatter.js +68 -0
  52. package/dist/core/message/message-bridge.js +109 -56
  53. package/dist/core/message/message-log.js +93 -0
  54. package/dist/core/message/message-processor.js +430 -212
  55. package/dist/core/message/message-queue.js +13 -6
  56. package/dist/core/permission.js +116 -11
  57. package/dist/core/session/adapters/codex-session-file-adapter.js +24 -2
  58. package/dist/core/session/session-fs-store.js +230 -0
  59. package/dist/core/session/session-manager.js +740 -777
  60. package/dist/core/session/session-mapper.js +87 -0
  61. package/dist/core/trigger/manager.js +122 -0
  62. package/dist/core/trigger/parser.js +128 -0
  63. package/dist/core/trigger/scheduler.js +224 -0
  64. package/dist/data/error-dict.json +118 -0
  65. package/dist/eck/baseagent-caps.js +18 -0
  66. package/dist/eck/detect.js +47 -0
  67. package/dist/eck/init.js +77 -0
  68. package/dist/eck/rules-loader.js +28 -0
  69. package/dist/index.js +560 -283
  70. package/dist/ipc.js +49 -0
  71. package/dist/net-check.js +640 -0
  72. package/dist/paths.js +73 -9
  73. package/dist/types.js +8 -2
  74. package/dist/utils/aid-lifecycle-log.js +33 -0
  75. package/dist/utils/atomic-write.js +89 -0
  76. package/dist/utils/channel-helpers.js +46 -0
  77. package/dist/utils/cross-platform.js +17 -26
  78. package/dist/utils/error-utils.js +10 -2
  79. package/dist/utils/instance-registry.js +434 -0
  80. package/dist/utils/log-writer.js +217 -0
  81. package/dist/utils/logger.js +34 -77
  82. package/dist/utils/media-cache.js +23 -0
  83. package/dist/utils/npm-ops.js +163 -0
  84. package/dist/utils/process-introspect.js +122 -0
  85. package/dist/utils/stats.js +192 -0
  86. package/dist/watch-msg.js +544 -0
  87. package/evolclaw-install-aun.md +127 -47
  88. package/kits/docs/GUIDE.md +20 -0
  89. package/kits/docs/INDEX.md +52 -0
  90. package/kits/docs/aun/CHEATSHEET.md +17 -0
  91. package/kits/docs/aun/SYNC_PROTOCOL.md +15 -0
  92. package/kits/docs/channels/aun.md +25 -0
  93. package/kits/docs/channels/feishu.md +27 -0
  94. package/kits/docs/eck_templates/GUIDE.template.md +22 -0
  95. package/kits/docs/eck_templates/INDEX.template.md +28 -0
  96. package/kits/docs/eck_templates/path-registry.template.md +33 -0
  97. package/kits/docs/eck_templates/runtime.template.md +19 -0
  98. package/kits/docs/evolclaw/AGENT_CMD.md +31 -0
  99. package/kits/docs/evolclaw/MSG_GROUP.md +30 -0
  100. package/kits/docs/evolclaw/MSG_PRIVATE.md +25 -0
  101. package/kits/docs/evolclaw/self-summary.md +29 -0
  102. package/kits/docs/evolclaw/tools.md +25 -0
  103. package/kits/docs/identity/AID_PROFILE_SPEC.md +27 -0
  104. package/kits/docs/identity/PATH_OPS.md +16 -0
  105. package/kits/docs/identity/ROLE_DETAIL.md +20 -0
  106. package/kits/docs/identity/identity-tools.md +26 -0
  107. package/kits/docs/path-registry.md +43 -0
  108. package/kits/eck_manifest.json +95 -0
  109. package/kits/rules/01-overview.md +120 -0
  110. package/kits/rules/02-navigation.md +75 -0
  111. package/kits/rules/03-identity.md +34 -0
  112. package/kits/rules/04-relation.md +49 -0
  113. package/kits/rules/05-venue.md +45 -0
  114. package/kits/rules/06-channel.md +43 -0
  115. package/kits/templates/system-fragments/baseagent.md +2 -0
  116. package/kits/templates/system-fragments/channel.md +10 -0
  117. package/kits/templates/system-fragments/identity.md +12 -0
  118. package/kits/templates/system-fragments/relation.md +9 -0
  119. package/kits/templates/system-fragments/runtime.md +19 -0
  120. package/kits/templates/system-fragments/venue.md +5 -0
  121. package/package.json +10 -6
  122. package/data/evolclaw.sample.json +0 -60
  123. package/dist/agents/templates.js +0 -122
  124. package/dist/channels/aun-ops.js +0 -275
  125. package/dist/cli.js +0 -2178
  126. package/dist/config.js +0 -591
  127. package/dist/core/agent-registry.js +0 -450
  128. package/dist/core/evolagent-schema.js +0 -72
  129. package/dist/core/message/stream-flusher.js +0 -238
  130. package/dist/core/message/thought-emitter.js +0 -162
  131. package/dist/core/reload-hooks.js +0 -87
  132. package/dist/prompts/templates.js +0 -122
  133. package/dist/templates/prompts.md +0 -104
  134. package/dist/templates/skills.md +0 -66
  135. package/dist/utils/channel-fingerprint.js +0 -59
  136. package/dist/utils/error-dict.js +0 -63
  137. package/dist/utils/format.js +0 -32
  138. package/dist/utils/init.js +0 -645
  139. package/dist/utils/migrate-project.js +0 -122
  140. package/dist/utils/reload-hooks.js +0 -87
  141. package/dist/utils/stats-collector.js +0 -99
  142. package/dist/utils/upgrade.js +0 -100
@@ -0,0 +1,1219 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { execFile } from 'child_process';
6
+ import { promisify } from 'util';
7
+ import { aidList, aidCreate } from '../aun/aid/identity.js';
8
+ import { msgSend, msgPull } from '../aun/msg/index.js';
9
+ import { getPackageRoot } from '../paths.js';
10
+ const execFileAsync = promisify(execFile);
11
+ // ==================== ANSI ====================
12
+ const GREEN = '\x1b[32m';
13
+ const RED = '\x1b[31m';
14
+ const YELLOW = '\x1b[33m';
15
+ const CYAN = '\x1b[36m';
16
+ const DIM = '\x1b[2m';
17
+ const BOLD = '\x1b[1m';
18
+ const RST = '\x1b[0m';
19
+ function ok(msg) { return ` ${GREEN}✓${RST} ${msg}`; }
20
+ function fail(msg) { return ` ${RED}✗${RST} ${msg}`; }
21
+ function warn(msg) { return ` ${YELLOW}!${RST} ${msg}`; }
22
+ // ==================== Helpers ====================
23
+ const PADDING_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
24
+ function makePadding(len) {
25
+ let s = '';
26
+ for (let i = 0; i < len; i++)
27
+ s += PADDING_ALPHABET[i % PADDING_ALPHABET.length];
28
+ return s;
29
+ }
30
+ function targetSize(cls) {
31
+ switch (cls) {
32
+ case 'S': return 50;
33
+ case 'M': return 500;
34
+ case 'L': return 5000;
35
+ }
36
+ }
37
+ function buildMessageText(seq, cls, timestamp, sessionId) {
38
+ const header = `[bench:${sessionId}:${String(seq).padStart(4, '0')}:${cls}:${timestamp}] `;
39
+ const padLen = Math.max(0, targetSize(cls) - header.length);
40
+ return header + makePadding(padLen);
41
+ }
42
+ const BENCH_RE = /^\[bench:([a-f0-9]+):(\d+):([SML]):(\d+)\]\s/;
43
+ function parseMessage(text, sessionId) {
44
+ const m = text.match(BENCH_RE);
45
+ if (!m || m[1] !== sessionId)
46
+ return null;
47
+ return { seq: parseInt(m[2], 10), sizeClass: m[3], sendTimestamp: parseInt(m[4], 10) };
48
+ }
49
+ // ==================== File Mode Helpers ====================
50
+ const FILE_BENCH_RE = /^\[fb:([a-f0-9]+):(\d+):(\d+):(\d+)\]/;
51
+ function buildFileChunkText(seq, totalChunks, timestamp, sessionId, chunkBase64) {
52
+ return `[fb:${sessionId}:${String(seq).padStart(4, '0')}:${totalChunks}:${timestamp}]${chunkBase64}`;
53
+ }
54
+ function parseFileChunk(text, sessionId) {
55
+ const m = text.match(FILE_BENCH_RE);
56
+ if (!m || m[1] !== sessionId)
57
+ return null;
58
+ const headerEnd = text.indexOf(']') + 1;
59
+ return {
60
+ seq: parseInt(m[2], 10),
61
+ totalChunks: parseInt(m[3], 10),
62
+ sendTimestamp: parseInt(m[4], 10),
63
+ data: text.slice(headerEnd),
64
+ };
65
+ }
66
+ function splitFileIntoChunks(buf, numChunks) {
67
+ const chunks = [];
68
+ let offset = 0;
69
+ const remaining = () => buf.length - offset;
70
+ for (let i = 0; i < numChunks; i++) {
71
+ const left = numChunks - i;
72
+ if (left === 1) {
73
+ chunks.push(buf.slice(offset));
74
+ break;
75
+ }
76
+ const avg = remaining() / left;
77
+ const min = Math.max(1, Math.floor(avg * 0.3));
78
+ const max = Math.floor(avg * 1.7);
79
+ const size = Math.min(remaining() - (left - 1), min + Math.floor(Math.random() * (max - min + 1)));
80
+ chunks.push(buf.slice(offset, offset + size));
81
+ offset += size;
82
+ }
83
+ return chunks;
84
+ }
85
+ async function compressDirectory(dirPath) {
86
+ const zlib = await import('zlib');
87
+ // Collect all files recursively, pack as a simple concatenation
88
+ // Use Node.js tar-like approach: JSON manifest + gzipped content
89
+ const files = [];
90
+ function walk(dir, prefix) {
91
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
92
+ if (entry.name === 'node_modules' || entry.name === '.git')
93
+ continue;
94
+ const full = path.join(dir, entry.name);
95
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
96
+ if (entry.isDirectory()) {
97
+ walk(full, rel);
98
+ }
99
+ else if (entry.isFile()) {
100
+ try {
101
+ files.push({ rel, data: fs.readFileSync(full) });
102
+ }
103
+ catch { }
104
+ }
105
+ }
106
+ }
107
+ walk(dirPath, '');
108
+ // Pack: JSON lines of {path, size} + concatenated data, then gzip
109
+ const manifest = files.map(f => ({ p: f.rel, s: f.data.length }));
110
+ const header = Buffer.from(JSON.stringify(manifest) + '\n');
111
+ const body = Buffer.concat([header, ...files.map(f => f.data)]);
112
+ return Buffer.from(zlib.gzipSync(body));
113
+ }
114
+ async function decompressToDir(buf, destDir) {
115
+ const zlib = await import('zlib');
116
+ const body = Buffer.from(zlib.gunzipSync(buf));
117
+ const nlIdx = body.indexOf(10); // newline
118
+ const manifest = JSON.parse(body.slice(0, nlIdx).toString());
119
+ let offset = nlIdx + 1;
120
+ for (const entry of manifest) {
121
+ const filePath = path.join(destDir, ...entry.p.split('/'));
122
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
123
+ fs.writeFileSync(filePath, body.slice(offset, offset + entry.s));
124
+ offset += entry.s;
125
+ }
126
+ }
127
+ function percentile(sorted, p) {
128
+ if (sorted.length === 0)
129
+ return 0;
130
+ const idx = Math.ceil((p / 100) * sorted.length) - 1;
131
+ return sorted[Math.max(0, idx)];
132
+ }
133
+ function getArgValue(args, flag) {
134
+ const idx = args.indexOf(flag);
135
+ return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : undefined;
136
+ }
137
+ // ==================== Promise Pool ====================
138
+ function withTimeout(promise, ms, label) {
139
+ return new Promise((resolve, reject) => {
140
+ const timer = setTimeout(() => reject(new Error(`timeout ${ms}ms: ${label}`)), ms);
141
+ promise.then(v => { clearTimeout(timer); resolve(v); }, e => { clearTimeout(timer); reject(e); });
142
+ });
143
+ }
144
+ async function runPool(tasks, concurrency, onDone) {
145
+ const results = new Array(tasks.length);
146
+ let nextIdx = 0;
147
+ const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
148
+ while (nextIdx < tasks.length) {
149
+ const i = nextIdx++;
150
+ results[i] = await tasks[i]();
151
+ onDone?.(results[i], i);
152
+ }
153
+ });
154
+ await Promise.all(workers);
155
+ return results;
156
+ }
157
+ // ==================== Progress Display ====================
158
+ function clearLine() {
159
+ process.stdout.write('\r\x1b[K');
160
+ }
161
+ function moveCursorUp(n) {
162
+ if (n > 0)
163
+ process.stdout.write(`\x1b[${n}A`);
164
+ }
165
+ function renderMultiLineProgress(aids, aidStats, total, done, rate) {
166
+ const lines = [];
167
+ for (const aid of aids) {
168
+ const s = aidStats.get(aid) || { sent: 0, ok: 0, fail: 0, timeouts: 0 };
169
+ const aidShort = aid.split('.')[0];
170
+ const barW = 12;
171
+ const perAidTotal = Math.ceil(total / aids.length);
172
+ const filled = Math.min(barW, Math.round((s.sent / Math.max(perAidTotal, 1)) * barW));
173
+ const bar = `${CYAN}${'█'.repeat(filled)}${'░'.repeat(barW - filled)}${RST}`;
174
+ const timeoutStr = s.timeouts > 0 ? ` ${YELLOW}⏳${s.timeouts}${RST}` : '';
175
+ const failStr = s.fail > 0 ? ` ${RED}✗${s.fail}${RST}` : '';
176
+ lines.push(` ${pad(aidShort, 14, 'left')}[${bar}] ${pad(String(s.sent), 4)}/${perAidTotal} ok=${s.ok}${failStr}${timeoutStr}`);
177
+ }
178
+ lines.push(` ${DIM}──${RST} total ${done}/${total} ${BOLD}${rate.toFixed(1)}${RST} msg/s`);
179
+ process.stdout.write(lines.join('\n') + '\n');
180
+ }
181
+ // ==================== Table Rendering ====================
182
+ function pad(s, w, align = 'right') {
183
+ if (s.length >= w)
184
+ return s.slice(0, w);
185
+ return align === 'left' ? s + ' '.repeat(w - s.length) : ' '.repeat(w - s.length) + s;
186
+ }
187
+ function renderTable(all, bySize, meta) {
188
+ const W = 10;
189
+ const LW = 17;
190
+ function fmtNum(n) { return String(n); }
191
+ function fmtPct(n) { return n.toFixed(1) + '%'; }
192
+ function fmtRate(n) { return n.toFixed(1) + '/s'; }
193
+ function fmtMs(n) { return Math.round(n) + 'ms'; }
194
+ const rows = [
195
+ ['Sent', fmtNum(all.sent), fmtNum(bySize.S.sent), fmtNum(bySize.M.sent), fmtNum(bySize.L.sent)],
196
+ ['Received', fmtNum(all.received), fmtNum(bySize.S.received), fmtNum(bySize.M.received), fmtNum(bySize.L.received)],
197
+ ['Loss %', fmtPct(all.lossRate), fmtPct(bySize.S.lossRate), fmtPct(bySize.M.lossRate), fmtPct(bySize.L.lossRate)],
198
+ ['Throughput', fmtRate(all.throughput), fmtRate(bySize.S.throughput), fmtRate(bySize.M.throughput), fmtRate(bySize.L.throughput)],
199
+ ['Latency avg', fmtMs(all.latencyAvg), fmtMs(bySize.S.latencyAvg), fmtMs(bySize.M.latencyAvg), fmtMs(bySize.L.latencyAvg)],
200
+ ['Latency P50', fmtMs(all.latencyP50), fmtMs(bySize.S.latencyP50), fmtMs(bySize.M.latencyP50), fmtMs(bySize.L.latencyP50)],
201
+ ['Latency P95', fmtMs(all.latencyP95), fmtMs(bySize.S.latencyP95), fmtMs(bySize.M.latencyP95), fmtMs(bySize.L.latencyP95)],
202
+ ['Latency P99', fmtMs(all.latencyP99), fmtMs(bySize.S.latencyP99), fmtMs(bySize.M.latencyP99), fmtMs(bySize.L.latencyP99)],
203
+ ['Send time avg', fmtMs(all.sendTimeAvg), fmtMs(bySize.S.sendTimeAvg), fmtMs(bySize.M.sendTimeAvg), fmtMs(bySize.L.sendTimeAvg)],
204
+ ];
205
+ const sep = `├${'─'.repeat(LW)}┼${'─'.repeat(W)}┼${'─'.repeat(W)}┼${'─'.repeat(W)}┼${'─'.repeat(W)}┤`;
206
+ const top = `┌${'─'.repeat(LW)}┬${'─'.repeat(W)}┬${'─'.repeat(W)}┬${'─'.repeat(W)}┬${'─'.repeat(W)}┐`;
207
+ const bot = `└${'─'.repeat(LW)}┴${'─'.repeat(W)}┴${'─'.repeat(W)}┴${'─'.repeat(W)}┴${'─'.repeat(W)}┘`;
208
+ function row(cells) {
209
+ return `│${pad(cells[0], LW, 'left')}│${pad(cells[1], W)}│${pad(cells[2], W)}│${pad(cells[3], W)}│${pad(cells[4], W)}│`;
210
+ }
211
+ const lines = [
212
+ '',
213
+ `${BOLD} AUN Messaging Benchmark Results${RST}`,
214
+ '',
215
+ top,
216
+ row([' Metric', 'All', 'Small', 'Medium', 'Large']),
217
+ sep,
218
+ ...rows.map(r => row([' ' + r[0], r[1], r[2], r[3], r[4]])),
219
+ sep,
220
+ row([' AIDs used', String(meta.aids), '', '', '']),
221
+ row([' Concurrency', String(meta.concurrency), '', '', '']),
222
+ row([' Duration', meta.duration.toFixed(2) + 's', '', '', '']),
223
+ bot,
224
+ '',
225
+ ];
226
+ return lines.join('\n');
227
+ }
228
+ // ==================== Metrics Calculation ====================
229
+ function computeMetrics(results, received, durationSec) {
230
+ const sent = results.filter(r => r.ok).length;
231
+ const recvCount = received.length;
232
+ const lossRate = sent > 0 ? ((sent - recvCount) / sent) * 100 : 0;
233
+ const throughput = durationSec > 0 ? sent / durationSec : 0;
234
+ const latencies = received
235
+ .map(r => r.serverTimestamp - r.sendTimestamp)
236
+ .filter(l => l >= 0)
237
+ .sort((a, b) => a - b);
238
+ const latencyAvg = latencies.length > 0 ? latencies.reduce((a, b) => a + b, 0) / latencies.length : 0;
239
+ const latencyP50 = percentile(latencies, 50);
240
+ const latencyP95 = percentile(latencies, 95);
241
+ const latencyP99 = percentile(latencies, 99);
242
+ const sendTimes = results.filter(r => r.ok).map(r => r.sendMs);
243
+ const sendTimeAvg = sendTimes.length > 0 ? sendTimes.reduce((a, b) => a + b, 0) / sendTimes.length : 0;
244
+ return { sent, received: recvCount, lossRate, throughput, latencyAvg, latencyP50, latencyP95, latencyP99, sendTimeAvg };
245
+ }
246
+ function filterBySize(results, received, cls, durationSec) {
247
+ return computeMetrics(results.filter(r => r.sizeClass === cls), received.filter(r => r.sizeClass === cls), durationSec);
248
+ }
249
+ async function benchAuth(aids, concurrency, aunPath, slotId) {
250
+ const { AUNClient } = await import('@agentunion/fastaun');
251
+ const path = (await import('path')).default;
252
+ const fs = (await import('fs')).default;
253
+ const os = (await import('os')).default;
254
+ const resolvedAunPath = aunPath ?? path.join(os.homedir(), '.aun');
255
+ const caCertPath = path.join(resolvedAunPath, 'CA', 'root', 'root.crt');
256
+ const tasks = aids.map(aid => async () => {
257
+ const start = Date.now();
258
+ try {
259
+ const clientOpts = { aun_path: resolvedAunPath, debug: false };
260
+ if (fs.existsSync(caCertPath))
261
+ clientOpts.root_ca_path = caCertPath;
262
+ const client = new AUNClient(clientOpts);
263
+ await client.auth.createAid({ aid });
264
+ const authResult = await client.auth.authenticate({ aid });
265
+ const accessToken = authResult?.access_token ?? client._access_token;
266
+ const gateway = client._gatewayUrl ?? authResult?.gateway ?? '';
267
+ await client.connect({ access_token: accessToken, gateway, slot_id: slotId ?? '', connection_kind: 'short' }, { auto_reconnect: false });
268
+ try {
269
+ await client.close();
270
+ }
271
+ catch { }
272
+ return { aid, ok: true, authMs: Date.now() - start, gateway };
273
+ }
274
+ catch (e) {
275
+ return { aid, ok: false, authMs: Date.now() - start, error: e.message };
276
+ }
277
+ });
278
+ return runPool(tasks, concurrency);
279
+ }
280
+ async function benchSwitch(aids, rounds, aunPath, useCli, slotId, encrypt) {
281
+ const results = [];
282
+ const start = Date.now();
283
+ let seq = 0;
284
+ const slot = slotId ?? 'bench';
285
+ for (let round = 0; round < rounds; round++) {
286
+ for (let i = 0; i < aids.length; i++) {
287
+ const from = aids[i];
288
+ const to = aids[(i + 1) % aids.length];
289
+ const sendTs = Date.now();
290
+ const text = `[switch:${String(seq).padStart(4, '0')}:${sendTs}] ping`;
291
+ const t0 = Date.now();
292
+ let ok = false;
293
+ let serverTimestamp;
294
+ if (useCli) {
295
+ try {
296
+ const res = await withTimeout(cliSend(from, to, text, slot, encrypt), 10000, `${from.split('.')[0]}→${to.split('.')[0]}`);
297
+ ok = res.ok;
298
+ serverTimestamp = res.timestamp;
299
+ }
300
+ catch {
301
+ ok = false;
302
+ }
303
+ }
304
+ else {
305
+ try {
306
+ const res = await withTimeout(msgSend({ from, to, body: { mode: 'text', text }, slotId: slot, aunPath, encrypt }), 10000, `${from.split('.')[0]}→${to.split('.')[0]}`);
307
+ ok = res.ok;
308
+ serverTimestamp = res.ok ? res.timestamp : undefined;
309
+ }
310
+ catch {
311
+ ok = false;
312
+ }
313
+ }
314
+ const totalMs = Date.now() - t0;
315
+ results.push({ seq: seq++, from, to, ok, totalMs, sendTimestamp: sendTs, serverTimestamp });
316
+ }
317
+ }
318
+ return { results, durationSec: (Date.now() - start) / 1000 };
319
+ }
320
+ // ==================== CLI Mode Helpers ====================
321
+ async function cliSend(from, to, text, slotId, encrypt) {
322
+ const path = (await import('path')).default;
323
+ const bin = path.join(getPackageRoot(), 'dist', 'cli', 'index.js');
324
+ const sendArgs = [bin, 'msg', 'send', from, to, text, '--app', slotId, '--format', 'json'];
325
+ if (encrypt)
326
+ sendArgs.push('--encrypt');
327
+ try {
328
+ const { stdout } = await execFileAsync('node', sendArgs, { timeout: 30000 });
329
+ const res = JSON.parse(stdout.trim());
330
+ return { ok: true, timestamp: res.timestamp };
331
+ }
332
+ catch (e) {
333
+ const stderr = e.stderr || e.message || String(e);
334
+ return { ok: false, error: stderr.slice(0, 200) };
335
+ }
336
+ }
337
+ async function cliPull(from, slotId, afterSeq) {
338
+ const path = (await import('path')).default;
339
+ const bin = path.join(getPackageRoot(), 'dist', 'cli', 'index.js');
340
+ const pullArgs = [bin, 'msg', 'pull', from, '--app', slotId, '--format', 'json', '--limit', '200'];
341
+ if (afterSeq !== undefined)
342
+ pullArgs.push('--after-seq', String(afterSeq));
343
+ try {
344
+ const { stdout } = await execFileAsync('node', pullArgs, { timeout: 30000 });
345
+ const res = JSON.parse(stdout.trim());
346
+ return { ok: true, messages: res.messages ?? [] };
347
+ }
348
+ catch (e) {
349
+ return { ok: false, error: (e.stderr || e.message || '').slice(0, 200) };
350
+ }
351
+ }
352
+ async function cliAuth(aid, slotId) {
353
+ const path = (await import('path')).default;
354
+ const bin = path.join(getPackageRoot(), 'dist', 'cli', 'index.js');
355
+ const start = Date.now();
356
+ try {
357
+ await execFileAsync('node', [bin, 'msg', 'online', aid, aid, '--app', slotId, '--format', 'json'], { timeout: 15000 });
358
+ return { ok: true, authMs: Date.now() - start };
359
+ }
360
+ catch {
361
+ return { ok: false, authMs: Date.now() - start };
362
+ }
363
+ }
364
+ // ==================== Main Command ====================
365
+ export async function cmdBench(args) {
366
+ if (args[0] === 'help' || args[0] === '--help' || args[0] === '-h') {
367
+ console.log(`用法: evolclaw bench [options]
368
+
369
+ AUN 消息系统性能基准测试。使用多个本地 AID 并发互发消息,
370
+ 测量吞吐量、延迟、丢失率,以及认证性能和账号切换性能。
371
+
372
+ Options:
373
+ --aids N 使用的 AID 数量 (默认 3, 范围 2-10)
374
+ --rounds N 每个 AID 发送的消息轮数 (默认 20)
375
+ --concurrency N 最大并发发送数 (默认 5)
376
+ --wait N 发送后等待传播的秒数 (默认 3)
377
+ --encrypt 发送加密消息(E2EE)
378
+ --file 文件传输验证模式(压缩 evolclaw 目录,拆片发送,接收后 MD5 校验+解压)
379
+ --cli 使用 CLI 子进程调用(测试完整命令行链路,含进程启动开销)
380
+ --aun-path <path> 自定义 AUN 目录
381
+ --format json 以 JSON 输出结果
382
+
383
+ 示例:
384
+ evolclaw bench --aids 5 --rounds 10
385
+ evolclaw bench --file --rounds 50
386
+ evolclaw bench --encrypt --aids 5 --rounds 20
387
+ evolclaw bench --cli --aids 3 --rounds 5`);
388
+ return;
389
+ }
390
+ const numAids = Math.min(10, Math.max(2, parseInt(getArgValue(args, '--aids') || '3', 10)));
391
+ const rounds = Math.max(1, parseInt(getArgValue(args, '--rounds') || '20', 10));
392
+ const concurrency = Math.max(1, parseInt(getArgValue(args, '--concurrency') || '5', 10));
393
+ const aunPath = getArgValue(args, '--aun-path');
394
+ const formatJson = args.includes('--format') && args[args.indexOf('--format') + 1] === 'json';
395
+ const cliMode = args.includes('--cli');
396
+ const encrypt = args.includes('--encrypt');
397
+ const fileMode = args.includes('--file');
398
+ const defaultWait = encrypt ? '10' : '3';
399
+ const waitSec = Math.max(1, parseInt(getArgValue(args, '--wait') || defaultWait, 10));
400
+ const sessionId = crypto.randomBytes(4).toString('hex');
401
+ const benchSlot = `bench-${sessionId}`;
402
+ if (!formatJson) {
403
+ console.log(`\n${BOLD} evolclaw bench${RST} — AUN 消息性能基准测试`);
404
+ console.log(` ${'━'.repeat(50)}`);
405
+ console.log(` ${DIM}模式: ${cliMode ? 'CLI 子进程(含进程启动开销)' : 'SDK 直调(纯网络性能)'}${encrypt ? ' + E2EE 加密' : ''}${RST}\n`);
406
+ }
407
+ // ── Phase 1: Prepare AIDs ──
408
+ if (!formatJson)
409
+ console.log(`${DIM} Phase 1: 准备 AIDs${RST}`);
410
+ const allAids = aidList(aunPath);
411
+ const aids = [];
412
+ // AID is usable if: has private key + cert not expired + key/cert public key match
413
+ const { aidShow } = await import('../aun/aid/identity.js');
414
+ const resolvedAunPath = aunPath ?? path.join(os.homedir(), '.aun');
415
+ const usableAids = [];
416
+ const skippedAids = [];
417
+ for (const a of allAids) {
418
+ if (!a.hasPrivateKey)
419
+ continue;
420
+ try {
421
+ const info = aidShow(a.aid, { aunPath });
422
+ if (!info.certExpiresAt) {
423
+ skippedAids.push({ aid: a.aid, reason: '无证书' });
424
+ continue;
425
+ }
426
+ const expiry = new Date(info.certExpiresAt).getTime();
427
+ if (expiry <= Date.now()) {
428
+ skippedAids.push({ aid: a.aid, reason: '证书过期' });
429
+ continue;
430
+ }
431
+ // Verify key/cert public key match (same check as SDK keystore)
432
+ const aidDir = path.join(resolvedAunPath, 'AIDs', a.aid);
433
+ const keyJsonPath = path.join(aidDir, 'private', 'key.json');
434
+ const certPemPath = path.join(aidDir, 'public', 'cert.pem');
435
+ if (!fs.existsSync(keyJsonPath) || !fs.existsSync(certPemPath)) {
436
+ skippedAids.push({ aid: a.aid, reason: '缺少 key.json 或 cert.pem' });
437
+ continue;
438
+ }
439
+ const keyJson = JSON.parse(fs.readFileSync(keyJsonPath, 'utf-8'));
440
+ const localPubB64 = keyJson.public_key_der_b64;
441
+ if (localPubB64) {
442
+ const certPem = fs.readFileSync(certPemPath, 'utf-8');
443
+ const x509 = new crypto.X509Certificate(certPem);
444
+ const certPubDer = x509.publicKey.export({ type: 'spki', format: 'der' });
445
+ const localPubDer = Buffer.from(localPubB64, 'base64');
446
+ if (!certPubDer.equals(localPubDer)) {
447
+ skippedAids.push({ aid: a.aid, reason: '私钥与证书公钥不匹配' });
448
+ continue;
449
+ }
450
+ }
451
+ usableAids.push(a.aid);
452
+ }
453
+ catch (e) {
454
+ skippedAids.push({ aid: a.aid, reason: e.message });
455
+ }
456
+ }
457
+ if (!formatJson) {
458
+ console.log(ok(`本地找到 ${usableAids.length} 个有效 AID(私钥+证书完好+未过期)`));
459
+ if (skippedAids.length > 0) {
460
+ console.log(` ${DIM}跳过 ${skippedAids.length} 个: ${skippedAids.slice(0, 3).map(s => `${s.aid.split('.')[0]}(${s.reason})`).join(', ')}${skippedAids.length > 3 ? ' ...' : ''}${RST}`);
461
+ }
462
+ }
463
+ if (usableAids.length >= numAids) {
464
+ aids.push(...usableAids.slice(0, numAids));
465
+ if (!formatJson)
466
+ console.log(ok(`选取 ${numAids} 个 AID`));
467
+ }
468
+ else {
469
+ aids.push(...usableAids);
470
+ const need = numAids - usableAids.length;
471
+ if (!formatJson)
472
+ console.log(warn(`仅 ${usableAids.length} 个可用,需创建 ${need} 个新 AID`));
473
+ for (let i = 0; i < need; i++) {
474
+ const hex = crypto.randomBytes(4).toString('hex');
475
+ const newAid = `bench-${hex}.agentid.pub`;
476
+ try {
477
+ await aidCreate(newAid, { aunPath });
478
+ aids.push(newAid);
479
+ if (!formatJson)
480
+ console.log(ok(`创建 ${newAid}`));
481
+ }
482
+ catch (e) {
483
+ if (!formatJson)
484
+ console.log(fail(`创建 ${newAid} 失败: ${e.message}`));
485
+ }
486
+ }
487
+ }
488
+ if (aids.length < 2) {
489
+ console.error(`${RED} ✗ 可用 AID 不足 2 个,无法测试${RST}`);
490
+ process.exit(1);
491
+ }
492
+ if (!formatJson) {
493
+ console.log(` ${DIM}AIDs: ${aids.join(', ')}${RST}\n`);
494
+ }
495
+ // ── Phase 2: Auth Benchmark ──
496
+ if (!formatJson)
497
+ console.log(`${DIM} Phase 2: 认证性能测试${RST}`);
498
+ const authRounds = 3;
499
+ const authTasks = [];
500
+ for (let r = 0; r < authRounds; r++)
501
+ authTasks.push(...aids);
502
+ let authResults;
503
+ if (cliMode) {
504
+ const cliAuthTasks = authTasks.map(aid => async () => {
505
+ const r = await cliAuth(aid, benchSlot);
506
+ return { aid, ok: r.ok, authMs: r.authMs };
507
+ });
508
+ authResults = await runPool(cliAuthTasks, concurrency);
509
+ }
510
+ else {
511
+ authResults = await benchAuth(authTasks, concurrency, aunPath, benchSlot);
512
+ }
513
+ const authOk = authResults.filter(r => r.ok);
514
+ const authFail = authResults.filter(r => !r.ok);
515
+ const authTimes = authOk.map(r => r.authMs).sort((a, b) => a - b);
516
+ const authAvg = authTimes.length > 0 ? authTimes.reduce((a, b) => a + b, 0) / authTimes.length : 0;
517
+ const authP50 = percentile(authTimes, 50);
518
+ const authP95 = percentile(authTimes, 95);
519
+ // Build AID → gateway map for error reporting
520
+ const gatewayMap = new Map();
521
+ for (const r of authResults) {
522
+ if (r.gateway && !gatewayMap.has(r.aid))
523
+ gatewayMap.set(r.aid, r.gateway);
524
+ }
525
+ if (!formatJson) {
526
+ console.log(ok(`认证 ${authOk.length}/${authResults.length} 次成功 avg=${Math.round(authAvg)}ms P50=${authP50}ms P95=${authP95}ms`));
527
+ if (authFail.length > 0)
528
+ console.log(fail(`${authFail.length} 次认证失败`));
529
+ console.log('');
530
+ }
531
+ // ── Phase 3: Concurrent Send ──
532
+ if (!formatJson)
533
+ console.log(`${DIM} Phase 3: 并发消息发送${fileMode ? '(文件传输验证模式)' : '(混合大小:S/M/L)'}${RST}`);
534
+ // File mode: compress evolclaw dir, split into chunks
535
+ let fileChunks = [];
536
+ let fileMd5 = '';
537
+ let compressedSize = 0;
538
+ if (fileMode) {
539
+ const evolclawDir = getPackageRoot();
540
+ if (!formatJson)
541
+ process.stdout.write(` ${DIM}压缩 ${evolclawDir} ...${RST}`);
542
+ const compressed = await compressDirectory(evolclawDir);
543
+ compressedSize = compressed.length;
544
+ fileMd5 = crypto.createHash('md5').update(compressed).digest('hex');
545
+ const totalMsgsForFile = rounds * aids.length;
546
+ fileChunks = splitFileIntoChunks(compressed, totalMsgsForFile);
547
+ if (!formatJson) {
548
+ clearLine();
549
+ console.log(ok(`压缩完成 ${(compressedSize / 1024).toFixed(1)} KB MD5=${fileMd5} 拆分 ${fileChunks.length} 片`));
550
+ }
551
+ }
552
+ // Record each AID's latest_seq before sending, so we can pull from there
553
+ const preSeqMap = new Map();
554
+ for (const aid of aids) {
555
+ try {
556
+ const res = await msgPull({ from: aid, slotId: '', limit: 1, aunPath });
557
+ if (res.ok)
558
+ preSeqMap.set(aid, res.latest_seq ?? 0);
559
+ else
560
+ preSeqMap.set(aid, 0);
561
+ }
562
+ catch {
563
+ preSeqMap.set(aid, 0);
564
+ }
565
+ }
566
+ const totalMsgs = rounds * aids.length;
567
+ const tasks = [];
568
+ const sizeClasses = ['S', 'M', 'L'];
569
+ for (let i = 0; i < totalMsgs; i++) {
570
+ const fromIdx = i % aids.length;
571
+ let toIdx = Math.floor(Math.random() * (aids.length - 1));
572
+ if (toIdx >= fromIdx)
573
+ toIdx++;
574
+ const cls = sizeClasses[i % 3];
575
+ tasks.push({
576
+ seq: i,
577
+ from: aids[fromIdx],
578
+ to: aids[toIdx],
579
+ sizeClass: cls,
580
+ text: '',
581
+ });
582
+ }
583
+ const sendResults = [];
584
+ const counts = { S: 0, M: 0, L: 0 };
585
+ const sendStart = Date.now();
586
+ let lastRender = 0;
587
+ const aidStats = new Map();
588
+ for (const aid of aids)
589
+ aidStats.set(aid, { sent: 0, ok: 0, fail: 0, timeouts: 0 });
590
+ let progressLinesDrawn = 0;
591
+ const MAX_RETRIES = 3;
592
+ const RETRY_DELAY_MS = 500;
593
+ const SEND_TIMEOUT_MS = 10000;
594
+ const sendErrors = [];
595
+ let stallCount = 0;
596
+ let lastProgressTime = Date.now();
597
+ const timeoutCountByAid = new Map();
598
+ // Suppress SDK error logs during send phase (we track errors ourselves)
599
+ const origError2 = console.error;
600
+ console.error = () => { };
601
+ const sendFns = tasks.map(t => async () => {
602
+ let lastError;
603
+ let retries = 0;
604
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
605
+ if (attempt > 0) {
606
+ retries++;
607
+ await new Promise(r => setTimeout(r, RETRY_DELAY_MS * attempt));
608
+ }
609
+ const sendTs = Date.now();
610
+ const text = fileMode
611
+ ? buildFileChunkText(t.seq, fileChunks.length, sendTs, sessionId, fileChunks[t.seq]?.toString('base64') ?? '')
612
+ : buildMessageText(t.seq, t.sizeClass, sendTs, sessionId);
613
+ const t0 = Date.now();
614
+ try {
615
+ const sendPromise = cliMode
616
+ ? cliSend(t.from, t.to, text, benchSlot, encrypt)
617
+ : msgSend({ from: t.from, to: t.to, body: { mode: 'text', text }, slotId: benchSlot, aunPath, encrypt });
618
+ const res = await withTimeout(sendPromise, SEND_TIMEOUT_MS, `${t.from.split('.')[0]}→${t.to.split('.')[0]}`);
619
+ if (cliMode) {
620
+ const cliRes = res;
621
+ if (cliRes.ok) {
622
+ return {
623
+ seq: t.seq, sizeClass: t.sizeClass, ok: true, sendMs: Date.now() - t0,
624
+ serverTimestamp: cliRes.timestamp, sendTimestamp: sendTs,
625
+ from: t.from, to: t.to, retries,
626
+ };
627
+ }
628
+ lastError = cliRes.error;
629
+ }
630
+ else {
631
+ const sdkRes = res;
632
+ if (sdkRes.ok) {
633
+ return {
634
+ seq: t.seq, sizeClass: t.sizeClass, ok: true, sendMs: Date.now() - t0,
635
+ serverTimestamp: sdkRes.timestamp, sendTimestamp: sendTs,
636
+ from: t.from, to: t.to, retries,
637
+ };
638
+ }
639
+ lastError = sdkRes.error;
640
+ }
641
+ }
642
+ catch (e) {
643
+ lastError = e.message || String(e);
644
+ if (lastError.includes('timeout')) {
645
+ stallCount++;
646
+ const aidStat = aidStats.get(t.from);
647
+ if (aidStat)
648
+ aidStat.timeouts++;
649
+ const aidCount = (timeoutCountByAid.get(t.from) || 0) + 1;
650
+ timeoutCountByAid.set(t.from, aidCount);
651
+ if (!formatJson) {
652
+ const gw = gatewayMap.get(t.from) || '?';
653
+ const now = new Date();
654
+ const ts = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}.${String(now.getMilliseconds()).padStart(3, '0')}`;
655
+ clearLine();
656
+ process.stdout.write(` ${YELLOW}⏳ [${ts}] 连接超时: ${t.from.split('.')[0]}→${t.to.split('.')[0]} gw=${gw} (${t.from.split('.')[0]} 第${aidCount}次超时)${RST}\n`);
657
+ progressLinesDrawn = 0;
658
+ }
659
+ }
660
+ }
661
+ }
662
+ return {
663
+ seq: t.seq, sizeClass: t.sizeClass, ok: false,
664
+ sendMs: 0, sendTimestamp: Date.now(),
665
+ from: t.from, to: t.to, error: lastError, retries,
666
+ };
667
+ });
668
+ // Stall watchdog: warn when no progress for 5s
669
+ const stallWatchdog = !formatJson ? setInterval(() => {
670
+ const stallMs = Date.now() - lastProgressTime;
671
+ if (stallMs > 5000) {
672
+ clearLine();
673
+ process.stdout.write(` ${YELLOW}⏳ ${(stallMs / 1000).toFixed(0)}s 无进展 — 等待连接超时中 (已完成 ${sendResults.length}/${totalMsgs}, timeout ${stallCount} 次)${RST}\n`);
674
+ progressLinesDrawn = 0;
675
+ }
676
+ }, 2000) : null;
677
+ await runPool(sendFns, concurrency, (r) => {
678
+ sendResults.push(r);
679
+ counts[r.sizeClass]++;
680
+ const stat = aidStats.get(r.from);
681
+ stat.sent++;
682
+ if (r.ok)
683
+ stat.ok++;
684
+ else
685
+ stat.fail++;
686
+ if (!r.ok && !formatJson) {
687
+ const gw = gatewayMap.get(r.from) || '?';
688
+ sendErrors.push(`seq=${r.seq} ${r.from.split('.')[0]}→${r.to.split('.')[0]} retry=${r.retries} gw=${gw} err=${(r.error || '').slice(0, 80)}`);
689
+ }
690
+ lastProgressTime = Date.now();
691
+ const now = Date.now();
692
+ if (!formatJson && (now - lastRender > 200 || sendResults.length === totalMsgs)) {
693
+ lastRender = now;
694
+ const elapsed = (now - sendStart) / 1000;
695
+ const rate = sendResults.length / Math.max(elapsed, 0.001);
696
+ if (progressLinesDrawn > 0)
697
+ moveCursorUp(progressLinesDrawn);
698
+ for (let i = 0; i < progressLinesDrawn; i++) {
699
+ clearLine();
700
+ process.stdout.write('\n');
701
+ }
702
+ if (progressLinesDrawn > 0)
703
+ moveCursorUp(progressLinesDrawn);
704
+ renderMultiLineProgress(aids, aidStats, totalMsgs, sendResults.length, rate);
705
+ progressLinesDrawn = aids.length + 1;
706
+ }
707
+ });
708
+ if (stallWatchdog)
709
+ clearInterval(stallWatchdog);
710
+ // Restore console.error after send phase
711
+ console.error = origError2;
712
+ const sendDuration = (Date.now() - sendStart) / 1000;
713
+ if (!formatJson) {
714
+ const okCount = sendResults.filter(r => r.ok).length;
715
+ const totalRetries = sendResults.reduce((sum, r) => sum + r.retries, 0);
716
+ const retriedCount = sendResults.filter(r => r.retries > 0).length;
717
+ let retryInfo = '';
718
+ if (totalRetries > 0)
719
+ retryInfo = ` ${DIM}(${retriedCount} 条重试,共 ${totalRetries} 次)${RST}`;
720
+ console.log(ok(`发送完成 ${okCount}/${totalMsgs} 耗时 ${sendDuration.toFixed(2)}s ${(okCount / sendDuration).toFixed(1)} msg/s${retryInfo}`));
721
+ if (sendErrors.length > 0) {
722
+ console.log(fail(`${sendErrors.length} 条最终失败:`));
723
+ for (const e of sendErrors.slice(0, 5)) {
724
+ console.log(` ${DIM}${e}${RST}`);
725
+ }
726
+ if (sendErrors.length > 5)
727
+ console.log(` ${DIM}... +${sendErrors.length - 5}${RST}`);
728
+ }
729
+ console.log('');
730
+ }
731
+ // ── Phase 4: Pull & Verify ──
732
+ const received = [];
733
+ const receivedSeqs = new Set();
734
+ const receivedFileChunks = new Map();
735
+ const sentSeqs = new Set(sendResults.filter(r => r.ok).map(r => r.seq));
736
+ if (encrypt) {
737
+ // Encrypted messages can't be pulled via short connection (SDK limitation).
738
+ // Use send result (ok=true means gateway confirmed delivery).
739
+ if (!formatJson)
740
+ console.log(`${DIM} Phase 4: 送达验证(加密模式:基于网关确认)${RST}`);
741
+ for (const r of sendResults) {
742
+ if (r.ok) {
743
+ receivedSeqs.add(r.seq);
744
+ received.push({
745
+ seq: r.seq,
746
+ sizeClass: r.sizeClass,
747
+ sendTimestamp: r.sendTimestamp,
748
+ serverTimestamp: r.serverTimestamp ?? r.sendTimestamp,
749
+ });
750
+ }
751
+ }
752
+ if (!formatJson) {
753
+ const delivered = received.length;
754
+ const sentOk = sendResults.filter(r => r.ok).length;
755
+ console.log(ok(`送达确认 ${delivered}/${sentOk} 条(网关返回 delivered)`));
756
+ if (delivered < sentOk) {
757
+ console.log(warn(`${sentOk - delivered} 条发送成功但未获得 delivered 确认`));
758
+ }
759
+ console.log('');
760
+ }
761
+ }
762
+ else {
763
+ if (!formatJson)
764
+ console.log(`${DIM} Phase 4: 拉取消息验证(等待 ${waitSec}s 传播)${RST}`);
765
+ await new Promise(r => setTimeout(r, waitSec * 1000));
766
+ async function pullAll() {
767
+ for (let i = 0; i < aids.length; i++) {
768
+ const aid = aids[i];
769
+ let afterSeq = preSeqMap.get(aid) ?? undefined;
770
+ for (let page = 0; page < 20; page++) {
771
+ let messages = [];
772
+ if (cliMode) {
773
+ const res = await cliPull(aid, '', afterSeq);
774
+ if (!res.ok) {
775
+ if (!formatJson)
776
+ console.log(fail(`拉取 ${aid} 失败: ${res.error}`));
777
+ break;
778
+ }
779
+ messages = res.messages ?? [];
780
+ }
781
+ else {
782
+ let res;
783
+ try {
784
+ res = await msgPull({ from: aid, slotId: '', limit: 200, afterSeq, aunPath });
785
+ }
786
+ catch (e) {
787
+ if (!formatJson)
788
+ console.log(fail(`拉取 ${aid} 异常: ${e.message}`));
789
+ break;
790
+ }
791
+ if (!res.ok) {
792
+ if (!formatJson)
793
+ console.log(fail(`拉取 ${aid} 失败: ${res.error}`));
794
+ break;
795
+ }
796
+ messages = res.messages ?? [];
797
+ }
798
+ for (const m of messages) {
799
+ const text = typeof m.payload?.text === 'string' ? m.payload.text : '';
800
+ if (fileMode) {
801
+ const chunk = parseFileChunk(text, sessionId);
802
+ if (chunk && !receivedSeqs.has(chunk.seq)) {
803
+ receivedSeqs.add(chunk.seq);
804
+ receivedFileChunks.set(chunk.seq, chunk.data);
805
+ received.push({
806
+ seq: chunk.seq,
807
+ sizeClass: 'M',
808
+ sendTimestamp: chunk.sendTimestamp,
809
+ serverTimestamp: m.timestamp,
810
+ });
811
+ }
812
+ }
813
+ else {
814
+ const parsed = parseMessage(text, sessionId);
815
+ if (parsed && !receivedSeqs.has(parsed.seq)) {
816
+ receivedSeqs.add(parsed.seq);
817
+ received.push({
818
+ seq: parsed.seq,
819
+ sizeClass: parsed.sizeClass,
820
+ sendTimestamp: parsed.sendTimestamp,
821
+ serverTimestamp: m.timestamp,
822
+ });
823
+ }
824
+ }
825
+ afterSeq = Math.max(afterSeq ?? 0, m.seq);
826
+ }
827
+ if (messages.length < 200)
828
+ break;
829
+ }
830
+ if (!formatJson) {
831
+ clearLine();
832
+ process.stdout.write(` Pulling [${CYAN}${'█'.repeat(i + 1)}${'░'.repeat(aids.length - i - 1)}${RST}] ${i + 1}/${aids.length} recv=${received.length}`);
833
+ }
834
+ }
835
+ }
836
+ const expectedCount = sentSeqs.size;
837
+ const PULL_MAX_WAIT_SEC = 60;
838
+ const PULL_INTERVAL_MS = 2000;
839
+ const pullStartTime = Date.now();
840
+ let lastNewCount = received.length;
841
+ let lastNewTime = Date.now();
842
+ let pullRound = 0;
843
+ while (true) {
844
+ await pullAll();
845
+ pullRound++;
846
+ const missing = expectedCount - receivedSeqs.size;
847
+ if (missing === 0)
848
+ break;
849
+ const elapsedSec = (Date.now() - pullStartTime) / 1000;
850
+ const noNewSec = (Date.now() - lastNewTime) / 1000;
851
+ if (received.length > lastNewCount) {
852
+ lastNewCount = received.length;
853
+ lastNewTime = Date.now();
854
+ }
855
+ if (elapsedSec > PULL_MAX_WAIT_SEC) {
856
+ if (!formatJson) {
857
+ clearLine();
858
+ console.log(warn(`拉取超时 ${PULL_MAX_WAIT_SEC}s,仍缺 ${missing} 条`));
859
+ }
860
+ break;
861
+ }
862
+ if (noNewSec > 20) {
863
+ if (!formatJson) {
864
+ clearLine();
865
+ console.log(warn(`连续 ${Math.round(noNewSec)}s 无新消息到达,仍缺 ${missing} 条,停止等待`));
866
+ }
867
+ break;
868
+ }
869
+ if (!formatJson) {
870
+ clearLine();
871
+ process.stdout.write(` ${DIM}等待中... 已收 ${receivedSeqs.size}/${expectedCount} 缺 ${missing} 条 (${elapsedSec.toFixed(0)}s elapsed, round ${pullRound})${RST}`);
872
+ }
873
+ await new Promise(r => setTimeout(r, PULL_INTERVAL_MS));
874
+ }
875
+ if (!formatJson) {
876
+ clearLine();
877
+ console.log(ok(`拉取完成 收到 ${received.length}/${sentSeqs.size} 条消息 (${pullRound} 轮, ${((Date.now() - pullStartTime) / 1000).toFixed(1)}s)`));
878
+ console.log('');
879
+ }
880
+ } // end else (non-encrypt pull)
881
+ // ── File mode: reassemble + verify ──
882
+ if (fileMode && receivedFileChunks.size > 0) {
883
+ if (!formatJson)
884
+ console.log(`${DIM} 文件还原验证${RST}`);
885
+ const totalChunks = fileChunks.length;
886
+ const missingChunks = [];
887
+ for (let i = 0; i < totalChunks; i++) {
888
+ if (!receivedFileChunks.has(i))
889
+ missingChunks.push(i);
890
+ }
891
+ if (missingChunks.length > 0) {
892
+ if (!formatJson)
893
+ console.log(fail(`缺失 ${missingChunks.length}/${totalChunks} 个片段,无法还原文件`));
894
+ }
895
+ else {
896
+ const parts = [];
897
+ for (let i = 0; i < totalChunks; i++) {
898
+ parts.push(Buffer.from(receivedFileChunks.get(i), 'base64'));
899
+ }
900
+ const reassembled = Buffer.concat(parts);
901
+ const recvMd5 = crypto.createHash('md5').update(reassembled).digest('hex');
902
+ const md5Match = recvMd5 === fileMd5;
903
+ if (!formatJson) {
904
+ console.log(ok(`还原文件 ${(reassembled.length / 1024).toFixed(1)} KB`));
905
+ console.log(` ${DIM}发送 MD5: ${fileMd5}${RST}`);
906
+ console.log(` ${DIM}接收 MD5: ${recvMd5}${RST}`);
907
+ if (md5Match) {
908
+ console.log(ok(`MD5 校验通过 ✓`));
909
+ // Decompress
910
+ const tmpDir = path.join(os.tmpdir(), `bench-recv-${sessionId}`);
911
+ try {
912
+ await decompressToDir(reassembled, tmpDir);
913
+ console.log(ok(`解压完成 → ${tmpDir}`));
914
+ }
915
+ catch (e) {
916
+ console.log(fail(`解压失败: ${e.message}`));
917
+ }
918
+ }
919
+ else {
920
+ console.log(fail(`MD5 不匹配!文件损坏`));
921
+ }
922
+ }
923
+ }
924
+ if (!formatJson)
925
+ console.log('');
926
+ }
927
+ // ── Phase 5: Switch-Account Benchmark ──
928
+ if (!formatJson)
929
+ console.log(`${DIM} Phase 5: 频繁切换账号收发测试${RST}`);
930
+ const switchRounds = Math.max(2, Math.min(rounds, 5));
931
+ const switchOut = await benchSwitch(aids, switchRounds, aunPath, cliMode, benchSlot, encrypt);
932
+ const switchOkResults = switchOut.results.filter(r => r.ok);
933
+ const switchLatencies = switchOkResults
934
+ .filter(r => r.serverTimestamp !== undefined)
935
+ .map(r => r.serverTimestamp - r.sendTimestamp)
936
+ .filter(l => l >= 0)
937
+ .sort((a, b) => a - b);
938
+ const switchSendTimes = switchOkResults.map(r => r.totalMs).sort((a, b) => a - b);
939
+ const switchAvgLatency = switchLatencies.length > 0 ? switchLatencies.reduce((a, b) => a + b, 0) / switchLatencies.length : 0;
940
+ const switchAvgSendMs = switchSendTimes.length > 0 ? switchSendTimes.reduce((a, b) => a + b, 0) / switchSendTimes.length : 0;
941
+ const switchThroughput = switchOut.durationSec > 0 ? switchOkResults.length / switchOut.durationSec : 0;
942
+ if (!formatJson) {
943
+ console.log(ok(`切换发送 ${switchOkResults.length}/${switchOut.results.length} 耗时 ${switchOut.durationSec.toFixed(2)}s ${switchThroughput.toFixed(1)} msg/s`));
944
+ console.log(` ${DIM}avg latency=${Math.round(switchAvgLatency)}ms avg send=${Math.round(switchAvgSendMs)}ms P95 send=${percentile(switchSendTimes, 95)}ms${RST}`);
945
+ console.log('');
946
+ }
947
+ // ── Phase 6: Compute Metrics & Output ──
948
+ // Detect unavailable AIDs: 100% loss as receiver
949
+ const unavailableAids = [];
950
+ for (const aid of aids) {
951
+ const sentToAid = sendResults.filter(r => r.ok && r.to === aid);
952
+ const recvFromAid = received.filter(r => {
953
+ const sr = sendResults.find(s => s.seq === r.seq);
954
+ return sr && sr.to === aid;
955
+ });
956
+ if (sentToAid.length > 0 && recvFromAid.length === 0) {
957
+ unavailableAids.push(aid);
958
+ }
959
+ }
960
+ // Filter out unavailable AIDs from metrics
961
+ const effectiveResults = unavailableAids.length > 0
962
+ ? sendResults.filter(r => !unavailableAids.includes(r.to))
963
+ : sendResults;
964
+ const effectiveReceived = unavailableAids.length > 0
965
+ ? received.filter(r => {
966
+ const sr = sendResults.find(s => s.seq === r.seq);
967
+ return sr && !unavailableAids.includes(sr.to);
968
+ })
969
+ : received;
970
+ const effectiveSentSeqs = new Set(effectiveResults.filter(r => r.ok).map(r => r.seq));
971
+ const all = computeMetrics(effectiveResults, effectiveReceived, sendDuration);
972
+ const bySize = {
973
+ S: filterBySize(effectiveResults, effectiveReceived, 'S', sendDuration),
974
+ M: filterBySize(effectiveResults, effectiveReceived, 'M', sendDuration),
975
+ L: filterBySize(effectiveResults, effectiveReceived, 'L', sendDuration),
976
+ };
977
+ const missingSeqs = [...effectiveSentSeqs].filter(s => !receivedSeqs.has(s));
978
+ if (formatJson) {
979
+ console.log(JSON.stringify({
980
+ ok: true,
981
+ config: { aids: aids.length, rounds, concurrency, waitSec, cliMode, encrypt },
982
+ aids,
983
+ auth: {
984
+ attempts: authResults.length,
985
+ ok: authOk.length,
986
+ failed: authFail.length,
987
+ avgMs: Math.round(authAvg),
988
+ p50Ms: authP50,
989
+ p95Ms: authP95,
990
+ },
991
+ messaging: {
992
+ all,
993
+ bySize,
994
+ durationSec: sendDuration,
995
+ missingSeqs,
996
+ },
997
+ switchAccount: {
998
+ rounds: switchRounds,
999
+ attempts: switchOut.results.length,
1000
+ ok: switchOkResults.length,
1001
+ durationSec: switchOut.durationSec,
1002
+ throughput: switchThroughput,
1003
+ avgLatencyMs: Math.round(switchAvgLatency),
1004
+ avgSendMs: Math.round(switchAvgSendMs),
1005
+ },
1006
+ }, null, 2));
1007
+ return;
1008
+ }
1009
+ console.log(renderTable(all, bySize, { aids: aids.length, concurrency, duration: sendDuration }));
1010
+ // 附加指标
1011
+ const W2 = 22;
1012
+ console.log(`${BOLD} 附加性能指标${RST}\n`);
1013
+ console.log(` ${pad('认证平均耗时', W2, 'left')} ${BOLD}${Math.round(authAvg)}ms${RST} (P50=${authP50}ms, P95=${authP95}ms, 样本 ${authOk.length})`);
1014
+ console.log(` ${pad('认证失败次数', W2, 'left')} ${authFail.length > 0 ? RED : GREEN}${authFail.length}${RST}`);
1015
+ console.log(` ${pad('切换账号 throughput', W2, 'left')} ${BOLD}${switchThroughput.toFixed(1)} msg/s${RST} (${switchOkResults.length} 次,${switchOut.durationSec.toFixed(2)}s)`);
1016
+ console.log(` ${pad('切换账号 avg latency', W2, 'left')} ${BOLD}${Math.round(switchAvgLatency)}ms${RST}`);
1017
+ console.log(` ${pad('切换账号 avg send', W2, 'left')} ${BOLD}${Math.round(switchAvgSendMs)}ms${RST}`);
1018
+ console.log('');
1019
+ // 带宽估算
1020
+ const totalBytes = sendResults.filter(r => r.ok).reduce((sum, r) => sum + targetSize(r.sizeClass), 0);
1021
+ const bandwidthKBps = sendDuration > 0 ? (totalBytes / 1024) / sendDuration : 0;
1022
+ console.log(` ${pad('总发送字节', W2, 'left')} ${BOLD}${(totalBytes / 1024).toFixed(1)} KB${RST}`);
1023
+ console.log(` ${pad('带宽(发送方向)', W2, 'left')} ${BOLD}${bandwidthKBps.toFixed(1)} KB/s${RST}`);
1024
+ console.log('');
1025
+ // ── 送达分析报告 ──
1026
+ renderDeliveryAnalysis(sendResults, received, receivedSeqs, sentSeqs, aids, totalMsgs, unavailableAids);
1027
+ }
1028
+ function classifyLossReason(r) {
1029
+ if (!r.ok) {
1030
+ const err = (r.error || '').toLowerCase();
1031
+ if (err.includes('429') || err.includes('rate') || err.includes('throttl'))
1032
+ return 'send_fail_429';
1033
+ if (err.includes('timeout') || err.includes('timed out'))
1034
+ return 'send_fail_timeout';
1035
+ if (err.includes('connect') || err.includes('econnrefused') || err.includes('socket'))
1036
+ return 'send_fail_conn';
1037
+ return 'send_fail_other';
1038
+ }
1039
+ return 'pull_not_found';
1040
+ }
1041
+ const REASON_LABELS = {
1042
+ send_fail_429: '网关限流 (429/rate limit)',
1043
+ send_fail_timeout: '发送超时',
1044
+ send_fail_conn: '连接建立失败',
1045
+ send_fail_other: '发送失败(其它)',
1046
+ pull_not_found: '发送成功但 pull 未收到',
1047
+ };
1048
+ function renderDeliveryAnalysis(sendResults, received, receivedSeqs, sentSeqs, aids, totalMsgs, unavailableAids) {
1049
+ const allSent = sendResults.length;
1050
+ const sendOkCount = sendResults.filter(r => r.ok).length;
1051
+ const sendFailCount = allSent - sendOkCount;
1052
+ const pullFoundCount = received.length;
1053
+ // Retry stats
1054
+ const totalRetries = sendResults.reduce((sum, r) => sum + r.retries, 0);
1055
+ const retriedCount = sendResults.filter(r => r.retries > 0).length;
1056
+ const retriedAndOk = sendResults.filter(r => r.retries > 0 && r.ok).length;
1057
+ const retriedAndFail = sendResults.filter(r => r.retries > 0 && !r.ok).length;
1058
+ // Build loss records
1059
+ const losses = [];
1060
+ // Send failures
1061
+ for (const r of sendResults.filter(r => !r.ok)) {
1062
+ losses.push({
1063
+ seq: r.seq, from: r.from, to: r.to, sizeClass: r.sizeClass,
1064
+ sendOk: false, pullFound: false,
1065
+ reason: classifyLossReason(r), error: r.error,
1066
+ sendTimestamp: r.sendTimestamp,
1067
+ });
1068
+ }
1069
+ // Send ok but pull not found
1070
+ for (const r of sendResults.filter(r => r.ok && !receivedSeqs.has(r.seq))) {
1071
+ losses.push({
1072
+ seq: r.seq, from: r.from, to: r.to, sizeClass: r.sizeClass,
1073
+ sendOk: true, pullFound: false,
1074
+ reason: 'pull_not_found', sendTimestamp: r.sendTimestamp,
1075
+ });
1076
+ }
1077
+ // Exclude unavailable AIDs from loss calculation
1078
+ const effectiveLosses = unavailableAids.length > 0
1079
+ ? losses.filter(l => !unavailableAids.includes(l.to))
1080
+ : losses;
1081
+ // Show unavailable AIDs separately
1082
+ if (unavailableAids.length > 0) {
1083
+ console.log(`${BOLD} 不可用 AID(已排除出统计)${RST}`);
1084
+ for (const aid of unavailableAids) {
1085
+ const sentTo = sendResults.filter(r => r.to === aid).length;
1086
+ const sentFrom = sendResults.filter(r => r.from === aid).length;
1087
+ const aidShort = aid.split('.')[0];
1088
+ console.log(` ${RED}${aidShort}${RST} 发出 ${sentFrom} 条 应收 ${sentTo} 条 pull 收到 0 条`);
1089
+ }
1090
+ console.log('');
1091
+ }
1092
+ if (effectiveLosses.length === 0 && sendFailCount === 0) {
1093
+ console.log(`${GREEN} ✓ 零丢失,所有 ${sendOkCount} 条消息全部送达${RST}`);
1094
+ if (totalRetries > 0) {
1095
+ console.log(` ${DIM}重试统计: ${retriedCount} 条消息触发重试,共 ${totalRetries} 次,重试后成功 ${retriedAndOk} 条,仍失败 ${retriedAndFail} 条${RST}`);
1096
+ }
1097
+ console.log('');
1098
+ return;
1099
+ }
1100
+ console.log(`${BOLD} 送达分析报告${RST}`);
1101
+ console.log(` ${'━'.repeat(50)}\n`);
1102
+ // ── 1. 总览 ──
1103
+ console.log(` ${BOLD}总览${RST}`);
1104
+ const effectiveSent = unavailableAids.length > 0 ? sendResults.filter(r => !unavailableAids.includes(r.to)).length : allSent;
1105
+ const effectiveSendOk = unavailableAids.length > 0 ? sendResults.filter(r => r.ok && !unavailableAids.includes(r.to)).length : sendOkCount;
1106
+ const effectivePullFound = unavailableAids.length > 0 ? received.filter(r => { const sr = sendResults.find(s => s.seq === r.seq); return sr && !unavailableAids.includes(sr.to); }).length : pullFoundCount;
1107
+ console.log(` 总发送 ${effectiveSent} 成功 ${effectiveSendOk} 失败 ${effectiveSent - effectiveSendOk} pull 收到 ${effectivePullFound} 未送达 ${effectiveLosses.length}`);
1108
+ if (totalRetries > 0) {
1109
+ console.log(` ${DIM}重试: ${retriedCount} 条触发重试,共 ${totalRetries} 次 → 成功 ${retriedAndOk} / 仍失败 ${retriedAndFail}${RST}`);
1110
+ }
1111
+ console.log('');
1112
+ // ── 2. 原因分类 ──
1113
+ console.log(` ${BOLD}原因分类${RST}`);
1114
+ const byReason = new Map();
1115
+ for (const l of effectiveLosses) {
1116
+ const arr = byReason.get(l.reason) || [];
1117
+ arr.push(l);
1118
+ byReason.set(l.reason, arr);
1119
+ }
1120
+ const reasonOrder = ['send_fail_429', 'send_fail_timeout', 'send_fail_conn', 'send_fail_other', 'pull_not_found'];
1121
+ for (const reason of reasonOrder) {
1122
+ const items = byReason.get(reason);
1123
+ if (!items || items.length === 0)
1124
+ continue;
1125
+ const pct = ((items.length / effectiveLosses.length) * 100).toFixed(1);
1126
+ const color = reason === 'pull_not_found' ? YELLOW : RED;
1127
+ console.log(` ${color}${pad(String(items.length), 4)} 条${RST} (${pad(pct + '%', 6)}) ${REASON_LABELS[reason]}`);
1128
+ }
1129
+ console.log('');
1130
+ // ── 3. 按接收方分布 ──
1131
+ console.log(` ${BOLD}按接收方 AID 分布${RST}`);
1132
+ const byReceiver = new Map();
1133
+ for (const r of sendResults) {
1134
+ if (unavailableAids.includes(r.to))
1135
+ continue;
1136
+ const entry = byReceiver.get(r.to) || { total: 0, lost: 0 };
1137
+ entry.total++;
1138
+ if (!r.ok || !receivedSeqs.has(r.seq))
1139
+ entry.lost++;
1140
+ byReceiver.set(r.to, entry);
1141
+ }
1142
+ let maxLossRate = 0;
1143
+ let worstAid = '';
1144
+ for (const [aid, stat] of byReceiver) {
1145
+ const rate = stat.total > 0 ? stat.lost / stat.total : 0;
1146
+ const pct = (rate * 100).toFixed(1);
1147
+ const flag = rate > 0.4 ? ` ${RED}← 异常${RST}` : rate > 0.25 ? ` ${YELLOW}← 偏高${RST}` : '';
1148
+ const aidShort = aid.split('.')[0];
1149
+ console.log(` ${pad(aidShort, 16, 'left')} ${stat.lost}/${stat.total} 丢失 (${pct}%)${flag}`);
1150
+ if (rate > maxLossRate) {
1151
+ maxLossRate = rate;
1152
+ worstAid = aid;
1153
+ }
1154
+ }
1155
+ console.log('');
1156
+ // ── 4. 时间段分析 ──
1157
+ console.log(` ${BOLD}时间段分析${RST}`);
1158
+ const midSeq = Math.floor(totalMsgs / 2);
1159
+ const effectiveFirst = sendResults.filter(r => r.seq < midSeq && !unavailableAids.includes(r.to));
1160
+ const effectiveSecond = sendResults.filter(r => r.seq >= midSeq && !unavailableAids.includes(r.to));
1161
+ const firstLost = effectiveFirst.filter(r => !r.ok || !receivedSeqs.has(r.seq)).length;
1162
+ const secondLost = effectiveSecond.filter(r => !r.ok || !receivedSeqs.has(r.seq)).length;
1163
+ const firstPct = effectiveFirst.length > 0 ? ((firstLost / effectiveFirst.length) * 100).toFixed(1) : '0.0';
1164
+ const secondPct = effectiveSecond.length > 0 ? ((secondLost / effectiveSecond.length) * 100).toFixed(1) : '0.0';
1165
+ const degraded = parseFloat(secondPct) > parseFloat(firstPct) * 1.5 && parseFloat(secondPct) > 5;
1166
+ console.log(` 前半段 (seq 0-${midSeq - 1}) ${pad(String(firstLost), 3)}/${effectiveFirst.length} 丢失 (${firstPct}%)`);
1167
+ console.log(` 后半段 (seq ${midSeq}-${totalMsgs - 1}) ${pad(String(secondLost), 3)}/${effectiveSecond.length} 丢失 (${secondPct}%)${degraded ? ` ${RED}← 劣化${RST}` : ''}`);
1168
+ console.log('');
1169
+ // ── 5. 丢失明细(前 10 条)──
1170
+ if (effectiveLosses.length > 0) {
1171
+ console.log(` ${BOLD}丢失明细${RST} ${DIM}(前 10 条)${RST}`);
1172
+ console.log(` ${DIM}${pad('seq', 5, 'left')}${pad('from', 12, 'left')}${pad('to', 12, 'left')}${pad('size', 5, 'left')}${pad('retry', 6, 'left')}${pad('send', 5, 'left')}${pad('pull', 5, 'left')}原因${RST}`);
1173
+ for (const l of effectiveLosses.slice(0, 10)) {
1174
+ const fromShort = l.from.split('.')[0].slice(0, 10);
1175
+ const toShort = l.to.split('.')[0].slice(0, 10);
1176
+ const sendMark = l.sendOk ? `${GREEN}✓${RST}` : `${RED}✗${RST}`;
1177
+ const pullMark = l.pullFound ? `${GREEN}✓${RST}` : `${RED}✗${RST}`;
1178
+ const sr = sendResults.find(s => s.seq === l.seq);
1179
+ const retryStr = sr && sr.retries > 0 ? `×${sr.retries}` : '-';
1180
+ console.log(` ${pad(String(l.seq), 5, 'left')}${pad(fromShort, 12, 'left')}${pad(toShort, 12, 'left')}${pad(l.sizeClass, 5, 'left')}${pad(retryStr, 6, 'left')}${sendMark}${' '.repeat(4)}${pullMark}${' '.repeat(4)}${REASON_LABELS[l.reason]}`);
1181
+ }
1182
+ if (effectiveLosses.length > 10) {
1183
+ console.log(` ${DIM}... +${effectiveLosses.length - 10} 条${RST}`);
1184
+ }
1185
+ console.log('');
1186
+ }
1187
+ // ── 6. 调优建议 ──
1188
+ console.log(` ${BOLD}调优建议${RST}`);
1189
+ const suggestions = [];
1190
+ const rate429 = byReason.get('send_fail_429')?.length ?? 0;
1191
+ if (rate429 > 0) {
1192
+ suggestions.push(`网关限流 ${rate429} 次,建议降低 --concurrency 或加入退避策略`);
1193
+ }
1194
+ const connFail = byReason.get('send_fail_conn')?.length ?? 0;
1195
+ if (connFail > 0) {
1196
+ suggestions.push(`连接失败 ${connFail} 次,检查网络稳定性或网关并发连接上限`);
1197
+ }
1198
+ const pullNotFound = effectiveLosses.filter(l => l.reason === 'pull_not_found').length;
1199
+ if (pullNotFound > 0) {
1200
+ suggestions.push(`${pullNotFound} 条发送成功但 pull 未收到 — 可能原因:`);
1201
+ suggestions.push(` • 网关消息保留窗口有限(消息过期)`);
1202
+ suggestions.push(` • pull limit 截断(当前 200/页,可增加 --wait 等待时间)`);
1203
+ suggestions.push(` • daemon 长连接消费了消息(建议测试前 evolclaw stop)`);
1204
+ }
1205
+ if (degraded) {
1206
+ suggestions.push(`后半段丢失率明显劣化,网关可能在持续高负载下降级,建议降低 --concurrency`);
1207
+ }
1208
+ if (maxLossRate > 0.4) {
1209
+ const worstShort = worstAid.split('.')[0];
1210
+ suggestions.push(`${worstShort} 丢失率异常高 (${(maxLossRate * 100).toFixed(0)}%),检查该 AID 连接稳定性`);
1211
+ }
1212
+ if (suggestions.length === 0) {
1213
+ suggestions.push('无明显异常');
1214
+ }
1215
+ for (const s of suggestions) {
1216
+ console.log(` ${s.startsWith(' ') ? DIM + s + RST : '• ' + s}`);
1217
+ }
1218
+ console.log('');
1219
+ }