Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 35
feat(sessions): derived conversion + resume commands#116
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
43bbf3cd396b039d28ed333b69775d0d701a078701a5b9ba6e8435a238c9a897e911dfFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Uh oh!
There was an error while loading. Please reload this page.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { parseMaxMessagesValue } = require('../lib/cli-session-utils'); | ||
| function ensureDir(dirPath) { | ||
| if (!dirPath) return; | ||
| if (fs.existsSync(dirPath)) return; | ||
| fs.mkdirSync(dirPath, { recursive: true }); | ||
| } | ||
| function resolveOutputPath(outputPath, defaultFileName) { | ||
| const fallback = path.resolve(process.cwd(), defaultFileName); | ||
| if (typeof outputPath !== 'string' || !outputPath.trim()) return fallback; | ||
| const trimmed = outputPath.trim(); | ||
| const resolved = path.resolve(trimmed); | ||
| if (/[\\\/]$/.test(trimmed)) { | ||
| ensureDir(resolved); | ||
| return path.join(resolved, defaultFileName); | ||
| } | ||
| if (fs.existsSync(resolved)) { | ||
| try { if (fs.statSync(resolved).isDirectory()) return path.join(resolved, defaultFileName); } catch (_) {} | ||
| } | ||
| return resolved; | ||
| } | ||
| function parseArgs(args = []) { | ||
| const options = { from: '', to: '', sessionId: '', filePath: '', output: '', maxMessages: undefined }; | ||
| const errors = []; | ||
| for (let i = 0; i < args.length; i += 1) { | ||
| const arg = String(args[i] || ''); | ||
| const next = args[i + 1] || ''; | ||
| if (!arg) continue; | ||
| if (arg === '--from') { options.from = next; i += 1; continue; } | ||
| if (arg.startsWith('--from=')) { options.from = arg.slice(7); continue; } | ||
| if (arg === '--to') { options.to = next; i += 1; continue; } | ||
| if (arg.startsWith('--to=')) { options.to = arg.slice(5); continue; } | ||
| if (arg === '--session-id') { options.sessionId = next; i += 1; continue; } | ||
| if (arg.startsWith('--session-id=')) { options.sessionId = arg.slice(13); continue; } | ||
| if (arg === '--file') { options.filePath = next; i += 1; continue; } | ||
| if (arg.startsWith('--file=')) { options.filePath = arg.slice(7); continue; } | ||
| if (arg === '--output') { options.output = next; i += 1; continue; } | ||
| if (arg.startsWith('--output=')) { options.output = arg.slice(9); continue; } | ||
| if (arg === '--max-messages') { options.maxMessages = next; i += 1; continue; } | ||
| if (arg.startsWith('--max-messages=')) { options.maxMessages = arg.slice(15); continue; } | ||
| errors.push(`未知参数: ${arg}`); | ||
| } | ||
| options.from = String(options.from || '').trim().toLowerCase(); | ||
| options.to = String(options.to || '').trim().toLowerCase(); | ||
| if (options.from !== 'codex' && options.from !== 'claude') errors.push('参数 --from 仅支持 codex 或 claude'); | ||
| if (options.to !== 'codex' && options.to !== 'claude') errors.push('参数 --to 仅支持 codex 或 claude'); | ||
| if (options.from && options.to && options.from === options.to) errors.push('--from 与 --to 不能相同'); | ||
| if (!options.from) errors.push('缺少 --from'); | ||
| if (!options.to) errors.push('缺少 --to'); | ||
| if (!options.sessionId && !options.filePath) errors.push('必须指定 --session-id 或 --file'); | ||
| if (options.maxMessages !== undefined) { | ||
| const parsed = parseMaxMessagesValue(options.maxMessages); | ||
| if (parsed === null) errors.push('参数 --max-messages 无效'); | ||
| else options.maxMessages = parsed === Infinity ? Infinity : Math.max(1, Math.floor(parsed)); | ||
| } | ||
| return { options, error: errors.length ? errors.join(';') : '' }; | ||
| } | ||
| module.exports = { ensureDir, resolveOutputPath, parseArgs }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| const fs = require('fs'); | ||
| const readline = require('readline'); | ||
| const { | ||
| toIsoTime, | ||
| extractMessageText, | ||
| normalizeRole, | ||
| resolveMaxMessagesValue | ||
| } = require('../lib/cli-session-utils'); | ||
| const { removeLeadingSystemMessage } = require('../lib/cli-sessions'); | ||
| async function readSessionMessages(filePath, source, maxMessages) { | ||
| const limit = resolveMaxMessagesValue(maxMessages, 200); | ||
| const state = { sessionId: '', cwd: '', updatedAt: '', messages: [], truncated: false }; | ||
| const stream = fs.createReadStream(filePath, { encoding: 'utf-8' }); | ||
| const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); | ||
| for await (const line of rl) { | ||
| const trimmed = String(line || '').trim(); | ||
| if (!trimmed) continue; | ||
| let record; | ||
| try { record = JSON.parse(trimmed); } catch (_) { continue; } | ||
| const timestamp = toIsoTime(record.timestamp, ''); | ||
| if (timestamp) state.updatedAt = timestamp; | ||
| if (source === 'codex' && record.type === 'session_meta' && record.payload) { | ||
| if (!state.sessionId && record.payload.id) state.sessionId = String(record.payload.id || ''); | ||
| if (!state.cwd && record.payload.cwd) state.cwd = String(record.payload.cwd || ''); | ||
| continue; | ||
| } | ||
| if (source === 'claude') { | ||
| if (!state.sessionId && record.sessionId) state.sessionId = String(record.sessionId || ''); | ||
| if (!state.cwd && record.cwd) state.cwd = String(record.cwd || ''); | ||
| } | ||
| let role = ''; | ||
| let text = ''; | ||
| if (source === 'codex' && record.type === 'response_item' && record.payload && record.payload.type === 'message') { | ||
| role = normalizeRole(record.payload.role); | ||
| text = extractMessageText(record.payload.content); | ||
| } else if (source === 'claude') { | ||
| role = normalizeRole(record.type); | ||
| text = extractMessageText(record.message ? record.message.content : ''); | ||
| } | ||
| if (!role || !text) continue; | ||
| state.messages.push({ role, text, timestamp }); | ||
| if (limit !== Infinity && state.messages.length > limit) { | ||
| state.messages.shift(); | ||
| state.truncated = true; | ||
| } | ||
| } | ||
| state.messages = removeLeadingSystemMessage(state.messages); | ||
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Leading Line 50 removes the first system message unconditionally. That mutates the conversation and can break “preserve message order and roles” behavior during conversion. Proposed fix-const { removeLeadingSystemMessage } = require('../lib/cli-sessions');
@@
- state.messages = removeLeadingSystemMessage(state.messages);
return state;🤖 Prompt for AI Agents | ||
| return state; | ||
| } | ||
| function buildTargetRecords(target, payload) { | ||
| const now = Date.now(); | ||
| const sessionId = String(payload.sessionId || '').trim(); | ||
| const cwd = String(payload.cwd || '').trim(); | ||
| const messages = Array.isArray(payload.messages) ? payload.messages : []; | ||
| if (target === 'codex') { | ||
| const records = [{ type: 'session_meta', timestamp: new Date(now).toISOString(), payload: { id: sessionId, cwd } }]; | ||
| for (let i = 0; i < messages.length; i += 1) { | ||
| const m = messages[i] || {}; | ||
| const role = normalizeRole(m.role); | ||
| const text = typeof m.text === 'string' ? m.text : ''; | ||
| if (!role || !text) continue; | ||
| records.push({ type: 'response_item', timestamp: m.timestamp || new Date(now + i).toISOString(), payload: { type: 'message', role, content: text } }); | ||
| } | ||
| return records; | ||
| } | ||
| const records = []; | ||
| for (let i = 0; i < messages.length; i += 1) { | ||
| const m = messages[i] || {}; | ||
| const role = normalizeRole(m.role); | ||
| const text = typeof m.text === 'string' ? m.text : ''; | ||
| if (!role || !text) continue; | ||
| records.push({ type: role, timestamp: m.timestamp || new Date(now + i).toISOString(), sessionId, cwd, message: { content: text } }); | ||
| } | ||
| return records; | ||
| } | ||
Comment on lines
+54
to
+79
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate Right now, any non- Proposed fix function buildTargetRecords(target, payload) {
+ if (target !== 'codex' && target !== 'claude') {+ throw new Error(`Unsupported target format: ${String(target)}`);+ }
const now = Date.now();
@@
- const records = [];+ const records = [];🤖 Prompt for AI Agents | ||
| module.exports = { readSessionMessages, buildTargetRecords }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { parseArgs, ensureDir, resolveOutputPath } = require('./session-convert-args'); | ||
| const { readSessionMessages, buildTargetRecords } = require('./session-convert-io'); | ||
| function printUsage() { | ||
| console.log('\n用法:'); | ||
| console.log(' codexmate convert-session --from <codex|claude> --to <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]'); | ||
| } | ||
| async function cmdConvertSession(args = [], deps = {}) { | ||
| const parsed = parseArgs(args); | ||
| if (parsed.error) { | ||
| console.error('错误:', parsed.error); | ||
| printUsage(); | ||
| process.exit(1); | ||
| } | ||
| if (!deps || typeof deps.resolveSessionFilePath !== 'function') { | ||
| console.error('错误: convert-session missing resolver'); | ||
| process.exit(1); | ||
| } | ||
| const opt = parsed.options; | ||
| const filePath = deps.resolveSessionFilePath(opt.from, opt.filePath, opt.sessionId); | ||
| if (!filePath) { | ||
| console.error('转换失败: Session file not found'); | ||
| process.exit(1); | ||
| } | ||
| const extracted = await readSessionMessages(filePath, opt.from, opt.maxMessages); | ||
| const sessionId = extracted.sessionId || opt.sessionId || path.basename(filePath, '.jsonl'); | ||
| const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_'); | ||
| const records = buildTargetRecords(opt.to, { sessionId, cwd: extracted.cwd || '', messages: extracted.messages }); | ||
| const jsonl = `${records.map(r => JSON.stringify(r)).join('\n')}\n`; | ||
| const outputPath = resolveOutputPath(opt.output, `${opt.to}-session-${safeSessionId}.jsonl`); | ||
| ensureDir(path.dirname(outputPath)); | ||
| fs.writeFileSync(outputPath, jsonl, 'utf-8'); | ||
| console.log('\n✓ 会话已转换:', outputPath); | ||
Comment on lines
+29
to
+37
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrap conversion I/O path with explicit error handling. Line 29 onward can throw on read/parse/write failures. Add a Suggested fix- const extracted = await readSessionMessages(filePath, opt.from, opt.maxMessages);- const sessionId = extracted.sessionId || opt.sessionId || path.basename(filePath, '.jsonl');- const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');- const records = buildTargetRecords(opt.to, { sessionId, cwd: extracted.cwd || '', messages: extracted.messages });- const jsonl = `${records.map(r => JSON.stringify(r)).join('\n')}\n`;- const outputPath = resolveOutputPath(opt.output, `${opt.to}-session-${safeSessionId}.jsonl`);- ensureDir(path.dirname(outputPath));- fs.writeFileSync(outputPath, jsonl, 'utf-8');- console.log('\n✓ 会话已转换:', outputPath);- if (extracted.truncated) console.log('! 已截断: 可使用 --max-messages=all');- console.log();+ try {+ const extracted = await readSessionMessages(filePath, opt.from, opt.maxMessages);+ const sessionId = extracted.sessionId || opt.sessionId || path.basename(filePath, '.jsonl');+ const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');+ const records = buildTargetRecords(opt.to, { sessionId, cwd: extracted.cwd || '', messages: extracted.messages });+ const jsonl = `${records.map(r => JSON.stringify(r)).join('\n')}\n`;+ const outputPath = resolveOutputPath(opt.output, `${opt.to}-session-${safeSessionId}.jsonl`);+ ensureDir(path.dirname(outputPath));+ fs.writeFileSync(outputPath, jsonl, 'utf-8');+ console.log('\n✓ 会话已转换:', outputPath);+ if (extracted.truncated) console.log('! 已截断: 可使用 --max-messages=all');+ console.log();+ } catch (e) {+ const msg = e && e.message ? e.message : String(e || 'unknown error');+ console.error('转换失败:', msg);+ process.exit(1);+ }🤖 Prompt for AI Agents | ||
| if (extracted.truncated) console.log('! 已截断: 可使用 --max-messages=all'); | ||
| console.log(); | ||
| } | ||
| module.exports = { cmdConvertSession }; | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Enforce mutual exclusivity for
--session-idand--file.Line 55 currently rejects only the “both missing” case. It should also reject “both present” to match command contract and avoid ambiguous resolution.
Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents