evolclaw 2.1.2 → 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 (54) hide show
  1. package/README.md +59 -30
  2. package/data/evolclaw.sample.json +15 -4
  3. package/dist/agents/claude-runner.js +685 -0
  4. package/dist/agents/codex-runner.js +315 -0
  5. package/dist/agents/gemini-runner.js +425 -0
  6. package/dist/channels/aun.js +580 -10
  7. package/dist/channels/feishu.js +888 -135
  8. package/dist/channels/wechat.js +127 -21
  9. package/dist/cli.js +519 -136
  10. package/dist/config.js +277 -25
  11. package/dist/core/agent-loader.js +39 -0
  12. package/dist/core/channel-loader.js +67 -0
  13. package/dist/core/command-handler.js +1537 -392
  14. package/dist/core/event-bus.js +32 -0
  15. package/dist/core/interaction-router.js +68 -0
  16. package/dist/core/message/message-bridge.js +216 -0
  17. package/dist/core/message/message-processor.js +1028 -0
  18. package/dist/core/message/message-queue.js +240 -0
  19. package/dist/core/message/stream-debouncer.js +122 -0
  20. package/dist/{utils → core/message}/stream-flusher.js +73 -13
  21. package/dist/{utils → core/message}/stream-idle-monitor.js +1 -1
  22. package/dist/core/permission.js +259 -0
  23. package/dist/core/session/adapters/claude-session-file-adapter.js +144 -0
  24. package/dist/core/session/adapters/codex-session-file-adapter.js +261 -0
  25. package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
  26. package/dist/core/session/session-file-adapter.js +7 -0
  27. package/dist/core/session/session-file-health.js +45 -0
  28. package/dist/core/session/session-manager.js +1072 -0
  29. package/dist/index.js +402 -252
  30. package/dist/ipc.js +106 -0
  31. package/dist/paths.js +1 -0
  32. package/dist/types.js +3 -0
  33. package/dist/utils/{platform.js → cross-platform.js} +38 -1
  34. package/dist/utils/error-utils.js +130 -5
  35. package/dist/utils/init-channel.js +649 -0
  36. package/dist/utils/init.js +190 -53
  37. package/dist/utils/logger.js +8 -3
  38. package/dist/utils/media-cache.js +207 -0
  39. package/dist/utils/migrate-project.js +122 -0
  40. package/dist/utils/rich-content-renderer.js +228 -0
  41. package/dist/utils/stats-collector.js +102 -0
  42. package/package.json +4 -2
  43. package/dist/core/agent-runner.js +0 -348
  44. package/dist/core/message-processor.js +0 -604
  45. package/dist/core/message-queue.js +0 -116
  46. package/dist/core/message-stream.js +0 -59
  47. package/dist/core/session-manager.js +0 -664
  48. package/dist/index.js.bak +0 -340
  49. package/dist/utils/init-feishu.js +0 -261
  50. package/dist/utils/init-wechat.js +0 -170
  51. package/dist/utils/markdown-to-feishu.js +0 -94
  52. package/dist/utils/permission.js +0 -43
  53. package/dist/utils/session-file-health.js +0 -68
  54. /package/dist/core/{message-cache.js → message/message-cache.js} +0 -0
@@ -7,7 +7,7 @@ import { execFileSync } from 'child_process';
7
7
  import { promisify } from 'util';
8
8
  import { execFile } from 'child_process';
9
9
  import { resolveRoot, resolvePaths, ensureDataDirs, getPackageRoot } from '../paths.js';
10
- import { isWindows, commandExists } from './platform.js';
10
+ import { isWindows, commandExists } from './cross-platform.js';
11
11
  const execFileAsync = promisify(execFile);
12
12
  // ==================== Helpers ====================
13
13
  function ask(rl, question) {
@@ -184,43 +184,73 @@ async function checkEnvironment(rl) {
184
184
  console.log(' → 请先安装: npm install -g @anthropic-ai/claude-code');
185
185
  return false;
186
186
  }
187
- // @anthropic-ai/claude-agent-sdk >= 0.2.75
188
- let sdkAction = 'ok';
187
+ // Agent SDK 检查:claude-agent-sdk / codex-sdk,至少需要一个
188
+ const MIN_CLAUDE_SDK = [0, 2, 75];
189
+ let hasClaudeSdk = false;
190
+ let hasCodexSdk = false;
191
+ // Check claude-agent-sdk
189
192
  try {
190
- // 用 require.resolve 找到 SDK 入口,推导 package.json 路径
191
193
  const esmRequire = createRequire(import.meta.url);
192
194
  const sdkEntry = esmRequire.resolve('@anthropic-ai/claude-agent-sdk');
193
195
  const sdkPkgPath = path.join(path.dirname(sdkEntry), 'package.json');
194
196
  const sdkPkg = JSON.parse(fs.readFileSync(sdkPkgPath, 'utf-8'));
195
197
  const sdkVer = sdkPkg.version;
196
198
  const parts = sdkVer.split('.').map(Number);
197
- const sdkOk = parts[0] > 0 || parts[1] > 2 || (parts[1] === 2 && parts[2] >= 75);
199
+ const sdkOk = parts[0] > MIN_CLAUDE_SDK[0]
200
+ || (parts[0] === MIN_CLAUDE_SDK[0] && parts[1] > MIN_CLAUDE_SDK[1])
201
+ || (parts[0] === MIN_CLAUDE_SDK[0] && parts[1] === MIN_CLAUDE_SDK[1] && parts[2] >= MIN_CLAUDE_SDK[2]);
198
202
  if (sdkOk) {
199
203
  console.log(` ✓ claude-agent-sdk v${sdkVer}`);
204
+ hasClaudeSdk = true;
200
205
  }
201
206
  else {
202
- console.log(` ✗ claude-agent-sdk v${sdkVer} — 需要 >= 0.2.75`);
203
- sdkAction = 'upgrade';
207
+ console.log(` ✗ claude-agent-sdk v${sdkVer} — 需要 >= ${MIN_CLAUDE_SDK.join('.')}`);
208
+ const answer = (await ask(rl, ' → 是否升级 claude-agent-sdk?[Y/n] ')).trim().toLowerCase();
209
+ if (answer !== 'n' && answer !== 'no') {
210
+ console.log(' 正在升级 claude-agent-sdk...');
211
+ try {
212
+ await npmInstallGlobal('@anthropic-ai/claude-agent-sdk@latest');
213
+ console.log(' ✓ claude-agent-sdk 升级完成');
214
+ hasClaudeSdk = true;
215
+ }
216
+ catch (e) {
217
+ console.log(` ✗ 升级失败: ${e.message?.slice(0, 200) || e}`);
218
+ }
219
+ }
220
+ }
221
+ }
222
+ catch {
223
+ console.log(' - claude-agent-sdk 未安装');
224
+ }
225
+ // Check @openai/codex-sdk (ESM-only, cannot use require.resolve)
226
+ try {
227
+ const codexPkgPath = path.join(getPackageRoot(), 'node_modules', '@openai', 'codex-sdk', 'package.json');
228
+ if (fs.existsSync(codexPkgPath)) {
229
+ const codexPkg = JSON.parse(fs.readFileSync(codexPkgPath, 'utf-8'));
230
+ console.log(` ✓ codex-sdk v${codexPkg.version}`);
231
+ hasCodexSdk = true;
232
+ }
233
+ else {
234
+ console.log(' - codex-sdk 未安装');
204
235
  }
205
236
  }
206
237
  catch {
207
- console.log(' claude-agent-sdk 未安装');
208
- sdkAction = 'install';
238
+ console.log(' - codex-sdk 未安装');
209
239
  }
210
- if (sdkAction !== 'ok') {
211
- const verb = sdkAction === 'install' ? '安装' : '升级';
212
- const answer = (await ask(rl, `是否${verb} claude-agent-sdk?[Y/n] `)).trim().toLowerCase();
240
+ if (!hasClaudeSdk && !hasCodexSdk) {
241
+ console.log('\n ✗ 需要至少安装一个 Agent SDK:claude-agent-sdk codex-sdk');
242
+ const answer = (await ask(rl, '是否安装 claude-agent-sdk?[Y/n] ')).trim().toLowerCase();
213
243
  if (answer === 'n' || answer === 'no') {
214
244
  console.log(' 已取消');
215
245
  return false;
216
246
  }
217
- console.log(` 正在${verb} claude-agent-sdk...`);
247
+ console.log(' 正在安装 claude-agent-sdk...');
218
248
  try {
219
249
  await npmInstallGlobal('@anthropic-ai/claude-agent-sdk@latest');
220
- console.log(` ✓ claude-agent-sdk ${verb}完成`);
250
+ console.log(' ✓ claude-agent-sdk 安装完成');
221
251
  }
222
252
  catch (e) {
223
- console.log(` ✗ ${verb}失败: ${e.message?.slice(0, 200) || e}`);
253
+ console.log(` ✗ 安装失败: ${e.message?.slice(0, 200) || e}`);
224
254
  return false;
225
255
  }
226
256
  }
@@ -312,6 +342,39 @@ async function initFeishuManual(rl, config) {
312
342
  config.channels.feishu.enabled = true;
313
343
  return true;
314
344
  }
345
+ // ==================== AUN Environment Check ====================
346
+ // Moved to init-channel.ts
347
+ // ==================== Rich Content Renderer ====================
348
+ async function offerRichContentRenderer(rl, config) {
349
+ const answer = (await ask(rl, '\n是否启用 LaTeX + Mermaid 渲染模块(约 35MB)?[y/N] ')).trim().toLowerCase();
350
+ const enableRich = answer === 'y' || answer === 'yes';
351
+ // 记录用户选择到全局配置
352
+ config.enableRichContent = enableRich;
353
+ if (!enableRich) {
354
+ console.log(' ✓ 已跳过富内容渲染模块安装');
355
+ return;
356
+ }
357
+ console.log(' 正在安装 katex 和 mermaid(可能需要 1-2 分钟)...');
358
+ try {
359
+ await npmInstallGlobal('katex');
360
+ await npmInstallGlobal('mermaid');
361
+ console.log(' ✓ LaTeX + Mermaid 渲染模块安装完成');
362
+ }
363
+ catch (e) {
364
+ const msg = e.message || '';
365
+ if (e.killed || msg.includes('ETIMEDOUT') || msg.includes('timed out')) {
366
+ console.log(' ✗ 安装超时,网络可能较慢');
367
+ }
368
+ else {
369
+ console.log(` ✗ 安装失败: ${msg.slice(0, 200)}`);
370
+ }
371
+ console.log(' → 可稍后手动安装: npm install -g katex mermaid');
372
+ // 安装失败时,将配置设为 false
373
+ config.enableRichContent = false;
374
+ }
375
+ }
376
+ // ==================== AUN AID Helpers ====================
377
+ // Moved to init-channel.ts
315
378
  // ==================== Main ====================
316
379
  export async function cmdInit() {
317
380
  const p = resolvePaths();
@@ -360,56 +423,83 @@ export async function cmdInit() {
360
423
  }
361
424
  const modelInput = (await ask(rl, ' 模型 [sonnet(默认)/opus/haiku]: ')).trim().toLowerCase();
362
425
  const model = ['opus', 'haiku'].includes(modelInput) ? modelInput : 'sonnet';
363
- // 渠道选择
364
- console.log('\n选择消息渠道:');
365
- console.log(' 1. 飞书 (Feishu)');
366
- console.log(' 2. 微信 (WeChat)');
367
- const channelChoice = (await ask(rl, '请选择 [1]: ')).trim() || '1';
426
+ // 渠道选择(支持退回重选)
368
427
  const config = JSON.parse(fs.readFileSync(sampleSrc, 'utf-8'));
369
428
  config.projects.defaultPath = defaultPath;
370
429
  config.projects.list = { [path.basename(defaultPath)]: defaultPath };
371
430
  config.agents.anthropic.model = model;
372
- if (channelChoice === '1') {
373
- console.log('\n飞书配置方式:');
374
- console.log(' 1. 扫码自动注册(推荐)');
375
- console.log(' 2. 手动输入 App ID/Secret');
376
- const feishuMethod = (await ask(rl, '请选择 [1]: ')).trim() || '1';
377
- if (feishuMethod === '1') {
378
- const { runFeishuQrFlow } = await import('./init-feishu.js');
379
- const result = await runFeishuQrFlow();
380
- if (!result) {
381
- console.log('已取消');
382
- return;
431
+ let channelConfigured = false;
432
+ while (!channelConfigured) {
433
+ console.log('\n选择消息渠道:');
434
+ console.log(' 1. 飞书 (Feishu)');
435
+ console.log(' 2. 微信 (WeChat)');
436
+ console.log(' 3. AUN (AgentUnin.Network)');
437
+ const channelChoice = (await ask(rl, '请选择 [1]: ')).trim() || '1';
438
+ if (channelChoice === '1') {
439
+ console.log('\n飞书配置方式:');
440
+ console.log(' 1. 扫码自动注册(推荐)');
441
+ console.log(' 2. 手动输入 App ID/Secret');
442
+ const feishuMethod = (await ask(rl, '请选择 [1]: ')).trim() || '1';
443
+ if (feishuMethod === '1') {
444
+ const { runFeishuQrFlow } = await import('./init-channel.js');
445
+ const result = await runFeishuQrFlow();
446
+ if (!result) {
447
+ console.log('已取消');
448
+ return;
449
+ }
450
+ config.channels.feishu.appId = result.appId;
451
+ config.channels.feishu.appSecret = result.appSecret;
452
+ config.channels.feishu.enabled = true;
453
+ if (result.openId)
454
+ config.channels.feishu.owner = result.openId;
455
+ }
456
+ else {
457
+ if (!await initFeishuManual(rl, config)) {
458
+ console.log('已取消');
459
+ return;
460
+ }
383
461
  }
384
- config.channels.feishu.appId = result.appId;
385
- config.channels.feishu.appSecret = result.appSecret;
386
- config.channels.feishu.enabled = true;
387
- if (result.openId)
388
- config.channels.feishu.owner = result.openId;
462
+ channelConfigured = true;
463
+ config.channels.defaultChannel = 'feishu';
389
464
  }
390
- else {
391
- if (!await initFeishuManual(rl, config)) {
465
+ else if (channelChoice === '2') {
466
+ const { runWechatQrFlow } = await import('./init-channel.js');
467
+ const result = await runWechatQrFlow();
468
+ if (!result) {
392
469
  console.log('已取消');
393
470
  return;
394
471
  }
472
+ config.channels.wechat = {
473
+ enabled: true,
474
+ baseUrl: result.baseUrl,
475
+ token: result.token,
476
+ };
477
+ channelConfigured = true;
478
+ config.channels.defaultChannel = 'wechat';
395
479
  }
396
- }
397
- else if (channelChoice === '2') {
398
- const { runWechatQrFlow } = await import('./init-wechat.js');
399
- const result = await runWechatQrFlow();
400
- if (!result) {
401
- console.log('已取消');
402
- return;
480
+ else if (channelChoice === '3') {
481
+ const { checkAunEnvironment, setupAunAid } = await import('./init-channel.js');
482
+ const aunReady = await checkAunEnvironment(rl);
483
+ if (!aunReady)
484
+ continue; // 退回重选渠道
485
+ const result = await setupAunAid(rl, config);
486
+ if (!result)
487
+ continue;
488
+ config.channels.aun = {
489
+ enabled: true,
490
+ aid: result.aid,
491
+ ...(result.gatewayPort && { gatewayPort: result.gatewayPort }),
492
+ };
493
+ channelConfigured = true;
494
+ config.channels.defaultChannel = 'aun';
495
+ }
496
+ else {
497
+ console.log(' 无效选择,请重新输入');
403
498
  }
404
- config.channels.wechat = {
405
- enabled: true,
406
- baseUrl: result.baseUrl,
407
- token: result.token,
408
- };
409
499
  }
410
- else {
411
- console.log('无效选择');
412
- return;
500
+ // 可选:富内容渲染模块(仅 Feishu 通道需要)
501
+ if (config.channels?.feishu?.enabled) {
502
+ await offerRichContentRenderer(rl, config);
413
503
  }
414
504
  fs.writeFileSync(p.config, JSON.stringify(config, null, 2) + '\n');
415
505
  console.log(`\n✓ 已创建配置文件: ${p.config}`);
@@ -419,3 +509,50 @@ export async function cmdInit() {
419
509
  rl.close();
420
510
  }
421
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
+ }
@@ -0,0 +1,122 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { resolvePaths } from '../paths.js';
5
+ import { loadConfig, saveConfig } from '../config.js';
6
+ /** 将绝对路径编码为 Claude Code 的目录名格式(/ \ . 替换为 -) */
7
+ function encodePath(p) {
8
+ return p.replace(/[/\\\.]/g, '-');
9
+ }
10
+ /** 查找最新的 ~/.codex/state_*.sqlite */
11
+ function findCodexDb() {
12
+ const codexHome = path.join(os.homedir(), '.codex');
13
+ if (!fs.existsSync(codexHome))
14
+ return null;
15
+ const files = fs.readdirSync(codexHome)
16
+ .filter(f => /^state_\d+\.sqlite$/.test(f))
17
+ .sort((a, b) => {
18
+ const va = parseInt(a.match(/state_(\d+)/)?.[1] || '0');
19
+ const vb = parseInt(b.match(/state_(\d+)/)?.[1] || '0');
20
+ return vb - va;
21
+ });
22
+ return files.length > 0 ? path.join(codexHome, files[0]) : null;
23
+ }
24
+ export async function migrateProject(oldPath, newPath) {
25
+ const result = {
26
+ claudeSessionsMoved: false,
27
+ claudeHistoryUpdated: false,
28
+ codexUpdated: 0,
29
+ evolclawDbUpdated: 0,
30
+ evolclawConfigUpdated: false,
31
+ directoryMoved: false,
32
+ };
33
+ const oldAbs = path.resolve(oldPath);
34
+ const newAbs = path.resolve(newPath);
35
+ if (!fs.existsSync(oldAbs))
36
+ throw new Error(`源目录不存在: ${oldAbs}`);
37
+ if (fs.existsSync(newAbs))
38
+ throw new Error(`目标目录已存在: ${newAbs}`);
39
+ const claudeProjects = path.join(os.homedir(), '.claude', 'projects');
40
+ const oldEncoded = encodePath(oldAbs);
41
+ const newEncoded = encodePath(newAbs);
42
+ // 1. 迁移 ~/.claude/projects/{encoded}/
43
+ const oldClaudeDir = path.join(claudeProjects, oldEncoded);
44
+ const newClaudeDir = path.join(claudeProjects, newEncoded);
45
+ if (fs.existsSync(oldClaudeDir)) {
46
+ fs.renameSync(oldClaudeDir, newClaudeDir);
47
+ result.claudeSessionsMoved = true;
48
+ }
49
+ // 2. .jsonl 内部路径不需要替换 — 它们是历史对话记录,
50
+ // resume 时模型会根据当前 cwd 工作,旧路径只是历史上下文
51
+ // 3. 更新 ~/.claude/history.jsonl
52
+ const historyFile = path.join(os.homedir(), '.claude', 'history.jsonl');
53
+ if (fs.existsSync(historyFile)) {
54
+ const lines = fs.readFileSync(historyFile, 'utf-8').split('\n');
55
+ const updated = lines.map(line => {
56
+ if (!line.trim())
57
+ return line;
58
+ try {
59
+ const obj = JSON.parse(line);
60
+ if (obj.project === oldAbs) {
61
+ obj.project = newAbs;
62
+ return JSON.stringify(obj);
63
+ }
64
+ }
65
+ catch { /* skip */ }
66
+ return line;
67
+ });
68
+ const newContent = updated.join('\n');
69
+ if (newContent !== fs.readFileSync(historyFile, 'utf-8')) {
70
+ fs.writeFileSync(historyFile, newContent, 'utf-8');
71
+ result.claudeHistoryUpdated = true;
72
+ }
73
+ }
74
+ // 4. 更新 Codex SQLite threads.cwd
75
+ const codexDbPath = findCodexDb();
76
+ if (codexDbPath) {
77
+ try {
78
+ const { DatabaseSync } = await import('node:sqlite');
79
+ const db = new DatabaseSync(codexDbPath);
80
+ const r = db.prepare('UPDATE threads SET cwd = ? WHERE cwd = ?').run(newAbs, oldAbs);
81
+ result.codexUpdated = r.changes ?? 0;
82
+ db.close();
83
+ }
84
+ catch { /* Codex not installed or DB locked */ }
85
+ }
86
+ // 5. 移动项目目录
87
+ fs.renameSync(oldAbs, newAbs);
88
+ result.directoryMoved = true;
89
+ // 6. 更新 EvolClaw sessions.db
90
+ const p = resolvePaths();
91
+ if (fs.existsSync(p.db)) {
92
+ try {
93
+ const { DatabaseSync } = await import('node:sqlite');
94
+ const db = new DatabaseSync(p.db);
95
+ const r = db.prepare('UPDATE sessions SET project_path = ? WHERE project_path = ?').run(newAbs, oldAbs);
96
+ result.evolclawDbUpdated = r.changes ?? 0;
97
+ db.close();
98
+ }
99
+ catch { /* DB not accessible */ }
100
+ }
101
+ // 7. 更新 evolclaw.json projects.list
102
+ if (fs.existsSync(p.config)) {
103
+ try {
104
+ const config = loadConfig(p.config);
105
+ if (config.projects?.list) {
106
+ let changed = false;
107
+ for (const [k, v] of Object.entries(config.projects.list)) {
108
+ if (v === oldAbs) {
109
+ config.projects.list[k] = newAbs;
110
+ changed = true;
111
+ }
112
+ }
113
+ if (changed) {
114
+ saveConfig(config, p.config);
115
+ result.evolclawConfigUpdated = true;
116
+ }
117
+ }
118
+ }
119
+ catch { /* config not accessible */ }
120
+ }
121
+ return result;
122
+ }