codexmate 0.0.25 → 0.0.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +11 -3
  2. package/README.zh.md +10 -2
  3. package/cli/builtin-proxy.js +315 -95
  4. package/cli/openai-bridge.js +99 -5
  5. package/cli/session-convert-args.js +65 -0
  6. package/cli/session-convert-io.js +82 -0
  7. package/cli/session-convert.js +43 -0
  8. package/cli.js +547 -32
  9. package/package.json +74 -74
  10. package/web-ui/app.js +24 -2
  11. package/web-ui/logic.session-convert.mjs +70 -0
  12. package/web-ui/logic.sessions.mjs +151 -0
  13. package/web-ui/modules/app.computed.dashboard.mjs +44 -1
  14. package/web-ui/modules/app.computed.session.mjs +336 -12
  15. package/web-ui/modules/app.methods.claude-config.mjs +11 -1
  16. package/web-ui/modules/app.methods.codex-config.mjs +76 -0
  17. package/web-ui/modules/app.methods.navigation.mjs +51 -3
  18. package/web-ui/modules/app.methods.session-actions.mjs +55 -3
  19. package/web-ui/modules/app.methods.session-browser.mjs +270 -3
  20. package/web-ui/modules/app.methods.session-timeline.mjs +34 -3
  21. package/web-ui/modules/app.methods.session-trash.mjs +16 -1
  22. package/web-ui/modules/app.methods.startup-claude.mjs +234 -125
  23. package/web-ui/modules/i18n.dict.mjs +76 -0
  24. package/web-ui/partials/index/panel-config-claude.html +12 -4
  25. package/web-ui/partials/index/panel-sessions.html +33 -10
  26. package/web-ui/partials/index/panel-settings.html +16 -0
  27. package/web-ui/partials/index/panel-usage.html +95 -85
  28. package/web-ui/session-helpers.mjs +3 -0
  29. package/web-ui/styles/base-theme.css +29 -25
  30. package/web-ui/styles/layout-shell.css +1 -1
  31. package/web-ui/styles/navigation-panels.css +9 -9
  32. package/web-ui/styles/sessions-list.css +17 -0
  33. package/web-ui/styles/sessions-toolbar-trash.css +62 -0
  34. package/web-ui/styles/sessions-usage.css +211 -83
  35. package/web-ui/styles/settings-panel.css +19 -0
package/cli.js CHANGED
@@ -115,6 +115,7 @@ const {
115
115
  const {
116
116
  createZipCommandController
117
117
  } = require('./cli/zip-commands');
118
+ const { cmdConvertSession } = require('./cli/session-convert');
118
119
  const {
119
120
  getCodexSkillsDir,
120
121
  getClaudeSkillsDir,
@@ -196,6 +197,11 @@ const CLAUDE_MD_FILE_NAME = 'CLAUDE.md';
196
197
  const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
197
198
  const CODEBUDDY_DIR = path.join(os.homedir(), '.codebuddy');
198
199
  const CODEBUDDY_PROJECTS_DIR = path.join(CODEBUDDY_DIR, 'projects');
200
+ const CODEXMATE_DIR = path.join(os.homedir(), '.codexmate');
201
+ const CODEXMATE_SESSIONS_DIR = path.join(CODEXMATE_DIR, 'sessions');
202
+ const CODEXMATE_DERIVED_SESSIONS_DIR = path.join(CODEXMATE_SESSIONS_DIR, 'derived');
203
+ const CODEXMATE_DERIVED_CODEX_DIR = path.join(CODEXMATE_DERIVED_SESSIONS_DIR, 'codex');
204
+ const CODEXMATE_DERIVED_CLAUDE_DIR = path.join(CODEXMATE_DERIVED_SESSIONS_DIR, 'claude');
199
205
  const GEMINI_DIR = path.join(os.homedir(), '.gemini');
200
206
  const GEMINI_TMP_DIR = path.join(GEMINI_DIR, 'tmp');
201
207
  const RECENT_CONFIGS_FILE = path.join(CONFIG_DIR, 'recent-configs.json');
@@ -216,6 +222,7 @@ const DEFAULT_MODELS = ['gpt-5.3-codex', 'gpt-5.1-codex-max', 'gpt-4-turbo', 'gp
216
222
  const SPEED_TEST_TIMEOUT_MS = 8000;
217
223
  const MAX_SESSION_LIST_SIZE = 300;
218
224
  const MAX_SESSION_TRASH_LIST_SIZE = 500;
225
+ const DEFAULT_SESSION_TRASH_RETENTION_DAYS = 30;
219
226
  const MAX_EXPORT_MESSAGES = 1000;
220
227
  const DEFAULT_SESSION_DETAIL_MESSAGES = 300;
221
228
  const MAX_SESSION_DETAIL_MESSAGES = 1000;
@@ -1308,6 +1315,58 @@ function getCodeBuddyProjectsDir() {
1308
1315
  return resolveExistingDir(candidates, CODEBUDDY_PROJECTS_DIR);
1309
1316
  }
1310
1317
 
1318
+ function getCodexmateDerivedSessionsRoot(target) {
1319
+ if (target === 'claude') {
1320
+ return CODEXMATE_DERIVED_CLAUDE_DIR;
1321
+ }
1322
+ return CODEXMATE_DERIVED_CODEX_DIR;
1323
+ }
1324
+
1325
+ function normalizeSessionDerivedTarget(value) {
1326
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
1327
+ if (normalized === 'codex' || normalized === 'claude') {
1328
+ return normalized;
1329
+ }
1330
+ return '';
1331
+ }
1332
+
1333
+ function normalizeSessionDerivedSource(value) {
1334
+ return normalizeSessionDerivedTarget(value);
1335
+ }
1336
+
1337
+ function buildSessionDerivedSourceKey(source, sessionId, filePath) {
1338
+ const baseSource = normalizeSessionDerivedSource(source);
1339
+ const id = typeof sessionId === 'string' ? sessionId.trim() : '';
1340
+ const pathValue = typeof filePath === 'string' ? filePath.trim() : '';
1341
+ const seed = `${baseSource}|${id}|${pathValue}`;
1342
+ return crypto.createHash('sha1').update(seed).digest('hex').slice(0, 16);
1343
+ }
1344
+
1345
+ function formatCompactTimestamp(value = Date.now()) {
1346
+ const stamp = new Date(value);
1347
+ const year = String(stamp.getFullYear());
1348
+ const month = String(stamp.getMonth() + 1).padStart(2, '0');
1349
+ const day = String(stamp.getDate()).padStart(2, '0');
1350
+ const hour = String(stamp.getHours()).padStart(2, '0');
1351
+ const minute = String(stamp.getMinutes()).padStart(2, '0');
1352
+ const second = String(stamp.getSeconds()).padStart(2, '0');
1353
+ return `${year}${month}${day}-${hour}${minute}${second}`;
1354
+ }
1355
+
1356
+ function buildDerivedSessionId(baseId) {
1357
+ const safeBase = typeof baseId === 'string' && baseId.trim() ? baseId.trim() : 'session';
1358
+ const normalized = safeBase.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64) || 'session';
1359
+ const suffix = crypto.randomBytes(3).toString('hex');
1360
+ return `${normalized}-${formatCompactTimestamp()}-${suffix}`;
1361
+ }
1362
+
1363
+ function buildDerivedSessionOutputDir(target, source, sourceKey) {
1364
+ const targetRoot = getCodexmateDerivedSessionsRoot(target);
1365
+ const safeSource = normalizeSessionDerivedSource(source) || 'codex';
1366
+ const safeKey = typeof sourceKey === 'string' && sourceKey.trim() ? sourceKey.trim() : 'unknown';
1367
+ return path.join(targetRoot, safeSource, safeKey);
1368
+ }
1369
+
1311
1370
  function readModelsCacheEntry(cacheKey) {
1312
1371
  if (!cacheKey) return null;
1313
1372
  const entry = g_modelsCache.get(cacheKey);
@@ -2869,6 +2928,46 @@ async function hydrateSessionItemsExactMessageCount(items) {
2869
2928
  });
2870
2929
  }
2871
2930
 
2931
+ function getSessionExportKeyForApi(item) {
2932
+ const source = item && item.source ? String(item.source).trim() : '';
2933
+ const sessionId = item && item.sessionId ? String(item.sessionId) : '';
2934
+ const filePath = item && item.filePath ? String(item.filePath) : '';
2935
+ return `${source || 'unknown'}:${sessionId}:${filePath}`;
2936
+ }
2937
+
2938
+ async function readSessionMessageCounts(params = {}) {
2939
+ const rawItems = Array.isArray(params.items) ? params.items : [];
2940
+ const rawLimit = Number(params.limit);
2941
+ const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(Math.floor(rawLimit), 80)) : 40;
2942
+ const items = rawItems.slice(0, limit);
2943
+ const hydrated = await mapWithConcurrency(items, 4, async (item) => {
2944
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
2945
+ return undefined;
2946
+ }
2947
+ const key = getSessionExportKeyForApi(item);
2948
+ const source = item.source === 'claude'
2949
+ ? 'claude'
2950
+ : (item.source === 'codex'
2951
+ ? 'codex'
2952
+ : (item.source === 'gemini' ? 'gemini' : (item.source === 'codebuddy' ? 'codebuddy' : '')));
2953
+ const filePath = typeof item.filePath === 'string' ? item.filePath : '';
2954
+ if (!source || !filePath || !fs.existsSync(filePath)) {
2955
+ return { key };
2956
+ }
2957
+ const exactMessageCount = await countConversationMessagesInFile(filePath, source);
2958
+ if (!Number.isFinite(Number(exactMessageCount))) {
2959
+ return { key };
2960
+ }
2961
+ return {
2962
+ key,
2963
+ messageCount: Math.max(0, Math.floor(Number(exactMessageCount)))
2964
+ };
2965
+ });
2966
+ return {
2967
+ items: hydrated.filter(Boolean)
2968
+ };
2969
+ }
2970
+
2872
2971
  function sortSessionsByUpdatedAt(items) {
2873
2972
  items.sort((a, b) => {
2874
2973
  const aTime = Date.parse(a.updatedAt || '') || 0;
@@ -3279,6 +3378,57 @@ function collectRecentJsonlFiles(rootDir, options = {}) {
3279
3378
  return filesMeta.slice(0, returnCount).map(item => item.filePath);
3280
3379
  }
3281
3380
 
3381
+ function collectRecentJsonlFilesFromRoots(rootDirs, options = {}) {
3382
+ const roots = Array.isArray(rootDirs)
3383
+ ? rootDirs.filter((dirPath) => typeof dirPath === 'string' && dirPath.trim() && fs.existsSync(dirPath.trim()))
3384
+ : [];
3385
+ if (roots.length === 0) {
3386
+ return [];
3387
+ }
3388
+
3389
+ const returnCount = Math.max(1, Number(options.returnCount) || 1);
3390
+ const maxFilesScanned = Math.max(returnCount, Number(options.maxFilesScanned) || 2000);
3391
+ const ignoreSubPath = typeof options.ignoreSubPath === 'string' ? options.ignoreSubPath : '';
3392
+ const stack = roots.map((dirPath) => dirPath.trim());
3393
+ const filesMeta = [];
3394
+ let scanned = 0;
3395
+
3396
+ while (stack.length > 0 && scanned < maxFilesScanned) {
3397
+ const dir = stack.pop();
3398
+ let entries = [];
3399
+ try {
3400
+ entries = fs.readdirSync(dir, { withFileTypes: true });
3401
+ } catch (_) {
3402
+ continue;
3403
+ }
3404
+
3405
+ for (const entry of entries) {
3406
+ const fullPath = path.join(dir, entry.name);
3407
+ if (entry.isDirectory()) {
3408
+ stack.push(fullPath);
3409
+ continue;
3410
+ }
3411
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) {
3412
+ continue;
3413
+ }
3414
+ if (ignoreSubPath && fullPath.includes(ignoreSubPath)) {
3415
+ continue;
3416
+ }
3417
+ scanned += 1;
3418
+ try {
3419
+ const stat = fs.statSync(fullPath);
3420
+ filesMeta.push({ filePath: fullPath, mtimeMs: stat.mtimeMs || 0 });
3421
+ } catch (_) {}
3422
+ if (scanned >= maxFilesScanned) {
3423
+ break;
3424
+ }
3425
+ }
3426
+ }
3427
+
3428
+ filesMeta.sort((a, b) => b.mtimeMs - a.mtimeMs);
3429
+ return filesMeta.slice(0, returnCount).map(item => item.filePath);
3430
+ }
3431
+
3282
3432
  function getSessionListCache(cacheKey, forceRefresh = false) {
3283
3433
  if (forceRefresh) {
3284
3434
  g_sessionListCache.delete(cacheKey);
@@ -4394,7 +4544,7 @@ function listCodexSessions(limit, options = {}) {
4394
4544
  const titleReadBytes = Number.isFinite(Number(options.titleReadBytes))
4395
4545
  ? Math.max(1024, Math.floor(Number(options.titleReadBytes)))
4396
4546
  : SESSION_TITLE_READ_BYTES;
4397
- const files = collectRecentJsonlFiles(codexSessionsDir, {
4547
+ const files = collectRecentJsonlFilesFromRoots([codexSessionsDir, getCodexmateDerivedSessionsRoot('codex')], {
4398
4548
  returnCount: scanCount,
4399
4549
  maxFilesScanned
4400
4550
  });
@@ -4406,7 +4556,10 @@ function listCodexSessions(limit, options = {}) {
4406
4556
  titleReadBytes
4407
4557
  });
4408
4558
  if (summary) {
4409
- sessions.push(summary);
4559
+ sessions.push({
4560
+ ...summary,
4561
+ derived: isDerivedSessionFile(filePath)
4562
+ });
4410
4563
  }
4411
4564
 
4412
4565
  if (sessions.length >= targetCount) {
@@ -4419,7 +4572,10 @@ function listCodexSessions(limit, options = {}) {
4419
4572
 
4420
4573
  function listClaudeSessions(limit, options = {}) {
4421
4574
  const claudeProjectsDir = getClaudeProjectsDir();
4422
- if (!fs.existsSync(claudeProjectsDir)) {
4575
+ const derivedClaudeRoot = getCodexmateDerivedSessionsRoot('claude');
4576
+ const hasProjectsDir = fs.existsSync(claudeProjectsDir);
4577
+ const hasDerivedDir = fs.existsSync(derivedClaudeRoot);
4578
+ if (!hasProjectsDir && !hasDerivedDir) {
4423
4579
  return [];
4424
4580
  }
4425
4581
 
@@ -4447,12 +4603,14 @@ function listClaudeSessions(limit, options = {}) {
4447
4603
 
4448
4604
  const sessions = [];
4449
4605
  let projectDirs = [];
4450
- try {
4451
- projectDirs = fs.readdirSync(claudeProjectsDir, { withFileTypes: true })
4452
- .filter(entry => entry.isDirectory())
4453
- .map(entry => path.join(claudeProjectsDir, entry.name));
4454
- } catch (e) {
4455
- projectDirs = [];
4606
+ if (hasProjectsDir) {
4607
+ try {
4608
+ projectDirs = fs.readdirSync(claudeProjectsDir, { withFileTypes: true })
4609
+ .filter(entry => entry.isDirectory())
4610
+ .map(entry => path.join(claudeProjectsDir, entry.name));
4611
+ } catch (e) {
4612
+ projectDirs = [];
4613
+ }
4456
4614
  }
4457
4615
 
4458
4616
  for (const projectDir of projectDirs) {
@@ -4583,6 +4741,7 @@ function listClaudeSessions(limit, options = {}) {
4583
4741
  models,
4584
4742
  __messageCountExact: quickRecords.length > 0 && isSessionSummaryMessageCountExact(fileStat, summaryReadBytes),
4585
4743
  filePath,
4744
+ derived: isDerivedSessionFile(filePath),
4586
4745
  keywords,
4587
4746
  capabilities
4588
4747
  });
@@ -4609,7 +4768,10 @@ function listClaudeSessions(limit, options = {}) {
4609
4768
  titleReadBytes
4610
4769
  });
4611
4770
  if (summary) {
4612
- sessions.push(summary);
4771
+ sessions.push({
4772
+ ...summary,
4773
+ derived: isDerivedSessionFile(filePath)
4774
+ });
4613
4775
  }
4614
4776
 
4615
4777
  if (sessions.length >= targetCount) {
@@ -4618,6 +4780,28 @@ function listClaudeSessions(limit, options = {}) {
4618
4780
  }
4619
4781
  }
4620
4782
 
4783
+ if (fs.existsSync(derivedClaudeRoot)) {
4784
+ const seen = new Set(sessions.map((item) => (item && item.filePath ? item.filePath : '')).filter(Boolean));
4785
+ const derivedFiles = collectRecentJsonlFiles(derivedClaudeRoot, {
4786
+ returnCount: scanCount,
4787
+ maxFilesScanned
4788
+ });
4789
+ for (const filePath of derivedFiles) {
4790
+ if (seen.has(filePath)) continue;
4791
+ const summary = parseClaudeSessionSummary(filePath, {
4792
+ summaryReadBytes,
4793
+ titleReadBytes
4794
+ });
4795
+ if (summary) {
4796
+ sessions.push({
4797
+ ...summary,
4798
+ derived: isDerivedSessionFile(filePath)
4799
+ });
4800
+ }
4801
+ seen.add(filePath);
4802
+ }
4803
+ }
4804
+
4621
4805
  return mergeAndLimitSessions(sessions, limit);
4622
4806
  }
4623
4807
 
@@ -4962,19 +5146,25 @@ function resolveSessionFilePath(source, filePath, sessionId) {
4962
5146
  const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy'
4963
5147
  ? source
4964
5148
  : 'codex';
4965
- const root = normalizedSource === 'claude'
4966
- ? getClaudeProjectsDir()
5149
+ const homeDir = process && process.env && process.env.HOME ? process.env.HOME : '';
5150
+ const derivedCodexDir = homeDir ? `${homeDir}/.codexmate/sessions/derived/codex` : '';
5151
+ const derivedClaudeDir = homeDir ? `${homeDir}/.codexmate/sessions/derived/claude` : '';
5152
+ const roots = normalizedSource === 'claude'
5153
+ ? [getClaudeProjectsDir(), derivedClaudeDir]
4967
5154
  : (normalizedSource === 'gemini'
4968
- ? getGeminiTmpDir()
4969
- : (normalizedSource === 'codebuddy' ? getCodeBuddyProjectsDir() : getCodexSessionsDir()));
4970
- if (!root || !fs.existsSync(root)) {
5155
+ ? [getGeminiTmpDir()]
5156
+ : (normalizedSource === 'codebuddy'
5157
+ ? [getCodeBuddyProjectsDir()]
5158
+ : [getCodexSessionsDir(), derivedCodexDir]));
5159
+ const availableRoots = roots.filter((dirPath) => dirPath && fs.existsSync(dirPath));
5160
+ if (availableRoots.length === 0) {
4971
5161
  return '';
4972
5162
  }
4973
5163
 
4974
5164
  if (typeof filePath === 'string' && filePath.trim()) {
4975
5165
  const expandedPath = expandHomePath(filePath.trim());
4976
5166
  const targetPath = expandedPath ? path.resolve(expandedPath) : '';
4977
- if (targetPath && fs.existsSync(targetPath) && isPathInside(targetPath, root)) {
5167
+ if (targetPath && fs.existsSync(targetPath) && availableRoots.some((rootPath) => isPathInside(targetPath, rootPath))) {
4978
5168
  return targetPath;
4979
5169
  }
4980
5170
  }
@@ -4984,7 +5174,7 @@ function resolveSessionFilePath(source, filePath, sessionId) {
4984
5174
  const lookupStore = g_sessionFileLookupCache[normalizedSource];
4985
5175
  if (lookupStore instanceof Map && lookupStore.has(targetId)) {
4986
5176
  const cachedPath = lookupStore.get(targetId);
4987
- if (cachedPath && fs.existsSync(cachedPath) && isPathInside(cachedPath, root)) {
5177
+ if (cachedPath && fs.existsSync(cachedPath) && availableRoots.some((rootPath) => isPathInside(cachedPath, rootPath))) {
4988
5178
  return cachedPath;
4989
5179
  }
4990
5180
  lookupStore.delete(targetId);
@@ -5019,7 +5209,11 @@ function resolveSessionFilePath(source, filePath, sessionId) {
5019
5209
  }
5020
5210
  matchedFile = filesMeta.find(item => path.basename(item, '.json').toLowerCase() === targetId) || '';
5021
5211
  } else {
5022
- const files = collectJsonlFiles(root, 5000);
5212
+ const files = [];
5213
+ for (const rootPath of availableRoots) {
5214
+ files.push(...collectJsonlFiles(rootPath, 5000));
5215
+ if (files.length >= 5000) break;
5216
+ }
5023
5217
  matchedFile = files.find(item => path.basename(item, '.jsonl').toLowerCase() === targetId) || '';
5024
5218
  }
5025
5219
  if (matchedFile && fs.existsSync(matchedFile)) {
@@ -5067,6 +5261,65 @@ function findClaudeSessionIndexPath(sessionFilePath) {
5067
5261
  return '';
5068
5262
  }
5069
5263
 
5264
+ function resolveClaudeProjectDirForCwd(cwd) {
5265
+ const projectsDir = getClaudeProjectsDir();
5266
+ const raw = typeof cwd === 'string' ? cwd.trim() : '';
5267
+ if (!projectsDir || !raw) {
5268
+ return '';
5269
+ }
5270
+ const ignoreCase = process.platform === 'win32';
5271
+ const resolvedCwd = path.resolve(expandHomePath(raw));
5272
+ let entries = [];
5273
+ try {
5274
+ entries = fs.readdirSync(projectsDir, { withFileTypes: true });
5275
+ } catch (_) {
5276
+ entries = [];
5277
+ }
5278
+ for (const entry of entries) {
5279
+ if (!entry || !entry.isDirectory()) continue;
5280
+ const projectDir = path.join(projectsDir, entry.name);
5281
+ const indexPath = path.join(projectDir, 'sessions-index.json');
5282
+ if (!fs.existsSync(indexPath)) continue;
5283
+ const index = readJsonFile(indexPath, null);
5284
+ const originalPathRaw = index && typeof index.originalPath === 'string' ? index.originalPath.trim() : '';
5285
+ if (!originalPathRaw) continue;
5286
+ const resolvedOriginal = path.resolve(expandHomePath(originalPathRaw));
5287
+ if (normalizePathForCompare(resolvedOriginal, { ignoreCase }) === normalizePathForCompare(resolvedCwd, { ignoreCase })) {
5288
+ return projectDir;
5289
+ }
5290
+ }
5291
+ const hash = crypto.createHash('sha1').update(resolvedCwd).digest('hex').slice(0, 12);
5292
+ return path.join(projectsDir, `codexmate-${hash}`);
5293
+ }
5294
+
5295
+ function ensureClaudeSessionsIndex(indexPath, originalPath) {
5296
+ if (!indexPath) return;
5297
+ const resolvedOriginal = typeof originalPath === 'string' && originalPath.trim()
5298
+ ? path.resolve(expandHomePath(originalPath.trim()))
5299
+ : '';
5300
+ const existing = readJsonFile(indexPath, null);
5301
+ const index = existing && typeof existing === 'object' && !Array.isArray(existing)
5302
+ ? { ...existing }
5303
+ : { entries: [] };
5304
+ if (!Array.isArray(index.entries)) {
5305
+ index.entries = [];
5306
+ }
5307
+ if (!index.originalPath && resolvedOriginal) {
5308
+ index.originalPath = resolvedOriginal;
5309
+ }
5310
+ if (!fs.existsSync(indexPath)) {
5311
+ if (!index.originalPath) {
5312
+ index.originalPath = resolvedOriginal || path.dirname(indexPath);
5313
+ }
5314
+ writeJsonAtomic(indexPath, index);
5315
+ return;
5316
+ }
5317
+ if (existing && typeof existing === 'object' && !Array.isArray(existing) && existing.originalPath === index.originalPath) {
5318
+ return;
5319
+ }
5320
+ writeJsonAtomic(indexPath, index);
5321
+ }
5322
+
5070
5323
  const {
5071
5324
  findAvailablePort,
5072
5325
  saveBuiltinProxySettings,
@@ -5349,6 +5602,35 @@ function readSessionTrashEntries(options = {}) {
5349
5602
  return normalizedEntries;
5350
5603
  }
5351
5604
 
5605
+ function purgeExpiredSessionTrashEntries(retentionDays) {
5606
+ const days = Number.isFinite(Number(retentionDays)) && Number(retentionDays) > 0
5607
+ ? Math.floor(Number(retentionDays))
5608
+ : DEFAULT_SESSION_TRASH_RETENTION_DAYS;
5609
+ const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000;
5610
+ const entries = readSessionTrashEntries({ cleanup: false });
5611
+ if (entries.length === 0) {
5612
+ return { purged: 0 };
5613
+ }
5614
+ const remaining = [];
5615
+ let purgedCount = 0;
5616
+ for (const entry of entries) {
5617
+ const deletedAtMs = Date.parse(entry.deletedAt || entry.updatedAt || '') || 0;
5618
+ if (deletedAtMs > 0 && deletedAtMs < cutoffMs) {
5619
+ const trashFilePath = resolveSessionTrashFilePath(entry);
5620
+ if (trashFilePath) {
5621
+ try { fs.unlinkSync(trashFilePath); } catch (_) {}
5622
+ }
5623
+ purgedCount += 1;
5624
+ } else {
5625
+ remaining.push(entry);
5626
+ }
5627
+ }
5628
+ if (purgedCount > 0) {
5629
+ writeSessionTrashEntries(remaining);
5630
+ }
5631
+ return { purged: purgedCount };
5632
+ }
5633
+
5352
5634
  function buildSessionTrashEntry(summary, options = {}) {
5353
5635
  const source = options.source === 'claude' ? 'claude' : 'codex';
5354
5636
  const sessionId = options.sessionId || summary.sessionId || path.basename(options.originalFilePath || summary.filePath || '', '.jsonl');
@@ -5560,6 +5842,9 @@ async function listSessionTrashItems(params = {}) {
5560
5842
  const limit = Number.isFinite(rawLimit)
5561
5843
  ? Math.max(1, Math.min(rawLimit, MAX_SESSION_TRASH_LIST_SIZE))
5562
5844
  : 200;
5845
+ if (params.autoPurge !== false) {
5846
+ purgeExpiredSessionTrashEntries(params.retentionDays);
5847
+ }
5563
5848
  const allEntries = readSessionTrashEntries();
5564
5849
  let items = source === 'codex' || source === 'claude' || source === 'gemini' || source === 'codebuddy'
5565
5850
  ? allEntries.filter((entry) => entry.source === source)
@@ -6122,6 +6407,28 @@ function buildSessionPlainText(messages) {
6122
6407
  return lines.join('\n');
6123
6408
  }
6124
6409
 
6410
+ function getDerivedSessionMetaPath(filePath) {
6411
+ if (!filePath) return '';
6412
+ const base = filePath.toLowerCase().endsWith('.jsonl')
6413
+ ? filePath.slice(0, -5)
6414
+ : filePath;
6415
+ return `${base}.meta.json`;
6416
+ }
6417
+
6418
+ function isDerivedSessionFile(filePath) {
6419
+ const metaPath = getDerivedSessionMetaPath(filePath);
6420
+ if (!metaPath) return false;
6421
+ try {
6422
+ if (fs.existsSync(metaPath)) {
6423
+ return true;
6424
+ }
6425
+ } catch (_) {
6426
+ return false;
6427
+ }
6428
+ const base = path.basename(filePath || '', path.extname(filePath || ''));
6429
+ return /-\d{8}-\d{6}-[0-9a-f]{6}$/i.test(base);
6430
+ }
6431
+
6125
6432
  function resolveStateMaxMessages(state) {
6126
6433
  if (!state || typeof state !== 'object') {
6127
6434
  return MAX_EXPORT_MESSAGES;
@@ -6467,6 +6774,20 @@ async function readSessionDetail(params = {}) {
6467
6774
  sessionId,
6468
6775
  cwd: extracted.cwd || '',
6469
6776
  updatedAt: extracted.updatedAt || '',
6777
+ derived: (() => {
6778
+ try {
6779
+ const metaPath = filePath.toLowerCase().endsWith('.jsonl')
6780
+ ? `${filePath.slice(0, -5)}.meta.json`
6781
+ : `${filePath}.meta.json`;
6782
+ if (fs.existsSync(metaPath)) {
6783
+ return true;
6784
+ }
6785
+ } catch (_) {
6786
+ return false;
6787
+ }
6788
+ const base = path.basename(filePath || '', path.extname(filePath || ''));
6789
+ return /-\d{8}-\d{6}-[0-9a-f]{6}$/i.test(base);
6790
+ })(),
6470
6791
  totalMessages: hasExactTotalMessages ? extracted.totalMessages : null,
6471
6792
  clipped: typeof extracted.clipped === 'boolean'
6472
6793
  ? extracted.clipped
@@ -6494,6 +6815,15 @@ async function readSessionPlain(params = {}) {
6494
6815
  return { error: 'Session file not found' };
6495
6816
  }
6496
6817
 
6818
+ const rawMaxMessages = params.maxMessages;
6819
+ const maxMessages = rawMaxMessages === Infinity || rawMaxMessages === 'all'
6820
+ ? Infinity
6821
+ : (
6822
+ Number.isFinite(Number(rawMaxMessages))
6823
+ ? Math.max(1, Math.floor(Number(rawMaxMessages)))
6824
+ : 50
6825
+ );
6826
+
6497
6827
  let extracted;
6498
6828
  if (source === 'gemini') {
6499
6829
  let json;
@@ -6514,15 +6844,19 @@ async function readSessionPlain(params = {}) {
6514
6844
  const text = extractMessageText(extractGeminiMessageText(entry.content ?? entry.message ?? entry.text));
6515
6845
  if (!text && role !== 'system') continue;
6516
6846
  messages.push({ role, text });
6847
+ if (maxMessages !== Infinity && messages.length >= maxMessages) {
6848
+ break;
6849
+ }
6517
6850
  }
6518
6851
  extracted = {
6519
6852
  sessionId: typeof json.sessionId === 'string' && json.sessionId.trim() ? json.sessionId.trim() : path.basename(filePath, '.json'),
6520
6853
  cwd: typeof json.projectRoot === 'string' ? json.projectRoot : '',
6521
- messages
6854
+ messages,
6855
+ truncated: maxMessages !== Infinity && rawMessages.length > messages.length
6522
6856
  };
6523
6857
  } else {
6524
6858
  try {
6525
- extracted = await extractMessagesFromFile(filePath, source, { maxMessages: Infinity });
6859
+ extracted = await extractMessagesFromFile(filePath, source, { maxMessages });
6526
6860
  } catch (e) {
6527
6861
  extracted = null;
6528
6862
  }
@@ -6536,7 +6870,7 @@ async function readSessionPlain(params = {}) {
6536
6870
  if (fallbackRecords.length === 0) {
6537
6871
  return { error: 'Session file is empty' };
6538
6872
  }
6539
- extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages: Infinity });
6873
+ extracted = extractMessagesFromRecords(fallbackRecords, source, { maxMessages });
6540
6874
  }
6541
6875
  }
6542
6876
 
@@ -6555,7 +6889,8 @@ async function readSessionPlain(params = {}) {
6555
6889
  sessionId,
6556
6890
  title: sessionId,
6557
6891
  filePath,
6558
- text
6892
+ text,
6893
+ clipped: maxMessages !== Infinity && !!(extracted && extracted.truncated)
6559
6894
  };
6560
6895
  }
6561
6896
 
@@ -6663,6 +6998,163 @@ async function exportSessionData(params = {}) {
6663
6998
  };
6664
6999
  }
6665
7000
 
7001
+ async function convertSessionToDerived(params = {}) {
7002
+ const source = normalizeSessionDerivedSource(params.source);
7003
+ const target = normalizeSessionDerivedTarget(params.target || params.to);
7004
+ if (!source || !target) {
7005
+ return { error: 'Invalid source/target' };
7006
+ }
7007
+ if (source === target) {
7008
+ return { error: 'source and target must be different' };
7009
+ }
7010
+
7011
+ const maxMessages = resolveMaxMessagesValue(params.maxMessages, MAX_EXPORT_MESSAGES);
7012
+ const filePath = resolveSessionFilePath(source, getSessionFileArg(params), params.sessionId);
7013
+ if (!filePath) {
7014
+ return { error: 'Session file not found' };
7015
+ }
7016
+
7017
+ let extracted;
7018
+ try {
7019
+ extracted = await extractMessagesFromFile(filePath, source, { maxMessages });
7020
+ } catch (_) {
7021
+ extracted = null;
7022
+ }
7023
+ if (!extracted) {
7024
+ return { error: 'Failed to parse session file' };
7025
+ }
7026
+
7027
+ const baseSessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
7028
+ const derivedSessionId = buildDerivedSessionId(baseSessionId);
7029
+ const sourceKey = buildSessionDerivedSourceKey(source, baseSessionId, filePath);
7030
+ const outputDir = target === 'codex'
7031
+ ? getCodexSessionsDir()
7032
+ : (target === 'claude'
7033
+ ? (resolveClaudeProjectDirForCwd(extracted.cwd || '') || path.join(getClaudeProjectsDir(), 'codexmate-derived'))
7034
+ : buildDerivedSessionOutputDir(target, source, sourceKey));
7035
+ ensureDir(outputDir);
7036
+ const outputPath = path.join(outputDir, `${derivedSessionId}.jsonl`);
7037
+ const metaPath = path.join(outputDir, `${derivedSessionId}.meta.json`);
7038
+
7039
+ const cwd = typeof extracted.cwd === 'string' ? extracted.cwd : '';
7040
+ const resolvedCwd = cwd ? path.resolve(expandHomePath(cwd)) : '';
7041
+ const messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []);
7042
+ const now = Date.now();
7043
+ const baseTime = new Date(now).toISOString();
7044
+ const lines = [];
7045
+
7046
+ if (target === 'codex') {
7047
+ lines.push(JSON.stringify({ type: 'session_meta', timestamp: baseTime, payload: { id: derivedSessionId, cwd } }));
7048
+ for (let i = 0; i < messages.length; i += 1) {
7049
+ const message = messages[i];
7050
+ if (!message) continue;
7051
+ const role = normalizeRole(message.role);
7052
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') continue;
7053
+ const text = typeof message.text === 'string' ? message.text : '';
7054
+ if (!text) continue;
7055
+ lines.push(JSON.stringify({
7056
+ type: 'response_item',
7057
+ timestamp: toIsoTime(message.timestamp, '') || new Date(now + i).toISOString(),
7058
+ payload: { type: 'message', role, content: text }
7059
+ }));
7060
+ }
7061
+ } else {
7062
+ const claudeIndexPath = target === 'claude' ? path.join(outputDir, 'sessions-index.json') : '';
7063
+ for (let i = 0; i < messages.length; i += 1) {
7064
+ const message = messages[i];
7065
+ if (!message) continue;
7066
+ const role = normalizeRole(message.role);
7067
+ if (role !== 'user' && role !== 'assistant' && role !== 'system') continue;
7068
+ const text = typeof message.text === 'string' ? message.text : '';
7069
+ if (!text) continue;
7070
+ lines.push(JSON.stringify({
7071
+ type: role,
7072
+ timestamp: toIsoTime(message.timestamp, '') || new Date(now + i).toISOString(),
7073
+ sessionId: derivedSessionId,
7074
+ cwd,
7075
+ message: { content: text }
7076
+ }));
7077
+ }
7078
+ if (claudeIndexPath) {
7079
+ ensureClaudeSessionsIndex(claudeIndexPath, resolvedCwd);
7080
+ }
7081
+ }
7082
+
7083
+ fs.writeFileSync(outputPath, `${lines.join('\n')}\n`, 'utf-8');
7084
+ writeJsonAtomic(metaPath, {
7085
+ version: 1,
7086
+ createdAt: baseTime,
7087
+ source: {
7088
+ type: source,
7089
+ sessionId: baseSessionId,
7090
+ filePath
7091
+ },
7092
+ target: {
7093
+ type: target,
7094
+ sessionId: derivedSessionId,
7095
+ filePath: outputPath
7096
+ },
7097
+ options: {
7098
+ maxMessages: maxMessages === Infinity ? 'all' : maxMessages
7099
+ }
7100
+ });
7101
+
7102
+ invalidateSessionListCache();
7103
+
7104
+ const summary = target === 'codex'
7105
+ ? parseCodexSessionSummary(outputPath, { summaryReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES, titleReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES })
7106
+ : parseClaudeSessionSummary(outputPath, { summaryReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES, titleReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES });
7107
+ if (target === 'claude' && summary) {
7108
+ const indexPath = path.join(outputDir, 'sessions-index.json');
7109
+ ensureClaudeSessionsIndex(indexPath, resolvedCwd);
7110
+ upsertClaudeSessionIndexEntry(indexPath, outputPath, {
7111
+ source: 'claude',
7112
+ trashId: summary.sessionId,
7113
+ trashFileName: `${summary.sessionId}.jsonl`,
7114
+ sessionId: summary.sessionId,
7115
+ title: summary.title,
7116
+ cwd: summary.cwd,
7117
+ createdAt: summary.createdAt,
7118
+ updatedAt: summary.updatedAt,
7119
+ messageCount: summary.messageCount,
7120
+ provider: summary.provider,
7121
+ keywords: summary.keywords,
7122
+ capabilities: summary.capabilities,
7123
+ claudeIndexEntry: resolvedCwd ? { projectPath: resolvedCwd } : null
7124
+ });
7125
+ }
7126
+ const maxMessagesLabel = maxMessages === Infinity ? 'all' : maxMessages;
7127
+
7128
+ return {
7129
+ derived: true,
7130
+ source,
7131
+ target,
7132
+ truncated: !!extracted.truncated,
7133
+ maxMessages: maxMessagesLabel,
7134
+ session: summary ? { ...summary, derived: true } : {
7135
+ source: target,
7136
+ sourceLabel: target === 'codex' ? 'Codex' : 'Claude Code',
7137
+ sessionId: derivedSessionId,
7138
+ title: derivedSessionId,
7139
+ cwd,
7140
+ createdAt: baseTime,
7141
+ updatedAt: baseTime,
7142
+ messageCount: messages.length,
7143
+ totalTokens: 0,
7144
+ contextWindow: 0,
7145
+ inputTokens: 0,
7146
+ cachedInputTokens: 0,
7147
+ outputTokens: 0,
7148
+ reasoningOutputTokens: 0,
7149
+ __messageCountExact: true,
7150
+ filePath: outputPath,
7151
+ derived: true,
7152
+ keywords: [],
7153
+ capabilities: {}
7154
+ }
7155
+ };
7156
+ }
7157
+
6666
7158
  function buildExportPayload(includeKeys) {
6667
7159
  const { config } = readConfigOrVirtualDefault();
6668
7160
  const providers = config.model_providers || {};
@@ -8294,17 +8786,32 @@ function probeCliBinary(binName) {
8294
8786
  let lastError = '';
8295
8787
 
8296
8788
  for (const args of attempts) {
8297
- const argString = args.join(' ').trim();
8298
- const commandLine = argString ? `${binName} ${argString}` : binName;
8299
8789
  try {
8300
- const stdout = execSync(commandLine, {
8301
- encoding: 'utf8',
8302
- windowsHide: true,
8303
- timeout: 5000,
8304
- stdio: ['ignore', 'pipe', 'pipe'],
8305
- shell: process.platform === 'win32'
8306
- });
8307
- const version = parseBinaryVersionOutput(String(stdout || ''));
8790
+ let output = '';
8791
+ let status = 0;
8792
+ if (process.platform === 'win32') {
8793
+ const argString = args.join(' ').trim();
8794
+ const commandLine = argString ? `${binName} ${argString}` : binName;
8795
+ const stdout = execSync(commandLine, {
8796
+ encoding: 'utf8',
8797
+ windowsHide: true,
8798
+ timeout: 5000,
8799
+ stdio: ['ignore', 'pipe', 'pipe'],
8800
+ shell: true
8801
+ });
8802
+ output = String(stdout || '');
8803
+ } else {
8804
+ const cmd = resolveSpawnCommand(binName);
8805
+ const probe = spawnSync(cmd, args, {
8806
+ encoding: 'utf8',
8807
+ windowsHide: true,
8808
+ timeout: 5000,
8809
+ stdio: ['ignore', 'pipe', 'pipe']
8810
+ });
8811
+ status = Number.isFinite(probe.status) ? probe.status : (probe.error ? 1 : 0);
8812
+ output = `${probe.stdout || ''}\n${probe.stderr || ''}`.trim();
8813
+ }
8814
+ const version = parseBinaryVersionOutput(output);
8308
8815
  return {
8309
8816
  installed: true,
8310
8817
  bin: binName,
@@ -9831,12 +10338,18 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
9831
10338
  case 'export-session':
9832
10339
  result = await exportSessionData(params);
9833
10340
  break;
10341
+ case 'convert-session':
10342
+ result = await convertSessionToDerived(params || {});
10343
+ break;
9834
10344
  case 'delete-session':
9835
10345
  result = await deleteSessionData(params || {});
9836
10346
  break;
9837
10347
  case 'clone-session':
9838
10348
  result = await cloneCodexSession(params || {});
9839
10349
  break;
10350
+ case 'session-message-counts':
10351
+ result = await readSessionMessageCounts(params || {});
10352
+ break;
9840
10353
  case 'session-detail':
9841
10354
  result = await readSessionDetail(params);
9842
10355
  break;
@@ -14614,6 +15127,7 @@ function printMainHelp() {
14614
15127
  console.log(' codexmate qwen [参数...] 等同于 qwen --yolo');
14615
15128
  console.log(' codexmate mcp [serve] [--transport stdio] [--allow-write|--read-only]');
14616
15129
  console.log(' codexmate export-session --source <codex|claude|gemini|codebuddy> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
15130
+ console.log(' codexmate convert-session --from <codex|claude> --to <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
14617
15131
  console.log(' codexmate zip <路径> [--max:级别] 压缩(系统 zip 优先,其次 zip-lib)');
14618
15132
  console.log(' codexmate unzip <zip文件> [输出目录] 解压(zip-lib)');
14619
15133
  console.log(' codexmate unzip-ext <zip目录> [输出目录] [--ext:后缀[,后缀...]] [--no-recursive] 批量提取 ZIP 指定后缀文件(默认递归)');
@@ -14712,6 +15226,7 @@ async function main() {
14712
15226
  }
14713
15227
  case 'mcp': await cmdMcp(args.slice(1)); break;
14714
15228
  case 'export-session': await cmdExportSession(args.slice(1)); break;
15229
+ case 'convert-session': await cmdConvertSession(args.slice(1), { resolveSessionFilePath }); break;
14715
15230
  case 'zip': {
14716
15231
  const { targetPath, options } = parseZipCommandArgs(args.slice(1));
14717
15232
  await cmdZip(targetPath, options);