Skip to content

Commit 9d28ed3

Browse files
committed
feat(session): persist derived conversions
1 parent d396b03 commit 9d28ed3

3 files changed

Lines changed: 321 additions & 47 deletions

File tree

cli.js

Lines changed: 289 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,11 @@ const CLAUDE_MD_FILE_NAME = 'CLAUDE.md';
197197
const CLAUDE_PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects');
198198
const CODEBUDDY_DIR = path.join(os.homedir(), '.codebuddy');
199199
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');
200205
const GEMINI_DIR = path.join(os.homedir(), '.gemini');
201206
const GEMINI_TMP_DIR = path.join(GEMINI_DIR, 'tmp');
202207
const RECENT_CONFIGS_FILE = path.join(CONFIG_DIR, 'recent-configs.json');
@@ -1309,6 +1314,58 @@ function getCodeBuddyProjectsDir() {
13091314
return resolveExistingDir(candidates, CODEBUDDY_PROJECTS_DIR);
13101315
}
13111316

1317+
function getCodexmateDerivedSessionsRoot(target) {
1318+
if (target === 'claude') {
1319+
return CODEXMATE_DERIVED_CLAUDE_DIR;
1320+
}
1321+
return CODEXMATE_DERIVED_CODEX_DIR;
1322+
}
1323+
1324+
function normalizeSessionDerivedTarget(value) {
1325+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
1326+
if (normalized === 'codex' || normalized === 'claude') {
1327+
return normalized;
1328+
}
1329+
return '';
1330+
}
1331+
1332+
function normalizeSessionDerivedSource(value) {
1333+
return normalizeSessionDerivedTarget(value);
1334+
}
1335+
1336+
function buildSessionDerivedSourceKey(source, sessionId, filePath) {
1337+
const baseSource = normalizeSessionDerivedSource(source);
1338+
const id = typeof sessionId === 'string' ? sessionId.trim() : '';
1339+
const pathValue = typeof filePath === 'string' ? filePath.trim() : '';
1340+
const seed = `${baseSource}|${id}|${pathValue}`;
1341+
return crypto.createHash('sha1').update(seed).digest('hex').slice(0, 16);
1342+
}
1343+
1344+
function formatCompactTimestamp(value = Date.now()) {
1345+
const stamp = new Date(value);
1346+
const year = String(stamp.getFullYear());
1347+
const month = String(stamp.getMonth() + 1).padStart(2, '0');
1348+
const day = String(stamp.getDate()).padStart(2, '0');
1349+
const hour = String(stamp.getHours()).padStart(2, '0');
1350+
const minute = String(stamp.getMinutes()).padStart(2, '0');
1351+
const second = String(stamp.getSeconds()).padStart(2, '0');
1352+
return `${year}${month}${day}-${hour}${minute}${second}`;
1353+
}
1354+
1355+
function buildDerivedSessionId(baseId) {
1356+
const safeBase = typeof baseId === 'string' && baseId.trim() ? baseId.trim() : 'session';
1357+
const normalized = safeBase.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64) || 'session';
1358+
const suffix = crypto.randomBytes(3).toString('hex');
1359+
return `${normalized}-${formatCompactTimestamp()}-${suffix}`;
1360+
}
1361+
1362+
function buildDerivedSessionOutputDir(target, source, sourceKey) {
1363+
const targetRoot = getCodexmateDerivedSessionsRoot(target);
1364+
const safeSource = normalizeSessionDerivedSource(source) || 'codex';
1365+
const safeKey = typeof sourceKey === 'string' && sourceKey.trim() ? sourceKey.trim() : 'unknown';
1366+
return path.join(targetRoot, safeSource, safeKey);
1367+
}
1368+
13121369
function readModelsCacheEntry(cacheKey) {
13131370
if (!cacheKey) return null;
13141371
const entry = g_modelsCache.get(cacheKey);
@@ -3280,6 +3337,57 @@ function collectRecentJsonlFiles(rootDir, options = {}) {
32803337
return filesMeta.slice(0, returnCount).map(item => item.filePath);
32813338
}
32823339

3340+
function collectRecentJsonlFilesFromRoots(rootDirs, options = {}) {
3341+
const roots = Array.isArray(rootDirs)
3342+
? rootDirs.filter((dirPath) => typeof dirPath === 'string' && dirPath.trim() && fs.existsSync(dirPath.trim()))
3343+
: [];
3344+
if (roots.length === 0) {
3345+
return [];
3346+
}
3347+
3348+
const returnCount = Math.max(1, Number(options.returnCount) || 1);
3349+
const maxFilesScanned = Math.max(returnCount, Number(options.maxFilesScanned) || 2000);
3350+
const ignoreSubPath = typeof options.ignoreSubPath === 'string' ? options.ignoreSubPath : '';
3351+
const stack = roots.map((dirPath) => dirPath.trim());
3352+
const filesMeta = [];
3353+
let scanned = 0;
3354+
3355+
while (stack.length > 0 && scanned < maxFilesScanned) {
3356+
const dir = stack.pop();
3357+
let entries = [];
3358+
try {
3359+
entries = fs.readdirSync(dir, { withFileTypes: true });
3360+
} catch (_) {
3361+
continue;
3362+
}
3363+
3364+
for (const entry of entries) {
3365+
const fullPath = path.join(dir, entry.name);
3366+
if (entry.isDirectory()) {
3367+
stack.push(fullPath);
3368+
continue;
3369+
}
3370+
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) {
3371+
continue;
3372+
}
3373+
if (ignoreSubPath && fullPath.includes(ignoreSubPath)) {
3374+
continue;
3375+
}
3376+
scanned += 1;
3377+
try {
3378+
const stat = fs.statSync(fullPath);
3379+
filesMeta.push({ filePath: fullPath, mtimeMs: stat.mtimeMs || 0 });
3380+
} catch (_) {}
3381+
if (scanned >= maxFilesScanned) {
3382+
break;
3383+
}
3384+
}
3385+
}
3386+
3387+
filesMeta.sort((a, b) => b.mtimeMs - a.mtimeMs);
3388+
return filesMeta.slice(0, returnCount).map(item => item.filePath);
3389+
}
3390+
32833391
function getSessionListCache(cacheKey, forceRefresh = false) {
32843392
if (forceRefresh) {
32853393
g_sessionListCache.delete(cacheKey);
@@ -4395,7 +4503,7 @@ function listCodexSessions(limit, options = {}) {
43954503
const titleReadBytes = Number.isFinite(Number(options.titleReadBytes))
43964504
? Math.max(1024, Math.floor(Number(options.titleReadBytes)))
43974505
: SESSION_TITLE_READ_BYTES;
4398-
const files = collectRecentJsonlFiles(codexSessionsDir, {
4506+
const files = collectRecentJsonlFilesFromRoots([codexSessionsDir, getCodexmateDerivedSessionsRoot('codex')], {
43994507
returnCount: scanCount,
44004508
maxFilesScanned
44014509
});
@@ -4420,7 +4528,10 @@ function listCodexSessions(limit, options = {}) {
44204528

44214529
function listClaudeSessions(limit, options = {}) {
44224530
const claudeProjectsDir = getClaudeProjectsDir();
4423-
if (!fs.existsSync(claudeProjectsDir)) {
4531+
const derivedClaudeRoot = getCodexmateDerivedSessionsRoot('claude');
4532+
const hasProjectsDir = fs.existsSync(claudeProjectsDir);
4533+
const hasDerivedDir = fs.existsSync(derivedClaudeRoot);
4534+
if (!hasProjectsDir && !hasDerivedDir) {
44244535
return [];
44254536
}
44264537

@@ -4448,12 +4559,14 @@ function listClaudeSessions(limit, options = {}) {
44484559

44494560
const sessions = [];
44504561
let projectDirs = [];
4451-
try {
4452-
projectDirs = fs.readdirSync(claudeProjectsDir, { withFileTypes: true })
4453-
.filter(entry => entry.isDirectory())
4454-
.map(entry => path.join(claudeProjectsDir, entry.name));
4455-
} catch (e) {
4456-
projectDirs = [];
4562+
if (hasProjectsDir) {
4563+
try {
4564+
projectDirs = fs.readdirSync(claudeProjectsDir, { withFileTypes: true })
4565+
.filter(entry => entry.isDirectory())
4566+
.map(entry => path.join(claudeProjectsDir, entry.name));
4567+
} catch (e) {
4568+
projectDirs = [];
4569+
}
44574570
}
44584571

44594572
for (const projectDir of projectDirs) {
@@ -4619,6 +4732,25 @@ function listClaudeSessions(limit, options = {}) {
46194732
}
46204733
}
46214734

4735+
if (fs.existsSync(derivedClaudeRoot)) {
4736+
const seen = new Set(sessions.map((item) => (item && item.filePath ? item.filePath : '')).filter(Boolean));
4737+
const derivedFiles = collectRecentJsonlFiles(derivedClaudeRoot, {
4738+
returnCount: scanCount,
4739+
maxFilesScanned
4740+
});
4741+
for (const filePath of derivedFiles) {
4742+
if (seen.has(filePath)) continue;
4743+
const summary = parseClaudeSessionSummary(filePath, {
4744+
summaryReadBytes,
4745+
titleReadBytes
4746+
});
4747+
if (summary) {
4748+
sessions.push(summary);
4749+
}
4750+
seen.add(filePath);
4751+
}
4752+
}
4753+
46224754
return mergeAndLimitSessions(sessions, limit);
46234755
}
46244756

@@ -4963,19 +5095,25 @@ function resolveSessionFilePath(source, filePath, sessionId) {
49635095
const normalizedSource = source === 'claude' || source === 'gemini' || source === 'codebuddy'
49645096
? source
49655097
: 'codex';
4966-
const root = normalizedSource === 'claude'
4967-
? getClaudeProjectsDir()
5098+
const homeDir = process && process.env && process.env.HOME ? process.env.HOME : '';
5099+
const derivedCodexDir = homeDir ? `${homeDir}/.codexmate/sessions/derived/codex` : '';
5100+
const derivedClaudeDir = homeDir ? `${homeDir}/.codexmate/sessions/derived/claude` : '';
5101+
const roots = normalizedSource === 'claude'
5102+
? [getClaudeProjectsDir(), derivedClaudeDir]
49685103
: (normalizedSource === 'gemini'
4969-
? getGeminiTmpDir()
4970-
: (normalizedSource === 'codebuddy' ? getCodeBuddyProjectsDir() : getCodexSessionsDir()));
4971-
if (!root || !fs.existsSync(root)) {
5104+
? [getGeminiTmpDir()]
5105+
: (normalizedSource === 'codebuddy'
5106+
? [getCodeBuddyProjectsDir()]
5107+
: [getCodexSessionsDir(), derivedCodexDir]));
5108+
const availableRoots = roots.filter((dirPath) => dirPath && fs.existsSync(dirPath));
5109+
if (availableRoots.length === 0) {
49725110
return '';
49735111
}
49745112

49755113
if (typeof filePath === 'string' && filePath.trim()) {
49765114
const expandedPath = expandHomePath(filePath.trim());
49775115
const targetPath = expandedPath ? path.resolve(expandedPath) : '';
4978-
if (targetPath && fs.existsSync(targetPath) && isPathInside(targetPath, root)) {
5116+
if (targetPath && fs.existsSync(targetPath) && availableRoots.some((rootPath) => isPathInside(targetPath, rootPath))) {
49795117
return targetPath;
49805118
}
49815119
}
@@ -4985,7 +5123,7 @@ function resolveSessionFilePath(source, filePath, sessionId) {
49855123
const lookupStore = g_sessionFileLookupCache[normalizedSource];
49865124
if (lookupStore instanceof Map && lookupStore.has(targetId)) {
49875125
const cachedPath = lookupStore.get(targetId);
4988-
if (cachedPath && fs.existsSync(cachedPath) && isPathInside(cachedPath, root)) {
5126+
if (cachedPath && fs.existsSync(cachedPath) && availableRoots.some((rootPath) => isPathInside(cachedPath, rootPath))) {
49895127
return cachedPath;
49905128
}
49915129
lookupStore.delete(targetId);
@@ -5020,7 +5158,11 @@ function resolveSessionFilePath(source, filePath, sessionId) {
50205158
}
50215159
matchedFile = filesMeta.find(item => path.basename(item, '.json').toLowerCase() === targetId) || '';
50225160
} else {
5023-
const files = collectJsonlFiles(root, 5000);
5161+
const files = [];
5162+
for (const rootPath of availableRoots) {
5163+
files.push(...collectJsonlFiles(rootPath, 5000));
5164+
if (files.length >= 5000) break;
5165+
}
50245166
matchedFile = files.find(item => path.basename(item, '.jsonl').toLowerCase() === targetId) || '';
50255167
}
50265168
if (matchedFile && fs.existsSync(matchedFile)) {
@@ -6664,6 +6806,134 @@ async function exportSessionData(params = {}) {
66646806
};
66656807
}
66666808

6809+
async function convertSessionToDerived(params = {}) {
6810+
const source = normalizeSessionDerivedSource(params.source);
6811+
const target = normalizeSessionDerivedTarget(params.target || params.to);
6812+
if (!source || !target) {
6813+
return { error: 'Invalid source/target' };
6814+
}
6815+
if (source === target) {
6816+
return { error: 'source and target must be different' };
6817+
}
6818+
6819+
const maxMessages = resolveMaxMessagesValue(params.maxMessages, MAX_EXPORT_MESSAGES);
6820+
const filePath = resolveSessionFilePath(source, getSessionFileArg(params), params.sessionId);
6821+
if (!filePath) {
6822+
return { error: 'Session file not found' };
6823+
}
6824+
6825+
let extracted;
6826+
try {
6827+
extracted = await extractMessagesFromFile(filePath, source, { maxMessages });
6828+
} catch (_) {
6829+
extracted = null;
6830+
}
6831+
if (!extracted) {
6832+
return { error: 'Failed to parse session file' };
6833+
}
6834+
6835+
const baseSessionId = extracted.sessionId || params.sessionId || path.basename(filePath, '.jsonl');
6836+
const derivedSessionId = buildDerivedSessionId(baseSessionId);
6837+
const sourceKey = buildSessionDerivedSourceKey(source, baseSessionId, filePath);
6838+
const outputDir = buildDerivedSessionOutputDir(target, source, sourceKey);
6839+
ensureDir(outputDir);
6840+
const outputPath = path.join(outputDir, `${derivedSessionId}.jsonl`);
6841+
const metaPath = path.join(outputDir, `${derivedSessionId}.meta.json`);
6842+
6843+
const cwd = typeof extracted.cwd === 'string' ? extracted.cwd : '';
6844+
const messages = removeLeadingSystemMessage(Array.isArray(extracted.messages) ? extracted.messages : []);
6845+
const now = Date.now();
6846+
const baseTime = new Date(now).toISOString();
6847+
const lines = [];
6848+
6849+
if (target === 'codex') {
6850+
lines.push(JSON.stringify({ type: 'session_meta', timestamp: baseTime, payload: { id: derivedSessionId, cwd } }));
6851+
for (let i = 0; i < messages.length; i += 1) {
6852+
const message = messages[i];
6853+
if (!message) continue;
6854+
const role = normalizeRole(message.role);
6855+
if (role !== 'user' && role !== 'assistant' && role !== 'system') continue;
6856+
const text = typeof message.text === 'string' ? message.text : '';
6857+
if (!text) continue;
6858+
lines.push(JSON.stringify({
6859+
type: 'response_item',
6860+
timestamp: toIsoTime(message.timestamp, '') || new Date(now + i).toISOString(),
6861+
payload: { type: 'message', role, content: text }
6862+
}));
6863+
}
6864+
} else {
6865+
for (let i = 0; i < messages.length; i += 1) {
6866+
const message = messages[i];
6867+
if (!message) continue;
6868+
const role = normalizeRole(message.role);
6869+
if (role !== 'user' && role !== 'assistant' && role !== 'system') continue;
6870+
const text = typeof message.text === 'string' ? message.text : '';
6871+
if (!text) continue;
6872+
lines.push(JSON.stringify({
6873+
type: role,
6874+
timestamp: toIsoTime(message.timestamp, '') || new Date(now + i).toISOString(),
6875+
sessionId: derivedSessionId,
6876+
cwd,
6877+
message: { content: text }
6878+
}));
6879+
}
6880+
}
6881+
6882+
fs.writeFileSync(outputPath, `${lines.join('\n')}\n`, 'utf-8');
6883+
writeJsonAtomic(metaPath, {
6884+
version: 1,
6885+
createdAt: baseTime,
6886+
source: {
6887+
type: source,
6888+
sessionId: baseSessionId,
6889+
filePath
6890+
},
6891+
target: {
6892+
type: target,
6893+
sessionId: derivedSessionId,
6894+
filePath: outputPath
6895+
},
6896+
options: {
6897+
maxMessages: maxMessages === Infinity ? 'all' : maxMessages
6898+
}
6899+
});
6900+
6901+
invalidateSessionListCache();
6902+
6903+
const summary = target === 'codex'
6904+
? parseCodexSessionSummary(outputPath, { summaryReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES, titleReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES })
6905+
: parseClaudeSessionSummary(outputPath, { summaryReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES, titleReadBytes: SESSION_BROWSE_SUMMARY_READ_BYTES });
6906+
const maxMessagesLabel = maxMessages === Infinity ? 'all' : maxMessages;
6907+
6908+
return {
6909+
derived: true,
6910+
source,
6911+
target,
6912+
truncated: !!extracted.truncated,
6913+
maxMessages: maxMessagesLabel,
6914+
session: summary || {
6915+
source: target,
6916+
sourceLabel: target === 'codex' ? 'Codex' : 'Claude Code',
6917+
sessionId: derivedSessionId,
6918+
title: derivedSessionId,
6919+
cwd,
6920+
createdAt: baseTime,
6921+
updatedAt: baseTime,
6922+
messageCount: messages.length,
6923+
totalTokens: 0,
6924+
contextWindow: 0,
6925+
inputTokens: 0,
6926+
cachedInputTokens: 0,
6927+
outputTokens: 0,
6928+
reasoningOutputTokens: 0,
6929+
__messageCountExact: true,
6930+
filePath: outputPath,
6931+
keywords: [],
6932+
capabilities: {}
6933+
}
6934+
};
6935+
}
6936+
66676937
function buildExportPayload(includeKeys) {
66686938
const { config } = readConfigOrVirtualDefault();
66696939
const providers = config.model_providers || {};
@@ -9832,6 +10102,9 @@ function createWebServer({ htmlPath, assetsDir, webDir, host, port, openBrowser
983210102
case 'export-session':
983310103
result = await exportSessionData(params);
983410104
break;
10105+
case 'convert-session':
10106+
result = await convertSessionToDerived(params || {});
10107+
break;
983510108
case 'delete-session':
983610109
result = await deleteSessionData(params || {});
983710110
break;

0 commit comments

Comments
 (0)