import type { Dirent } from "node:fs"; import { readdir, readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, join } from "node:path"; import { type ChatMessage, type ChatSession, clampText, compactJson, isInScanRoots, stableMessageId, toDateOrUndefined, } from "./chat-types"; interface CodexRecord { timestamp?: string; type?: string; payload?: Record; } interface CodexSessionMeta { id?: string; cwd?: string; originator?: string; cli_version?: string; } function extractCodexMessageText(content: unknown): string { if (typeof content === "string") { return content.trim(); } if (!Array.isArray(content)) { return ""; } const segments: string[] = []; for (const part of content) { if (typeof part === "string") { if (part.trim()) segments.push(part.trim()); continue; } if (part && typeof part === "object") { const candidate = (part as { text?: unknown }).text || (part as { input_text?: unknown }).input_text || (part as { output_text?: unknown }).output_text; if (typeof candidate === "string" && candidate.trim()) { segments.push(candidate.trim()); } } } return segments.join("\n\n"); } function extractReasoningText(payload: Record): string { const segments: string[] = []; const summary = payload.summary; if (Array.isArray(summary)) { for (const part of summary) { if (part && typeof part === "object") { const text = (part as { text?: unknown }).text; if (typeof text === "string" && text.trim()) { segments.push(text.trim()); } } } } const content = payload.content; if (typeof content === "string" && content.trim()) { segments.push(content.trim()); } return segments.join("\n\n"); } function normalizeCodexMessage( record: CodexRecord, threadId: string, index: number, ): ChatMessage | null { if (record.type !== "response_item" || !record.payload) { return null; } const timestamp = toDateOrUndefined(record.timestamp); if (!timestamp) { return null; } const payload = record.payload; const payloadType = typeof payload.type === "string" ? payload.type : ""; let role: ChatMessage["role"]; let text = ""; let toolName: string | undefined; switch (payloadType) { case "message": { const payloadRole = typeof payload.role === "string" ? payload.role : "assistant"; role = payloadRole === "user" ? "user" : payloadRole === "assistant" ? "assistant" : payloadRole === "tool" ? "tool" : "system"; text = extractCodexMessageText(payload.content); break; } case "function_call": { role = "tool"; toolName = typeof payload.name === "string" ? payload.name : "function_call"; const args = compactJson(payload.arguments, 12000); text = args ? `${toolName}\n${args}` : toolName; break; } case "function_call_output": { role = "tool"; toolName = typeof payload.name === "string" ? payload.name : typeof payload.call_id === "string" ? payload.call_id : "function_output"; text = compactJson(payload.output, 32000); if (!text) { text = toolName; } break; } case "reasoning": { role = "assistant"; text = extractReasoningText(payload); break; } default: return null; } if (!text) { return null; } const messageIdHint = (typeof payload.id === "string" && payload.id) || (typeof payload.call_id === "string" && payload.call_id) || `${payloadType}-${index}`; return { externalMessageId: stableMessageId(threadId, index, messageIdHint), role, timestamp, text: clampText(text, 32000), toolName, rawJson: { type: payloadType, role: payload.role, name: payload.name, callId: payload.call_id, }, }; } async function existsDir(path: string): Promise { try { const stats = await stat(path); return stats.isDirectory(); } catch { return false; } } async function* walkCodexSessionFiles( dir: string, depth: number, ): AsyncGenerator { if (depth > 4) return; let entries: Dirent[] = []; try { entries = await readdir(dir, { withFileTypes: true, encoding: "utf8" }); } catch { return; } for (const entry of entries) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { yield* walkCodexSessionFiles(fullPath, depth + 1); continue; } if (entry.isFile() && entry.name.endsWith(".jsonl")) { yield fullPath; } } } export function getCodexSessionsDir(): string { return join(homedir(), ".codex", "sessions"); } export async function parseCodexSessionFile( filePath: string, ): Promise { try { const fileContent = await readFile(filePath, "utf-8"); const lines = fileContent.split("\n").filter((line) => line.trim()); let sessionMeta: CodexSessionMeta = {}; let externalThreadId = basename(filePath).replace(/\.jsonl$/, ""); const messages: ChatMessage[] = []; // Surface silently-skipped malformed lines instead of losing them with zero // diagnostic (a session file corrupted mid-write must not look like "no activity"). let skippedLines = 0; let firstSkippedSample: string | undefined; for (let i = 0; i < lines.length; i++) { let record: CodexRecord; try { record = JSON.parse(lines[i]) as CodexRecord; } catch { skippedLines++; if (firstSkippedSample === undefined) { firstSkippedSample = lines[i].slice(0, 120); } continue; } if (record.type === "session_meta" && record.payload) { const meta = record.payload as CodexSessionMeta; sessionMeta = meta; if (typeof meta.id === "string" && meta.id) { externalThreadId = meta.id; } continue; } const message = normalizeCodexMessage(record, externalThreadId, i); if (message) { messages.push(message); } } if (skippedLines > 0) { console.warn( `[codex] skipped ${skippedLines} malformed line(s) in ${filePath}` + (firstSkippedSample ? ` (first: ${firstSkippedSample})` : ""), ); } if (messages.length === 0) { return null; } messages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); return { providerId: "codex", externalThreadId, projectPath: typeof sessionMeta.cwd === "string" ? sessionMeta.cwd : undefined, title: basename(filePath).replace(/\.jsonl$/, ""), conversationFile: filePath, startedAt: messages[0]?.timestamp, endedAt: messages[messages.length - 1]?.timestamp, metadata: { originator: typeof sessionMeta.originator === "string" ? sessionMeta.originator : undefined, cliVersion: typeof sessionMeta.cli_version === "string" ? sessionMeta.cli_version : undefined, }, messages, }; } catch (err) { console.warn( `[codex] failed to read/parse session file ${filePath}: ${err}`, ); return null; } } export async function* getCodexSessionsStreaming( scanRoots: string[], since?: Date, options?: { dataRoots?: string[] }, ): AsyncGenerator { const roots = options?.dataRoots ?? [getCodexSessionsDir()]; for (const root of roots) { if (!(await existsDir(root))) { continue; } for await (const filePath of walkCodexSessionFiles(root, 0)) { if (since) { try { const fileStats = await stat(filePath); if (fileStats.mtime < since) continue; } catch { continue; } } const session = await parseCodexSessionFile(filePath); if (!session) continue; if (!isInScanRoots(session.projectPath, scanRoots)) continue; if (since) { const filteredMessages = session.messages.filter( (message) => message.timestamp >= since, ); if (filteredMessages.length === 0) continue; session.messages = filteredMessages; } yield session; } } }