codexmate 0.0.5 → 0.0.6

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.
package/cli.js CHANGED
@@ -5,12 +5,14 @@ const os = require('os');
5
5
  const crypto = require('crypto');
6
6
  const toml = require('@iarna/toml');
7
7
  const JSON5 = require('json5');
8
+ const zipLib = require('zip-lib');
8
9
  const { exec, execSync } = require('child_process');
9
10
  const http = require('http');
10
11
  const https = require('https');
11
12
  const readline = require('readline');
12
13
 
13
- const DEFAULT_WEB_PORT = 3737;
14
+ const DEFAULT_WEB_PORT = 3737;
15
+ const DEFAULT_WEB_HOST = '127.0.0.1';
14
16
 
15
17
  // ============================================================================
16
18
  // 配置
@@ -45,15 +47,15 @@ const SESSION_CONTENT_READ_BYTES = SESSION_SUMMARY_READ_BYTES;
45
47
  const DEFAULT_CONTENT_SCAN_LIMIT = 10;
46
48
  const SESSION_SCAN_FACTOR = 4;
47
49
  const SESSION_SCAN_MIN_FILES = 800;
48
- const MAX_SESSION_PATH_LIST_SIZE = 2000;
49
- const AGENTS_FILE_NAME = 'AGENTS.md';
50
- const UTF8_BOM = '\ufeff';
51
- const MODELS_CACHE_TTL_MS = 60 * 1000;
52
- const MODELS_NEGATIVE_CACHE_TTL_MS = 5 * 1000;
53
- const MODELS_CACHE_MAX_ENTRIES = 50;
54
- const MODELS_RESPONSE_MAX_BYTES = 1024 * 1024;
55
- const MAX_RECENT_CONFIGS = 3;
56
- const BOOTSTRAP_TEXT_MARKERS = [
50
+ const MAX_SESSION_PATH_LIST_SIZE = 2000;
51
+ const AGENTS_FILE_NAME = 'AGENTS.md';
52
+ const UTF8_BOM = '\ufeff';
53
+ const MODELS_CACHE_TTL_MS = 60 * 1000;
54
+ const MODELS_NEGATIVE_CACHE_TTL_MS = 5 * 1000;
55
+ const MODELS_CACHE_MAX_ENTRIES = 50;
56
+ const MODELS_RESPONSE_MAX_BYTES = 1024 * 1024;
57
+ const MAX_RECENT_CONFIGS = 3;
58
+ const BOOTSTRAP_TEXT_MARKERS = [
57
59
  'agents.md instructions',
58
60
  '<instructions>',
59
61
  '<environment_context>',
@@ -64,14 +66,26 @@ const BOOTSTRAP_TEXT_MARKERS = [
64
66
  const HTTP_KEEP_ALIVE_AGENT = new http.Agent({ keepAlive: true });
65
67
  const HTTPS_KEEP_ALIVE_AGENT = new https.Agent({ keepAlive: true });
66
68
 
67
- function resolveWebPort() {
68
- const raw = process.env.CODEXMATE_PORT;
69
- if (!raw) return DEFAULT_WEB_PORT;
70
- const parsed = parseInt(raw, 10);
71
- if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_WEB_PORT;
72
- return parsed;
73
- }
74
-
69
+ function resolveWebPort() {
70
+ const raw = process.env.CODEXMATE_PORT;
71
+ if (!raw) return DEFAULT_WEB_PORT;
72
+ const parsed = parseInt(raw, 10);
73
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_WEB_PORT;
74
+ return parsed;
75
+ }
76
+
77
+ function resolveWebHost(options = {}) {
78
+ const optionHost = typeof options.host === 'string' ? options.host.trim() : '';
79
+ if (optionHost) {
80
+ return optionHost;
81
+ }
82
+ const envHost = typeof process.env.CODEXMATE_HOST === 'string' ? process.env.CODEXMATE_HOST.trim() : '';
83
+ if (envHost) {
84
+ return envHost;
85
+ }
86
+ return DEFAULT_WEB_HOST;
87
+ }
88
+
75
89
  const EMPTY_CONFIG_FALLBACK_TEMPLATE = `model = "gpt-5.3-codex"
76
90
  model_reasoning_effort = "high"
77
91
  disable_response_storage = true
@@ -609,6 +623,27 @@ function resolveOpenclawWorkspaceDir(config) {
609
623
  return path.join(OPENCLAW_DIR, resolved);
610
624
  }
611
625
 
626
+ function normalizeOpenclawWorkspaceFileName(input) {
627
+ const raw = typeof input === 'string' ? input.trim() : '';
628
+ if (!raw) {
629
+ return { error: '文件名不能为空' };
630
+ }
631
+ if (raw.includes('\0')) {
632
+ return { error: '文件名非法' };
633
+ }
634
+ if (raw.includes('/') || raw.includes('\\') || raw.includes('..')) {
635
+ return { error: '文件名非法' };
636
+ }
637
+ const baseName = path.basename(raw);
638
+ if (baseName !== raw) {
639
+ return { error: '文件名非法' };
640
+ }
641
+ if (!raw.toLowerCase().endsWith('.md')) {
642
+ return { error: '仅支持 .md 文件' };
643
+ }
644
+ return { ok: true, name: raw };
645
+ }
646
+
612
647
  function readOpenclawConfigFile() {
613
648
  const filePath = OPENCLAW_CONFIG_FILE;
614
649
  if (!fs.existsSync(filePath)) {
@@ -708,6 +743,81 @@ function applyOpenclawAgentsFile(params = {}) {
708
743
  };
709
744
  }
710
745
 
746
+ function readOpenclawWorkspaceFile(params = {}) {
747
+ const nameResult = normalizeOpenclawWorkspaceFileName(params.fileName);
748
+ if (nameResult.error) {
749
+ return { error: nameResult.error };
750
+ }
751
+ const workspaceInfo = getOpenclawWorkspaceInfo();
752
+ const baseDir = workspaceInfo.workspaceDir;
753
+ const filePath = path.join(baseDir, nameResult.name);
754
+
755
+ if (!fs.existsSync(baseDir)) {
756
+ return {
757
+ exists: false,
758
+ path: filePath,
759
+ content: '',
760
+ lineEnding: os.EOL === '\r\n' ? '\r\n' : '\n',
761
+ workspaceDir: baseDir,
762
+ configError: workspaceInfo.configError,
763
+ baseDirMissing: true
764
+ };
765
+ }
766
+
767
+ if (!fs.existsSync(filePath)) {
768
+ return {
769
+ exists: false,
770
+ path: filePath,
771
+ content: '',
772
+ lineEnding: os.EOL === '\r\n' ? '\r\n' : '\n',
773
+ workspaceDir: baseDir,
774
+ configError: workspaceInfo.configError
775
+ };
776
+ }
777
+
778
+ try {
779
+ const raw = fs.readFileSync(filePath, 'utf-8');
780
+ return {
781
+ exists: true,
782
+ path: filePath,
783
+ content: stripUtf8Bom(raw),
784
+ lineEnding: detectLineEnding(raw),
785
+ workspaceDir: baseDir,
786
+ configError: workspaceInfo.configError
787
+ };
788
+ } catch (e) {
789
+ return { error: `读取 OpenClaw 工作区文件失败: ${e.message}` };
790
+ }
791
+ }
792
+
793
+ function applyOpenclawWorkspaceFile(params = {}) {
794
+ const nameResult = normalizeOpenclawWorkspaceFileName(params.fileName);
795
+ if (nameResult.error) {
796
+ return { error: nameResult.error };
797
+ }
798
+ const workspaceInfo = getOpenclawWorkspaceInfo();
799
+ const baseDir = workspaceInfo.workspaceDir;
800
+ ensureDir(baseDir);
801
+
802
+ const content = typeof params.content === 'string' ? params.content : '';
803
+ const lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
804
+ const normalized = normalizeLineEnding(content, lineEnding);
805
+ const finalContent = ensureUtf8Bom(normalized);
806
+ const filePath = path.join(baseDir, nameResult.name);
807
+
808
+ try {
809
+ fs.writeFileSync(filePath, finalContent, 'utf-8');
810
+ return {
811
+ success: true,
812
+ path: filePath,
813
+ workspaceDir: baseDir,
814
+ configError: workspaceInfo.configError
815
+ };
816
+ } catch (e) {
817
+ return { error: `写入 OpenClaw 工作区文件失败: ${e.message}` };
818
+ }
819
+ }
820
+
711
821
  function applyOpenclawConfig(params = {}) {
712
822
  const content = typeof params.content === 'string' ? params.content : '';
713
823
  const lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
@@ -1379,10 +1489,60 @@ async function buildConfigHealthReport(params = {}) {
1379
1489
  } else {
1380
1490
  const timeoutMs = Number.isFinite(params.timeoutMs)
1381
1491
  ? Math.max(1000, Number(params.timeoutMs))
1382
- : HEALTH_CHECK_TIMEOUT_MS;
1383
- remote = await runRemoteHealthCheck(provider, modelName, { timeoutMs });
1384
- if (remote && Array.isArray(remote.issues)) {
1385
- issues.push(...remote.issues);
1492
+ : undefined;
1493
+ const apiKey = typeof provider.preferred_auth_method === 'string'
1494
+ ? provider.preferred_auth_method.trim()
1495
+ : '';
1496
+ const speedResult = await runSpeedTest(baseUrl, apiKey, { timeoutMs });
1497
+ const status = speedResult && typeof speedResult.status === 'number'
1498
+ ? speedResult.status
1499
+ : 0;
1500
+ const durationMs = speedResult && typeof speedResult.durationMs === 'number'
1501
+ ? speedResult.durationMs
1502
+ : 0;
1503
+ const error = speedResult && speedResult.error ? String(speedResult.error) : '';
1504
+ remote = {
1505
+ type: 'speed-test',
1506
+ url: baseUrl,
1507
+ ok: !!speedResult.ok,
1508
+ status,
1509
+ durationMs,
1510
+ error
1511
+ };
1512
+
1513
+ if (!speedResult.ok) {
1514
+ const errorLower = error.toLowerCase();
1515
+ if (errorLower.includes('timeout')) {
1516
+ issues.push({
1517
+ code: 'remote-speedtest-timeout',
1518
+ message: '远程测速超时',
1519
+ suggestion: '检查网络或 base_url 是否可达'
1520
+ });
1521
+ } else if (errorLower.includes('invalid url')) {
1522
+ issues.push({
1523
+ code: 'remote-speedtest-invalid-url',
1524
+ message: '远程测速失败:base_url 无效',
1525
+ suggestion: '请设置为 http/https 的完整 URL'
1526
+ });
1527
+ } else {
1528
+ issues.push({
1529
+ code: 'remote-speedtest-unreachable',
1530
+ message: `远程测速失败:${error || '无法连接'}`,
1531
+ suggestion: '检查网络或 base_url 是否可用'
1532
+ });
1533
+ }
1534
+ } else if (status === 401 || status === 403) {
1535
+ issues.push({
1536
+ code: 'remote-speedtest-auth-failed',
1537
+ message: '远程测速鉴权失败(401/403)',
1538
+ suggestion: '检查 API Key 或认证方式'
1539
+ });
1540
+ } else if (status >= 400) {
1541
+ issues.push({
1542
+ code: 'remote-speedtest-http-error',
1543
+ message: `远程测速返回异常状态: ${status}`,
1544
+ suggestion: '检查 base_url 或服务状态'
1545
+ });
1386
1546
  }
1387
1547
  }
1388
1548
  }
@@ -1424,6 +1584,16 @@ function toIsoTime(value, fallback = '') {
1424
1584
  return date.toISOString();
1425
1585
  }
1426
1586
 
1587
+ function updateLatestIso(currentIso, candidate) {
1588
+ const currentTime = Date.parse(currentIso || '') || 0;
1589
+ const candidateIso = toIsoTime(candidate, '');
1590
+ const candidateTime = Date.parse(candidateIso || '') || 0;
1591
+ if (!candidateTime) {
1592
+ return currentIso;
1593
+ }
1594
+ return candidateTime > currentTime ? candidateIso : currentIso;
1595
+ }
1596
+
1427
1597
  function truncateText(text, maxLength = 90) {
1428
1598
  if (!text) return '';
1429
1599
  const normalized = String(text).replace(/\s+/g, ' ').trim();
@@ -2297,7 +2467,7 @@ function parseCodexSessionSummary(filePath) {
2297
2467
 
2298
2468
  for (const record of records) {
2299
2469
  if (record.timestamp) {
2300
- updatedAt = toIsoTime(record.timestamp, updatedAt);
2470
+ updatedAt = updateLatestIso(updatedAt, record.timestamp);
2301
2471
  }
2302
2472
 
2303
2473
  if (record.type === 'session_meta' && record.payload) {
@@ -2386,7 +2556,7 @@ function parseClaudeSessionSummary(filePath) {
2386
2556
  createdAt = toIsoTime(record.timestamp, createdAt);
2387
2557
  }
2388
2558
  if (record.timestamp) {
2389
- updatedAt = toIsoTime(record.timestamp, updatedAt);
2559
+ updatedAt = updateLatestIso(updatedAt, record.timestamp);
2390
2560
  }
2391
2561
 
2392
2562
  if (!cwd && record.cwd) {
@@ -2747,7 +2917,269 @@ function resolveSessionFilePath(source, filePath, sessionId) {
2747
2917
  return '';
2748
2918
  }
2749
2919
 
2750
- function buildSessionMarkdown(payload) {
2920
+ function findClaudeSessionIndexPath(sessionFilePath) {
2921
+ const root = getClaudeProjectsDir();
2922
+ if (!root || !sessionFilePath) {
2923
+ return '';
2924
+ }
2925
+ if (!isPathInside(sessionFilePath, root)) {
2926
+ return '';
2927
+ }
2928
+ let current = path.dirname(sessionFilePath);
2929
+ const resolvedRoot = path.resolve(root);
2930
+ while (current && isPathInside(current, resolvedRoot)) {
2931
+ const candidate = path.join(current, 'sessions-index.json');
2932
+ if (fs.existsSync(candidate)) {
2933
+ return candidate;
2934
+ }
2935
+ const parent = path.dirname(current);
2936
+ if (parent === current) {
2937
+ break;
2938
+ }
2939
+ current = parent;
2940
+ }
2941
+ return '';
2942
+ }
2943
+
2944
+ function updateClaudeSessionIndex(indexPath, sessionFilePath, sessionId) {
2945
+ if (!indexPath || !fs.existsSync(indexPath)) {
2946
+ return;
2947
+ }
2948
+ const index = readJsonFile(indexPath, null);
2949
+ if (!index || !Array.isArray(index.entries)) {
2950
+ return;
2951
+ }
2952
+ const resolvedFile = sessionFilePath ? path.resolve(sessionFilePath) : '';
2953
+ const resolvedLower = resolvedFile ? resolvedFile.toLowerCase() : '';
2954
+ const filtered = index.entries.filter((entry) => {
2955
+ if (!entry || typeof entry !== 'object') {
2956
+ return false;
2957
+ }
2958
+ const entrySessionId = typeof entry.sessionId === 'string' ? entry.sessionId : '';
2959
+ if (sessionId && entrySessionId === sessionId) {
2960
+ return false;
2961
+ }
2962
+ if (entry.fullPath) {
2963
+ const expanded = expandHomePath(entry.fullPath);
2964
+ const entryPath = expanded ? path.resolve(expanded) : '';
2965
+ if (entryPath && resolvedLower && entryPath.toLowerCase() === resolvedLower) {
2966
+ return false;
2967
+ }
2968
+ }
2969
+ return true;
2970
+ });
2971
+ if (filtered.length === index.entries.length) {
2972
+ return;
2973
+ }
2974
+ index.entries = filtered;
2975
+ try {
2976
+ fs.writeFileSync(indexPath, JSON.stringify(index, null, 2), 'utf-8');
2977
+ } catch (e) {}
2978
+ }
2979
+
2980
+ async function deleteSessionData(params = {}) {
2981
+ const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : '');
2982
+ if (!source) {
2983
+ return { error: 'Invalid source' };
2984
+ }
2985
+
2986
+ const filePath = resolveSessionFilePath(source, params.filePath, params.sessionId);
2987
+ if (!filePath) {
2988
+ return { error: 'Session file not found' };
2989
+ }
2990
+
2991
+ const sessionId = params.sessionId || path.basename(filePath, '.jsonl');
2992
+ try {
2993
+ fs.unlinkSync(filePath);
2994
+ } catch (e) {
2995
+ return { error: `删除会话失败: ${e.message}` };
2996
+ }
2997
+
2998
+ if (source === 'claude') {
2999
+ const indexPath = findClaudeSessionIndexPath(filePath);
3000
+ if (indexPath) {
3001
+ updateClaudeSessionIndex(indexPath, filePath, sessionId);
3002
+ }
3003
+ }
3004
+
3005
+ invalidateSessionListCache();
3006
+
3007
+ return {
3008
+ success: true,
3009
+ source,
3010
+ sessionId,
3011
+ filePath
3012
+ };
3013
+ }
3014
+
3015
+ function generateCloneSessionId() {
3016
+ if (crypto.randomUUID) {
3017
+ return `clone-${crypto.randomUUID()}`;
3018
+ }
3019
+ const timePart = Date.now().toString(36);
3020
+ const randomPart = crypto.randomBytes(8).toString('hex');
3021
+ return `clone-${timePart}-${randomPart}`;
3022
+ }
3023
+
3024
+ function allocateCloneSessionTarget(dirPath) {
3025
+ for (let attempt = 0; attempt < 6; attempt += 1) {
3026
+ const sessionId = generateCloneSessionId();
3027
+ const filePath = path.join(dirPath, `${sessionId}.jsonl`);
3028
+ if (!fs.existsSync(filePath)) {
3029
+ return { sessionId, filePath };
3030
+ }
3031
+ }
3032
+ const fallbackId = `clone-${Date.now().toString(36)}-${crypto.randomBytes(8).toString('hex')}`;
3033
+ return { sessionId: fallbackId, filePath: path.join(dirPath, `${fallbackId}.jsonl`) };
3034
+ }
3035
+
3036
+ function parseTimestampMs(value) {
3037
+ if (value === undefined || value === null || value === '') {
3038
+ return null;
3039
+ }
3040
+ if (typeof value === 'number' && Number.isFinite(value)) {
3041
+ if (value > 1e12) return value;
3042
+ if (value > 1e9) return value * 1000;
3043
+ return value;
3044
+ }
3045
+ if (typeof value === 'string') {
3046
+ const parsed = Date.parse(value);
3047
+ if (Number.isFinite(parsed)) {
3048
+ return parsed;
3049
+ }
3050
+ const numeric = Number(value);
3051
+ if (Number.isFinite(numeric)) {
3052
+ if (numeric > 1e12) return numeric;
3053
+ if (numeric > 1e9) return numeric * 1000;
3054
+ return numeric;
3055
+ }
3056
+ }
3057
+ return null;
3058
+ }
3059
+
3060
+ async function cloneCodexSession(params = {}) {
3061
+ const source = params.source === 'codex' ? 'codex' : '';
3062
+ if (!source) {
3063
+ return { error: '仅支持 Codex 会话克隆' };
3064
+ }
3065
+
3066
+ const filePath = resolveSessionFilePath(source, params.filePath, params.sessionId);
3067
+ if (!filePath) {
3068
+ return { error: 'Session file not found' };
3069
+ }
3070
+
3071
+ let content = '';
3072
+ try {
3073
+ content = fs.readFileSync(filePath, 'utf-8');
3074
+ } catch (e) {
3075
+ return { error: `读取会话失败: ${e.message}` };
3076
+ }
3077
+
3078
+ if (!content.trim()) {
3079
+ return { error: 'Session file is empty' };
3080
+ }
3081
+
3082
+ const lineEnding = detectLineEnding(content);
3083
+ const rawLines = content.split(/\r?\n/);
3084
+ if (rawLines.length > 0 && rawLines[rawLines.length - 1] === '') {
3085
+ rawLines.pop();
3086
+ }
3087
+
3088
+ let originalSessionId = typeof params.sessionId === 'string' ? params.sessionId.trim() : '';
3089
+ if (!originalSessionId) {
3090
+ originalSessionId = path.basename(filePath, '.jsonl');
3091
+ }
3092
+ let maxTimestampMs = 0;
3093
+
3094
+ for (const line of rawLines) {
3095
+ const trimmed = line.trim();
3096
+ if (!trimmed) continue;
3097
+ try {
3098
+ const record = JSON.parse(trimmed);
3099
+ if (record && record.type === 'session_meta' && record.payload) {
3100
+ if (record.payload.id) {
3101
+ originalSessionId = record.payload.id;
3102
+ }
3103
+ }
3104
+ if (record && record.timestamp !== undefined) {
3105
+ const ts = parseTimestampMs(record.timestamp);
3106
+ if (Number.isFinite(ts) && ts > maxTimestampMs) {
3107
+ maxTimestampMs = ts;
3108
+ }
3109
+ }
3110
+ } catch (e) {}
3111
+ }
3112
+
3113
+ const sessionsDir = getCodexSessionsDir();
3114
+ ensureDir(sessionsDir);
3115
+ const target = allocateCloneSessionTarget(sessionsDir);
3116
+ const newSessionId = target.sessionId;
3117
+ const newFilePath = target.filePath;
3118
+ const offsetMs = maxTimestampMs ? (Date.now() - maxTimestampMs) : 0;
3119
+ const cloneTime = new Date(Date.now() + 1);
3120
+ const cloneIso = cloneTime.toISOString();
3121
+
3122
+ const outputLines = [];
3123
+ for (const line of rawLines) {
3124
+ const trimmed = line.trim();
3125
+ if (!trimmed) {
3126
+ outputLines.push(line);
3127
+ continue;
3128
+ }
3129
+ let record;
3130
+ try {
3131
+ record = JSON.parse(trimmed);
3132
+ } catch (e) {
3133
+ outputLines.push(line);
3134
+ continue;
3135
+ }
3136
+
3137
+ if (originalSessionId && typeof record.sessionId === 'string' && record.sessionId === originalSessionId) {
3138
+ record.sessionId = newSessionId;
3139
+ }
3140
+ if (originalSessionId && typeof record.session_id === 'string' && record.session_id === originalSessionId) {
3141
+ record.session_id = newSessionId;
3142
+ }
3143
+ if (offsetMs && record.timestamp !== undefined) {
3144
+ const ts = parseTimestampMs(record.timestamp);
3145
+ if (Number.isFinite(ts)) {
3146
+ record.timestamp = new Date(ts + offsetMs).toISOString();
3147
+ }
3148
+ }
3149
+ if (record && record.type === 'session_meta' && record.payload && typeof record.payload === 'object') {
3150
+ record.payload = {
3151
+ ...record.payload,
3152
+ id: newSessionId,
3153
+ timestamp: cloneIso
3154
+ };
3155
+ record.timestamp = cloneIso;
3156
+ }
3157
+
3158
+ outputLines.push(JSON.stringify(record));
3159
+ }
3160
+
3161
+ const output = outputLines.join(lineEnding) + lineEnding;
3162
+ try {
3163
+ fs.writeFileSync(newFilePath, output, 'utf-8');
3164
+ } catch (e) {
3165
+ return { error: `写入克隆会话失败: ${e.message}` };
3166
+ }
3167
+ try {
3168
+ fs.utimesSync(newFilePath, cloneTime, cloneTime);
3169
+ } catch (e) {}
3170
+
3171
+ invalidateSessionListCache();
3172
+
3173
+ return {
3174
+ success: true,
3175
+ source,
3176
+ sourceLabel: 'Codex',
3177
+ sessionId: newSessionId,
3178
+ filePath: newFilePath
3179
+ };
3180
+ }
3181
+
3182
+ function buildSessionMarkdown(payload) {
2751
3183
  const lines = [
2752
3184
  '# AI Session Export',
2753
3185
  '',
@@ -2776,24 +3208,75 @@ function buildSessionMarkdown(payload) {
2776
3208
  lines.push('');
2777
3209
  });
2778
3210
 
2779
- return lines.join('\n');
2780
- }
2781
-
2782
- function resolveStateMaxMessages(state) {
2783
- if (!state || typeof state !== 'object') {
2784
- return MAX_EXPORT_MESSAGES;
2785
- }
2786
-
2787
- if (state.maxMessages === Infinity) {
2788
- return Infinity;
2789
- }
2790
-
2791
- const rawMax = Number(state.maxMessages);
2792
- if (Number.isFinite(rawMax) && rawMax > 0) {
2793
- return Math.floor(rawMax);
2794
- }
2795
- return MAX_EXPORT_MESSAGES;
2796
- }
3211
+ return lines.join('\n');
3212
+ }
3213
+
3214
+ function buildSessionPlainText(messages) {
3215
+ if (!Array.isArray(messages) || messages.length === 0) {
3216
+ return '';
3217
+ }
3218
+
3219
+ const lines = [];
3220
+ messages.forEach((message) => {
3221
+ const role = normalizeRole(message && message.role) || 'unknown';
3222
+ const text = message && typeof message.text === 'string' ? message.text : '';
3223
+ lines.push(role);
3224
+ lines.push(text);
3225
+ lines.push('');
3226
+ });
3227
+
3228
+ while (lines.length > 0 && lines[lines.length - 1] === '') {
3229
+ lines.pop();
3230
+ }
3231
+
3232
+ return lines.join('\n');
3233
+ }
3234
+
3235
+ function parseMaxMessagesValue(value) {
3236
+ if (value === Infinity) {
3237
+ return Infinity;
3238
+ }
3239
+
3240
+ if (typeof value === 'string') {
3241
+ const trimmed = value.trim();
3242
+ if (!trimmed) {
3243
+ return null;
3244
+ }
3245
+ const lower = trimmed.toLowerCase();
3246
+ if (lower === 'all' || lower === 'infinity' || lower === 'inf') {
3247
+ return Infinity;
3248
+ }
3249
+ const parsed = Number(trimmed);
3250
+ if (Number.isFinite(parsed)) {
3251
+ return parsed;
3252
+ }
3253
+ return null;
3254
+ }
3255
+
3256
+ if (Number.isFinite(value)) {
3257
+ return value;
3258
+ }
3259
+ return null;
3260
+ }
3261
+
3262
+ function resolveMaxMessagesValue(value, fallback) {
3263
+ const parsed = parseMaxMessagesValue(value);
3264
+ if (parsed === null) {
3265
+ return fallback;
3266
+ }
3267
+ if (parsed === Infinity) {
3268
+ return Infinity;
3269
+ }
3270
+ return Math.max(1, Math.floor(parsed));
3271
+ }
3272
+
3273
+ function resolveStateMaxMessages(state) {
3274
+ if (!state || typeof state !== 'object') {
3275
+ return MAX_EXPORT_MESSAGES;
3276
+ }
3277
+
3278
+ return resolveMaxMessagesValue(state.maxMessages, MAX_EXPORT_MESSAGES);
3279
+ }
2797
3280
 
2798
3281
  function canAppendMessage(state) {
2799
3282
  const maxMessages = resolveStateMaxMessages(state);
@@ -2830,7 +3313,7 @@ function extractCodexMessageFromRecord(record, state, lineIndex = -1) {
2830
3313
  }
2831
3314
  }
2832
3315
 
2833
- function extractClaudeMessageFromRecord(record, state, lineIndex = -1) {
3316
+ function extractClaudeMessageFromRecord(record, state, lineIndex = -1) {
2834
3317
  if (record.timestamp) {
2835
3318
  state.updatedAt = toIsoTime(record.timestamp, state.updatedAt);
2836
3319
  }
@@ -2855,89 +3338,130 @@ function extractClaudeMessageFromRecord(record, state, lineIndex = -1) {
2855
3338
  recordLineIndex: Number.isInteger(lineIndex) ? lineIndex : -1
2856
3339
  });
2857
3340
  }
2858
- }
2859
- }
2860
-
2861
- function extractMessagesFromRecords(records, source, options = {}) {
2862
- const maxMessages = options.maxMessages === Infinity
2863
- ? Infinity
2864
- : (Number.isFinite(Number(options.maxMessages)) ? Math.max(1, Number(options.maxMessages)) : MAX_EXPORT_MESSAGES);
2865
- const state = {
2866
- sessionId: '',
2867
- cwd: '',
2868
- updatedAt: '',
2869
- messages: [],
2870
- maxMessages
2871
- };
2872
-
2873
- let lineIndex = 0;
2874
- for (const record of records) {
2875
- if (source === 'codex') {
2876
- extractCodexMessageFromRecord(record, state, lineIndex);
2877
- } else {
2878
- extractClaudeMessageFromRecord(record, state, lineIndex);
2879
- }
2880
-
2881
- lineIndex += 1;
2882
-
2883
- if (state.maxMessages !== Infinity && state.messages.length >= state.maxMessages) {
2884
- break;
2885
- }
2886
- }
2887
-
2888
- return state;
2889
- }
2890
-
2891
- async function extractMessagesFromFile(filePath, source, options = {}) {
2892
- const maxMessages = options.maxMessages === Infinity
2893
- ? Infinity
2894
- : (Number.isFinite(Number(options.maxMessages)) ? Math.max(1, Number(options.maxMessages)) : MAX_EXPORT_MESSAGES);
2895
- const state = {
2896
- sessionId: '',
2897
- cwd: '',
2898
- updatedAt: '',
2899
- messages: [],
2900
- maxMessages
2901
- };
2902
-
2903
- let stream;
2904
- let rl;
2905
- try {
2906
- stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
2907
- rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
2908
-
2909
- let lineIndex = 0;
2910
- for await (const line of rl) {
2911
- const currentLineIndex = lineIndex;
2912
- lineIndex += 1;
3341
+ }
3342
+ }
3343
+
3344
+ function recordHasCodexMessage(record) {
3345
+ if (!record || record.type !== 'response_item' || !record.payload) {
3346
+ return false;
3347
+ }
3348
+ if (record.payload.type !== 'message') {
3349
+ return false;
3350
+ }
3351
+ const role = normalizeRole(record.payload.role);
3352
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') {
3353
+ return false;
3354
+ }
3355
+ const text = extractMessageText(record.payload.content);
3356
+ return !!text;
3357
+ }
3358
+
3359
+ function recordHasClaudeMessage(record) {
3360
+ if (!record) {
3361
+ return false;
3362
+ }
3363
+ const role = normalizeRole(record.type);
3364
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') {
3365
+ return false;
3366
+ }
3367
+ const content = record.message ? record.message.content : '';
3368
+ const text = extractMessageText(content);
3369
+ return !!text;
3370
+ }
3371
+
3372
+ function recordHasMessage(record, source) {
3373
+ return source === 'codex'
3374
+ ? recordHasCodexMessage(record)
3375
+ : recordHasClaudeMessage(record);
3376
+ }
3377
+
3378
+ function extractMessagesFromRecords(records, source, options = {}) {
3379
+ const maxMessages = resolveMaxMessagesValue(options.maxMessages, MAX_EXPORT_MESSAGES);
3380
+ const state = {
3381
+ sessionId: '',
3382
+ cwd: '',
3383
+ updatedAt: '',
3384
+ messages: [],
3385
+ maxMessages,
3386
+ truncated: false
3387
+ };
3388
+
3389
+ for (let lineIndex = 0; lineIndex < records.length; lineIndex++) {
3390
+ const record = records[lineIndex];
3391
+ if (source === 'codex') {
3392
+ extractCodexMessageFromRecord(record, state, lineIndex);
3393
+ } else {
3394
+ extractClaudeMessageFromRecord(record, state, lineIndex);
3395
+ }
3396
+
3397
+ if (state.maxMessages !== Infinity && state.messages.length >= state.maxMessages) {
3398
+ for (let i = lineIndex + 1; i < records.length; i++) {
3399
+ if (recordHasMessage(records[i], source)) {
3400
+ state.truncated = true;
3401
+ break;
3402
+ }
3403
+ }
3404
+ break;
3405
+ }
3406
+ }
3407
+
3408
+ return state;
3409
+ }
3410
+
3411
+ async function extractMessagesFromFile(filePath, source, options = {}) {
3412
+ const maxMessages = resolveMaxMessagesValue(options.maxMessages, MAX_EXPORT_MESSAGES);
3413
+ const state = {
3414
+ sessionId: '',
3415
+ cwd: '',
3416
+ updatedAt: '',
3417
+ messages: [],
3418
+ maxMessages,
3419
+ truncated: false
3420
+ };
3421
+
3422
+ let stream;
3423
+ let rl;
3424
+ try {
3425
+ stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
3426
+ rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
3427
+
3428
+ let lineIndex = 0;
3429
+ let limitReached = false;
3430
+ for await (const line of rl) {
3431
+ const currentLineIndex = lineIndex;
3432
+ lineIndex += 1;
2913
3433
 
2914
3434
  const trimmed = line.trim();
2915
3435
  if (!trimmed) continue;
2916
3436
 
2917
3437
  let record;
2918
- try {
2919
- record = JSON.parse(trimmed);
2920
- } catch (e) {
2921
- continue;
2922
- }
2923
-
2924
- if (source === 'codex') {
2925
- extractCodexMessageFromRecord(record, state, currentLineIndex);
2926
- } else {
2927
- extractClaudeMessageFromRecord(record, state, currentLineIndex);
2928
- }
2929
-
2930
- if (state.maxMessages !== Infinity && state.messages.length >= state.maxMessages) {
2931
- rl.close();
2932
- if (stream.destroy) {
2933
- stream.destroy();
2934
- }
2935
- break;
2936
- }
2937
- }
2938
- } catch (e) {
2939
- const fallbackRecords = readJsonlRecords(filePath);
2940
- return extractMessagesFromRecords(fallbackRecords, source, { maxMessages });
3438
+ try {
3439
+ record = JSON.parse(trimmed);
3440
+ } catch (e) {
3441
+ continue;
3442
+ }
3443
+
3444
+ if (limitReached) {
3445
+ if (recordHasMessage(record, source)) {
3446
+ state.truncated = true;
3447
+ break;
3448
+ }
3449
+ continue;
3450
+ }
3451
+
3452
+ if (source === 'codex') {
3453
+ extractCodexMessageFromRecord(record, state, currentLineIndex);
3454
+ } else {
3455
+ extractClaudeMessageFromRecord(record, state, currentLineIndex);
3456
+ }
3457
+
3458
+ if (state.maxMessages !== Infinity && state.messages.length >= state.maxMessages) {
3459
+ limitReached = true;
3460
+ }
3461
+ }
3462
+ } catch (e) {
3463
+ const fallbackRecords = readJsonlRecords(filePath);
3464
+ return extractMessagesFromRecords(fallbackRecords, source, { maxMessages });
2941
3465
  } finally {
2942
3466
  if (rl) {
2943
3467
  try { rl.close(); } catch (e) {}
@@ -2950,7 +3474,7 @@ async function extractMessagesFromFile(filePath, source, options = {}) {
2950
3474
  return state;
2951
3475
  }
2952
3476
 
2953
- async function readSessionDetail(params = {}) {
3477
+ async function readSessionDetail(params = {}) {
2954
3478
  const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : '');
2955
3479
  if (!source) {
2956
3480
  return { error: 'Invalid source' };
@@ -2977,37 +3501,83 @@ async function readSessionDetail(params = {}) {
2977
3501
  const startIndex = Math.max(0, allMessages.length - messageLimit);
2978
3502
  const clippedMessages = allMessages.slice(startIndex);
2979
3503
 
2980
- return {
2981
- source,
2982
- sourceLabel,
2983
- sessionId,
3504
+ return {
3505
+ source,
3506
+ sourceLabel,
3507
+ sessionId,
2984
3508
  cwd: extracted.cwd || '',
2985
3509
  updatedAt: extracted.updatedAt || '',
2986
3510
  totalMessages: allMessages.length,
2987
3511
  clipped: allMessages.length > clippedMessages.length,
2988
3512
  messageLimit,
2989
3513
  messages: clippedMessages,
2990
- filePath
2991
- };
2992
- }
2993
-
2994
- async function exportSessionData(params = {}) {
2995
- const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : '');
2996
- if (!source) {
2997
- return { error: 'Invalid source' };
2998
- }
2999
-
3000
- const filePath = resolveSessionFilePath(source, params.filePath, params.sessionId);
3001
- if (!filePath) {
3002
- return { error: 'Session file not found' };
3003
- }
3004
-
3005
- let extracted;
3006
- try {
3007
- extracted = await extractMessagesFromFile(filePath, source);
3008
- } catch (e) {
3009
- extracted = null;
3010
- }
3514
+ filePath
3515
+ };
3516
+ }
3517
+
3518
+ async function readSessionPlain(params = {}) {
3519
+ const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : '');
3520
+ if (!source) {
3521
+ return { error: 'Invalid source' };
3522
+ }
3523
+
3524
+ const filePath = resolveSessionFilePath(source, params.filePath, params.sessionId);
3525
+ if (!filePath) {
3526
+ return { error: 'Session file not found' };
3527
+ }
3528
+
3529
+ let extracted;
3530
+ try {
3531
+ extracted = await extractMessagesFromFile(filePath, source, { maxMessages: Infinity });
3532
+ } catch (e) {
3533
+ extracted = null;
3534
+ }
3535
+
3536
+ if (!extracted) {
3537
+ return { error: 'Failed to parse session file' };
3538
+ }
3539
+
3540
+ if ((!extracted.messages || extracted.messages.length === 0) && !extracted.sessionId && !extracted.cwd) {
3541
+ const fallbackRecords = readJsonlRecords(filePath);
3542
+ if (fallbackRecords.length === 0) {
3543
+ return { error: 'Session file is empty' };
3544
+ }
3545
+ extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages: Infinity });
3546
+ }
3547
+
3548
+ const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
3549
+ const sourceLabel = source === 'codex' ? 'Codex' : 'Claude Code';
3550
+ const messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []);
3551
+ const text = buildSessionPlainText(messages);
3552
+
3553
+ return {
3554
+ source,
3555
+ sourceLabel,
3556
+ sessionId,
3557
+ title: sessionId,
3558
+ filePath,
3559
+ text
3560
+ };
3561
+ }
3562
+
3563
+ async function exportSessionData(params = {}) {
3564
+ const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : '');
3565
+ if (!source) {
3566
+ return { error: 'Invalid source' };
3567
+ }
3568
+
3569
+ const maxMessages = resolveMaxMessagesValue(params.maxMessages, MAX_EXPORT_MESSAGES);
3570
+ const filePath = resolveSessionFilePath(source, params.filePath, params.sessionId);
3571
+ if (!filePath) {
3572
+ return { error: 'Session file not found' };
3573
+ }
3574
+
3575
+ let extracted;
3576
+ try {
3577
+ extracted = await extractMessagesFromFile(filePath, source, { maxMessages });
3578
+ } catch (e) {
3579
+ extracted = null;
3580
+ }
3011
3581
 
3012
3582
  if (!extracted) {
3013
3583
  return { error: 'Failed to parse session file' };
@@ -3015,11 +3585,11 @@ async function exportSessionData(params = {}) {
3015
3585
 
3016
3586
  if ((!extracted.messages || extracted.messages.length === 0) && !extracted.sessionId && !extracted.cwd) {
3017
3587
  const fallbackRecords = readJsonlRecords(filePath);
3018
- if (fallbackRecords.length === 0) {
3019
- return { error: 'Session file is empty' };
3020
- }
3021
- extracted = extractMessagesFromRecords(fallbackRecords, source);
3022
- }
3588
+ if (fallbackRecords.length === 0) {
3589
+ return { error: 'Session file is empty' };
3590
+ }
3591
+ extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages });
3592
+ }
3023
3593
 
3024
3594
  extracted.messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []);
3025
3595
 
@@ -3031,25 +3601,29 @@ async function exportSessionData(params = {}) {
3031
3601
  }
3032
3602
 
3033
3603
  const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
3034
- const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
3035
- const sourceLabel = source === 'codex' ? 'Codex' : 'Claude Code';
3036
- const markdown = buildSessionMarkdown({
3037
- sourceLabel,
3038
- sessionId,
3039
- updatedAt: extracted.updatedAt,
3040
- cwd: extracted.cwd,
3604
+ const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
3605
+ const sourceLabel = source === 'codex' ? 'Codex' : 'Claude Code';
3606
+ const truncated = !!extracted.truncated;
3607
+ const maxMessagesLabel = maxMessages === Infinity ? 'all' : maxMessages;
3608
+ const markdown = buildSessionMarkdown({
3609
+ sourceLabel,
3610
+ sessionId,
3611
+ updatedAt: extracted.updatedAt,
3612
+ cwd: extracted.cwd,
3041
3613
  filePath,
3042
3614
  messages: extracted.messages
3043
3615
  });
3044
3616
 
3045
- return {
3046
- source,
3047
- sourceLabel,
3048
- sessionId,
3049
- fileName: `${source}-session-${safeSessionId}.md`,
3050
- content: markdown
3051
- };
3052
- }
3617
+ return {
3618
+ source,
3619
+ sourceLabel,
3620
+ sessionId,
3621
+ fileName: `${source}-session-${safeSessionId}.md`,
3622
+ content: markdown,
3623
+ truncated,
3624
+ maxMessages: maxMessagesLabel
3625
+ };
3626
+ }
3053
3627
 
3054
3628
  function buildExportPayload(includeKeys) {
3055
3629
  const { config } = readConfigOrVirtualDefault();
@@ -3213,7 +3787,7 @@ function resolveSpeedTestTarget(params) {
3213
3787
  return { error: 'Missing name or url' };
3214
3788
  }
3215
3789
 
3216
- function runSpeedTest(targetUrl, apiKey) {
3790
+ function runSpeedTest(targetUrl, apiKey, options = {}) {
3217
3791
  return new Promise((resolve) => {
3218
3792
  let parsed;
3219
3793
  try {
@@ -3222,6 +3796,10 @@ function runSpeedTest(targetUrl, apiKey) {
3222
3796
  return resolve({ ok: false, error: 'Invalid URL' });
3223
3797
  }
3224
3798
 
3799
+ const timeoutMs = Number.isFinite(options.timeoutMs)
3800
+ ? Math.max(1000, Number(options.timeoutMs))
3801
+ : SPEED_TEST_TIMEOUT_MS;
3802
+
3225
3803
  const transport = parsed.protocol === 'https:' ? https : http;
3226
3804
  const headers = {
3227
3805
  'User-Agent': 'codexmate-speed-test',
@@ -3243,7 +3821,7 @@ function runSpeedTest(targetUrl, apiKey) {
3243
3821
  });
3244
3822
  });
3245
3823
 
3246
- req.setTimeout(SPEED_TEST_TIMEOUT_MS, () => {
3824
+ req.setTimeout(timeoutMs, () => {
3247
3825
  req.destroy(new Error('timeout'));
3248
3826
  });
3249
3827
 
@@ -3938,8 +4516,67 @@ function applyToClaudeSettings(config = {}) {
3938
4516
  }
3939
4517
  }
3940
4518
 
3941
- // 多线程压缩
3942
- function cmdZip(targetPath, options = {}) {
4519
+ function commandExists(command, args = '') {
4520
+ try {
4521
+ execSync(`${command} ${args}`, { stdio: 'ignore' });
4522
+ return true;
4523
+ } catch (e) {
4524
+ return false;
4525
+ }
4526
+ }
4527
+
4528
+ const SEVEN_ZIP_PATHS = [
4529
+ 'C:\\Program Files\\7-Zip\\7z.exe',
4530
+ 'C:\\Program Files (x86)\\7-Zip\\7z.exe',
4531
+ '7z'
4532
+ ];
4533
+
4534
+ function findSevenZipExecutable() {
4535
+ for (const candidate of SEVEN_ZIP_PATHS) {
4536
+ try {
4537
+ if (candidate === '7z') {
4538
+ if (commandExists('7z', '--help')) {
4539
+ return '7z';
4540
+ }
4541
+ } else if (fs.existsSync(candidate)) {
4542
+ return candidate;
4543
+ }
4544
+ } catch (e) {}
4545
+ }
4546
+ return null;
4547
+ }
4548
+
4549
+ function resolveZipTool() {
4550
+ const sevenZipExe = findSevenZipExecutable();
4551
+ if (sevenZipExe) {
4552
+ return { type: '7z', cmd: sevenZipExe };
4553
+ }
4554
+ return { type: 'lib', cmd: 'zip-lib' };
4555
+ }
4556
+
4557
+ function resolveUnzipTool() {
4558
+ const sevenZipExe = findSevenZipExecutable();
4559
+ if (sevenZipExe) {
4560
+ return { type: '7z', cmd: sevenZipExe };
4561
+ }
4562
+ return { type: 'lib', cmd: 'zip-lib' };
4563
+ }
4564
+
4565
+ async function zipWithLibrary(absPath, outputPath) {
4566
+ const stat = fs.lstatSync(absPath);
4567
+ if (stat.isDirectory()) {
4568
+ await zipLib.archiveFolder(absPath, outputPath);
4569
+ return;
4570
+ }
4571
+ await zipLib.archiveFile(absPath, outputPath);
4572
+ }
4573
+
4574
+ async function unzipWithLibrary(zipPath, outputDir) {
4575
+ await zipLib.extract(zipPath, outputDir);
4576
+ }
4577
+
4578
+ // 压缩(7-Zip 优先)
4579
+ async function cmdZip(targetPath, options = {}) {
3943
4580
  if (!targetPath) {
3944
4581
  console.error('用法: codexmate zip <文件或文件夹路径> [--max:压缩级别]');
3945
4582
  console.log('\n示例:');
@@ -3967,58 +4604,43 @@ function cmdZip(targetPath, options = {}) {
3967
4604
  const outputDir = path.dirname(absPath);
3968
4605
  const outputPath = path.join(outputDir, `${baseName}.zip`);
3969
4606
 
3970
- // 查找 7-Zip
3971
- const sevenZipPaths = [
3972
- 'C:\\Program Files\\7-Zip\\7z.exe',
3973
- 'C:\\Program Files (x86)\\7-Zip\\7z.exe',
3974
- '7z'
3975
- ];
3976
-
3977
- let sevenZipExe = null;
3978
- for (const p of sevenZipPaths) {
3979
- try {
3980
- if (p === '7z') {
3981
- execSync('7z --help', { stdio: 'ignore' });
3982
- sevenZipExe = '7z';
3983
- break;
3984
- } else if (fs.existsSync(p)) {
3985
- sevenZipExe = p;
3986
- break;
3987
- }
3988
- } catch (e) {}
3989
- }
3990
-
3991
- if (!sevenZipExe) {
3992
- console.error('错误: 未找到 7-Zip,请先安装 7-Zip');
3993
- console.log('下载地址: https://www.7-zip.org/');
3994
- process.exit(1);
3995
- }
4607
+ const zipTool = resolveZipTool();
3996
4608
 
3997
4609
  console.log('\n压缩配置:');
3998
4610
  console.log(' 源路径:', absPath);
3999
4611
  console.log(' 输出文件:', outputPath);
4000
4612
  console.log(' 压缩级别:', compressionLevel);
4001
- console.log(' 多线程: 启用');
4613
+ console.log(' 压缩工具:', zipTool.type === '7z' ? '7-Zip' : 'zip-lib');
4614
+ console.log(' 多线程:', zipTool.type === '7z' ? '启用' : '未启用(JS 库)');
4615
+ if (zipTool.type !== '7z') {
4616
+ console.log(' 提示: JS 库不支持压缩级别,已忽略 --max');
4617
+ }
4002
4618
  console.log('\n开始压缩...\n');
4003
4619
 
4004
4620
  try {
4005
- const cmd = `"${sevenZipExe}" a -tzip -mmt=on -mx=${compressionLevel} "${outputPath}" "${absPath}"`;
4006
- const result = execSync(cmd, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
4007
-
4008
- // 解析输出获取文件信息
4009
- const sizeMatch = result.match(/Archive size:\s*(\d+)\s*bytes/);
4010
- const filesMatch = result.match(/(\d+)\s*files/);
4621
+ if (zipTool.type === '7z') {
4622
+ const cmd = `"${zipTool.cmd}" a -tzip -mmt=on -mx=${compressionLevel} "${outputPath}" "${absPath}"`;
4623
+ const result = execSync(cmd, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
4624
+ const sizeMatch = result.match(/Archive size:\s*(\d+)\s*bytes/);
4625
+ const filesMatch = result.match(/(\d+)\s*files/);
4626
+
4627
+ console.log('✓ 压缩完成!');
4628
+ console.log(' 输出文件:', outputPath);
4629
+ if (sizeMatch) {
4630
+ const sizeBytes = parseInt(sizeMatch[1]);
4631
+ const sizeMB = (sizeBytes / 1024 / 1024).toFixed(2);
4632
+ console.log(' 压缩大小:', sizeMB, 'MB');
4633
+ }
4634
+ if (filesMatch) {
4635
+ console.log(' 文件数量:', filesMatch[1]);
4636
+ }
4637
+ console.log();
4638
+ return;
4639
+ }
4011
4640
 
4641
+ await zipWithLibrary(absPath, outputPath);
4012
4642
  console.log('✓ 压缩完成!');
4013
4643
  console.log(' 输出文件:', outputPath);
4014
- if (sizeMatch) {
4015
- const sizeBytes = parseInt(sizeMatch[1]);
4016
- const sizeMB = (sizeBytes / 1024 / 1024).toFixed(2);
4017
- console.log(' 压缩大小:', sizeMB, 'MB');
4018
- }
4019
- if (filesMatch) {
4020
- console.log(' 文件数量:', filesMatch[1]);
4021
- }
4022
4644
  console.log();
4023
4645
  } catch (e) {
4024
4646
  console.error('压缩失败:', e.message);
@@ -4026,8 +4648,8 @@ function cmdZip(targetPath, options = {}) {
4026
4648
  }
4027
4649
  }
4028
4650
 
4029
- // 多线程解压
4030
- function cmdUnzip(zipPath, outputDir) {
4651
+ // 解压(7-Zip 优先)
4652
+ async function cmdUnzip(zipPath, outputDir) {
4031
4653
  if (!zipPath) {
4032
4654
  console.error('用法: codexmate unzip <zip文件路径> [输出目录]');
4033
4655
  console.log('\n示例:');
@@ -4053,65 +4675,254 @@ function cmdUnzip(zipPath, outputDir) {
4053
4675
  const defaultOutputDir = path.join(path.dirname(absZipPath), baseName);
4054
4676
  const absOutputDir = outputDir ? path.resolve(outputDir) : defaultOutputDir;
4055
4677
 
4056
- // 查找 7-Zip
4057
- const sevenZipPaths = [
4058
- 'C:\\Program Files\\7-Zip\\7z.exe',
4059
- 'C:\\Program Files (x86)\\7-Zip\\7z.exe',
4060
- '7z'
4061
- ];
4062
-
4063
- let sevenZipExe = null;
4064
- for (const p of sevenZipPaths) {
4065
- try {
4066
- if (p === '7z') {
4067
- execSync('7z --help', { stdio: 'ignore' });
4068
- sevenZipExe = '7z';
4069
- break;
4070
- } else if (fs.existsSync(p)) {
4071
- sevenZipExe = p;
4072
- break;
4073
- }
4074
- } catch (e) {}
4075
- }
4076
-
4077
- if (!sevenZipExe) {
4078
- console.error('错误: 未找到 7-Zip,请先安装 7-Zip');
4079
- console.log('下载地址: https://www.7-zip.org/');
4080
- process.exit(1);
4081
- }
4678
+ const unzipTool = resolveUnzipTool();
4082
4679
 
4083
4680
  console.log('\n解压配置:');
4084
4681
  console.log(' 源文件:', absZipPath);
4085
4682
  console.log(' 输出目录:', absOutputDir);
4086
- console.log(' 多线程: 启用');
4683
+ console.log(' 解压工具:', unzipTool.type === '7z' ? '7-Zip' : 'zip-lib');
4684
+ console.log(' 多线程:', unzipTool.type === '7z' ? '启用' : '未启用(JS 库)');
4087
4685
  console.log('\n开始解压...\n');
4088
4686
 
4089
4687
  try {
4090
- const cmd = `"${sevenZipExe}" x -mmt=on -o"${absOutputDir}" "${absZipPath}" -y`;
4091
- const result = execSync(cmd, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
4092
-
4093
- // 解析输出获取文件信息
4094
- const filesMatch = result.match(/(\d+)\s*files/);
4688
+ if (unzipTool.type === '7z') {
4689
+ const cmd = `"${unzipTool.cmd}" x -mmt=on -o"${absOutputDir}" "${absZipPath}" -y`;
4690
+ const result = execSync(cmd, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
4691
+ const filesMatch = result.match(/(\d+)\s*files/);
4692
+ console.log('✓ 解压完成!');
4693
+ console.log(' 输出目录:', absOutputDir);
4694
+ if (filesMatch) {
4695
+ console.log(' 文件数量:', filesMatch[1]);
4696
+ }
4697
+ console.log();
4698
+ return;
4699
+ }
4095
4700
 
4701
+ await unzipWithLibrary(absZipPath, absOutputDir);
4096
4702
  console.log('✓ 解压完成!');
4097
4703
  console.log(' 输出目录:', absOutputDir);
4098
- if (filesMatch) {
4099
- console.log(' 文件数量:', filesMatch[1]);
4100
- }
4101
4704
  console.log();
4102
4705
  } catch (e) {
4103
4706
  console.error('解压失败:', e.message);
4104
4707
  process.exit(1);
4105
- }
4106
- }
4107
-
4108
- // 打开 Web UI
4109
- function cmdStart() {
4110
- const htmlPath = path.join(__dirname, 'web-ui.html');
4111
- if (!fs.existsSync(htmlPath)) {
4112
- console.error('错误: web-ui.html 不存在');
4113
- process.exit(1);
4114
- }
4708
+ }
4709
+ }
4710
+
4711
+ function resolveExportOutputPath(outputPath, defaultFileName) {
4712
+ const fallback = path.resolve(process.cwd(), defaultFileName);
4713
+ if (typeof outputPath !== 'string' || !outputPath.trim()) {
4714
+ return fallback;
4715
+ }
4716
+
4717
+ const trimmed = outputPath.trim();
4718
+ const resolved = path.resolve(trimmed);
4719
+ const hasTrailingSep = /[\\\/]$/.test(trimmed);
4720
+ if (hasTrailingSep) {
4721
+ ensureDir(resolved);
4722
+ return path.join(resolved, defaultFileName);
4723
+ }
4724
+
4725
+ if (fs.existsSync(resolved)) {
4726
+ try {
4727
+ const stat = fs.statSync(resolved);
4728
+ if (stat.isDirectory()) {
4729
+ return path.join(resolved, defaultFileName);
4730
+ }
4731
+ } catch (e) {}
4732
+ }
4733
+
4734
+ return resolved;
4735
+ }
4736
+
4737
+ function printExportSessionUsage() {
4738
+ console.log('\n用法: codexmate export-session --source <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
4739
+ console.log('\n示例:');
4740
+ console.log(' codexmate export-session --source codex --session-id 123456');
4741
+ console.log(' codexmate export-session --source claude --file "~/.claude/projects/demo/session.jsonl"');
4742
+ console.log(' codexmate export-session --source codex --session-id 123456 --max-messages=all');
4743
+ }
4744
+
4745
+ function parseExportSessionArgs(args = []) {
4746
+ const options = {
4747
+ source: '',
4748
+ sessionId: '',
4749
+ filePath: '',
4750
+ output: '',
4751
+ maxMessages: undefined
4752
+ };
4753
+ const errors = [];
4754
+
4755
+ for (let i = 0; i < args.length; i++) {
4756
+ const arg = args[i];
4757
+ if (!arg) continue;
4758
+
4759
+ if (arg.startsWith('--source=')) {
4760
+ options.source = arg.slice('--source='.length);
4761
+ continue;
4762
+ }
4763
+ if (arg === '--source') {
4764
+ options.source = args[i + 1] || '';
4765
+ i += 1;
4766
+ continue;
4767
+ }
4768
+ if (arg.startsWith('--session-id=')) {
4769
+ options.sessionId = arg.slice('--session-id='.length);
4770
+ continue;
4771
+ }
4772
+ if (arg === '--session-id') {
4773
+ options.sessionId = args[i + 1] || '';
4774
+ i += 1;
4775
+ continue;
4776
+ }
4777
+ if (arg.startsWith('--file=')) {
4778
+ options.filePath = arg.slice('--file='.length);
4779
+ continue;
4780
+ }
4781
+ if (arg === '--file') {
4782
+ options.filePath = args[i + 1] || '';
4783
+ i += 1;
4784
+ continue;
4785
+ }
4786
+ if (arg.startsWith('--output=')) {
4787
+ options.output = arg.slice('--output='.length);
4788
+ continue;
4789
+ }
4790
+ if (arg === '--output') {
4791
+ options.output = args[i + 1] || '';
4792
+ i += 1;
4793
+ continue;
4794
+ }
4795
+ if (arg.startsWith('--max-messages=')) {
4796
+ options.maxMessages = arg.slice('--max-messages='.length);
4797
+ continue;
4798
+ }
4799
+ if (arg === '--max-messages') {
4800
+ options.maxMessages = args[i + 1] || '';
4801
+ i += 1;
4802
+ continue;
4803
+ }
4804
+
4805
+ errors.push(`未知参数: ${arg}`);
4806
+ }
4807
+
4808
+ const normalizedSource = options.source.trim().toLowerCase();
4809
+ if (normalizedSource && normalizedSource !== 'codex' && normalizedSource !== 'claude') {
4810
+ errors.push('参数 --source 仅支持 codex 或 claude');
4811
+ }
4812
+ options.source = normalizedSource;
4813
+
4814
+ if (!options.source) {
4815
+ errors.push('缺少 --source');
4816
+ }
4817
+
4818
+ if (!options.sessionId && !options.filePath) {
4819
+ errors.push('必须指定 --session-id 或 --file');
4820
+ }
4821
+
4822
+ if (options.maxMessages !== undefined) {
4823
+ const parsed = parseMaxMessagesValue(options.maxMessages);
4824
+ if (parsed === null) {
4825
+ errors.push('参数 --max-messages 无效');
4826
+ } else {
4827
+ options.maxMessages = parsed === Infinity ? Infinity : Math.max(1, Math.floor(parsed));
4828
+ }
4829
+ }
4830
+
4831
+ return {
4832
+ options,
4833
+ error: errors.length > 0 ? errors.join(';') : ''
4834
+ };
4835
+ }
4836
+
4837
+ async function cmdExportSession(args = []) {
4838
+ const parsed = parseExportSessionArgs(args);
4839
+ if (parsed.error) {
4840
+ console.error('错误:', parsed.error);
4841
+ printExportSessionUsage();
4842
+ process.exit(1);
4843
+ }
4844
+
4845
+ const options = parsed.options;
4846
+ const maxMessages = resolveMaxMessagesValue(options.maxMessages, MAX_EXPORT_MESSAGES);
4847
+ let result;
4848
+ try {
4849
+ result = await exportSessionData({
4850
+ source: options.source,
4851
+ sessionId: options.sessionId,
4852
+ filePath: options.filePath,
4853
+ maxMessages
4854
+ });
4855
+ } catch (e) {
4856
+ console.error('导出失败:', e.message || e);
4857
+ process.exit(1);
4858
+ }
4859
+
4860
+ if (result && result.error) {
4861
+ console.error('导出失败:', result.error);
4862
+ process.exit(1);
4863
+ }
4864
+
4865
+ const defaultFileName = (result && result.fileName)
4866
+ ? result.fileName
4867
+ : `${options.source}-session-${options.sessionId || Date.now()}.md`;
4868
+ const outputPath = resolveExportOutputPath(options.output, defaultFileName);
4869
+ ensureDir(path.dirname(outputPath));
4870
+ fs.writeFileSync(outputPath, (result && result.content) ? result.content : '', 'utf-8');
4871
+
4872
+ console.log('\n✓ 会话已导出:', outputPath);
4873
+ if (result && result.truncated) {
4874
+ const label = maxMessages === Infinity ? 'all' : maxMessages;
4875
+ console.log(`! 已截断: 仅导出前 ${label} 条消息`);
4876
+ console.log(' 可使用 --max-messages=all 导出完整内容');
4877
+ }
4878
+ console.log();
4879
+ }
4880
+
4881
+ function parseStartOptions(args = []) {
4882
+ const options = { host: '' };
4883
+ if (!Array.isArray(args)) {
4884
+ return options;
4885
+ }
4886
+
4887
+ for (let i = 0; i < args.length; i++) {
4888
+ const arg = args[i];
4889
+ if (!arg) continue;
4890
+ if (arg.startsWith('--host=')) {
4891
+ options.host = arg.slice('--host='.length);
4892
+ continue;
4893
+ }
4894
+ if (arg === '--host') {
4895
+ options.host = args[i + 1] || '';
4896
+ i += 1;
4897
+ }
4898
+ }
4899
+
4900
+ return options;
4901
+ }
4902
+
4903
+ function isAnyAddressHost(host) {
4904
+ return host === '0.0.0.0' || host === '::';
4905
+ }
4906
+
4907
+ function formatHostForUrl(host) {
4908
+ const value = typeof host === 'string' ? host.trim() : '';
4909
+ if (!value) return '';
4910
+ if (value.startsWith('[') && value.endsWith(']')) {
4911
+ return value;
4912
+ }
4913
+ if (value.includes(':')) {
4914
+ return `[${value}]`;
4915
+ }
4916
+ return value;
4917
+ }
4918
+
4919
+ // 打开 Web UI
4920
+ function cmdStart(options = {}) {
4921
+ const htmlPath = path.join(__dirname, 'web-ui.html');
4922
+ if (!fs.existsSync(htmlPath)) {
4923
+ console.error('错误: web-ui.html 不存在');
4924
+ process.exit(1);
4925
+ }
4115
4926
 
4116
4927
  const server = http.createServer((req, res) => {
4117
4928
  if (req.url === '/api') {
@@ -4207,6 +5018,12 @@ function cmdStart() {
4207
5018
  case 'apply-openclaw-agents-file':
4208
5019
  result = applyOpenclawAgentsFile(params || {});
4209
5020
  break;
5021
+ case 'get-openclaw-workspace-file':
5022
+ result = readOpenclawWorkspaceFile(params || {});
5023
+ break;
5024
+ case 'apply-openclaw-workspace-file':
5025
+ result = applyOpenclawWorkspaceFile(params || {});
5026
+ break;
4210
5027
  case 'switch':
4211
5028
  case 'use':
4212
5029
  case 'add':
@@ -4255,12 +5072,21 @@ function cmdStart() {
4255
5072
  case 'export-session':
4256
5073
  result = await exportSessionData(params);
4257
5074
  break;
4258
- case 'session-detail':
4259
- result = await readSessionDetail(params);
5075
+ case 'delete-session':
5076
+ result = await deleteSessionData(params || {});
4260
5077
  break;
4261
- default:
4262
- result = { error: '未知操作' };
4263
- }
5078
+ case 'clone-session':
5079
+ result = await cloneCodexSession(params || {});
5080
+ break;
5081
+ case 'session-detail':
5082
+ result = await readSessionDetail(params);
5083
+ break;
5084
+ case 'session-plain':
5085
+ result = await readSessionPlain(params);
5086
+ break;
5087
+ default:
5088
+ result = { error: '未知操作' };
5089
+ }
4264
5090
 
4265
5091
  res.writeHead(200, { 'Content-Type': 'application/json' });
4266
5092
  res.end(JSON.stringify(result));
@@ -4276,18 +5102,28 @@ function cmdStart() {
4276
5102
  }
4277
5103
  });
4278
5104
 
4279
- const port = resolveWebPort();
4280
- server.listen(port, () => {
4281
- console.log('\n✓ Web UI 已启动: http://localhost:' + port);
4282
- console.log(' 按 Ctrl+C 退出\n');
4283
-
4284
- // 打开浏览器
4285
- const platform = process.platform;
4286
- let command;
4287
- const url = `http://localhost:${port}`;
4288
-
4289
- if (platform === 'win32') {
4290
- command = `start "" "${url}"`;
5105
+ const port = resolveWebPort();
5106
+ const host = resolveWebHost(options);
5107
+ const openHost = isAnyAddressHost(host) ? DEFAULT_WEB_HOST : host;
5108
+ const openUrl = `http://${formatHostForUrl(openHost)}:${port}`;
5109
+ server.listen(port, host, () => {
5110
+ console.log('\n✓ Web UI 已启动:', openUrl);
5111
+ if (host && host !== openHost) {
5112
+ console.log(' 监听地址:', host);
5113
+ }
5114
+ console.log(' 按 Ctrl+C 退出\n');
5115
+ if (isAnyAddressHost(host)) {
5116
+ console.warn('! 安全提示: 当前监听所有网卡(无鉴权)。');
5117
+ console.warn(' 建议仅在可信网络使用,或改用 --host 127.0.0.1。');
5118
+ }
5119
+
5120
+ // 打开浏览器
5121
+ const platform = process.platform;
5122
+ let command;
5123
+ const url = openUrl;
5124
+
5125
+ if (platform === 'win32') {
5126
+ command = `start "" "${url}"`;
4291
5127
  } else if (platform === 'darwin') {
4292
5128
  command = `open "${url}"`;
4293
5129
  } else {
@@ -4324,14 +5160,15 @@ async function main() {
4324
5160
  console.log(' codexmate use <模型> 切换模型');
4325
5161
  console.log(' codexmate add <名称> <URL> [密钥]');
4326
5162
  console.log(' codexmate delete <名称> 删除提供商');
4327
- console.log(' codexmate add-model <模型> 添加模型');
4328
- console.log(' codexmate delete-model <模型> 删除模型');
4329
- console.log(' codexmate start 启动 Web 界面');
4330
- console.log(' codexmate zip <路径> [--max:级别] 多线程压缩');
4331
- console.log(' codexmate unzip <zip文件> [输出目录] 多线程解压');
4332
- console.log('');
4333
- process.exit(0);
4334
- }
5163
+ console.log(' codexmate add-model <模型> 添加模型');
5164
+ console.log(' codexmate delete-model <模型> 删除模型');
5165
+ console.log(' codexmate start [--host <HOST>] 启动 Web 界面');
5166
+ console.log(' codexmate export-session --source <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
5167
+ console.log(' codexmate zip <路径> [--max:级别] 压缩(7-Zip 优先)');
5168
+ console.log(' codexmate unzip <zip文件> [输出目录] 解压(7-Zip 优先)');
5169
+ console.log('');
5170
+ process.exit(0);
5171
+ }
4335
5172
 
4336
5173
  const command = args[0];
4337
5174
 
@@ -4344,12 +5181,13 @@ async function main() {
4344
5181
  case 'use': cmdUseModel(args[1]); break;
4345
5182
  case 'add': cmdAdd(args[1], args[2], args[3]); break;
4346
5183
  case 'delete': cmdDelete(args[1]); break;
4347
- case 'add-model': cmdAddModel(args[1]); break;
4348
- case 'delete-model': cmdDeleteModel(args[1]); break;
4349
- case 'start': cmdStart(); break;
4350
- case 'zip': {
4351
- // 解析 --max:N 参数
4352
- const zipOptions = {};
5184
+ case 'add-model': cmdAddModel(args[1]); break;
5185
+ case 'delete-model': cmdDeleteModel(args[1]); break;
5186
+ case 'start': cmdStart(parseStartOptions(args.slice(1))); break;
5187
+ case 'export-session': await cmdExportSession(args.slice(1)); break;
5188
+ case 'zip': {
5189
+ // 解析 --max:N 参数
5190
+ const zipOptions = {};
4353
5191
  let targetPath = null;
4354
5192
  for (let i = 1; i < args.length; i++) {
4355
5193
  const arg = args[i];
@@ -4359,10 +5197,10 @@ async function main() {
4359
5197
  targetPath = arg;
4360
5198
  }
4361
5199
  }
4362
- cmdZip(targetPath, zipOptions);
5200
+ await cmdZip(targetPath, zipOptions);
4363
5201
  break;
4364
5202
  }
4365
- case 'unzip': cmdUnzip(args[1], args[2]); break;
5203
+ case 'unzip': await cmdUnzip(args[1], args[2]); break;
4366
5204
  default:
4367
5205
  console.error('错误: 未知命令:', command);
4368
5206
  console.log('运行 "codexmate" 查看帮助');
@@ -4374,3 +5212,4 @@ main().catch((err) => {
4374
5212
  console.error('错误:', err && err.message ? err.message : err);
4375
5213
  process.exit(1);
4376
5214
  });
5215
+