232 lines
8.0 KiB
JavaScript
232 lines
8.0 KiB
JavaScript
const fs = require('fs');
|
||
const path = require('path');
|
||
const crypto = require('crypto');
|
||
|
||
function createCodexRolloutStore(deps) {
|
||
const { codexSessionsDir, sessionsDir, normalizeSession, sanitizeToolInput } = deps;
|
||
|
||
function extractCodexMessageText(content) {
|
||
if (!Array.isArray(content)) return '';
|
||
return content
|
||
.filter((item) => item && (item.type === 'input_text' || item.type === 'output_text'))
|
||
.map((item) => item.text || '')
|
||
.join('');
|
||
}
|
||
|
||
function appendAssistantContent(turn, text) {
|
||
if (!turn || !text || !text.trim()) return;
|
||
turn.content = turn.content ? `${turn.content}\n\n${text}` : text;
|
||
}
|
||
|
||
function extractCcwebSourceConversation(text) {
|
||
const match = String(text || '').match(/^来自「([^」]+)」对话(ID:\s*([0-9a-fA-F-]{36}))的消息:/);
|
||
if (!match) return null;
|
||
return { title: match[1], id: match[2].toLowerCase() };
|
||
}
|
||
|
||
function parseCodexRolloutLines(lines) {
|
||
const messages = [];
|
||
const pendingToolCalls = new Map();
|
||
const meta = {
|
||
threadId: null,
|
||
cwd: null,
|
||
title: '',
|
||
updatedAt: null,
|
||
cliVersion: null,
|
||
source: null,
|
||
sourceConversationId: null,
|
||
sourceConversationTitle: '',
|
||
};
|
||
const totalUsage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
|
||
let currentAssistant = null;
|
||
let sawRealUserMessage = false;
|
||
const fallbackUserMessages = [];
|
||
|
||
function rememberSourceConversation(text) {
|
||
if (meta.sourceConversationId) return;
|
||
const sourceConversation = extractCcwebSourceConversation(text);
|
||
if (!sourceConversation) return;
|
||
meta.sourceConversationId = sourceConversation.id;
|
||
meta.sourceConversationTitle = sourceConversation.title;
|
||
}
|
||
|
||
function ensureAssistant(ts) {
|
||
if (!currentAssistant) {
|
||
currentAssistant = { role: 'assistant', content: '', toolCalls: [], timestamp: ts || null };
|
||
} else if (!currentAssistant.timestamp && ts) {
|
||
currentAssistant.timestamp = ts;
|
||
}
|
||
return currentAssistant;
|
||
}
|
||
|
||
function flushAssistant() {
|
||
if (!currentAssistant) return;
|
||
if ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0) {
|
||
messages.push(currentAssistant);
|
||
}
|
||
currentAssistant = null;
|
||
pendingToolCalls.clear();
|
||
}
|
||
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) continue;
|
||
let entry;
|
||
try { entry = JSON.parse(trimmed); } catch { continue; }
|
||
const ts = entry.timestamp || null;
|
||
if (ts) meta.updatedAt = ts;
|
||
|
||
if (entry.type === 'session_meta') {
|
||
meta.threadId = entry.payload?.id || meta.threadId;
|
||
meta.cwd = entry.payload?.cwd || meta.cwd;
|
||
meta.cliVersion = entry.payload?.cli_version || meta.cliVersion;
|
||
meta.source = entry.payload?.source || meta.source;
|
||
continue;
|
||
}
|
||
|
||
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count') {
|
||
const total = entry.payload?.info?.total_token_usage || null;
|
||
const usage = entry.payload?.info?.last_token_usage || null;
|
||
if (total) {
|
||
totalUsage.inputTokens = Math.max(totalUsage.inputTokens, total.input_tokens || 0);
|
||
totalUsage.cachedInputTokens = Math.max(totalUsage.cachedInputTokens, total.cached_input_tokens || 0);
|
||
totalUsage.outputTokens = Math.max(totalUsage.outputTokens, total.output_tokens || 0);
|
||
} else if (usage) {
|
||
totalUsage.inputTokens += usage.input_tokens || 0;
|
||
totalUsage.cachedInputTokens += usage.cached_input_tokens || 0;
|
||
totalUsage.outputTokens += usage.output_tokens || 0;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
|
||
const text = String(entry.payload?.message || '').trim();
|
||
if (text) {
|
||
sawRealUserMessage = true;
|
||
flushAssistant();
|
||
rememberSourceConversation(text);
|
||
if (!meta.title) meta.title = text.slice(0, 80).replace(/\n/g, ' ');
|
||
messages.push({ role: 'user', content: text, timestamp: ts });
|
||
}
|
||
continue;
|
||
}
|
||
|
||
if (entry.type !== 'response_item') continue;
|
||
|
||
const payload = entry.payload || {};
|
||
switch (payload.type) {
|
||
case 'message': {
|
||
if (payload.role === 'assistant') {
|
||
const text = extractCodexMessageText(payload.content);
|
||
if (text.trim()) {
|
||
if (currentAssistant && ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0)) {
|
||
flushAssistant();
|
||
}
|
||
appendAssistantContent(ensureAssistant(ts), text);
|
||
}
|
||
} else if (payload.role === 'user' && !sawRealUserMessage) {
|
||
const text = extractCodexMessageText(payload.content);
|
||
if (text.trim()) {
|
||
rememberSourceConversation(text);
|
||
fallbackUserMessages.push({ role: 'user', content: text, timestamp: ts });
|
||
}
|
||
}
|
||
break;
|
||
}
|
||
case 'function_call': {
|
||
const assistant = ensureAssistant(ts);
|
||
const toolUseId = payload.call_id || payload.id || crypto.randomUUID();
|
||
const tc = {
|
||
name: payload.name || 'FunctionCall',
|
||
id: toolUseId,
|
||
input: sanitizeToolInput(payload.name || 'FunctionCall', payload.arguments || ''),
|
||
done: false,
|
||
};
|
||
assistant.toolCalls.push(tc);
|
||
pendingToolCalls.set(toolUseId, tc);
|
||
break;
|
||
}
|
||
case 'function_call_output': {
|
||
const assistant = ensureAssistant(ts);
|
||
const toolUseId = payload.call_id || crypto.randomUUID();
|
||
let tc = pendingToolCalls.get(toolUseId);
|
||
if (!tc) {
|
||
tc = { name: 'FunctionCall', id: toolUseId, input: null, done: false };
|
||
assistant.toolCalls.push(tc);
|
||
pendingToolCalls.set(toolUseId, tc);
|
||
}
|
||
tc.done = true;
|
||
tc.result = (typeof payload.output === 'string'
|
||
? payload.output
|
||
: JSON.stringify(payload.output || '')).slice(0, 2000);
|
||
break;
|
||
}
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
flushAssistant();
|
||
if (!sawRealUserMessage && fallbackUserMessages.length > 0) {
|
||
const fallback = fallbackUserMessages[0];
|
||
if (!meta.title) meta.title = fallback.content.trim().slice(0, 80).replace(/\n/g, ' ');
|
||
return { meta, messages: fallbackUserMessages.concat(messages), totalUsage };
|
||
}
|
||
return { meta, messages, totalUsage };
|
||
}
|
||
|
||
function walkFiles(dir, files = []) {
|
||
let entries;
|
||
try {
|
||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||
} catch {
|
||
return files;
|
||
}
|
||
for (const entry of entries) {
|
||
const fullPath = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) walkFiles(fullPath, files);
|
||
else if (entry.isFile()) files.push(fullPath);
|
||
}
|
||
return files;
|
||
}
|
||
|
||
function getCodexRolloutFiles() {
|
||
if (!fs.existsSync(codexSessionsDir)) return [];
|
||
return walkFiles(codexSessionsDir, []).filter((filePath) => filePath.endsWith('.jsonl')).sort().reverse();
|
||
}
|
||
|
||
function getImportedCodexThreadIds(agent = 'codex') {
|
||
const field = agent === 'codexapp' ? 'codexAppThreadId' : 'codexThreadId';
|
||
const imported = new Set();
|
||
try {
|
||
for (const f of fs.readdirSync(sessionsDir).filter((name) => name.endsWith('.json'))) {
|
||
try {
|
||
const session = normalizeSession(JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf8')));
|
||
if (session[field]) imported.add(session[field]);
|
||
} catch {}
|
||
}
|
||
} catch {}
|
||
return imported;
|
||
}
|
||
|
||
function parseCodexRolloutFile(filePath) {
|
||
try {
|
||
const content = fs.readFileSync(filePath, 'utf8');
|
||
const parsed = parseCodexRolloutLines(content.split('\n'));
|
||
parsed.filePath = filePath;
|
||
return parsed;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
return {
|
||
parseCodexRolloutLines,
|
||
getCodexRolloutFiles,
|
||
getImportedCodexThreadIds,
|
||
parseCodexRolloutFile,
|
||
};
|
||
}
|
||
|
||
module.exports = { createCodexRolloutStore };
|