codexmate 0.0.2

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 ADDED
@@ -0,0 +1,3022 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const os = require('os');
5
+ const toml = require('@iarna/toml');
6
+ const { exec, execSync } = require('child_process');
7
+ const http = require('http');
8
+ const https = require('https');
9
+ const readline = require('readline');
10
+
11
+ const PORT = 3737;
12
+
13
+ // ============================================================================
14
+ // 配置
15
+ // ============================================================================
16
+ const CONFIG_DIR = path.join(os.homedir(), '.codex');
17
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.toml');
18
+ const AUTH_FILE = path.join(CONFIG_DIR, 'auth.json');
19
+ const MODELS_FILE = path.join(CONFIG_DIR, 'models.json');
20
+ const CURRENT_MODELS_FILE = path.join(CONFIG_DIR, 'provider-current-models.json');
21
+ const INIT_MARK_FILE = path.join(CONFIG_DIR, 'codexmate-init.json');
22
+ const CODEX_SESSIONS_DIR = path.join(CONFIG_DIR, 'sessions');
23
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
24
+ const CLAUDE_SETTINGS_FILE = path.join(CLAUDE_DIR, 'settings.json');
25
+ const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
26
+
27
+ const DEFAULT_MODELS = ['gpt-5.3-codex', 'gpt-5.1-codex-max', 'gpt-4-turbo', 'gpt-4'];
28
+ const SPEED_TEST_TIMEOUT_MS = 8000;
29
+ const MAX_SESSION_LIST_SIZE = 300;
30
+ const MAX_EXPORT_MESSAGES = 1000;
31
+ const DEFAULT_SESSION_DETAIL_MESSAGES = 300;
32
+ const MAX_SESSION_DETAIL_MESSAGES = 1000;
33
+ const SESSION_TITLE_READ_BYTES = 64 * 1024;
34
+ const CODEXMATE_MANAGED_MARKER = '# codexmate-managed: true';
35
+ const SESSION_LIST_CACHE_TTL_MS = 4000;
36
+ const SESSION_SUMMARY_READ_BYTES = 256 * 1024;
37
+ const SESSION_CONTENT_READ_BYTES = SESSION_SUMMARY_READ_BYTES;
38
+ const DEFAULT_CONTENT_SCAN_LIMIT = 10;
39
+ const SESSION_SCAN_FACTOR = 4;
40
+ const SESSION_SCAN_MIN_FILES = 800;
41
+ const MAX_SESSION_PATH_LIST_SIZE = 2000;
42
+ const AGENTS_FILE_NAME = 'AGENTS.md';
43
+ const UTF8_BOM = '\ufeff';
44
+ const BOOTSTRAP_TEXT_MARKERS = [
45
+ 'agents.md instructions',
46
+ '<instructions>',
47
+ '<environment_context>',
48
+ 'you are a coding agent',
49
+ 'codex cli'
50
+ ];
51
+
52
+ const EMPTY_CONFIG_FALLBACK_TEMPLATE = `model = "gpt-5.3-codex"
53
+ model_reasoning_effort = "high"
54
+ disable_response_storage = true
55
+ approval_policy = "never"
56
+ sandbox_mode = "danger-full-access"
57
+ model_provider = "maxx"
58
+ personality = "pragmatic"
59
+ web_search = "live"
60
+
61
+ [model_providers.maxx]
62
+ name = "maxx"
63
+ base_url = "https://maxx-direct.cloverstd.com"
64
+ wire_api = "responses"
65
+ requires_openai_auth = false
66
+ preferred_auth_method = "sk-"
67
+ request_max_retries = 4
68
+ stream_max_retries = 10
69
+ stream_idle_timeout_ms = 300000
70
+ `;
71
+
72
+ let g_initNotice = '';
73
+ let g_sessionListCache = new Map();
74
+
75
+ // ============================================================================
76
+ // 工具函数
77
+ // ============================================================================
78
+ function ensureConfigDir() {
79
+ if (!fs.existsSync(CONFIG_DIR)) {
80
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
81
+ }
82
+ }
83
+
84
+ function readConfig() {
85
+ if (!fs.existsSync(CONFIG_FILE)) {
86
+ throw new Error(`配置文件不存在: ${CONFIG_FILE}`);
87
+ }
88
+ try {
89
+ const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
90
+ return toml.parse(content);
91
+ } catch (e) {
92
+ throw new Error(`配置文件解析失败: ${e.message}`);
93
+ }
94
+ }
95
+
96
+ function writeConfig(content) {
97
+ try {
98
+ fs.writeFileSync(CONFIG_FILE, content, 'utf-8');
99
+ } catch (e) {
100
+ throw new Error(`写入配置失败: ${e.message}`);
101
+ }
102
+ }
103
+
104
+ function readModels() {
105
+ if (fs.existsSync(MODELS_FILE)) {
106
+ try {
107
+ return JSON.parse(fs.readFileSync(MODELS_FILE, 'utf-8'));
108
+ } catch (e) {}
109
+ }
110
+ return [...DEFAULT_MODELS];
111
+ }
112
+
113
+ function writeModels(models) {
114
+ fs.writeFileSync(MODELS_FILE, JSON.stringify(models, null, 2), 'utf-8');
115
+ }
116
+
117
+ function readCurrentModels() {
118
+ if (fs.existsSync(CURRENT_MODELS_FILE)) {
119
+ try {
120
+ return JSON.parse(fs.readFileSync(CURRENT_MODELS_FILE, 'utf-8'));
121
+ } catch (e) {}
122
+ }
123
+ return {};
124
+ }
125
+
126
+ function writeCurrentModels(data) {
127
+ fs.writeFileSync(CURRENT_MODELS_FILE, JSON.stringify(data, null, 2), 'utf-8');
128
+ }
129
+
130
+ function updateAuthJson(apiKey) {
131
+ let authData = {};
132
+ if (fs.existsSync(AUTH_FILE)) {
133
+ try {
134
+ const content = fs.readFileSync(AUTH_FILE, 'utf-8');
135
+ if (content.trim()) authData = JSON.parse(content);
136
+ } catch (e) {}
137
+ }
138
+ authData['OPENAI_API_KEY'] = apiKey;
139
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(authData, null, 2), 'utf-8');
140
+ }
141
+
142
+ function readJsonFile(filePath, fallback = null) {
143
+ if (!fs.existsSync(filePath)) {
144
+ return fallback;
145
+ }
146
+ try {
147
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
148
+ } catch (e) {
149
+ return fallback;
150
+ }
151
+ }
152
+
153
+ function ensureDir(dirPath) {
154
+ if (!fs.existsSync(dirPath)) {
155
+ fs.mkdirSync(dirPath, { recursive: true });
156
+ }
157
+ }
158
+
159
+ function hasUtf8Bom(text) {
160
+ return typeof text === 'string' && text.charCodeAt(0) === 0xfeff;
161
+ }
162
+
163
+ function stripUtf8Bom(text) {
164
+ if (!text) return '';
165
+ return hasUtf8Bom(text) ? text.slice(1) : text;
166
+ }
167
+
168
+ function ensureUtf8Bom(text) {
169
+ const content = typeof text === 'string' ? text : '';
170
+ return hasUtf8Bom(content) ? content : UTF8_BOM + content;
171
+ }
172
+
173
+ function detectLineEnding(text) {
174
+ return typeof text === 'string' && text.includes('\r\n') ? '\r\n' : '\n';
175
+ }
176
+
177
+ function normalizeLineEnding(text, lineEnding) {
178
+ const normalized = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
179
+ return lineEnding === '\r\n' ? normalized.replace(/\n/g, '\r\n') : normalized;
180
+ }
181
+
182
+ function resolveAgentsFilePath(params = {}) {
183
+ const baseDir = typeof params.baseDir === 'string' && params.baseDir.trim()
184
+ ? params.baseDir.trim()
185
+ : CONFIG_DIR;
186
+ return path.join(baseDir, AGENTS_FILE_NAME);
187
+ }
188
+
189
+ function validateAgentsBaseDir(filePath) {
190
+ const dirPath = path.dirname(filePath);
191
+ try {
192
+ const stat = fs.statSync(dirPath);
193
+ if (!stat.isDirectory()) {
194
+ return { error: `目标不是目录: ${dirPath}` };
195
+ }
196
+ } catch (e) {
197
+ return { error: `目标目录不存在: ${dirPath}` };
198
+ }
199
+ return { ok: true, dirPath };
200
+ }
201
+
202
+ function readAgentsFile(params = {}) {
203
+ const filePath = resolveAgentsFilePath(params);
204
+ const dirCheck = validateAgentsBaseDir(filePath);
205
+ if (dirCheck.error) {
206
+ return { error: dirCheck.error };
207
+ }
208
+
209
+ if (!fs.existsSync(filePath)) {
210
+ return {
211
+ exists: false,
212
+ path: filePath,
213
+ content: '',
214
+ lineEnding: os.EOL === '\r\n' ? '\r\n' : '\n'
215
+ };
216
+ }
217
+
218
+ try {
219
+ const raw = fs.readFileSync(filePath, 'utf-8');
220
+ return {
221
+ exists: true,
222
+ path: filePath,
223
+ content: stripUtf8Bom(raw),
224
+ lineEnding: detectLineEnding(raw)
225
+ };
226
+ } catch (e) {
227
+ return { error: `读取 AGENTS.md 失败: ${e.message}` };
228
+ }
229
+ }
230
+
231
+ function applyAgentsFile(params = {}) {
232
+ const filePath = resolveAgentsFilePath(params);
233
+ const dirCheck = validateAgentsBaseDir(filePath);
234
+ if (dirCheck.error) {
235
+ return { error: dirCheck.error };
236
+ }
237
+
238
+ const content = typeof params.content === 'string' ? params.content : '';
239
+ const lineEnding = params.lineEnding === '\r\n' ? '\r\n' : '\n';
240
+ const normalized = normalizeLineEnding(content, lineEnding);
241
+ const finalContent = ensureUtf8Bom(normalized);
242
+
243
+ try {
244
+ fs.writeFileSync(filePath, finalContent, 'utf-8');
245
+ return { success: true, path: filePath };
246
+ } catch (e) {
247
+ return { error: `写入 AGENTS.md 失败: ${e.message}` };
248
+ }
249
+ }
250
+
251
+ function readJsonObjectFromFile(filePath, fallback = {}) {
252
+ if (!fs.existsSync(filePath)) {
253
+ return { ok: true, exists: false, data: { ...fallback } };
254
+ }
255
+
256
+ try {
257
+ const content = fs.readFileSync(filePath, 'utf-8');
258
+ if (!content.trim()) {
259
+ return { ok: true, exists: true, data: { ...fallback } };
260
+ }
261
+
262
+ const parsed = JSON.parse(content);
263
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
264
+ return {
265
+ ok: false,
266
+ exists: true,
267
+ error: `配置文件格式错误(根节点必须是对象): ${filePath}`
268
+ };
269
+ }
270
+ return { ok: true, exists: true, data: parsed };
271
+ } catch (e) {
272
+ return {
273
+ ok: false,
274
+ exists: true,
275
+ error: `配置文件解析失败: ${e.message}`
276
+ };
277
+ }
278
+ }
279
+
280
+ function backupFileIfNeededOnce(filePath, backupPrefix = 'codexmate-backup') {
281
+ if (!fs.existsSync(filePath)) {
282
+ return '';
283
+ }
284
+
285
+ const dirPath = path.dirname(filePath);
286
+ const baseName = path.basename(filePath);
287
+ const existingPrefix = `${baseName}.${backupPrefix}-`;
288
+ const hasBackup = fs.readdirSync(dirPath).some(fileName =>
289
+ fileName.startsWith(existingPrefix) && fileName.endsWith('.bak')
290
+ );
291
+
292
+ if (hasBackup) {
293
+ return '';
294
+ }
295
+
296
+ const backupPath = path.join(dirPath, `${existingPrefix}${formatTimestampForFileName()}.bak`);
297
+ fs.copyFileSync(filePath, backupPath);
298
+ return backupPath;
299
+ }
300
+
301
+ function writeJsonAtomic(filePath, data) {
302
+ const dirPath = path.dirname(filePath);
303
+ ensureDir(dirPath);
304
+
305
+ const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
306
+ const content = `${JSON.stringify(data, null, 2)}\n`;
307
+
308
+ try {
309
+ fs.writeFileSync(tmpPath, content, 'utf-8');
310
+ try {
311
+ fs.renameSync(tmpPath, filePath);
312
+ } catch (renameError) {
313
+ if (process.platform === 'win32') {
314
+ fs.copyFileSync(tmpPath, filePath);
315
+ fs.unlinkSync(tmpPath);
316
+ } else {
317
+ throw renameError;
318
+ }
319
+ }
320
+ } catch (e) {
321
+ if (fs.existsSync(tmpPath)) {
322
+ try { fs.unlinkSync(tmpPath); } catch (_) {}
323
+ }
324
+ throw new Error(`写入 JSON 文件失败: ${e.message}`);
325
+ }
326
+ }
327
+
328
+ function formatTimestampForFileName(value) {
329
+ const date = value ? new Date(value) : new Date();
330
+ const normalized = Number.isNaN(date.getTime()) ? new Date() : date;
331
+ const pad = (num) => String(num).padStart(2, '0');
332
+ return [
333
+ normalized.getFullYear(),
334
+ pad(normalized.getMonth() + 1),
335
+ pad(normalized.getDate()),
336
+ '-',
337
+ pad(normalized.getHours()),
338
+ pad(normalized.getMinutes()),
339
+ pad(normalized.getSeconds())
340
+ ].join('');
341
+ }
342
+
343
+ function toIsoTime(value, fallback = '') {
344
+ if (value === undefined || value === null || value === '') {
345
+ return fallback;
346
+ }
347
+ const date = new Date(value);
348
+ if (Number.isNaN(date.getTime())) {
349
+ return fallback;
350
+ }
351
+ return date.toISOString();
352
+ }
353
+
354
+ function truncateText(text, maxLength = 90) {
355
+ if (!text) return '';
356
+ const normalized = String(text).replace(/\s+/g, ' ').trim();
357
+ if (normalized.length <= maxLength) return normalized;
358
+ return normalized.slice(0, maxLength - 1) + '…';
359
+ }
360
+
361
+ function extractMessageText(content) {
362
+ if (typeof content === 'string') {
363
+ return content.trim();
364
+ }
365
+
366
+ if (Array.isArray(content)) {
367
+ const parts = content
368
+ .map(item => extractMessageText(item))
369
+ .filter(Boolean);
370
+ return parts.join('\n').trim();
371
+ }
372
+
373
+ if (!content || typeof content !== 'object') {
374
+ return '';
375
+ }
376
+
377
+ if (typeof content.text === 'string') {
378
+ return content.text.trim();
379
+ }
380
+
381
+ if (typeof content.value === 'string') {
382
+ return content.value.trim();
383
+ }
384
+
385
+ if (content.content !== undefined) {
386
+ return extractMessageText(content.content);
387
+ }
388
+
389
+ if (typeof content.output === 'string') {
390
+ return content.output.trim();
391
+ }
392
+
393
+ return '';
394
+ }
395
+
396
+ function buildDefaultConfigContent(initializedAt) {
397
+ const defaultModel = DEFAULT_MODELS[0] || 'gpt-4';
398
+ return `${CODEXMATE_MANAGED_MARKER}
399
+ # codexmate-initialized-at: ${initializedAt}
400
+
401
+ model_provider = "openai"
402
+ model = "${defaultModel}"
403
+
404
+ [model_providers.openai]
405
+ name = "openai"
406
+ base_url = "https://api.openai.com/v1"
407
+ wire_api = "responses"
408
+ requires_openai_auth = false
409
+ preferred_auth_method = ""
410
+ request_max_retries = 4
411
+ stream_max_retries = 10
412
+ stream_idle_timeout_ms = 300000
413
+ `;
414
+ }
415
+
416
+ function buildVirtualDefaultConfig() {
417
+ return toml.parse(EMPTY_CONFIG_FALLBACK_TEMPLATE);
418
+ }
419
+
420
+ function readConfigOrVirtualDefault() {
421
+ if (fs.existsSync(CONFIG_FILE)) {
422
+ try {
423
+ return {
424
+ config: readConfig(),
425
+ isVirtual: false,
426
+ reason: ''
427
+ };
428
+ } catch (e) {
429
+ return {
430
+ config: buildVirtualDefaultConfig(),
431
+ isVirtual: true,
432
+ reason: e.message || '配置文件无效,已回退到默认模板'
433
+ };
434
+ }
435
+ }
436
+
437
+ return {
438
+ config: buildVirtualDefaultConfig(),
439
+ isVirtual: true,
440
+ reason: `配置文件不存在: ${CONFIG_FILE}`
441
+ };
442
+ }
443
+
444
+ function normalizeTopLevelConfigWithTemplate(template, selectedProvider, selectedModel) {
445
+ let content = typeof template === 'string' ? template : '';
446
+ if (!content.trim()) {
447
+ throw new Error('模板内容为空');
448
+ }
449
+
450
+ const provider = typeof selectedProvider === 'string' ? selectedProvider.trim() : '';
451
+ const model = typeof selectedModel === 'string' ? selectedModel.trim() : '';
452
+
453
+ if (provider) {
454
+ if (/^\s*model_provider\s*=.*$/m.test(content)) {
455
+ content = content.replace(/^\s*model_provider\s*=.*$/m, `model_provider = "${provider}"`);
456
+ } else {
457
+ content = `model_provider = "${provider}"\n` + content;
458
+ }
459
+ }
460
+
461
+ if (model) {
462
+ if (/^\s*model\s*=.*$/m.test(content)) {
463
+ content = content.replace(/^\s*model\s*=.*$/m, `model = "${model}"`);
464
+ } else {
465
+ content = `model = "${model}"\n` + content;
466
+ }
467
+ }
468
+
469
+ return content;
470
+ }
471
+
472
+ function getConfigTemplate(params = {}) {
473
+ let content = EMPTY_CONFIG_FALLBACK_TEMPLATE;
474
+ if (fs.existsSync(CONFIG_FILE)) {
475
+ try {
476
+ const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
477
+ if (raw && raw.trim()) {
478
+ content = raw;
479
+ }
480
+ } catch (e) {}
481
+ }
482
+ const selectedProvider = params.provider || '';
483
+ const selectedModel = params.model || '';
484
+ return {
485
+ template: normalizeTopLevelConfigWithTemplate(content, selectedProvider, selectedModel)
486
+ };
487
+ }
488
+
489
+ function applyConfigTemplate(params = {}) {
490
+ const template = typeof params.template === 'string' ? params.template : '';
491
+ if (!template.trim()) {
492
+ return { error: '模板内容不能为空' };
493
+ }
494
+
495
+ let parsed;
496
+ try {
497
+ parsed = toml.parse(template);
498
+ } catch (e) {
499
+ return { error: `模板 TOML 解析失败: ${e.message}` };
500
+ }
501
+
502
+ if (!parsed.model_provider || typeof parsed.model_provider !== 'string') {
503
+ return { error: '模板缺少 model_provider' };
504
+ }
505
+
506
+ if (!parsed.model || typeof parsed.model !== 'string') {
507
+ return { error: '模板缺少 model' };
508
+ }
509
+
510
+ if (!parsed.model_providers || typeof parsed.model_providers !== 'object') {
511
+ return { error: '模板缺少 model_providers 配置块' };
512
+ }
513
+
514
+ const activeProvider = parsed.model_provider;
515
+ const activeProviderBlock = parsed.model_providers[activeProvider];
516
+ if (!activeProviderBlock || typeof activeProviderBlock !== 'object') {
517
+ return { error: `模板中找不到当前 provider: ${activeProvider}` };
518
+ }
519
+
520
+ writeConfig(template.trim() + '\n');
521
+ updateAuthJson(activeProviderBlock.preferred_auth_method || '');
522
+
523
+ const models = readModels();
524
+ if (!models.includes(parsed.model)) {
525
+ models.push(parsed.model);
526
+ writeModels(models);
527
+ }
528
+
529
+ const currentModels = readCurrentModels();
530
+ currentModels[activeProvider] = parsed.model;
531
+ writeCurrentModels(currentModels);
532
+
533
+ return { success: true };
534
+ }
535
+
536
+ function ensureSupportFiles(defaultProvider, defaultModel) {
537
+ if (!fs.existsSync(MODELS_FILE)) {
538
+ writeModels([...DEFAULT_MODELS]);
539
+ } else {
540
+ const existingModels = readModels();
541
+ const mergedModels = Array.isArray(existingModels) ? [...existingModels] : [];
542
+ let hasNewDefaultModel = false;
543
+ for (const model of DEFAULT_MODELS) {
544
+ if (!mergedModels.includes(model)) {
545
+ mergedModels.push(model);
546
+ hasNewDefaultModel = true;
547
+ }
548
+ }
549
+ if (hasNewDefaultModel) {
550
+ writeModels(mergedModels);
551
+ }
552
+ }
553
+
554
+ const currentModels = readCurrentModels();
555
+ if (!currentModels[defaultProvider]) {
556
+ currentModels[defaultProvider] = defaultModel;
557
+ writeCurrentModels(currentModels);
558
+ }
559
+
560
+ if (!fs.existsSync(AUTH_FILE)) {
561
+ updateAuthJson('');
562
+ }
563
+ }
564
+
565
+ function writeInitMark(payload) {
566
+ fs.writeFileSync(INIT_MARK_FILE, JSON.stringify(payload, null, 2), 'utf-8');
567
+ }
568
+
569
+ function ensureManagedConfigBootstrap() {
570
+ ensureConfigDir();
571
+
572
+ const initializedAt = new Date().toISOString();
573
+ const defaultProvider = 'openai';
574
+ const defaultModel = DEFAULT_MODELS[0] || 'gpt-4';
575
+ const forceResetExistingConfig = process.env.CODEXMATE_FORCE_RESET_EXISTING_CONFIG === '1';
576
+ const mark = readJsonFile(INIT_MARK_FILE, null);
577
+ const hasConfig = fs.existsSync(CONFIG_FILE);
578
+
579
+ if (mark) {
580
+ if (!hasConfig) {
581
+ writeConfig(buildDefaultConfigContent(initializedAt));
582
+ ensureSupportFiles(defaultProvider, defaultModel);
583
+ g_initNotice = '检测到配置缺失,已自动重建默认配置。';
584
+ return { notice: g_initNotice };
585
+ }
586
+ ensureSupportFiles(defaultProvider, defaultModel);
587
+ return { notice: '' };
588
+ }
589
+
590
+ if (hasConfig) {
591
+ let existingContent = '';
592
+ try {
593
+ existingContent = fs.readFileSync(CONFIG_FILE, 'utf-8');
594
+ } catch (e) {}
595
+
596
+ if (existingContent.includes(CODEXMATE_MANAGED_MARKER)) {
597
+ writeInitMark({
598
+ version: 1,
599
+ initializedAt,
600
+ mode: 'managed-config-detected',
601
+ backupFile: ''
602
+ });
603
+ ensureSupportFiles(defaultProvider, defaultModel);
604
+ return { notice: '' };
605
+ }
606
+
607
+ const backupFile = `config.toml.codexmate-backup-${formatTimestampForFileName(initializedAt)}.bak`;
608
+ const backupPath = path.join(CONFIG_DIR, backupFile);
609
+ fs.copyFileSync(CONFIG_FILE, backupPath);
610
+
611
+ if (forceResetExistingConfig) {
612
+ writeConfig(buildDefaultConfigContent(initializedAt));
613
+ ensureSupportFiles(defaultProvider, defaultModel);
614
+ writeInitMark({
615
+ version: 1,
616
+ initializedAt,
617
+ mode: 'first-run-reset',
618
+ backupFile
619
+ });
620
+
621
+ g_initNotice = `首次使用已备份原配置到 ${backupFile},并重建默认配置。`;
622
+ return { notice: g_initNotice, backupFile };
623
+ }
624
+
625
+ ensureSupportFiles(defaultProvider, defaultModel);
626
+ writeInitMark({
627
+ version: 1,
628
+ initializedAt,
629
+ mode: 'legacy-config-preserved',
630
+ backupFile
631
+ });
632
+ g_initNotice = `检测到已有配置,已备份到 ${backupFile},并保留原配置不覆盖。`;
633
+ return { notice: g_initNotice, backupFile };
634
+ }
635
+
636
+ writeConfig(buildDefaultConfigContent(initializedAt));
637
+ ensureSupportFiles(defaultProvider, defaultModel);
638
+ writeInitMark({
639
+ version: 1,
640
+ initializedAt,
641
+ mode: 'fresh-install',
642
+ backupFile: ''
643
+ });
644
+ g_initNotice = '首次使用已创建默认配置。';
645
+ return { notice: g_initNotice };
646
+ }
647
+
648
+ function consumeInitNotice() {
649
+ const notice = g_initNotice;
650
+ g_initNotice = '';
651
+ return notice;
652
+ }
653
+
654
+ function isPathInside(targetPath, rootPath) {
655
+ const resolvedTarget = path.resolve(targetPath).toLowerCase();
656
+ const resolvedRoot = path.resolve(rootPath).toLowerCase();
657
+ if (resolvedTarget === resolvedRoot) {
658
+ return true;
659
+ }
660
+ const rootWithSlash = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;
661
+ return resolvedTarget.startsWith(rootWithSlash);
662
+ }
663
+
664
+ function collectJsonlFiles(rootDir, maxFiles = 5000) {
665
+ if (!fs.existsSync(rootDir)) {
666
+ return [];
667
+ }
668
+
669
+ const stack = [rootDir];
670
+ const files = [];
671
+ while (stack.length > 0 && files.length < maxFiles) {
672
+ const dir = stack.pop();
673
+ let entries = [];
674
+ try {
675
+ entries = fs.readdirSync(dir, { withFileTypes: true });
676
+ } catch (e) {
677
+ continue;
678
+ }
679
+
680
+ for (const entry of entries) {
681
+ const fullPath = path.join(dir, entry.name);
682
+ if (entry.isDirectory()) {
683
+ stack.push(fullPath);
684
+ } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
685
+ files.push(fullPath);
686
+ }
687
+
688
+ if (files.length >= maxFiles) {
689
+ break;
690
+ }
691
+ }
692
+ }
693
+
694
+ return files;
695
+ }
696
+
697
+ function readJsonlRecords(filePath) {
698
+ let content = '';
699
+ try {
700
+ content = fs.readFileSync(filePath, 'utf-8');
701
+ } catch (e) {
702
+ return [];
703
+ }
704
+
705
+ const records = [];
706
+ const lines = content.split(/\r?\n/);
707
+ for (const line of lines) {
708
+ const trimmed = line.trim();
709
+ if (!trimmed) continue;
710
+ try {
711
+ records.push(JSON.parse(trimmed));
712
+ } catch (e) {}
713
+ }
714
+ return records;
715
+ }
716
+
717
+ function getFileHeadText(filePath, maxBytes = SESSION_SUMMARY_READ_BYTES) {
718
+ let fd;
719
+ try {
720
+ fd = fs.openSync(filePath, 'r');
721
+ const stat = fs.fstatSync(fd);
722
+ const size = Math.min(maxBytes, stat.size);
723
+ if (size <= 0) {
724
+ return '';
725
+ }
726
+
727
+ const buffer = Buffer.alloc(size);
728
+ fs.readSync(fd, buffer, 0, size, 0);
729
+ return buffer.toString('utf-8');
730
+ } catch (e) {
731
+ return '';
732
+ } finally {
733
+ if (fd !== undefined) {
734
+ try { fs.closeSync(fd); } catch (e) {}
735
+ }
736
+ }
737
+ }
738
+
739
+ function parseJsonlContent(content) {
740
+ if (!content) {
741
+ return [];
742
+ }
743
+
744
+ const records = [];
745
+ const lines = content.split(/\r?\n/);
746
+ for (const line of lines) {
747
+ const trimmed = line.trim();
748
+ if (!trimmed) continue;
749
+ try {
750
+ records.push(JSON.parse(trimmed));
751
+ } catch (e) {}
752
+ }
753
+ return records;
754
+ }
755
+
756
+ function parseJsonlHeadRecords(filePath, maxBytes = SESSION_SUMMARY_READ_BYTES) {
757
+ const headText = getFileHeadText(filePath, maxBytes);
758
+ if (!headText) {
759
+ return [];
760
+ }
761
+
762
+ return parseJsonlContent(headText);
763
+ }
764
+
765
+ function normalizeRole(value) {
766
+ if (typeof value !== 'string') {
767
+ return '';
768
+ }
769
+ const role = value.trim().toLowerCase();
770
+ if (role === 'assistant' || role === 'user' || role === 'system') {
771
+ return role;
772
+ }
773
+ return '';
774
+ }
775
+
776
+ function isBootstrapLikeText(text) {
777
+ if (!text || typeof text !== 'string') {
778
+ return false;
779
+ }
780
+
781
+ const normalized = text.toLowerCase().replace(/\s+/g, ' ').trim();
782
+ if (!normalized) {
783
+ return false;
784
+ }
785
+
786
+ return BOOTSTRAP_TEXT_MARKERS.some(marker => normalized.includes(marker));
787
+ }
788
+
789
+ function removeLeadingSystemMessage(messages) {
790
+ if (!Array.isArray(messages) || messages.length === 0) {
791
+ return [];
792
+ }
793
+
794
+ let startIndex = 1;
795
+ while (startIndex < messages.length) {
796
+ const item = messages[startIndex];
797
+ const role = item ? normalizeRole(item.role) : '';
798
+ const text = item && typeof item.text === 'string' ? item.text : '';
799
+ const isSystemRole = role === 'system';
800
+ const isBootstrapText = isBootstrapLikeText(text);
801
+ if (!item || isSystemRole || isBootstrapText) {
802
+ startIndex += 1;
803
+ continue;
804
+ }
805
+ break;
806
+ }
807
+
808
+ if (startIndex <= 0) {
809
+ return messages;
810
+ }
811
+ return messages.slice(startIndex);
812
+ }
813
+
814
+ function countConversationMessagesInRecords(records, source) {
815
+ const messages = [];
816
+ for (const record of records) {
817
+ if (source === 'codex') {
818
+ if (record.type === 'response_item' && record.payload && record.payload.type === 'message') {
819
+ const role = normalizeRole(record.payload.role);
820
+ if (role === 'assistant' || role === 'user' || role === 'system') {
821
+ messages.push({
822
+ role,
823
+ text: extractMessageText(record.payload.content)
824
+ });
825
+ }
826
+ }
827
+ continue;
828
+ }
829
+
830
+ const role = normalizeRole(record.type);
831
+ if (role === 'assistant' || role === 'user' || role === 'system') {
832
+ const content = record.message ? record.message.content : '';
833
+ messages.push({
834
+ role,
835
+ text: extractMessageText(content)
836
+ });
837
+ }
838
+ }
839
+
840
+ return removeLeadingSystemMessage(messages).length;
841
+ }
842
+
843
+ function sortSessionsByUpdatedAt(items) {
844
+ items.sort((a, b) => {
845
+ const aTime = Date.parse(a.updatedAt || '') || 0;
846
+ const bTime = Date.parse(b.updatedAt || '') || 0;
847
+ return bTime - aTime;
848
+ });
849
+ return items;
850
+ }
851
+
852
+ function mergeAndLimitSessions(items, limit) {
853
+ const deduped = [];
854
+ const seen = new Set();
855
+ for (const item of items) {
856
+ if (!item || !item.filePath) continue;
857
+ const key = `${item.source}:${item.filePath}`;
858
+ if (seen.has(key)) continue;
859
+ seen.add(key);
860
+ deduped.push(item);
861
+ }
862
+
863
+ return sortSessionsByUpdatedAt(deduped).slice(0, limit);
864
+ }
865
+
866
+ function normalizeSessionPathFilter(pathFilter) {
867
+ if (typeof pathFilter !== 'string') {
868
+ return '';
869
+ }
870
+ const trimmed = pathFilter.trim();
871
+ return trimmed ? trimmed.toLowerCase() : '';
872
+ }
873
+
874
+ function matchesSessionPathFilter(session, normalizedFilter) {
875
+ if (!normalizedFilter) {
876
+ return true;
877
+ }
878
+ if (!session || typeof session !== 'object') {
879
+ return false;
880
+ }
881
+
882
+ const cwd = typeof session.cwd === 'string' ? session.cwd.toLowerCase() : '';
883
+ return cwd.includes(normalizedFilter);
884
+ }
885
+
886
+ function normalizeQueryTokens(query) {
887
+ if (typeof query !== 'string') {
888
+ return [];
889
+ }
890
+ return query
891
+ .split(/\s+/)
892
+ .map(item => item.trim())
893
+ .map(item => item.toLowerCase())
894
+ .filter(Boolean);
895
+ }
896
+
897
+ function normalizeQueryMode(mode) {
898
+ return mode === 'or' ? 'or' : 'and';
899
+ }
900
+
901
+ function normalizeQueryScope(scope) {
902
+ if (scope === 'content' || scope === 'all' || scope === 'summary') {
903
+ return scope;
904
+ }
905
+ return 'summary';
906
+ }
907
+
908
+ function normalizeRoleFilter(roleFilter) {
909
+ if (roleFilter === 'all' || roleFilter === undefined || roleFilter === null) {
910
+ return 'all';
911
+ }
912
+ const normalized = normalizeRole(String(roleFilter));
913
+ return normalized || 'all';
914
+ }
915
+
916
+ function matchTokensInText(text, tokens, mode = 'and') {
917
+ if (!Array.isArray(tokens) || tokens.length === 0) {
918
+ return true;
919
+ }
920
+ const haystack = String(text || '').toLowerCase();
921
+ if (!haystack) {
922
+ return false;
923
+ }
924
+ if (mode === 'or') {
925
+ return tokens.some(token => haystack.includes(token));
926
+ }
927
+ return tokens.every(token => haystack.includes(token));
928
+ }
929
+
930
+ function buildSessionSummaryText(session) {
931
+ if (!session) {
932
+ return '';
933
+ }
934
+ return [
935
+ session.title,
936
+ session.sessionId,
937
+ session.cwd,
938
+ session.filePath,
939
+ session.sourceLabel
940
+ ].filter(Boolean).join(' ');
941
+ }
942
+
943
+ function extractMessageFromRecord(record, source) {
944
+ if (!record) {
945
+ return null;
946
+ }
947
+ if (source === 'codex') {
948
+ if (record.type === 'response_item' && record.payload && record.payload.type === 'message') {
949
+ const role = normalizeRole(record.payload.role);
950
+ const text = extractMessageText(record.payload.content);
951
+ if (!role || !text) {
952
+ return null;
953
+ }
954
+ return { role, text };
955
+ }
956
+ return null;
957
+ }
958
+
959
+ const role = normalizeRole(record.type);
960
+ if (!role) {
961
+ return null;
962
+ }
963
+ const content = record.message ? record.message.content : '';
964
+ const text = extractMessageText(content);
965
+ if (!text) {
966
+ return null;
967
+ }
968
+ return { role, text };
969
+ }
970
+
971
+ function scanSessionContentForQuery(session, tokens, options = {}) {
972
+ if (!session || !Array.isArray(tokens) || tokens.length === 0) {
973
+ return { hit: false, count: 0, snippets: [] };
974
+ }
975
+
976
+ const filePath = resolveSessionFilePath(session.source, session.filePath, session.sessionId);
977
+ if (!filePath) {
978
+ return { hit: false, count: 0, snippets: [] };
979
+ }
980
+
981
+ const maxBytes = Number.isFinite(Number(options.maxBytes))
982
+ ? Math.max(1024, Number(options.maxBytes))
983
+ : SESSION_CONTENT_READ_BYTES;
984
+ const headText = getFileHeadText(filePath, maxBytes);
985
+ if (!headText) {
986
+ return { hit: false, count: 0, snippets: [] };
987
+ }
988
+
989
+ const records = parseJsonlContent(headText);
990
+ const mode = normalizeQueryMode(options.mode);
991
+ const roleFilter = normalizeRoleFilter(options.roleFilter);
992
+ const maxMatches = Number.isFinite(Number(options.maxMatches))
993
+ ? Math.max(1, Number(options.maxMatches))
994
+ : 1;
995
+ const snippetLimit = Number.isFinite(Number(options.snippetLimit))
996
+ ? Math.max(0, Number(options.snippetLimit))
997
+ : 0;
998
+
999
+ const messages = [];
1000
+ for (const record of records) {
1001
+ const message = extractMessageFromRecord(record, session.source);
1002
+ if (!message || !message.text) {
1003
+ continue;
1004
+ }
1005
+ messages.push(message);
1006
+ }
1007
+
1008
+ const filteredMessages = roleFilter === 'system'
1009
+ ? messages
1010
+ : removeLeadingSystemMessage(messages);
1011
+
1012
+ let count = 0;
1013
+ const snippets = [];
1014
+
1015
+ for (const message of filteredMessages) {
1016
+ if (roleFilter !== 'all' && message.role !== roleFilter) {
1017
+ continue;
1018
+ }
1019
+ if (!matchTokensInText(message.text, tokens, mode)) {
1020
+ continue;
1021
+ }
1022
+
1023
+ count += 1;
1024
+ if (snippetLimit > 0 && snippets.length < snippetLimit) {
1025
+ snippets.push(truncateText(message.text));
1026
+ }
1027
+ if (count >= maxMatches) {
1028
+ break;
1029
+ }
1030
+ }
1031
+
1032
+ return { hit: count > 0, count, snippets };
1033
+ }
1034
+
1035
+ function applySessionQueryFilter(sessions, options = {}) {
1036
+ const tokens = Array.isArray(options.tokens) ? options.tokens : [];
1037
+ if (tokens.length === 0) {
1038
+ return sessions;
1039
+ }
1040
+
1041
+ const mode = normalizeQueryMode(options.queryMode);
1042
+ const scope = normalizeQueryScope(options.queryScope);
1043
+ const roleFilter = normalizeRoleFilter(options.roleFilter);
1044
+ const contentScanLimit = Number.isFinite(Number(options.contentScanLimit))
1045
+ ? Math.max(1, Number(options.contentScanLimit))
1046
+ : DEFAULT_CONTENT_SCAN_LIMIT;
1047
+ const contentScanBytes = Number.isFinite(Number(options.contentScanBytes))
1048
+ ? Math.max(1024, Number(options.contentScanBytes))
1049
+ : SESSION_CONTENT_READ_BYTES;
1050
+
1051
+ let scanned = 0;
1052
+ const results = [];
1053
+
1054
+ for (const session of sessions) {
1055
+ if (scope === 'content' && scanned >= contentScanLimit) {
1056
+ break;
1057
+ }
1058
+
1059
+ const summaryText = buildSessionSummaryText(session);
1060
+ const summaryHit = scope !== 'content' && matchTokensInText(summaryText, tokens, mode);
1061
+ let contentHit = false;
1062
+ let contentInfo = null;
1063
+
1064
+ if (scope !== 'summary' && (!summaryHit || scope === 'content')) {
1065
+ if (scanned < contentScanLimit) {
1066
+ scanned += 1;
1067
+ contentInfo = scanSessionContentForQuery(session, tokens, {
1068
+ mode,
1069
+ roleFilter,
1070
+ maxBytes: contentScanBytes,
1071
+ maxMatches: 1,
1072
+ snippetLimit: 2
1073
+ });
1074
+ contentHit = contentInfo.hit;
1075
+ }
1076
+ }
1077
+
1078
+ const hit = scope === 'summary'
1079
+ ? summaryHit
1080
+ : (scope === 'content' ? contentHit : (summaryHit || contentHit));
1081
+ if (!hit) {
1082
+ continue;
1083
+ }
1084
+
1085
+ const matchInfo = contentInfo && contentInfo.hit
1086
+ ? contentInfo
1087
+ : { hit: true, count: 1, snippets: [] };
1088
+ session.match = {
1089
+ hit: true,
1090
+ count: matchInfo.count || 1,
1091
+ snippets: Array.isArray(matchInfo.snippets) ? matchInfo.snippets : []
1092
+ };
1093
+ results.push(session);
1094
+ }
1095
+
1096
+ return results;
1097
+ }
1098
+ function collectRecentJsonlFiles(rootDir, options = {}) {
1099
+ if (!fs.existsSync(rootDir)) {
1100
+ return [];
1101
+ }
1102
+
1103
+ const returnCount = Math.max(1, Number(options.returnCount) || 1);
1104
+ const maxFilesScanned = Math.max(returnCount, Number(options.maxFilesScanned) || 2000);
1105
+ const ignoreSubPath = typeof options.ignoreSubPath === 'string' ? options.ignoreSubPath : '';
1106
+ const stack = [rootDir];
1107
+ const filesMeta = [];
1108
+ let scanned = 0;
1109
+
1110
+ while (stack.length > 0 && scanned < maxFilesScanned) {
1111
+ const dir = stack.pop();
1112
+ let entries = [];
1113
+ try {
1114
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1115
+ } catch (e) {
1116
+ continue;
1117
+ }
1118
+
1119
+ for (const entry of entries) {
1120
+ const fullPath = path.join(dir, entry.name);
1121
+ if (entry.isDirectory()) {
1122
+ stack.push(fullPath);
1123
+ continue;
1124
+ }
1125
+
1126
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) {
1127
+ continue;
1128
+ }
1129
+
1130
+ if (ignoreSubPath && fullPath.includes(ignoreSubPath)) {
1131
+ continue;
1132
+ }
1133
+
1134
+ scanned += 1;
1135
+ try {
1136
+ const stat = fs.statSync(fullPath);
1137
+ filesMeta.push({ filePath: fullPath, mtimeMs: stat.mtimeMs || 0 });
1138
+ } catch (e) {}
1139
+
1140
+ if (scanned >= maxFilesScanned) {
1141
+ break;
1142
+ }
1143
+ }
1144
+ }
1145
+
1146
+ filesMeta.sort((a, b) => b.mtimeMs - a.mtimeMs);
1147
+ return filesMeta.slice(0, returnCount).map(item => item.filePath);
1148
+ }
1149
+
1150
+ function getSessionListCache(cacheKey, forceRefresh = false) {
1151
+ if (forceRefresh) {
1152
+ g_sessionListCache.delete(cacheKey);
1153
+ return null;
1154
+ }
1155
+
1156
+ const cached = g_sessionListCache.get(cacheKey);
1157
+ if (!cached) {
1158
+ return null;
1159
+ }
1160
+
1161
+ if ((Date.now() - cached.timestamp) > SESSION_LIST_CACHE_TTL_MS) {
1162
+ g_sessionListCache.delete(cacheKey);
1163
+ return null;
1164
+ }
1165
+
1166
+ return cached.value;
1167
+ }
1168
+
1169
+ function setSessionListCache(cacheKey, value) {
1170
+ g_sessionListCache.set(cacheKey, {
1171
+ timestamp: Date.now(),
1172
+ value
1173
+ });
1174
+
1175
+ if (g_sessionListCache.size > 20) {
1176
+ const firstKey = g_sessionListCache.keys().next().value;
1177
+ if (firstKey) {
1178
+ g_sessionListCache.delete(firstKey);
1179
+ }
1180
+ }
1181
+ }
1182
+
1183
+ function invalidateSessionListCache() {
1184
+ g_sessionListCache.clear();
1185
+ }
1186
+
1187
+ function parseCodexSessionSummary(filePath) {
1188
+ const records = parseJsonlHeadRecords(filePath);
1189
+ if (records.length === 0) {
1190
+ return null;
1191
+ }
1192
+
1193
+ let stat;
1194
+ try {
1195
+ stat = fs.statSync(filePath);
1196
+ } catch (e) {
1197
+ return null;
1198
+ }
1199
+
1200
+ let sessionId = path.basename(filePath, '.jsonl');
1201
+ let cwd = '';
1202
+ let createdAt = '';
1203
+ let updatedAt = stat.mtime.toISOString();
1204
+ let firstPrompt = '';
1205
+ let messageCount = 0;
1206
+ const previewMessages = [];
1207
+
1208
+ for (const record of records) {
1209
+ if (record.timestamp) {
1210
+ updatedAt = toIsoTime(record.timestamp, updatedAt);
1211
+ }
1212
+
1213
+ if (record.type === 'session_meta' && record.payload) {
1214
+ sessionId = record.payload.id || sessionId;
1215
+ cwd = record.payload.cwd || cwd;
1216
+ createdAt = toIsoTime(record.payload.timestamp || record.timestamp, createdAt);
1217
+ continue;
1218
+ }
1219
+
1220
+ if (record.type === 'response_item' && record.payload && record.payload.type === 'message') {
1221
+ const role = normalizeRole(record.payload.role);
1222
+ if (role === 'user' || role === 'assistant' || role === 'system') {
1223
+ const text = extractMessageText(record.payload.content);
1224
+ previewMessages.push({ role, text });
1225
+ }
1226
+ }
1227
+ }
1228
+
1229
+ const filteredPreviewMessages = removeLeadingSystemMessage(previewMessages);
1230
+ messageCount = filteredPreviewMessages.length;
1231
+ const firstUser = filteredPreviewMessages.find(item => item.role === 'user' && item.text);
1232
+ if (firstUser) {
1233
+ firstPrompt = truncateText(firstUser.text);
1234
+ }
1235
+
1236
+ if (!firstPrompt) {
1237
+ const titleRecords = parseJsonlHeadRecords(filePath, SESSION_TITLE_READ_BYTES);
1238
+ const titleMessages = [];
1239
+ for (const record of titleRecords) {
1240
+ if (record.type === 'response_item' && record.payload && record.payload.type === 'message') {
1241
+ const role = normalizeRole(record.payload.role);
1242
+ if (role === 'user' || role === 'assistant' || role === 'system') {
1243
+ titleMessages.push({
1244
+ role,
1245
+ text: extractMessageText(record.payload.content)
1246
+ });
1247
+ }
1248
+ }
1249
+ }
1250
+
1251
+ const filteredTitleMessages = removeLeadingSystemMessage(titleMessages);
1252
+ const titleUser = filteredTitleMessages.find(item => item.role === 'user' && item.text);
1253
+ if (titleUser) {
1254
+ firstPrompt = truncateText(titleUser.text);
1255
+ }
1256
+ }
1257
+
1258
+ messageCount = Math.max(0, messageCount);
1259
+
1260
+ return {
1261
+ source: 'codex',
1262
+ sourceLabel: 'Codex',
1263
+ sessionId,
1264
+ title: firstPrompt || sessionId,
1265
+ cwd,
1266
+ createdAt,
1267
+ updatedAt,
1268
+ messageCount,
1269
+ filePath
1270
+ };
1271
+ }
1272
+
1273
+ function parseClaudeSessionSummary(filePath) {
1274
+ const records = parseJsonlHeadRecords(filePath);
1275
+ if (records.length === 0) {
1276
+ return null;
1277
+ }
1278
+
1279
+ let stat;
1280
+ try {
1281
+ stat = fs.statSync(filePath);
1282
+ } catch (e) {
1283
+ return null;
1284
+ }
1285
+
1286
+ const sessionId = path.basename(filePath, '.jsonl');
1287
+ let cwd = '';
1288
+ let firstPrompt = '';
1289
+ let messageCount = 0;
1290
+ const previewMessages = [];
1291
+ let createdAt = '';
1292
+ let updatedAt = stat.mtime.toISOString();
1293
+
1294
+ for (const record of records) {
1295
+ if (!createdAt && record.timestamp) {
1296
+ createdAt = toIsoTime(record.timestamp, createdAt);
1297
+ }
1298
+ if (record.timestamp) {
1299
+ updatedAt = toIsoTime(record.timestamp, updatedAt);
1300
+ }
1301
+
1302
+ if (!cwd && record.cwd) {
1303
+ cwd = record.cwd;
1304
+ }
1305
+
1306
+ const role = normalizeRole(record.type);
1307
+ if (role === 'assistant' || role === 'user' || role === 'system') {
1308
+ const userContent = record.message ? record.message.content : '';
1309
+ previewMessages.push({
1310
+ role,
1311
+ text: extractMessageText(userContent)
1312
+ });
1313
+ }
1314
+ }
1315
+
1316
+ const filteredPreviewMessages = removeLeadingSystemMessage(previewMessages);
1317
+ messageCount = filteredPreviewMessages.length;
1318
+ const firstUser = filteredPreviewMessages.find(item => item.role === 'user' && item.text);
1319
+ if (firstUser) {
1320
+ firstPrompt = truncateText(firstUser.text);
1321
+ }
1322
+
1323
+ if (!firstPrompt) {
1324
+ const titleRecords = parseJsonlHeadRecords(filePath, SESSION_TITLE_READ_BYTES);
1325
+ const titleMessages = [];
1326
+ for (const record of titleRecords) {
1327
+ const role = normalizeRole(record.type);
1328
+ if (role === 'assistant' || role === 'user' || role === 'system') {
1329
+ const userContent = record.message ? record.message.content : '';
1330
+ titleMessages.push({
1331
+ role,
1332
+ text: extractMessageText(userContent)
1333
+ });
1334
+ }
1335
+ }
1336
+
1337
+ const filteredTitleMessages = removeLeadingSystemMessage(titleMessages);
1338
+ const titleUser = filteredTitleMessages.find(item => item.role === 'user' && item.text);
1339
+ if (titleUser) {
1340
+ firstPrompt = truncateText(titleUser.text);
1341
+ }
1342
+ }
1343
+
1344
+ messageCount = Math.max(0, messageCount);
1345
+
1346
+ return {
1347
+ source: 'claude',
1348
+ sourceLabel: 'Claude Code',
1349
+ sessionId,
1350
+ title: firstPrompt || sessionId,
1351
+ cwd,
1352
+ createdAt,
1353
+ updatedAt,
1354
+ messageCount,
1355
+ filePath
1356
+ };
1357
+ }
1358
+
1359
+ function listCodexSessions(limit, options = {}) {
1360
+ const scanFactor = Number.isFinite(Number(options.scanFactor))
1361
+ ? Math.max(1, Number(options.scanFactor))
1362
+ : SESSION_SCAN_FACTOR;
1363
+ const minFiles = Number.isFinite(Number(options.minFiles))
1364
+ ? Math.max(1, Number(options.minFiles))
1365
+ : Math.min(SESSION_SCAN_MIN_FILES, MAX_SESSION_LIST_SIZE * SESSION_SCAN_FACTOR);
1366
+ const targetCount = Number.isFinite(Number(options.targetCount))
1367
+ ? Math.max(1, Math.floor(Number(options.targetCount)))
1368
+ : Math.max(1, Math.floor(limit * scanFactor));
1369
+ const scanCount = Number.isFinite(Number(options.scanCount))
1370
+ ? Math.max(targetCount, Math.floor(Number(options.scanCount)))
1371
+ : Math.max(targetCount, minFiles);
1372
+ const maxFilesScanned = Number.isFinite(Number(options.maxFilesScanned))
1373
+ ? Math.max(scanCount, Math.floor(Number(options.maxFilesScanned)))
1374
+ : Math.max(scanCount * 2, minFiles);
1375
+ const files = collectRecentJsonlFiles(CODEX_SESSIONS_DIR, {
1376
+ returnCount: scanCount,
1377
+ maxFilesScanned
1378
+ });
1379
+ const sessions = [];
1380
+
1381
+ for (const filePath of files) {
1382
+ const summary = parseCodexSessionSummary(filePath);
1383
+ if (summary) {
1384
+ sessions.push(summary);
1385
+ }
1386
+
1387
+ if (sessions.length >= targetCount) {
1388
+ break;
1389
+ }
1390
+ }
1391
+
1392
+ return mergeAndLimitSessions(sessions, limit);
1393
+ }
1394
+
1395
+ function listClaudeSessions(limit, options = {}) {
1396
+ if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) {
1397
+ return [];
1398
+ }
1399
+
1400
+ const scanFactor = Number.isFinite(Number(options.scanFactor))
1401
+ ? Math.max(1, Number(options.scanFactor))
1402
+ : SESSION_SCAN_FACTOR;
1403
+ const minFiles = Number.isFinite(Number(options.minFiles))
1404
+ ? Math.max(1, Number(options.minFiles))
1405
+ : Math.min(SESSION_SCAN_MIN_FILES, MAX_SESSION_LIST_SIZE * SESSION_SCAN_FACTOR);
1406
+ const targetCount = Number.isFinite(Number(options.targetCount))
1407
+ ? Math.max(1, Math.floor(Number(options.targetCount)))
1408
+ : Math.max(1, Math.floor(limit * scanFactor));
1409
+ const scanCount = Number.isFinite(Number(options.scanCount))
1410
+ ? Math.max(targetCount, Math.floor(Number(options.scanCount)))
1411
+ : Math.max(targetCount, minFiles);
1412
+ const maxFilesScanned = Number.isFinite(Number(options.maxFilesScanned))
1413
+ ? Math.max(scanCount, Math.floor(Number(options.maxFilesScanned)))
1414
+ : Math.max(scanCount * 2, minFiles);
1415
+
1416
+ const sessions = [];
1417
+ let projectDirs = [];
1418
+ try {
1419
+ projectDirs = fs.readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true })
1420
+ .filter(entry => entry.isDirectory())
1421
+ .map(entry => path.join(CLAUDE_PROJECTS_DIR, entry.name));
1422
+ } catch (e) {
1423
+ projectDirs = [];
1424
+ }
1425
+
1426
+ for (const projectDir of projectDirs) {
1427
+ const indexPath = path.join(projectDir, 'sessions-index.json');
1428
+ const index = readJsonFile(indexPath, null);
1429
+ if (!index || !Array.isArray(index.entries)) {
1430
+ continue;
1431
+ }
1432
+
1433
+ for (const entry of index.entries) {
1434
+ if (!entry || typeof entry !== 'object') continue;
1435
+ const sessionId = entry.sessionId || '';
1436
+ if (!sessionId) continue;
1437
+
1438
+ let filePath = typeof entry.fullPath === 'string' && entry.fullPath
1439
+ ? entry.fullPath
1440
+ : path.join(projectDir, `${sessionId}.jsonl`);
1441
+
1442
+ if (!fs.existsSync(filePath)) {
1443
+ continue;
1444
+ }
1445
+
1446
+ const updatedAt = toIsoTime(entry.modified || entry.fileMtime, '');
1447
+ const createdAt = toIsoTime(entry.created, '');
1448
+ let title = truncateText(entry.summary || entry.firstPrompt || sessionId, 120);
1449
+ let messageCount = Number.isFinite(entry.messageCount) ? Math.max(0, entry.messageCount - 1) : 0;
1450
+
1451
+ const quickRecords = parseJsonlHeadRecords(filePath, SESSION_TITLE_READ_BYTES);
1452
+ if (quickRecords.length > 0) {
1453
+ const filteredCount = countConversationMessagesInRecords(quickRecords, 'claude');
1454
+ if (filteredCount > 0 || messageCount === 0) {
1455
+ messageCount = filteredCount;
1456
+ }
1457
+
1458
+ const quickMessages = [];
1459
+ for (const record of quickRecords) {
1460
+ const role = normalizeRole(record.type);
1461
+ if (role === 'assistant' || role === 'user' || role === 'system') {
1462
+ const content = record.message ? record.message.content : '';
1463
+ quickMessages.push({ role, text: extractMessageText(content) });
1464
+ }
1465
+ }
1466
+ const filteredQuickMessages = removeLeadingSystemMessage(quickMessages);
1467
+ const firstUser = filteredQuickMessages.find(item => item.role === 'user' && item.text);
1468
+ if (firstUser) {
1469
+ title = truncateText(firstUser.text, 120);
1470
+ }
1471
+ }
1472
+
1473
+ sessions.push({
1474
+ source: 'claude',
1475
+ sourceLabel: 'Claude Code',
1476
+ sessionId,
1477
+ title,
1478
+ cwd: entry.projectPath || index.originalPath || '',
1479
+ createdAt,
1480
+ updatedAt,
1481
+ messageCount,
1482
+ filePath
1483
+ });
1484
+
1485
+ if (sessions.length >= targetCount) {
1486
+ break;
1487
+ }
1488
+ }
1489
+
1490
+ if (sessions.length >= targetCount) {
1491
+ break;
1492
+ }
1493
+ }
1494
+
1495
+ if (sessions.length === 0) {
1496
+ const fallbackFiles = collectRecentJsonlFiles(CLAUDE_PROJECTS_DIR, {
1497
+ returnCount: scanCount,
1498
+ maxFilesScanned,
1499
+ ignoreSubPath: `${path.sep}subagents${path.sep}`
1500
+ });
1501
+ for (const filePath of fallbackFiles) {
1502
+ const summary = parseClaudeSessionSummary(filePath);
1503
+ if (summary) {
1504
+ sessions.push(summary);
1505
+ }
1506
+
1507
+ if (sessions.length >= targetCount) {
1508
+ break;
1509
+ }
1510
+ }
1511
+ }
1512
+
1513
+ return mergeAndLimitSessions(sessions, limit);
1514
+ }
1515
+
1516
+ function listAllSessions(params = {}) {
1517
+ const source = params.source === 'codex' || params.source === 'claude'
1518
+ ? params.source
1519
+ : 'all';
1520
+ const rawLimit = Number(params.limit);
1521
+ const limit = Number.isFinite(rawLimit)
1522
+ ? Math.max(1, Math.min(rawLimit, MAX_SESSION_LIST_SIZE))
1523
+ : 120;
1524
+ const forceRefresh = !!params.forceRefresh;
1525
+ const normalizedPathFilter = normalizeSessionPathFilter(params.pathFilter);
1526
+ const hasPathFilter = !!normalizedPathFilter;
1527
+ const queryTokens = normalizeQueryTokens(params.query);
1528
+ const hasQuery = queryTokens.length > 0;
1529
+ const cacheKey = hasQuery ? '' : `${source}:${limit}:${normalizedPathFilter}`;
1530
+ if (!hasQuery) {
1531
+ const cached = getSessionListCache(cacheKey, forceRefresh);
1532
+ if (cached) {
1533
+ return cached;
1534
+ }
1535
+ }
1536
+
1537
+ const scanOptions = hasPathFilter
1538
+ ? {
1539
+ scanFactor: SESSION_SCAN_FACTOR * 2,
1540
+ minFiles: SESSION_SCAN_MIN_FILES * 2
1541
+ }
1542
+ : {};
1543
+
1544
+ let sessions = [];
1545
+ if (source === 'all' || source === 'codex') {
1546
+ sessions = sessions.concat(listCodexSessions(limit, scanOptions));
1547
+ }
1548
+ if (source === 'all' || source === 'claude') {
1549
+ sessions = sessions.concat(listClaudeSessions(limit, scanOptions));
1550
+ }
1551
+
1552
+ if (hasPathFilter) {
1553
+ sessions = sessions.filter(item => matchesSessionPathFilter(item, normalizedPathFilter));
1554
+ }
1555
+
1556
+ let result = sessions;
1557
+ if (hasQuery) {
1558
+ result = applySessionQueryFilter(result, {
1559
+ tokens: queryTokens,
1560
+ queryMode: params.queryMode,
1561
+ queryScope: params.queryScope,
1562
+ roleFilter: params.roleFilter,
1563
+ contentScanLimit: params.contentScanLimit,
1564
+ contentScanBytes: params.contentScanBytes
1565
+ });
1566
+ }
1567
+ result = mergeAndLimitSessions(result, limit);
1568
+ if (!hasQuery) {
1569
+ setSessionListCache(cacheKey, result);
1570
+ }
1571
+ return result;
1572
+ }
1573
+
1574
+ function listSessionPaths(params = {}) {
1575
+ const source = params.source === 'codex' || params.source === 'claude'
1576
+ ? params.source
1577
+ : 'all';
1578
+ const rawLimit = Number(params.limit);
1579
+ const limit = Number.isFinite(rawLimit)
1580
+ ? Math.max(1, Math.min(rawLimit, MAX_SESSION_PATH_LIST_SIZE))
1581
+ : 500;
1582
+ const forceRefresh = !!params.forceRefresh;
1583
+ const cacheKey = `paths:${source}:${limit}`;
1584
+ const cached = getSessionListCache(cacheKey, forceRefresh);
1585
+ if (cached) {
1586
+ return cached;
1587
+ }
1588
+
1589
+ const gatherLimit = Math.min(MAX_SESSION_PATH_LIST_SIZE, Math.max(limit * 4, 800));
1590
+ const scanOptions = {
1591
+ scanFactor: SESSION_SCAN_FACTOR * 2,
1592
+ minFiles: SESSION_SCAN_MIN_FILES * 2,
1593
+ targetCount: Math.max(gatherLimit * 2, 1000)
1594
+ };
1595
+
1596
+ let sessions = [];
1597
+ if (source === 'all' || source === 'codex') {
1598
+ sessions = sessions.concat(listCodexSessions(gatherLimit, scanOptions));
1599
+ }
1600
+ if (source === 'all' || source === 'claude') {
1601
+ sessions = sessions.concat(listClaudeSessions(gatherLimit, scanOptions));
1602
+ }
1603
+
1604
+ const dedupedPaths = [];
1605
+ const seen = new Set();
1606
+ const sorted = sortSessionsByUpdatedAt(sessions);
1607
+ for (const session of sorted) {
1608
+ const cwd = typeof session.cwd === 'string' ? session.cwd.trim() : '';
1609
+ if (!cwd) {
1610
+ continue;
1611
+ }
1612
+ const key = cwd.toLowerCase();
1613
+ if (seen.has(key)) {
1614
+ continue;
1615
+ }
1616
+ seen.add(key);
1617
+ dedupedPaths.push(cwd);
1618
+ if (dedupedPaths.length >= limit) {
1619
+ break;
1620
+ }
1621
+ }
1622
+
1623
+ setSessionListCache(cacheKey, dedupedPaths);
1624
+ return dedupedPaths;
1625
+ }
1626
+
1627
+ function resolveSessionFilePath(source, filePath, sessionId) {
1628
+ const root = source === 'claude' ? CLAUDE_PROJECTS_DIR : CODEX_SESSIONS_DIR;
1629
+ if (!root || !fs.existsSync(root)) {
1630
+ return '';
1631
+ }
1632
+
1633
+ if (typeof filePath === 'string' && filePath.trim()) {
1634
+ const targetPath = path.resolve(filePath.trim());
1635
+ if (fs.existsSync(targetPath) && isPathInside(targetPath, root)) {
1636
+ return targetPath;
1637
+ }
1638
+ }
1639
+
1640
+ if (typeof sessionId === 'string' && sessionId.trim()) {
1641
+ const targetId = sessionId.trim().toLowerCase();
1642
+ const files = collectJsonlFiles(root, 5000);
1643
+ const matchedFile = files.find(item => path.basename(item).toLowerCase().includes(targetId));
1644
+ if (matchedFile && fs.existsSync(matchedFile)) {
1645
+ return matchedFile;
1646
+ }
1647
+ }
1648
+
1649
+ return '';
1650
+ }
1651
+
1652
+ function buildSessionMarkdown(payload) {
1653
+ const lines = [
1654
+ '# AI Session Export',
1655
+ '',
1656
+ `- Source: ${payload.sourceLabel}`,
1657
+ `- Session ID: ${payload.sessionId}`,
1658
+ `- Updated At: ${payload.updatedAt || 'unknown'}`,
1659
+ `- Working Directory: ${payload.cwd || 'unknown'}`,
1660
+ `- Original File: ${payload.filePath}`,
1661
+ '',
1662
+ '## Messages',
1663
+ ''
1664
+ ];
1665
+
1666
+ if (!payload.messages || payload.messages.length === 0) {
1667
+ lines.push('(no user/assistant messages found)');
1668
+ lines.push('');
1669
+ return lines.join('\n');
1670
+ }
1671
+
1672
+ payload.messages.forEach((message, index) => {
1673
+ const role = message.role === 'assistant' ? 'Assistant' : 'User';
1674
+ const timeInfo = message.timestamp ? ` · ${message.timestamp}` : '';
1675
+ lines.push(`### ${index + 1}. ${role}${timeInfo}`);
1676
+ lines.push('');
1677
+ lines.push(message.text || '(empty message)');
1678
+ lines.push('');
1679
+ });
1680
+
1681
+ return lines.join('\n');
1682
+ }
1683
+
1684
+ function resolveStateMaxMessages(state) {
1685
+ if (!state || typeof state !== 'object') {
1686
+ return MAX_EXPORT_MESSAGES;
1687
+ }
1688
+
1689
+ if (state.maxMessages === Infinity) {
1690
+ return Infinity;
1691
+ }
1692
+
1693
+ const rawMax = Number(state.maxMessages);
1694
+ if (Number.isFinite(rawMax) && rawMax > 0) {
1695
+ return Math.floor(rawMax);
1696
+ }
1697
+ return MAX_EXPORT_MESSAGES;
1698
+ }
1699
+
1700
+ function canAppendMessage(state) {
1701
+ const maxMessages = resolveStateMaxMessages(state);
1702
+ if (maxMessages === Infinity) {
1703
+ return true;
1704
+ }
1705
+ return state.messages.length < maxMessages;
1706
+ }
1707
+
1708
+ function extractCodexMessageFromRecord(record, state, lineIndex = -1) {
1709
+ if (record.timestamp) {
1710
+ state.updatedAt = toIsoTime(record.timestamp, state.updatedAt);
1711
+ }
1712
+
1713
+ if (record.type === 'session_meta' && record.payload) {
1714
+ state.sessionId = record.payload.id || state.sessionId;
1715
+ state.cwd = record.payload.cwd || state.cwd;
1716
+ return;
1717
+ }
1718
+
1719
+ if (record.type === 'response_item' && record.payload && record.payload.type === 'message') {
1720
+ const role = normalizeRole(record.payload.role);
1721
+ if (role === 'user' || role === 'assistant' || role === 'system') {
1722
+ const text = extractMessageText(record.payload.content);
1723
+ if (text && canAppendMessage(state)) {
1724
+ state.messages.push({
1725
+ role,
1726
+ text,
1727
+ timestamp: toIsoTime(record.timestamp, ''),
1728
+ recordLineIndex: Number.isInteger(lineIndex) ? lineIndex : -1
1729
+ });
1730
+ }
1731
+ }
1732
+ }
1733
+ }
1734
+
1735
+ function extractClaudeMessageFromRecord(record, state, lineIndex = -1) {
1736
+ if (record.timestamp) {
1737
+ state.updatedAt = toIsoTime(record.timestamp, state.updatedAt);
1738
+ }
1739
+
1740
+ if (!state.sessionId && record.sessionId) {
1741
+ state.sessionId = record.sessionId;
1742
+ }
1743
+
1744
+ if (!state.cwd && record.cwd) {
1745
+ state.cwd = record.cwd;
1746
+ }
1747
+
1748
+ const role = normalizeRole(record.type);
1749
+ if (role === 'user' || role === 'assistant' || role === 'system') {
1750
+ const content = record.message ? record.message.content : '';
1751
+ const text = extractMessageText(content);
1752
+ if (text && canAppendMessage(state)) {
1753
+ state.messages.push({
1754
+ role,
1755
+ text,
1756
+ timestamp: toIsoTime(record.timestamp, ''),
1757
+ recordLineIndex: Number.isInteger(lineIndex) ? lineIndex : -1
1758
+ });
1759
+ }
1760
+ }
1761
+ }
1762
+
1763
+ function extractMessagesFromRecords(records, source, options = {}) {
1764
+ const maxMessages = options.maxMessages === Infinity
1765
+ ? Infinity
1766
+ : (Number.isFinite(Number(options.maxMessages)) ? Math.max(1, Number(options.maxMessages)) : MAX_EXPORT_MESSAGES);
1767
+ const state = {
1768
+ sessionId: '',
1769
+ cwd: '',
1770
+ updatedAt: '',
1771
+ messages: [],
1772
+ maxMessages
1773
+ };
1774
+
1775
+ let lineIndex = 0;
1776
+ for (const record of records) {
1777
+ if (source === 'codex') {
1778
+ extractCodexMessageFromRecord(record, state, lineIndex);
1779
+ } else {
1780
+ extractClaudeMessageFromRecord(record, state, lineIndex);
1781
+ }
1782
+
1783
+ lineIndex += 1;
1784
+
1785
+ if (state.maxMessages !== Infinity && state.messages.length >= state.maxMessages) {
1786
+ break;
1787
+ }
1788
+ }
1789
+
1790
+ return state;
1791
+ }
1792
+
1793
+ async function extractMessagesFromFile(filePath, source, options = {}) {
1794
+ const maxMessages = options.maxMessages === Infinity
1795
+ ? Infinity
1796
+ : (Number.isFinite(Number(options.maxMessages)) ? Math.max(1, Number(options.maxMessages)) : MAX_EXPORT_MESSAGES);
1797
+ const state = {
1798
+ sessionId: '',
1799
+ cwd: '',
1800
+ updatedAt: '',
1801
+ messages: [],
1802
+ maxMessages
1803
+ };
1804
+
1805
+ let stream;
1806
+ let rl;
1807
+ try {
1808
+ stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
1809
+ rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
1810
+
1811
+ let lineIndex = 0;
1812
+ for await (const line of rl) {
1813
+ const currentLineIndex = lineIndex;
1814
+ lineIndex += 1;
1815
+
1816
+ const trimmed = line.trim();
1817
+ if (!trimmed) continue;
1818
+
1819
+ let record;
1820
+ try {
1821
+ record = JSON.parse(trimmed);
1822
+ } catch (e) {
1823
+ continue;
1824
+ }
1825
+
1826
+ if (source === 'codex') {
1827
+ extractCodexMessageFromRecord(record, state, currentLineIndex);
1828
+ } else {
1829
+ extractClaudeMessageFromRecord(record, state, currentLineIndex);
1830
+ }
1831
+
1832
+ if (state.maxMessages !== Infinity && state.messages.length >= state.maxMessages) {
1833
+ rl.close();
1834
+ if (stream.destroy) {
1835
+ stream.destroy();
1836
+ }
1837
+ break;
1838
+ }
1839
+ }
1840
+ } catch (e) {
1841
+ const fallbackRecords = readJsonlRecords(filePath);
1842
+ return extractMessagesFromRecords(fallbackRecords, source, { maxMessages });
1843
+ } finally {
1844
+ if (rl) {
1845
+ try { rl.close(); } catch (e) {}
1846
+ }
1847
+ if (stream && !stream.destroyed && stream.destroy) {
1848
+ try { stream.destroy(); } catch (e) {}
1849
+ }
1850
+ }
1851
+
1852
+ return state;
1853
+ }
1854
+
1855
+ async function readSessionDetail(params = {}) {
1856
+ const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : '');
1857
+ if (!source) {
1858
+ return { error: 'Invalid source' };
1859
+ }
1860
+
1861
+ const filePath = resolveSessionFilePath(source, params.filePath, params.sessionId);
1862
+ if (!filePath) {
1863
+ return { error: 'Session file not found' };
1864
+ }
1865
+
1866
+ const rawLimit = Number(params.messageLimit);
1867
+ const messageLimit = Number.isFinite(rawLimit)
1868
+ ? Math.max(1, Math.min(rawLimit, MAX_SESSION_DETAIL_MESSAGES))
1869
+ : DEFAULT_SESSION_DETAIL_MESSAGES;
1870
+
1871
+ const extracted = await extractMessagesFromFile(filePath, source);
1872
+ const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
1873
+ const sourceLabel = source === 'codex' ? 'Codex' : 'Claude Code';
1874
+ const allMessages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : [])
1875
+ .map((message, messageIndex) => ({
1876
+ ...message,
1877
+ messageIndex
1878
+ }));
1879
+ const startIndex = Math.max(0, allMessages.length - messageLimit);
1880
+ const clippedMessages = allMessages.slice(startIndex);
1881
+
1882
+ return {
1883
+ source,
1884
+ sourceLabel,
1885
+ sessionId,
1886
+ cwd: extracted.cwd || '',
1887
+ updatedAt: extracted.updatedAt || '',
1888
+ totalMessages: allMessages.length,
1889
+ clipped: allMessages.length > clippedMessages.length,
1890
+ messageLimit,
1891
+ messages: clippedMessages,
1892
+ filePath
1893
+ };
1894
+ }
1895
+
1896
+ async function exportSessionData(params = {}) {
1897
+ const source = params.source === 'claude' ? 'claude' : (params.source === 'codex' ? 'codex' : '');
1898
+ if (!source) {
1899
+ return { error: 'Invalid source' };
1900
+ }
1901
+
1902
+ const filePath = resolveSessionFilePath(source, params.filePath, params.sessionId);
1903
+ if (!filePath) {
1904
+ return { error: 'Session file not found' };
1905
+ }
1906
+
1907
+ let extracted;
1908
+ try {
1909
+ extracted = await extractMessagesFromFile(filePath, source);
1910
+ } catch (e) {
1911
+ extracted = null;
1912
+ }
1913
+
1914
+ if (!extracted) {
1915
+ return { error: 'Failed to parse session file' };
1916
+ }
1917
+
1918
+ if ((!extracted.messages || extracted.messages.length === 0) && !extracted.sessionId && !extracted.cwd) {
1919
+ const fallbackRecords = readJsonlRecords(filePath);
1920
+ if (fallbackRecords.length === 0) {
1921
+ return { error: 'Session file is empty' };
1922
+ }
1923
+ extracted = extractMessagesFromRecords(fallbackRecords, source);
1924
+ }
1925
+
1926
+ extracted.messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []);
1927
+
1928
+ if (!extracted.messages || extracted.messages.length === 0) {
1929
+ const stat = fs.existsSync(filePath) ? fs.statSync(filePath) : null;
1930
+ if (!stat || stat.size === 0) {
1931
+ return { error: 'Session file is empty' };
1932
+ }
1933
+ }
1934
+
1935
+ const sessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
1936
+ const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
1937
+ const sourceLabel = source === 'codex' ? 'Codex' : 'Claude Code';
1938
+ const markdown = buildSessionMarkdown({
1939
+ sourceLabel,
1940
+ sessionId,
1941
+ updatedAt: extracted.updatedAt,
1942
+ cwd: extracted.cwd,
1943
+ filePath,
1944
+ messages: extracted.messages
1945
+ });
1946
+
1947
+ return {
1948
+ source,
1949
+ sourceLabel,
1950
+ sessionId,
1951
+ fileName: `${source}-session-${safeSessionId}.md`,
1952
+ content: markdown
1953
+ };
1954
+ }
1955
+
1956
+ function buildExportPayload(includeKeys) {
1957
+ const { config } = readConfigOrVirtualDefault();
1958
+ const providers = config.model_providers || {};
1959
+ const providerData = {};
1960
+ for (const [name, provider] of Object.entries(providers)) {
1961
+ providerData[name] = {
1962
+ baseUrl: provider.base_url || '',
1963
+ apiKey: includeKeys ? (provider.preferred_auth_method || '') : null
1964
+ };
1965
+ }
1966
+
1967
+ return {
1968
+ version: 1,
1969
+ currentProvider: config.model_provider || '',
1970
+ currentModel: config.model || '',
1971
+ providers: providerData,
1972
+ models: readModels(),
1973
+ currentModels: readCurrentModels()
1974
+ };
1975
+ }
1976
+
1977
+ function normalizeImportPayload(payload) {
1978
+ if (!payload || typeof payload !== 'object') {
1979
+ return { error: 'Invalid import payload' };
1980
+ }
1981
+
1982
+ const rawProviders = payload.providers || payload.model_providers || [];
1983
+ const providers = {};
1984
+ if (Array.isArray(rawProviders)) {
1985
+ for (const item of rawProviders) {
1986
+ if (!item || typeof item !== 'object') continue;
1987
+ const name = item.name || item.provider || '';
1988
+ const baseUrl = item.baseUrl || item.base_url || item.url || '';
1989
+ const apiKey = item.apiKey ?? item.key ?? item.preferred_auth_method ?? null;
1990
+ if (name && baseUrl) {
1991
+ providers[name] = { baseUrl, apiKey };
1992
+ }
1993
+ }
1994
+ } else if (typeof rawProviders === 'object') {
1995
+ for (const [name, item] of Object.entries(rawProviders)) {
1996
+ if (!item || typeof item !== 'object') continue;
1997
+ const baseUrl = item.baseUrl || item.base_url || item.url || '';
1998
+ const apiKey = item.apiKey ?? item.key ?? item.preferred_auth_method ?? null;
1999
+ if (name && baseUrl) {
2000
+ providers[name] = { baseUrl, apiKey };
2001
+ }
2002
+ }
2003
+ }
2004
+
2005
+ return {
2006
+ providers,
2007
+ models: Array.isArray(payload.models) ? payload.models : [],
2008
+ currentProvider: typeof payload.currentProvider === 'string' ? payload.currentProvider : '',
2009
+ currentModel: typeof payload.currentModel === 'string' ? payload.currentModel : '',
2010
+ currentModels: payload.currentModels && typeof payload.currentModels === 'object' ? payload.currentModels : {}
2011
+ };
2012
+ }
2013
+
2014
+ function importConfigData(payload, options = {}) {
2015
+ const normalized = normalizeImportPayload(payload);
2016
+ if (normalized.error) {
2017
+ return { error: normalized.error };
2018
+ }
2019
+
2020
+ const overwriteProviders = !!options.overwriteProviders;
2021
+ const applyCurrent = !!options.applyCurrent;
2022
+ const applyCurrentModels = !!options.applyCurrentModels;
2023
+
2024
+ const { config: existingConfig } = readConfigOrVirtualDefault();
2025
+ const existingProviders = existingConfig.model_providers || {};
2026
+ let addedProviders = 0;
2027
+ let updatedProviders = 0;
2028
+
2029
+ for (const [name, provider] of Object.entries(normalized.providers)) {
2030
+ if (existingProviders[name]) {
2031
+ if (overwriteProviders) {
2032
+ const apiKey = typeof provider.apiKey === 'string' && provider.apiKey
2033
+ ? provider.apiKey
2034
+ : undefined;
2035
+ cmdUpdate(name, provider.baseUrl, apiKey, true);
2036
+ updatedProviders += 1;
2037
+ }
2038
+ } else {
2039
+ const apiKey = typeof provider.apiKey === 'string' ? provider.apiKey : '';
2040
+ cmdAdd(name, provider.baseUrl, apiKey, true);
2041
+ addedProviders += 1;
2042
+ }
2043
+ }
2044
+
2045
+ let addedModels = 0;
2046
+ if (normalized.models.length > 0) {
2047
+ const existingModels = new Set(readModels());
2048
+ for (const model of normalized.models) {
2049
+ if (typeof model !== 'string' || !model.trim()) continue;
2050
+ if (!existingModels.has(model)) {
2051
+ cmdAddModel(model, true);
2052
+ existingModels.add(model);
2053
+ addedModels += 1;
2054
+ }
2055
+ }
2056
+ }
2057
+
2058
+ if (applyCurrentModels && normalized.currentModels) {
2059
+ const currentModels = readCurrentModels();
2060
+ for (const [name, model] of Object.entries(normalized.currentModels)) {
2061
+ if (typeof model !== 'string' || !model) continue;
2062
+ currentModels[name] = model;
2063
+ }
2064
+ writeCurrentModels(currentModels);
2065
+ }
2066
+
2067
+ const { config: finalConfig } = readConfigOrVirtualDefault();
2068
+ const finalProviders = finalConfig.model_providers || {};
2069
+ if (applyCurrent && normalized.currentProvider) {
2070
+ if (finalProviders[normalized.currentProvider]) {
2071
+ cmdSwitch(normalized.currentProvider, true);
2072
+ }
2073
+ if (normalized.currentModel) {
2074
+ const models = readModels();
2075
+ if (!models.includes(normalized.currentModel)) {
2076
+ cmdAddModel(normalized.currentModel, true);
2077
+ }
2078
+ cmdUseModel(normalized.currentModel, true);
2079
+ }
2080
+ }
2081
+
2082
+ return {
2083
+ success: true,
2084
+ summary: {
2085
+ addedProviders,
2086
+ updatedProviders,
2087
+ addedModels
2088
+ }
2089
+ };
2090
+ }
2091
+
2092
+ function resolveSpeedTestTarget(params) {
2093
+ if (!params) return { error: 'Missing params' };
2094
+
2095
+ if (params.name) {
2096
+ const { config } = readConfigOrVirtualDefault();
2097
+ const providers = config.model_providers || {};
2098
+ const provider = providers[params.name];
2099
+ if (!provider) {
2100
+ return { error: 'Provider not found' };
2101
+ }
2102
+ if (!provider.base_url) {
2103
+ return { error: 'Provider missing URL' };
2104
+ }
2105
+ return {
2106
+ url: provider.base_url,
2107
+ apiKey: provider.preferred_auth_method || ''
2108
+ };
2109
+ }
2110
+
2111
+ if (params.url) {
2112
+ return { url: params.url, apiKey: '' };
2113
+ }
2114
+
2115
+ return { error: 'Missing name or url' };
2116
+ }
2117
+
2118
+ function runSpeedTest(targetUrl, apiKey) {
2119
+ return new Promise((resolve) => {
2120
+ let parsed;
2121
+ try {
2122
+ parsed = new URL(targetUrl);
2123
+ } catch (e) {
2124
+ return resolve({ ok: false, error: 'Invalid URL' });
2125
+ }
2126
+
2127
+ const transport = parsed.protocol === 'https:' ? https : http;
2128
+ const headers = {
2129
+ 'User-Agent': 'codexmate-speed-test',
2130
+ 'Accept': 'application/json'
2131
+ };
2132
+ if (apiKey) {
2133
+ headers['Authorization'] = `Bearer ${apiKey}`;
2134
+ }
2135
+
2136
+ const start = Date.now();
2137
+ const req = transport.request(parsed, { method: 'GET', headers }, (res) => {
2138
+ res.on('data', () => {});
2139
+ res.on('end', () => {
2140
+ resolve({
2141
+ ok: true,
2142
+ status: res.statusCode || 0,
2143
+ durationMs: Date.now() - start
2144
+ });
2145
+ });
2146
+ });
2147
+
2148
+ req.setTimeout(SPEED_TEST_TIMEOUT_MS, () => {
2149
+ req.destroy(new Error('timeout'));
2150
+ });
2151
+
2152
+ req.on('error', (err) => {
2153
+ resolve({ ok: false, error: err.message, durationMs: Date.now() - start });
2154
+ });
2155
+
2156
+ req.end();
2157
+ });
2158
+ }
2159
+
2160
+ // ============================================================================
2161
+ // 命令
2162
+ // ============================================================================
2163
+
2164
+ // 显示当前状态
2165
+ function cmdStatus() {
2166
+ const { config, isVirtual } = readConfigOrVirtualDefault();
2167
+ const current = config.model_provider || '未设置';
2168
+ const currentModel = config.model || '未设置';
2169
+ const models = readModels();
2170
+ const currentModels = readCurrentModels();
2171
+
2172
+ console.log('\n当前状态:');
2173
+ console.log(' 提供商:', current);
2174
+ console.log(' 模型:', currentModel);
2175
+ console.log(' 模型列表:', models.length, '个');
2176
+ if (isVirtual) {
2177
+ console.log(' 说明: 当前为虚拟默认配置(config.toml 尚未创建)');
2178
+ }
2179
+ console.log();
2180
+ }
2181
+
2182
+ // 列出所有提供商
2183
+ function cmdList() {
2184
+ const { config, isVirtual } = readConfigOrVirtualDefault();
2185
+ const providers = config.model_providers || {};
2186
+ const current = config.model_provider;
2187
+
2188
+ console.log('\n提供商列表:');
2189
+ console.log('┌─────────────────────────────────────────────────────────┐');
2190
+
2191
+ const names = Object.keys(providers);
2192
+ if (names.length === 0) {
2193
+ console.log('│ (无) │');
2194
+ } else {
2195
+ names.forEach(name => {
2196
+ const p = providers[name];
2197
+ const isCurrent = name === current;
2198
+ const marker = isCurrent ? '●' : ' ';
2199
+ const key = p.preferred_auth_method || '(无密钥)';
2200
+ const displayKey = key.length > 30 ? key.substring(0, 27) + '...' : key;
2201
+
2202
+ console.log(`│ ${marker} ${name.padEnd(20)} ${displayKey.padEnd(31)} │`);
2203
+ });
2204
+ }
2205
+
2206
+ console.log('└─────────────────────────────────────────────────────────┘');
2207
+ console.log(`总计: ${names.length} 个提供商`);
2208
+ if (isVirtual) {
2209
+ console.log('提示: 当前使用虚拟默认配置(config.toml 尚未创建)');
2210
+ }
2211
+ console.log();
2212
+ }
2213
+
2214
+ // 列出所有模型
2215
+ function cmdModels() {
2216
+ const models = readModels();
2217
+ const currentModels = readCurrentModels();
2218
+
2219
+ console.log('\n可用模型:');
2220
+ models.forEach((m, i) => {
2221
+ const users = Object.entries(currentModels)
2222
+ .filter(([_, model]) => model === m)
2223
+ .map(([name, _]) => name);
2224
+ const usage = users.length > 0 ? users.join(', ') : '(未使用)';
2225
+ console.log(` ${i + 1}. ${m}`);
2226
+ if (users.length > 0) {
2227
+ console.log(` → ${usage}`);
2228
+ }
2229
+ });
2230
+ console.log();
2231
+ }
2232
+
2233
+ // 切换提供商
2234
+ function cmdSwitch(providerName, silent = false) {
2235
+ const config = readConfig();
2236
+ const providers = config.model_providers || {};
2237
+
2238
+ if (!providers[providerName]) {
2239
+ if (!silent) {
2240
+ console.error('错误: 提供商不存在:', providerName);
2241
+ console.log('\n可用的提供商:');
2242
+ Object.keys(providers).forEach(name => console.log(' -', name));
2243
+ }
2244
+ throw new Error('提供商不存在');
2245
+ }
2246
+
2247
+ // 切换提供商
2248
+ const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
2249
+ const newContent = content.replace(
2250
+ /^(model_provider\s*=\s*)(["']).*?(["'])/m,
2251
+ `$1$2${providerName}$3`
2252
+ );
2253
+ writeConfig(newContent);
2254
+
2255
+ // 更新认证信息
2256
+ const apiKey = providers[providerName].preferred_auth_method || '';
2257
+ updateAuthJson(apiKey);
2258
+
2259
+ // 切换到该提供商的模型
2260
+ const currentModels = readCurrentModels();
2261
+ const targetModel = currentModels[providerName] || readModels()[0];
2262
+ const content2 = fs.readFileSync(CONFIG_FILE, 'utf-8');
2263
+ const modelRegex = /^(model\s*=\s*)(["']).*?(["'])/m;
2264
+ if (modelRegex.test(content2)) {
2265
+ const newContent2 = content2.replace(modelRegex, `$1$2${targetModel}$3`);
2266
+ writeConfig(newContent2);
2267
+ }
2268
+
2269
+ if (!silent) {
2270
+ console.log('✓ 已切换到:', providerName);
2271
+ console.log('✓ 当前模型:', targetModel);
2272
+ console.log();
2273
+ }
2274
+ return targetModel;
2275
+ }
2276
+
2277
+ // 切换模型
2278
+ function cmdUseModel(modelName, silent = false) {
2279
+ const models = readModels();
2280
+ if (!models.includes(modelName)) {
2281
+ if (!silent) {
2282
+ console.error('错误: 模型不存在:', modelName);
2283
+ console.log('\n可用的模型:');
2284
+ models.forEach(m => console.log(' -', m));
2285
+ }
2286
+ throw new Error('模型不存在');
2287
+ }
2288
+
2289
+ const config = readConfig();
2290
+ const currentProvider = config.model_provider;
2291
+ if (!currentProvider) {
2292
+ if (!silent) console.error('错误: 未设置当前提供商');
2293
+ throw new Error('未设置当前提供商');
2294
+ }
2295
+
2296
+ // 更新模型
2297
+ const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
2298
+ const modelRegex = /^(model\s*=\s*)(["']).*?(["'])/m;
2299
+ if (modelRegex.test(content)) {
2300
+ const newContent = content.replace(modelRegex, `$1$2${modelName}$3`);
2301
+ writeConfig(newContent);
2302
+ }
2303
+
2304
+ // 保存当前提供商的模型选择
2305
+ const currentModels = readCurrentModels();
2306
+ currentModels[currentProvider] = modelName;
2307
+ writeCurrentModels(currentModels);
2308
+
2309
+ if (!silent) {
2310
+ console.log('✓ 已切换模型:', modelName);
2311
+ console.log();
2312
+ }
2313
+ }
2314
+
2315
+ // 添加提供商
2316
+ function cmdAdd(name, baseUrl, apiKey, silent = false) {
2317
+ if (!name || !baseUrl) {
2318
+ if (!silent) {
2319
+ console.error('用法: codexmate add <名称> <URL> [密钥]');
2320
+ console.log('\n示例:');
2321
+ console.log(' codexmate add 88code https://api.88code.ai/v1 sk-xxx');
2322
+ }
2323
+ throw new Error('名称和URL必填');
2324
+ }
2325
+
2326
+ const config = readConfig();
2327
+ if (config.model_providers && config.model_providers[name]) {
2328
+ if (!silent) console.error('错误: 提供商已存在:', name);
2329
+ throw new Error('提供商已存在');
2330
+ }
2331
+
2332
+ const newBlock = `
2333
+ [model_providers.${name}]
2334
+ name = "${name}"
2335
+ base_url = "${baseUrl}"
2336
+ wire_api = "responses"
2337
+ requires_openai_auth = false
2338
+ preferred_auth_method = "${apiKey || ''}"
2339
+ request_max_retries = 4
2340
+ stream_max_retries = 10
2341
+ stream_idle_timeout_ms = 300000
2342
+ `;
2343
+
2344
+ const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
2345
+ writeConfig(content.trimEnd() + '\n' + newBlock);
2346
+
2347
+ // 初始化当前模型
2348
+ const currentModels = readCurrentModels();
2349
+ if (!currentModels[name]) {
2350
+ currentModels[name] = readModels()[0];
2351
+ writeCurrentModels(currentModels);
2352
+ }
2353
+
2354
+ if (!silent) {
2355
+ console.log('✓ 已添加提供商:', name);
2356
+ console.log(' URL:', baseUrl);
2357
+ console.log();
2358
+ }
2359
+ }
2360
+
2361
+ // 删除提供商
2362
+ function cmdDelete(name, silent = false) {
2363
+ const config = readConfig();
2364
+ if (!config.model_providers || !config.model_providers[name]) {
2365
+ if (!silent) console.error('错误: 提供商不存在:', name);
2366
+ throw new Error('提供商不存在');
2367
+ }
2368
+
2369
+ const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
2370
+ const safeName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2371
+ const sectionRegex = new RegExp(`\\[\\s*model_providers\\s*\\.\\s*${safeName}\\s*\\]`);
2372
+ const match = content.match(sectionRegex);
2373
+ if (!match) {
2374
+ if (!silent) console.error('错误: 无法找到提供商配置块');
2375
+ throw new Error('无法找到提供商配置块');
2376
+ }
2377
+
2378
+ const startIdx = match.index;
2379
+ const rest = content.slice(startIdx + match[0].length);
2380
+ const nextIdx = rest.indexOf('[');
2381
+ const endIdx = nextIdx === -1 ? content.length : (startIdx + match[0].length + nextIdx);
2382
+
2383
+ const newContent = content.slice(0, startIdx) + content.slice(endIdx);
2384
+ writeConfig(newContent.trim());
2385
+
2386
+ // 删除当前模型记录
2387
+ const currentModels = readCurrentModels();
2388
+ delete currentModels[name];
2389
+ writeCurrentModels(currentModels);
2390
+
2391
+ if (!silent) {
2392
+ console.log('✓ 已删除提供商:', name);
2393
+ console.log();
2394
+ }
2395
+ }
2396
+
2397
+ // 更新提供商
2398
+ function cmdUpdate(name, baseUrl, apiKey, silent = false) {
2399
+ if (!name) {
2400
+ if (!silent) console.error('错误: 提供商名称必填');
2401
+ throw new Error('提供商名称必填');
2402
+ }
2403
+
2404
+ const config = readConfig();
2405
+ if (!config.model_providers || !config.model_providers[name]) {
2406
+ if (!silent) console.error('错误: 提供商不存在:', name);
2407
+ throw new Error('提供商不存在');
2408
+ }
2409
+
2410
+ const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
2411
+ const safeName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2412
+ const sectionRegex = new RegExp(`\\[\\s*model_providers\\s*\\.\\s*${safeName}\\s*\\]`);
2413
+ const match = content.match(sectionRegex);
2414
+ if (!match) {
2415
+ if (!silent) console.error('错误: 无法找到提供商配置块');
2416
+ throw new Error('无法找到提供商配置块');
2417
+ }
2418
+
2419
+ const startIdx = match.index;
2420
+ const rest = content.slice(startIdx + match[0].length);
2421
+ const nextIdx = rest.indexOf('[');
2422
+ const endIdx = nextIdx === -1 ? content.length : (startIdx + match[0].length + nextIdx);
2423
+
2424
+ // 提取该提供商的配置块
2425
+ const providerBlock = content.slice(startIdx, endIdx);
2426
+
2427
+ // 替换 base_url
2428
+ let updatedBlock = providerBlock;
2429
+ if (baseUrl) {
2430
+ updatedBlock = updatedBlock.replace(
2431
+ /^(base_url\s*=\s*)(["']).*?\2/m,
2432
+ `$1$2${baseUrl}$2`
2433
+ );
2434
+ }
2435
+
2436
+ // 替换 preferred_auth_method (API Key)
2437
+ if (apiKey !== undefined) {
2438
+ updatedBlock = updatedBlock.replace(
2439
+ /^(preferred_auth_method\s*=\s*)(["']).*?\2/m,
2440
+ `$1$2${apiKey}$2`
2441
+ );
2442
+ }
2443
+
2444
+ // 组合新的内容
2445
+ const newContent = content.slice(0, startIdx) + updatedBlock + content.slice(endIdx);
2446
+ writeConfig(newContent.trim());
2447
+
2448
+ // 如果更新了 API Key 且该提供商是当前激活的,同步更新 auth.json
2449
+ const currentProvider = config.model_provider;
2450
+ if (apiKey !== undefined && name === currentProvider) {
2451
+ updateAuthJson(apiKey);
2452
+ }
2453
+
2454
+ if (!silent) {
2455
+ console.log('✓ 已更新提供商:', name);
2456
+ console.log();
2457
+ }
2458
+ }
2459
+
2460
+ // 添加模型
2461
+ function cmdAddModel(modelName, silent = false) {
2462
+ if (!modelName) {
2463
+ if (!silent) console.error('用法: codexmate add-model <模型名称>');
2464
+ throw new Error('模型名称必填');
2465
+ }
2466
+
2467
+ const models = readModels();
2468
+ if (models.includes(modelName)) {
2469
+ if (!silent) console.log('模型已存在:', modelName);
2470
+ return;
2471
+ }
2472
+
2473
+ models.push(modelName);
2474
+ writeModels(models);
2475
+
2476
+ if (!silent) {
2477
+ console.log('✓ 已添加模型:', modelName);
2478
+ console.log();
2479
+ }
2480
+ }
2481
+
2482
+ // 删除模型
2483
+ function cmdDeleteModel(modelName, silent = false) {
2484
+ const models = readModels();
2485
+ const index = models.indexOf(modelName);
2486
+ if (index === -1) {
2487
+ if (!silent) console.error('错误: 模型不存在:', modelName);
2488
+ throw new Error('模型不存在');
2489
+ }
2490
+
2491
+ if (models.length <= 1) {
2492
+ if (!silent) console.error('错误: 至少需要保留一个模型');
2493
+ throw new Error('至少需要保留一个模型');
2494
+ }
2495
+
2496
+ models.splice(index, 1);
2497
+ writeModels(models);
2498
+
2499
+ // 检查是否有提供商使用该模型
2500
+ const currentModels = readCurrentModels();
2501
+ let needsUpdate = false;
2502
+ for (const [provider, currentModel] of Object.entries(currentModels)) {
2503
+ if (currentModel === modelName) {
2504
+ currentModels[provider] = models[0];
2505
+ needsUpdate = true;
2506
+ }
2507
+ }
2508
+
2509
+ if (needsUpdate) {
2510
+ writeCurrentModels(currentModels);
2511
+ }
2512
+
2513
+ if (!silent) {
2514
+ console.log('✓ 已删除模型:', modelName);
2515
+ console.log();
2516
+ }
2517
+ }
2518
+
2519
+ // 脱敏 key
2520
+ function maskKey(key) {
2521
+ if (!key) return '';
2522
+ if (key.length <= 8) return '****';
2523
+ return key.substring(0, 4) + '...' + key.substring(key.length - 4);
2524
+ }
2525
+
2526
+ // 应用到系统环境变量
2527
+ function applyToSystemEnv(config = {}) {
2528
+ try {
2529
+ const apiKey = config.apiKey || '';
2530
+
2531
+ // Windows 使用 setx 命令设置用户环境变量
2532
+ if (process.platform === 'win32') {
2533
+ const envVars = [
2534
+ ['ANTHROPIC_API_KEY', apiKey],
2535
+ ['ANTHROPIC_AUTH_TOKEN', apiKey],
2536
+ ['ANTHROPIC_BASE_URL', config.baseUrl || 'https://open.bigmodel.cn/api/anthropic'],
2537
+ ['CLAUDE_CODE_USE_KEY', '1'],
2538
+ ['ANTHROPIC_MODEL', config.model || 'glm-4.7']
2539
+ ];
2540
+
2541
+ const errors = [];
2542
+ for (const [key, value] of envVars) {
2543
+ try {
2544
+ // 转义值中的双引号,防止命令注入
2545
+ const safeValue = value.replace(/"/g, '""');
2546
+ execSync(`setx ${key} "${safeValue}"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
2547
+ } catch (e) {
2548
+ errors.push(`${key}: ${e.message || '设置失败'}`);
2549
+ }
2550
+ }
2551
+
2552
+ if (errors.length > 0) {
2553
+ return {
2554
+ success: false,
2555
+ mode: 'env-vars',
2556
+ error: `部分环境变量设置失败:\n${errors.join('\n')}`
2557
+ };
2558
+ }
2559
+ return {
2560
+ success: true,
2561
+ mode: 'env-vars',
2562
+ updatedKeys: envVars.map(([key]) => key)
2563
+ };
2564
+ } else {
2565
+ return { success: false, mode: 'env-vars', error: '仅支持 Windows 系统' };
2566
+ }
2567
+ } catch (e) {
2568
+ return { success: false, mode: 'env-vars', error: e.message };
2569
+ }
2570
+ }
2571
+
2572
+ // 应用到 Claude Code settings.json(跨平台)
2573
+ function applyToClaudeSettings(config = {}) {
2574
+ try {
2575
+ const apiKey = (config.apiKey || '').trim();
2576
+ if (!apiKey) {
2577
+ return { success: false, mode: 'settings-file', error: '请先输入 API Key' };
2578
+ }
2579
+
2580
+ const baseUrl = (config.baseUrl || 'https://open.bigmodel.cn/api/anthropic').trim();
2581
+ const model = (config.model || 'glm-4.7').trim();
2582
+ const readResult = readJsonObjectFromFile(CLAUDE_SETTINGS_FILE, {});
2583
+ if (!readResult.ok) {
2584
+ return { success: false, mode: 'settings-file', error: readResult.error };
2585
+ }
2586
+
2587
+ const currentSettings = readResult.data;
2588
+ const currentEnv = (currentSettings.env && typeof currentSettings.env === 'object' && !Array.isArray(currentSettings.env))
2589
+ ? currentSettings.env
2590
+ : {};
2591
+
2592
+ const nextEnv = {
2593
+ ...currentEnv,
2594
+ ANTHROPIC_API_KEY: apiKey,
2595
+ ANTHROPIC_AUTH_TOKEN: apiKey,
2596
+ ANTHROPIC_BASE_URL: baseUrl,
2597
+ ANTHROPIC_MODEL: model,
2598
+ CLAUDE_CODE_USE_KEY: '1'
2599
+ };
2600
+
2601
+ const nextSettings = {
2602
+ ...currentSettings,
2603
+ env: nextEnv
2604
+ };
2605
+
2606
+ ensureDir(CLAUDE_DIR);
2607
+ const backupPath = backupFileIfNeededOnce(CLAUDE_SETTINGS_FILE);
2608
+ writeJsonAtomic(CLAUDE_SETTINGS_FILE, nextSettings);
2609
+
2610
+ const result = {
2611
+ success: true,
2612
+ mode: 'settings-file',
2613
+ targetPath: CLAUDE_SETTINGS_FILE,
2614
+ updatedKeys: [
2615
+ 'env.ANTHROPIC_API_KEY',
2616
+ 'env.ANTHROPIC_AUTH_TOKEN',
2617
+ 'env.ANTHROPIC_BASE_URL',
2618
+ 'env.ANTHROPIC_MODEL',
2619
+ 'env.CLAUDE_CODE_USE_KEY'
2620
+ ]
2621
+ };
2622
+ if (backupPath) {
2623
+ result.backupPath = backupPath;
2624
+ }
2625
+ return result;
2626
+ } catch (e) {
2627
+ return {
2628
+ success: false,
2629
+ mode: 'settings-file',
2630
+ error: e.message || '应用 Claude 配置失败'
2631
+ };
2632
+ }
2633
+ }
2634
+
2635
+ // 多线程压缩
2636
+ function cmdZip(targetPath, options = {}) {
2637
+ if (!targetPath) {
2638
+ console.error('用法: codexmate zip <文件或文件夹路径> [--max:压缩级别]');
2639
+ console.log('\n示例:');
2640
+ console.log(' codexmate zip ./myproject');
2641
+ console.log(' codexmate zip ./myproject --max:9');
2642
+ console.log(' codexmate zip D:/data/folder --max:1');
2643
+ console.log('\n压缩级别: 0(仅存储) ~ 9(极限压缩), 默认: 5');
2644
+ process.exit(1);
2645
+ }
2646
+
2647
+ const absPath = path.resolve(targetPath);
2648
+ if (!fs.existsSync(absPath)) {
2649
+ console.error('错误: 路径不存在:', absPath);
2650
+ process.exit(1);
2651
+ }
2652
+
2653
+ const compressionLevel = options.max !== undefined ? options.max : 5;
2654
+ if (compressionLevel < 0 || compressionLevel > 9) {
2655
+ console.error('错误: 压缩级别必须在 0-9 之间');
2656
+ process.exit(1);
2657
+ }
2658
+
2659
+ // 生成输出文件名
2660
+ const baseName = path.basename(absPath);
2661
+ const outputDir = path.dirname(absPath);
2662
+ const outputPath = path.join(outputDir, `${baseName}.zip`);
2663
+
2664
+ // 查找 7-Zip
2665
+ const sevenZipPaths = [
2666
+ 'C:\\Program Files\\7-Zip\\7z.exe',
2667
+ 'C:\\Program Files (x86)\\7-Zip\\7z.exe',
2668
+ '7z'
2669
+ ];
2670
+
2671
+ let sevenZipExe = null;
2672
+ for (const p of sevenZipPaths) {
2673
+ try {
2674
+ if (p === '7z') {
2675
+ execSync('7z --help', { stdio: 'ignore' });
2676
+ sevenZipExe = '7z';
2677
+ break;
2678
+ } else if (fs.existsSync(p)) {
2679
+ sevenZipExe = p;
2680
+ break;
2681
+ }
2682
+ } catch (e) {}
2683
+ }
2684
+
2685
+ if (!sevenZipExe) {
2686
+ console.error('错误: 未找到 7-Zip,请先安装 7-Zip');
2687
+ console.log('下载地址: https://www.7-zip.org/');
2688
+ process.exit(1);
2689
+ }
2690
+
2691
+ console.log('\n压缩配置:');
2692
+ console.log(' 源路径:', absPath);
2693
+ console.log(' 输出文件:', outputPath);
2694
+ console.log(' 压缩级别:', compressionLevel);
2695
+ console.log(' 多线程: 启用');
2696
+ console.log('\n开始压缩...\n');
2697
+
2698
+ try {
2699
+ const cmd = `"${sevenZipExe}" a -tzip -mmt=on -mx=${compressionLevel} "${outputPath}" "${absPath}"`;
2700
+ const result = execSync(cmd, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
2701
+
2702
+ // 解析输出获取文件信息
2703
+ const sizeMatch = result.match(/Archive size:\s*(\d+)\s*bytes/);
2704
+ const filesMatch = result.match(/(\d+)\s*files/);
2705
+
2706
+ console.log('✓ 压缩完成!');
2707
+ console.log(' 输出文件:', outputPath);
2708
+ if (sizeMatch) {
2709
+ const sizeBytes = parseInt(sizeMatch[1]);
2710
+ const sizeMB = (sizeBytes / 1024 / 1024).toFixed(2);
2711
+ console.log(' 压缩大小:', sizeMB, 'MB');
2712
+ }
2713
+ if (filesMatch) {
2714
+ console.log(' 文件数量:', filesMatch[1]);
2715
+ }
2716
+ console.log();
2717
+ } catch (e) {
2718
+ console.error('压缩失败:', e.message);
2719
+ process.exit(1);
2720
+ }
2721
+ }
2722
+
2723
+ // 多线程解压
2724
+ function cmdUnzip(zipPath, outputDir) {
2725
+ if (!zipPath) {
2726
+ console.error('用法: codexmate unzip <zip文件路径> [输出目录]');
2727
+ console.log('\n示例:');
2728
+ console.log(' codexmate unzip ./archive.zip');
2729
+ console.log(' codexmate unzip ./archive.zip ./output');
2730
+ console.log(' codexmate unzip D:/data/file.zip D:/extracted');
2731
+ process.exit(1);
2732
+ }
2733
+
2734
+ const absZipPath = path.resolve(zipPath);
2735
+ if (!fs.existsSync(absZipPath)) {
2736
+ console.error('错误: 文件不存在:', absZipPath);
2737
+ process.exit(1);
2738
+ }
2739
+
2740
+ if (!absZipPath.toLowerCase().endsWith('.zip')) {
2741
+ console.error('错误: 仅支持 .zip 文件');
2742
+ process.exit(1);
2743
+ }
2744
+
2745
+ // 默认输出目录:zip文件同级目录下同名文件夹
2746
+ const baseName = path.basename(absZipPath, '.zip');
2747
+ const defaultOutputDir = path.join(path.dirname(absZipPath), baseName);
2748
+ const absOutputDir = outputDir ? path.resolve(outputDir) : defaultOutputDir;
2749
+
2750
+ // 查找 7-Zip
2751
+ const sevenZipPaths = [
2752
+ 'C:\\Program Files\\7-Zip\\7z.exe',
2753
+ 'C:\\Program Files (x86)\\7-Zip\\7z.exe',
2754
+ '7z'
2755
+ ];
2756
+
2757
+ let sevenZipExe = null;
2758
+ for (const p of sevenZipPaths) {
2759
+ try {
2760
+ if (p === '7z') {
2761
+ execSync('7z --help', { stdio: 'ignore' });
2762
+ sevenZipExe = '7z';
2763
+ break;
2764
+ } else if (fs.existsSync(p)) {
2765
+ sevenZipExe = p;
2766
+ break;
2767
+ }
2768
+ } catch (e) {}
2769
+ }
2770
+
2771
+ if (!sevenZipExe) {
2772
+ console.error('错误: 未找到 7-Zip,请先安装 7-Zip');
2773
+ console.log('下载地址: https://www.7-zip.org/');
2774
+ process.exit(1);
2775
+ }
2776
+
2777
+ console.log('\n解压配置:');
2778
+ console.log(' 源文件:', absZipPath);
2779
+ console.log(' 输出目录:', absOutputDir);
2780
+ console.log(' 多线程: 启用');
2781
+ console.log('\n开始解压...\n');
2782
+
2783
+ try {
2784
+ const cmd = `"${sevenZipExe}" x -mmt=on -o"${absOutputDir}" "${absZipPath}" -y`;
2785
+ const result = execSync(cmd, { encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024 });
2786
+
2787
+ // 解析输出获取文件信息
2788
+ const filesMatch = result.match(/(\d+)\s*files/);
2789
+
2790
+ console.log('✓ 解压完成!');
2791
+ console.log(' 输出目录:', absOutputDir);
2792
+ if (filesMatch) {
2793
+ console.log(' 文件数量:', filesMatch[1]);
2794
+ }
2795
+ console.log();
2796
+ } catch (e) {
2797
+ console.error('解压失败:', e.message);
2798
+ process.exit(1);
2799
+ }
2800
+ }
2801
+
2802
+ // 打开 Web UI
2803
+ function cmdStart() {
2804
+ const htmlPath = path.join(__dirname, 'web-ui.html');
2805
+ if (!fs.existsSync(htmlPath)) {
2806
+ console.error('错误: web-ui.html 不存在');
2807
+ process.exit(1);
2808
+ }
2809
+
2810
+ const server = http.createServer((req, res) => {
2811
+ if (req.url === '/api') {
2812
+ let body = '';
2813
+ req.on('data', chunk => body += chunk);
2814
+ req.on('end', async () => {
2815
+ try {
2816
+ const { action, params } = JSON.parse(body);
2817
+ let result;
2818
+
2819
+ switch (action) {
2820
+ case 'status':
2821
+ const statusConfigResult = readConfigOrVirtualDefault();
2822
+ const config = statusConfigResult.config;
2823
+ result = {
2824
+ provider: config.model_provider || '未设置',
2825
+ model: config.model || '未设置',
2826
+ configReady: !statusConfigResult.isVirtual,
2827
+ configNotice: statusConfigResult.reason || '',
2828
+ initNotice: consumeInitNotice()
2829
+ };
2830
+ break;
2831
+ case 'list':
2832
+ const listConfigResult = readConfigOrVirtualDefault();
2833
+ const listConfig = listConfigResult.config;
2834
+ const providers = listConfig.model_providers || {};
2835
+ const current = listConfig.model_provider;
2836
+ result = {
2837
+ configReady: !listConfigResult.isVirtual,
2838
+ providers: Object.entries(providers).map(([name, p]) => ({
2839
+ name,
2840
+ url: p.base_url || '',
2841
+ key: maskKey(p.preferred_auth_method || ''),
2842
+ hasKey: !!(p.preferred_auth_method && p.preferred_auth_method.trim()),
2843
+ current: name === current
2844
+ }))
2845
+ };
2846
+ break;
2847
+ case 'models':
2848
+ result = { models: readModels() };
2849
+ break;
2850
+ case 'get-config-template':
2851
+ result = getConfigTemplate(params || {});
2852
+ break;
2853
+ case 'apply-config-template':
2854
+ result = applyConfigTemplate(params || {});
2855
+ break;
2856
+ case 'get-agents-file':
2857
+ result = readAgentsFile(params || {});
2858
+ break;
2859
+ case 'apply-agents-file':
2860
+ result = applyAgentsFile(params || {});
2861
+ break;
2862
+ case 'switch':
2863
+ case 'use':
2864
+ case 'add':
2865
+ case 'delete':
2866
+ case 'update':
2867
+ result = { error: 'Codex 配置改动已切换为模板确认模式,请使用模板编辑器并手动确认应用。' };
2868
+ break;
2869
+ case 'add-model':
2870
+ cmdAddModel(params.model, true);
2871
+ result = { success: true };
2872
+ break;
2873
+ case 'delete-model':
2874
+ cmdDeleteModel(params.model, true);
2875
+ result = { success: true };
2876
+ break;
2877
+ case 'apply-claude-config':
2878
+ result = applyToClaudeSettings(params.config);
2879
+ break;
2880
+ case 'apply-env':
2881
+ result = applyToSystemEnv(params.config);
2882
+ break;
2883
+ case 'export-config':
2884
+ result = {
2885
+ data: buildExportPayload(!!params.includeKeys)
2886
+ };
2887
+ break;
2888
+ case 'import-config':
2889
+ result = importConfigData(params.payload, params.options || {});
2890
+ break;
2891
+ case 'speed-test': {
2892
+ const target = resolveSpeedTestTarget(params);
2893
+ if (target.error) {
2894
+ result = { error: target.error };
2895
+ break;
2896
+ }
2897
+ result = await runSpeedTest(target.url, target.apiKey);
2898
+ break;
2899
+ }
2900
+ case 'list-sessions':
2901
+ result = {
2902
+ sessions: listAllSessions(params)
2903
+ };
2904
+ break;
2905
+ case 'list-session-paths':
2906
+ result = {
2907
+ paths: listSessionPaths(params)
2908
+ };
2909
+ break;
2910
+ case 'export-session':
2911
+ result = await exportSessionData(params);
2912
+ break;
2913
+ case 'session-detail':
2914
+ result = await readSessionDetail(params);
2915
+ break;
2916
+ default:
2917
+ result = { error: '未知操作' };
2918
+ }
2919
+
2920
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2921
+ res.end(JSON.stringify(result));
2922
+ } catch (e) {
2923
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2924
+ res.end(JSON.stringify({ error: e.message }));
2925
+ }
2926
+ });
2927
+ } else {
2928
+ const html = fs.readFileSync(htmlPath, 'utf-8');
2929
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
2930
+ res.end(html);
2931
+ }
2932
+ });
2933
+
2934
+ server.listen(PORT, () => {
2935
+ console.log('\n✓ Web UI 已启动: http://localhost:' + PORT);
2936
+ console.log(' 按 Ctrl+C 退出\n');
2937
+
2938
+ // 打开浏览器
2939
+ const platform = process.platform;
2940
+ let command;
2941
+ const url = `http://localhost:${PORT}`;
2942
+
2943
+ if (platform === 'win32') {
2944
+ command = `start "" "${url}"`;
2945
+ } else if (platform === 'darwin') {
2946
+ command = `open "${url}"`;
2947
+ } else {
2948
+ command = `xdg-open "${url}"`;
2949
+ }
2950
+
2951
+ exec(command, (error) => {
2952
+ if (error) console.warn('无法自动打开浏览器,请手动访问:', url);
2953
+ });
2954
+ });
2955
+ }
2956
+
2957
+ // ============================================================================
2958
+ // 主程序
2959
+ // ============================================================================
2960
+ function main() {
2961
+ const bootstrap = ensureManagedConfigBootstrap();
2962
+ if (bootstrap && bootstrap.notice) {
2963
+ console.log(`\n[Init] ${bootstrap.notice}`);
2964
+ }
2965
+
2966
+ const args = process.argv.slice(2);
2967
+ if (args.length === 0) {
2968
+ console.log('\nCodex Mate - Codex 提供商管理工具');
2969
+ console.log('\n用法:');
2970
+ console.log(' codexmate status 显示当前状态');
2971
+ console.log(' codexmate list 列出所有提供商');
2972
+ console.log(' codexmate models 列出所有模型');
2973
+ console.log(' codexmate switch <名称> 切换提供商');
2974
+ console.log(' codexmate use <模型> 切换模型');
2975
+ console.log(' codexmate add <名称> <URL> [密钥]');
2976
+ console.log(' codexmate delete <名称> 删除提供商');
2977
+ console.log(' codexmate add-model <模型> 添加模型');
2978
+ console.log(' codexmate delete-model <模型> 删除模型');
2979
+ console.log(' codexmate start 启动 Web 界面');
2980
+ console.log(' codexmate zip <路径> [--max:级别] 多线程压缩');
2981
+ console.log(' codexmate unzip <zip文件> [输出目录] 多线程解压');
2982
+ console.log('');
2983
+ process.exit(0);
2984
+ }
2985
+
2986
+ const command = args[0];
2987
+
2988
+ switch (command) {
2989
+ case 'status': cmdStatus(); break;
2990
+ case 'list': cmdList(); break;
2991
+ case 'models': cmdModels(); break;
2992
+ case 'switch': cmdSwitch(args[1]); break;
2993
+ case 'use': cmdUseModel(args[1]); break;
2994
+ case 'add': cmdAdd(args[1], args[2], args[3]); break;
2995
+ case 'delete': cmdDelete(args[1]); break;
2996
+ case 'add-model': cmdAddModel(args[1]); break;
2997
+ case 'delete-model': cmdDeleteModel(args[1]); break;
2998
+ case 'start': cmdStart(); break;
2999
+ case 'zip': {
3000
+ // 解析 --max:N 参数
3001
+ const zipOptions = {};
3002
+ let targetPath = null;
3003
+ for (let i = 1; i < args.length; i++) {
3004
+ const arg = args[i];
3005
+ if (arg.startsWith('--max:')) {
3006
+ zipOptions.max = parseInt(arg.substring(6), 10);
3007
+ } else if (!targetPath) {
3008
+ targetPath = arg;
3009
+ }
3010
+ }
3011
+ cmdZip(targetPath, zipOptions);
3012
+ break;
3013
+ }
3014
+ case 'unzip': cmdUnzip(args[1], args[2]); break;
3015
+ default:
3016
+ console.error('错误: 未知命令:', command);
3017
+ console.log('运行 "codexmate" 查看帮助');
3018
+ process.exit(1);
3019
+ }
3020
+ }
3021
+
3022
+ main();