evolclaw 2.2.0 → 2.3.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 (43) hide show
  1. package/README.md +49 -27
  2. package/data/evolclaw.sample.json +6 -3
  3. package/dist/agents/claude-runner.js +125 -52
  4. package/dist/agents/codex-runner.js +10 -5
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +247 -84
  7. package/dist/channels/feishu.js +556 -96
  8. package/dist/channels/wechat.js +98 -74
  9. package/dist/cli.js +132 -50
  10. package/dist/config.js +185 -31
  11. package/dist/core/channel-loader.js +11 -4
  12. package/dist/core/command-handler.js +750 -209
  13. package/dist/core/interaction-router.js +68 -0
  14. package/dist/core/message/message-bridge.js +216 -0
  15. package/dist/core/{message-processor.js → message/message-processor.js} +386 -105
  16. package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
  17. package/dist/{utils → core/message}/stream-debouncer.js +1 -1
  18. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  19. package/dist/core/permission.js +212 -11
  20. package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
  21. package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
  22. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  23. package/dist/{utils → core/session}/session-file-health.js +1 -1
  24. package/dist/core/{session-manager.js → session/session-manager.js} +57 -11
  25. package/dist/index.js +138 -54
  26. package/dist/{core/ipc-server.js → ipc.js} +36 -1
  27. package/dist/types.js +3 -0
  28. package/dist/utils/cross-platform.js +38 -1
  29. package/dist/utils/error-utils.js +130 -5
  30. package/dist/utils/init-channel.js +649 -0
  31. package/dist/utils/init.js +55 -150
  32. package/dist/utils/logger.js +8 -3
  33. package/dist/utils/media-cache.js +207 -0
  34. package/dist/{core → utils}/stats-collector.js +16 -0
  35. package/package.json +3 -3
  36. package/dist/core/message-bridge.js +0 -187
  37. package/dist/utils/init-feishu.js +0 -263
  38. package/dist/utils/init-wechat.js +0 -172
  39. package/dist/utils/ipc-client.js +0 -36
  40. package/dist/utils/permission-utils.js +0 -71
  41. /package/dist/{utils → core/message}/message-cache.js +0 -0
  42. /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
  43. /package/dist/core/{session-file-adapter.js → session/session-file-adapter.js} +0 -0
@@ -343,42 +343,13 @@ async function initFeishuManual(rl, config) {
343
343
  return true;
344
344
  }
345
345
  // ==================== AUN Environment Check ====================
346
- export async function checkAunEnvironment(rl) {
347
- console.log('\n🔍 AUN 环境检查...\n');
348
- // Check python3 (needed for aun_cli.py AID management)
349
- if (!commandExists('python3')) {
350
- console.log(' ✗ python3 未找到');
351
- console.log(' → 请先安装 Python 3: https://www.python.org/downloads/');
352
- const answer = (await ask(rl, ' → 返回重新选择渠道?[Y/n] ')).trim().toLowerCase();
353
- if (answer === 'n' || answer === 'no')
354
- process.exit(0);
355
- return false;
356
- }
357
- console.log(' ✓ python3');
358
- // Check aun_core
359
- try {
360
- execFileSync('python3', ['-c', 'import aun_core'], { encoding: 'utf-8', stdio: 'pipe' });
361
- console.log(' ✓ aun_core');
362
- }
363
- catch {
364
- console.log(' ✗ aun_core 未安装');
365
- console.log(' → 请安装: pip3 install aun-core');
366
- const answer = (await ask(rl, ' → 返回重新选择渠道?[Y/n] ')).trim().toLowerCase();
367
- if (answer === 'n' || answer === 'no')
368
- process.exit(0);
369
- return false;
370
- }
371
- console.log('');
372
- return true;
373
- }
346
+ // Moved to init-channel.ts
374
347
  // ==================== Rich Content Renderer ====================
375
348
  async function offerRichContentRenderer(rl, config) {
376
349
  const answer = (await ask(rl, '\n是否启用 LaTeX + Mermaid 渲染模块(约 35MB)?[y/N] ')).trim().toLowerCase();
377
350
  const enableRich = answer === 'y' || answer === 'yes';
378
- // 记录用户选择到配置文件(仅 Feishu 通道需要)
379
- if (config.channels?.feishu) {
380
- config.channels.feishu.enableRichContent = enableRich;
381
- }
351
+ // 记录用户选择到全局配置
352
+ config.enableRichContent = enableRich;
382
353
  if (!enableRich) {
383
354
  console.log(' ✓ 已跳过富内容渲染模块安装');
384
355
  return;
@@ -399,125 +370,11 @@ async function offerRichContentRenderer(rl, config) {
399
370
  }
400
371
  console.log(' → 可稍后手动安装: npm install -g katex mermaid');
401
372
  // 安装失败时,将配置设为 false
402
- if (config.channels?.feishu) {
403
- config.channels.feishu.enableRichContent = false;
404
- }
373
+ config.enableRichContent = false;
405
374
  }
406
375
  }
407
376
  // ==================== AUN AID Helpers ====================
408
- function isValidAid(name) {
409
- const labels = name.split('.');
410
- return labels.length >= 3 && labels.every(l => /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(l));
411
- }
412
- async function setupAunAid(rl, config) {
413
- const pythonBin = config.channels?.aun?.pythonBin || process.env.AUN_PYTHON || 'python3';
414
- const cliScript = path.join(getPackageRoot(), 'aun', 'aun_cli.py');
415
- let aid = '';
416
- let gatewayPort;
417
- // Outer loop: allows retrying with a different AID
418
- while (true) {
419
- // Ask AID with format validation
420
- aid = '';
421
- while (!aid) {
422
- aid = (await ask(rl, ' AUN Agent ID (例: mybot.agentid.pub): ')).trim();
423
- if (!aid) {
424
- console.log(' ⚠ 不能为空');
425
- continue;
426
- }
427
- if (!isValidAid(aid)) {
428
- console.log(' ⚠ 无效 AID 格式(需要合法域名,至少三级,如 alice.agentid.pub)');
429
- aid = '';
430
- }
431
- }
432
- const portStr = (await ask(rl, ' Gateway 端口 [留空使用默认 443]: ')).trim();
433
- gatewayPort = portStr ? parseInt(portStr, 10) : undefined;
434
- if (gatewayPort !== undefined && (isNaN(gatewayPort) || gatewayPort < 1 || gatewayPort > 65535)) {
435
- console.log(' ⚠ 端口号无效,使用默认 443');
436
- gatewayPort = undefined;
437
- }
438
- // Check if AID exists locally
439
- const aidDir = path.join(os.homedir(), '.aun', 'AIDs', aid);
440
- if (fs.existsSync(aidDir)) {
441
- console.log(` ✓ AID ${aid} 已存在`);
442
- break;
443
- }
444
- const answer = (await ask(rl, ` ⚠ AID ${aid} 本地不存在,是否创建?[Y/n] `)).trim().toLowerCase();
445
- if (answer === 'n' || answer === 'no') {
446
- console.log(' 已跳过 AID 创建(启动时可能连接失败)');
447
- break;
448
- }
449
- // Try creating
450
- console.log(' 正在创建 AID...');
451
- const args = [cliScript, 'aid', 'new', aid];
452
- if (gatewayPort)
453
- args.push('-p', String(gatewayPort));
454
- let failed = false;
455
- try {
456
- const { stdout, stderr } = await execFileAsync(pythonBin, args, { timeout: 30000, encoding: 'utf-8' });
457
- const output = (stdout + stderr).trim();
458
- if (output)
459
- console.log(` ${output}`);
460
- // aun_cli.py 可能 exit 0 但输出包含错误
461
- failed = output.includes('✗') || output.includes('失败');
462
- }
463
- catch (e) {
464
- const msg = e.stderr || e.stdout || e.message || '';
465
- console.log(` ✗ AID 创建失败: ${msg.trim().slice(0, 200)}`);
466
- failed = true;
467
- }
468
- if (!failed) {
469
- console.log(` ✓ AID ${aid} 创建成功`);
470
- break;
471
- }
472
- // Creation failed — retry or give up
473
- const retry = (await ask(rl, ' → 重新输入 (r) / 跳过 (s) / 取消 (c)?[r/s/c] ')).trim().toLowerCase();
474
- if (retry === 'c')
475
- return null;
476
- if (retry === 's')
477
- break;
478
- // default: retry with new AID
479
- }
480
- return { aid, gatewayPort };
481
- }
482
- // ==================== Init AUN (standalone) ====================
483
- export async function cmdInitAun() {
484
- const p = resolvePaths();
485
- if (!fs.existsSync(p.config)) {
486
- console.log('❌ 配置文件不存在,请先运行 evolclaw init');
487
- return;
488
- }
489
- const config = JSON.parse(fs.readFileSync(p.config, 'utf-8'));
490
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
491
- try {
492
- if (config.channels?.aun?.aid) {
493
- const answer = (await ask(rl, '已有 AUN 配置,是否重新配置?[y/N] ')).trim().toLowerCase();
494
- if (answer !== 'y' && answer !== 'yes') {
495
- console.log('已取消');
496
- return;
497
- }
498
- }
499
- if (!await checkAunEnvironment(rl)) {
500
- return;
501
- }
502
- const result = await setupAunAid(rl, config);
503
- if (!result)
504
- return;
505
- if (!config.channels)
506
- config.channels = {};
507
- config.channels.aun = {
508
- enabled: true,
509
- aid: result.aid,
510
- ...(result.gatewayPort && { gatewayPort: result.gatewayPort }),
511
- };
512
- if (!config.channels.defaultChannel)
513
- config.channels.defaultChannel = 'aun';
514
- fs.writeFileSync(p.config, JSON.stringify(config, null, 2) + '\n');
515
- console.log('\n✓ AUN 配置已写入');
516
- }
517
- finally {
518
- rl.close();
519
- }
520
- }
377
+ // Moved to init-channel.ts
521
378
  // ==================== Main ====================
522
379
  export async function cmdInit() {
523
380
  const p = resolvePaths();
@@ -584,7 +441,7 @@ export async function cmdInit() {
584
441
  console.log(' 2. 手动输入 App ID/Secret');
585
442
  const feishuMethod = (await ask(rl, '请选择 [1]: ')).trim() || '1';
586
443
  if (feishuMethod === '1') {
587
- const { runFeishuQrFlow } = await import('./init-feishu.js');
444
+ const { runFeishuQrFlow } = await import('./init-channel.js');
588
445
  const result = await runFeishuQrFlow();
589
446
  if (!result) {
590
447
  console.log('已取消');
@@ -606,7 +463,7 @@ export async function cmdInit() {
606
463
  config.channels.defaultChannel = 'feishu';
607
464
  }
608
465
  else if (channelChoice === '2') {
609
- const { runWechatQrFlow } = await import('./init-wechat.js');
466
+ const { runWechatQrFlow } = await import('./init-channel.js');
610
467
  const result = await runWechatQrFlow();
611
468
  if (!result) {
612
469
  console.log('已取消');
@@ -621,6 +478,7 @@ export async function cmdInit() {
621
478
  config.channels.defaultChannel = 'wechat';
622
479
  }
623
480
  else if (channelChoice === '3') {
481
+ const { checkAunEnvironment, setupAunAid } = await import('./init-channel.js');
624
482
  const aunReady = await checkAunEnvironment(rl);
625
483
  if (!aunReady)
626
484
  continue; // 退回重选渠道
@@ -651,3 +509,50 @@ export async function cmdInit() {
651
509
  rl.close();
652
510
  }
653
511
  }
512
+ /**
513
+ * Present instance selection menu when existing instances are found.
514
+ * Returns the user's choice, or null if cancelled.
515
+ */
516
+ export async function selectInstance(rl, channelType, instances) {
517
+ const typeLabel = channelType === 'feishu' ? '飞书' : channelType === 'wechat' ? '微信' : 'AUN';
518
+ console.log(`\n发现已有 ${typeLabel} 机器人:`);
519
+ const letters = 'abcdefghijklmnopqrstuvwxyz';
520
+ for (let i = 0; i < instances.length; i++) {
521
+ console.log(` ${letters[i]}. ${instances[i].name}`);
522
+ }
523
+ const addLetter = letters[instances.length];
524
+ console.log(` ${addLetter}. 添加新机器人`);
525
+ console.log('');
526
+ const validOptions = letters.slice(0, instances.length + 1).split('');
527
+ let choice = '';
528
+ while (!validOptions.includes(choice)) {
529
+ choice = (await ask(rl, '请选择: ')).trim().toLowerCase();
530
+ if (!validOptions.includes(choice)) {
531
+ console.log(`无效选择,请输入 ${validOptions.join('/')}`);
532
+ }
533
+ }
534
+ const choiceIndex = letters.indexOf(choice);
535
+ if (choiceIndex === instances.length) {
536
+ // Add new — ask for name
537
+ let name = '';
538
+ while (!name) {
539
+ name = (await ask(rl, '请输入新机器人名称: ')).trim();
540
+ if (!name)
541
+ console.log(' 名称不能为空');
542
+ if (instances.some(i => i.name === name)) {
543
+ console.log(` 名称 "${name}" 已存在,请换一个`);
544
+ name = '';
545
+ }
546
+ }
547
+ return { action: 'add', name };
548
+ }
549
+ // Overwrite — requires confirmation
550
+ const target = instances[choiceIndex];
551
+ console.log(`\n已选择:${target.name}`);
552
+ const confirm = (await ask(rl, `⚠️ 即将覆盖该机器人配置,确认?(y/N) `)).trim().toLowerCase();
553
+ if (confirm !== 'y' && confirm !== 'yes') {
554
+ console.log('已取消');
555
+ return null;
556
+ }
557
+ return { action: 'overwrite', index: choiceIndex, name: target.name };
558
+ }
@@ -25,10 +25,15 @@ function write(stream, data) {
25
25
  const line = typeof data === 'string' ? data : JSON.stringify(data);
26
26
  stream.write(`${line}\n`);
27
27
  }
28
+ export function localTimestamp() {
29
+ const d = new Date();
30
+ const pad = (n) => String(n).padStart(2, '0');
31
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`;
32
+ }
28
33
  function log(level, ...args) {
29
34
  if (!shouldLog(level))
30
35
  return;
31
- const timestamp = new Date().toISOString();
36
+ const timestamp = localTimestamp();
32
37
  const msg = `[${timestamp}] [${level}] ${args.join(' ')}`;
33
38
  // 只写文件,不输出到 console(避免重定向时重复)
34
39
  write(streams.main, msg);
@@ -39,9 +44,9 @@ export const logger = {
39
44
  warn: (...args) => log('WARN', ...args),
40
45
  error: (...args) => log('ERROR', ...args),
41
46
  message: (data) => {
42
- write(streams.message, { ts: new Date().toISOString(), ...data });
47
+ write(streams.message, { ts: localTimestamp(), ...data });
43
48
  },
44
49
  event: (data) => {
45
- write(streams.event, { ts: new Date().toISOString(), ...data });
50
+ write(streams.event, { ts: localTimestamp(), ...data });
46
51
  }
47
52
  };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * 统一媒体下载/缓存/SSRF防护框架
3
+ *
4
+ * 提供跨通道复用的文件处理能力:
5
+ * - 文件保存到 uploads 目录(自动去重、文件名清洗)
6
+ * - 图片类型白名单 + 大小限制
7
+ * - URL SSRF 防护(域名白名单 + 私有 IP 拦截)
8
+ * - 下载缓存(相同 key 不重复下载)
9
+ */
10
+ import path from 'path';
11
+ import fs from 'fs';
12
+ import crypto from 'crypto';
13
+ import { logger } from './logger.js';
14
+ // ── 常量 ──────────────────────────────────────────────────────────────────────
15
+ const DEFAULT_MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
16
+ const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
17
+ const ALLOWED_IMAGE_MIMES = new Set([
18
+ 'image/png', 'image/jpeg', 'image/gif', 'image/webp',
19
+ ]);
20
+ const UPLOADS_SUBDIR = '.evolclaw/uploads';
21
+ // ── SSRF 防护 ─────────────────────────────────────────────────────────────────
22
+ /** 已知安全的 CDN 域名白名单 */
23
+ const ALLOWED_CDN_HOSTS = new Set([
24
+ 'novac2c.cdn.weixin.qq.com',
25
+ 'open.feishu.cn',
26
+ 'internal-api-lark-file.feishu.cn',
27
+ ]);
28
+ /** 私有 IP 段正则(IPv4) */
29
+ const PRIVATE_IP_RE = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|0\.|169\.254\.|::1|fc|fd|fe80)/;
30
+ /**
31
+ * 检查 URL 是否安全(非 SSRF)
32
+ * - 拒绝私有 IP
33
+ * - 仅允许 https(或白名单域名的 http)
34
+ * - 可选:域名白名单模式
35
+ */
36
+ export function validateUrl(url, opts) {
37
+ let parsed;
38
+ try {
39
+ parsed = new URL(url);
40
+ }
41
+ catch {
42
+ return { ok: false, reason: `Invalid URL: ${url}` };
43
+ }
44
+ // 协议检查
45
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
46
+ return { ok: false, reason: `Blocked protocol: ${parsed.protocol}` };
47
+ }
48
+ // 私有 IP 检查
49
+ const host = parsed.hostname;
50
+ if (PRIVATE_IP_RE.test(host)) {
51
+ return { ok: false, reason: `Blocked private IP: ${host}` };
52
+ }
53
+ // 域名白名单(如果提供)
54
+ const allowed = opts?.allowedHosts ?? ALLOWED_CDN_HOSTS;
55
+ if (allowed.size > 0 && !allowed.has(host)) {
56
+ return { ok: false, reason: `Host not in allowlist: ${host}` };
57
+ }
58
+ return { ok: true };
59
+ }
60
+ // ── 文件名清洗 ────────────────────────────────────────────────────────────────
61
+ /**
62
+ * 清洗文件名:
63
+ * - 移除路径穿越字符(只保留 basename)
64
+ * - 替换非法字符
65
+ * - 空结果兜底
66
+ */
67
+ export function sanitizeFileName(name) {
68
+ return path.basename(name).replace(/[<>:"|?*\x00-\x1f]/g, '_') || `file_${Date.now()}`;
69
+ }
70
+ // ── 图片验证 ──────────────────────────────────────────────────────────────────
71
+ /**
72
+ * 验证图片 Buffer:类型白名单 + 大小限制
73
+ * 返回检测到的 MIME 类型,验证失败返回 null + reason
74
+ */
75
+ export async function validateImage(buffer, opts) {
76
+ const maxSize = opts?.maxSize ?? DEFAULT_MAX_IMAGE_SIZE;
77
+ const allowed = opts?.allowedMimes ?? ALLOWED_IMAGE_MIMES;
78
+ if (buffer.length === 0) {
79
+ return { mime: null, reason: 'Empty buffer' };
80
+ }
81
+ if (buffer.length > maxSize) {
82
+ return { mime: null, reason: `Image too large: ${buffer.length} bytes (max ${maxSize})` };
83
+ }
84
+ // 动态导入 image-type(ESM only)
85
+ const { default: imageType } = await import('image-type');
86
+ const type = await imageType(buffer);
87
+ if (!type) {
88
+ return { mime: null, reason: 'Unable to detect image type' };
89
+ }
90
+ if (!allowed.has(type.mime)) {
91
+ return { mime: null, reason: `Unsupported image type: ${type.mime}` };
92
+ }
93
+ return { mime: type.mime };
94
+ }
95
+ /**
96
+ * 保存 Buffer 到 uploads 目录
97
+ * - 自动创建目录
98
+ * - 文件名清洗
99
+ * - 同名文件自动添加时间戳后缀
100
+ */
101
+ export function saveToUploads(buffer, rawFileName, projectPath, opts) {
102
+ const fileName = sanitizeFileName(rawFileName);
103
+ const uploadsDir = path.join(projectPath, UPLOADS_SUBDIR);
104
+ fs.mkdirSync(uploadsDir, { recursive: true });
105
+ let targetName = fileName;
106
+ const targetPath = path.join(uploadsDir, targetName);
107
+ // 去重:内容相同则复用
108
+ if (opts?.dedup !== false && fs.existsSync(targetPath)) {
109
+ const existingHash = hashFile(targetPath);
110
+ const newHash = crypto.createHash('md5').update(buffer).digest('hex');
111
+ if (existingHash === newHash) {
112
+ logger.debug(`[MediaCache] Dedup hit: ${targetName}`);
113
+ return { filePath: targetPath, fileName: targetName, size: buffer.length };
114
+ }
115
+ // 不同内容同名 → 加时间戳
116
+ const ext = path.extname(fileName);
117
+ const base = path.basename(fileName, ext);
118
+ targetName = `${base}_${Date.now()}${ext}`;
119
+ }
120
+ const finalPath = path.join(uploadsDir, targetName);
121
+ fs.writeFileSync(finalPath, buffer);
122
+ logger.info(`[MediaCache] Saved: ${finalPath} (${buffer.length} bytes)`);
123
+ return { filePath: finalPath, fileName: targetName, size: buffer.length };
124
+ }
125
+ function hashFile(filePath) {
126
+ const content = fs.readFileSync(filePath);
127
+ return crypto.createHash('md5').update(content).digest('hex');
128
+ }
129
+ /**
130
+ * 安全下载 URL 内容,带 SSRF 防护
131
+ */
132
+ export async function safeFetch(url, opts) {
133
+ const maxSize = opts?.maxSize ?? DEFAULT_MAX_FILE_SIZE;
134
+ const timeout = opts?.timeout ?? 30_000;
135
+ // SSRF 检查
136
+ if (!opts?.skipSsrfCheck) {
137
+ const check = validateUrl(url, { allowedHosts: opts?.allowedHosts });
138
+ if (!check.ok) {
139
+ throw new Error(`SSRF blocked: ${check.reason}`);
140
+ }
141
+ }
142
+ const controller = new AbortController();
143
+ const timer = setTimeout(() => controller.abort(), timeout);
144
+ try {
145
+ const res = await fetch(url, { signal: controller.signal });
146
+ if (!res.ok)
147
+ throw new Error(`Download failed: ${res.status} ${res.statusText}`);
148
+ // 检查 Content-Length(如果可用)
149
+ const contentLength = res.headers.get('content-length');
150
+ if (contentLength && parseInt(contentLength) > maxSize) {
151
+ throw new Error(`File too large: ${contentLength} bytes (max ${maxSize})`);
152
+ }
153
+ const buffer = Buffer.from(await res.arrayBuffer());
154
+ if (buffer.length > maxSize) {
155
+ throw new Error(`File too large: ${buffer.length} bytes (max ${maxSize})`);
156
+ }
157
+ return buffer;
158
+ }
159
+ finally {
160
+ clearTimeout(timer);
161
+ }
162
+ }
163
+ /**
164
+ * 内存级下载缓存
165
+ * - 相同 key 不重复下载
166
+ * - TTL 过期自动清理
167
+ * - 最大条目限制防止内存泄漏
168
+ */
169
+ export class DownloadCache {
170
+ cache = new Map();
171
+ ttlMs;
172
+ maxEntries;
173
+ constructor(opts) {
174
+ this.ttlMs = opts?.ttlMs ?? 5 * 60 * 1000; // 5 min
175
+ this.maxEntries = opts?.maxEntries ?? 100;
176
+ }
177
+ /**
178
+ * 获取或下载:缓存命中直接返回,否则执行 fetcher 并缓存结果
179
+ */
180
+ async getOrFetch(key, fetcher) {
181
+ this.evict();
182
+ const cached = this.cache.get(key);
183
+ if (cached) {
184
+ logger.debug(`[DownloadCache] Hit: ${key}`);
185
+ return cached.buffer;
186
+ }
187
+ const buffer = await fetcher();
188
+ this.cache.set(key, { buffer, createdAt: Date.now() });
189
+ // 超过 maxEntries 时删最旧
190
+ if (this.cache.size > this.maxEntries) {
191
+ const oldest = this.cache.keys().next().value;
192
+ this.cache.delete(oldest);
193
+ }
194
+ return buffer;
195
+ }
196
+ evict() {
197
+ const now = Date.now();
198
+ for (const [key, entry] of this.cache) {
199
+ if (now - entry.createdAt > this.ttlMs) {
200
+ this.cache.delete(key);
201
+ }
202
+ }
203
+ }
204
+ clear() {
205
+ this.cache.clear();
206
+ }
207
+ }
@@ -23,6 +23,12 @@ export class StatsCollector {
23
23
  eventBus.subscribe('session:safe-mode-entered', (_event) => {
24
24
  this.recordEvent({ type: 'safe-mode-entered', timestamp: Date.now() });
25
25
  });
26
+ eventBus.subscribe('tool:result', (event) => {
27
+ const e = event;
28
+ if (e.isError) {
29
+ this.recordEvent({ type: 'tool-error', timestamp: Date.now(), toolName: e.toolName });
30
+ }
31
+ });
26
32
  }
27
33
  recordEvent(record) {
28
34
  this.events.push(record);
@@ -40,6 +46,8 @@ export class StatsCollector {
40
46
  let completed = 0;
41
47
  let errors = 0;
42
48
  const errorsByType = {};
49
+ let toolErrors = 0;
50
+ const toolErrorsByName = {};
43
51
  let interrupts = 0;
44
52
  let safeModeEntries = 0;
45
53
  let totalDuration = 0;
@@ -62,6 +70,12 @@ export class StatsCollector {
62
70
  errorsByType[event.errorType] = (errorsByType[event.errorType] || 0) + 1;
63
71
  }
64
72
  break;
73
+ case 'tool-error':
74
+ toolErrors++;
75
+ if (event.toolName) {
76
+ toolErrorsByName[event.toolName] = (toolErrorsByName[event.toolName] || 0) + 1;
77
+ }
78
+ break;
65
79
  case 'interrupted':
66
80
  interrupts++;
67
81
  break;
@@ -77,6 +91,8 @@ export class StatsCollector {
77
91
  completed,
78
92
  errors,
79
93
  errorsByType,
94
+ toolErrors,
95
+ toolErrorsByName,
80
96
  interrupts,
81
97
  safeModeEntries,
82
98
  avgResponseMs: durationCount > 0 ? totalDuration / durationCount : 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "evolclaw",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Lightweight AI Agent gateway connecting Claude Agent SDK to messaging channels (Feishu, ACP) with multi-project session management",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -22,8 +22,8 @@
22
22
  "prepublishOnly": "npm run build && npm test"
23
23
  },
24
24
  "dependencies": {
25
- "@anthropic-ai/claude-agent-sdk": "^0.2.75",
26
- "@aun/core-node": "file:aun/aun-sdk-core/ts",
25
+ "@anthropic-ai/claude-agent-sdk": "^0.2.100",
26
+ "@eleans/aun-core-node": "^0.3.0",
27
27
  "@larksuiteoapi/node-sdk": "^1.59.0",
28
28
  "@openai/codex-sdk": "^0.118.0",
29
29
  "image-type": "^6.0.0",