Created
May 23, 2026 18:56
-
-
Save wlib/919faabee22755c80a3816c703ce672c to your computer and use it in GitHub Desktop.
Copy out relevant messages from codex
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| // codex-thread — print a single Codex thread as markdown to stdout. | |
| // | |
| // Usage: | |
| // codex-thread <id-or-path> # print one thread | |
| // codex-thread --list # list all threads (id, date, name) | |
| // codex-thread --help | |
| // | |
| // <id-or-path> may be: | |
| // - a full thread id (e.g. 019dea12-7596-7ac0-ac9a-893d790e3278) | |
| // - a unique id prefix (e.g. 019dea12) | |
| // - a path to a rollout-*.jsonl file | |
| // | |
| // Output: one thread, top-level agent only, in the same format as the | |
| // batch export. Subagent transcripts (spawn_agent) are excluded — they | |
| // live inside function_call_output records which this never reads. | |
| // Assistant content is only the final_answer phase (no commentary/reasoning). | |
| // User <environment_context> wrappers are stripped; empty user records dropped. | |
| import fs from "node:fs"; | |
| import path from "node:path"; | |
| import readline from "node:readline"; | |
| import os from "node:os"; | |
| const CODEX_DIR = path.join(os.homedir(), ".codex"); | |
| const SESSIONS_DIR = path.join(CODEX_DIR, "sessions"); | |
| const INDEX_PATH = path.join(CODEX_DIR, "session_index.jsonl"); | |
| function die(msg, code = 1) { process.stderr.write(msg + "\n"); process.exit(code); } | |
| function loadIndex() { | |
| if (!fs.existsSync(INDEX_PATH)) return new Map(); | |
| const map = new Map(); | |
| for (const line of fs.readFileSync(INDEX_PATH, "utf8").split("\n")) { | |
| if (!line.trim()) continue; | |
| try { const e = JSON.parse(line); map.set(e.id, e); } catch {} | |
| } | |
| return map; | |
| } | |
| function findRolloutFiles() { | |
| const files = []; | |
| if (!fs.existsSync(SESSIONS_DIR)) return files; | |
| (function walk(dir) { | |
| for (const name of fs.readdirSync(dir)) { | |
| const full = path.join(dir, name); | |
| const stat = fs.statSync(full); | |
| if (stat.isDirectory()) walk(full); | |
| else if (name.startsWith("rollout-") && name.endsWith(".jsonl")) files.push(full); | |
| } | |
| })(SESSIONS_DIR); | |
| return files; | |
| } | |
| function idFromFilename(name) { | |
| const m = name.match(/rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f-]+)\.jsonl$/); | |
| return m ? m[1] : null; | |
| } | |
| function resolveTarget(arg) { | |
| if (arg.includes("/") || arg.endsWith(".jsonl")) { | |
| if (!fs.existsSync(arg)) die(`File not found: ${arg}`); | |
| return arg; | |
| } | |
| const all = findRolloutFiles(); | |
| const matches = all.filter((f) => { | |
| const id = idFromFilename(path.basename(f)); | |
| return id && (id === arg || id.startsWith(arg)); | |
| }); | |
| if (matches.length === 0) die(`No thread matches id "${arg}". Try: codex-thread --list`); | |
| if (matches.length > 1) { | |
| process.stderr.write(`Ambiguous id "${arg}", matches:\n`); | |
| for (const m of matches) process.stderr.write(` ${idFromFilename(path.basename(m))} ${m}\n`); | |
| process.exit(1); | |
| } | |
| return matches[0]; | |
| } | |
| async function readJsonl(file) { | |
| const items = []; | |
| const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity }); | |
| for await (const line of rl) { | |
| if (!line.trim()) continue; | |
| try { items.push(JSON.parse(line)); } catch {} | |
| } | |
| return items; | |
| } | |
| const joinContent = (content, type) => | |
| Array.isArray(content) | |
| ? content.filter((c) => c && c.type === type && typeof c.text === "string").map((c) => c.text).join("") | |
| : ""; | |
| const stripEnvContext = (t) => t.replace(/^<environment_context>[\s\S]*?<\/environment_context>\n*/, ""); | |
| async function extractPairs(file) { | |
| const items = await readJsonl(file); | |
| const pairs = []; | |
| let lastFinalAnswer = null; | |
| for (const it of items) { | |
| if (!it || it.type !== "response_item" || !it.payload || it.payload.type !== "message") continue; | |
| const p = it.payload; | |
| if (p.role === "assistant" && p.phase === "final_answer") { | |
| lastFinalAnswer = joinContent(p.content, "output_text"); | |
| } else if (p.role === "user") { | |
| const userText = stripEnvContext(joinContent(p.content, "input_text")); | |
| if (userText.trim() === "") continue; | |
| pairs.push({ assistant: lastFinalAnswer, user: userText }); | |
| lastFinalAnswer = null; | |
| } | |
| } | |
| return pairs; | |
| } | |
| function renderThread(meta, file, pairs) { | |
| const header = meta | |
| ? `## ${meta.thread_name} — ${meta.updated_at}` | |
| : `## ${path.basename(file)}`; | |
| const lines = [header, "", `_${path.basename(file)}_`, ""]; | |
| pairs.forEach((p, i) => { | |
| lines.push( | |
| `### Turn ${i + 1}`, | |
| "", | |
| `**Assistant (final_answer):**`, | |
| "", | |
| p.assistant == null ? "> _(no prior assistant message)_" : p.assistant, | |
| "", | |
| `**User:**`, | |
| "", | |
| p.user, | |
| "" | |
| ); | |
| }); | |
| return lines.join("\n"); | |
| } | |
| async function cmdList() { | |
| const index = loadIndex(); | |
| const all = findRolloutFiles(); | |
| const rows = []; | |
| for (const f of all) { | |
| const id = idFromFilename(path.basename(f)); | |
| if (!id) continue; | |
| const meta = index.get(id); | |
| rows.push({ | |
| id, | |
| updated_at: meta?.updated_at ?? "?", | |
| name: meta?.thread_name ?? "(unknown)", | |
| }); | |
| } | |
| rows.sort((a, b) => a.updated_at.localeCompare(b.updated_at)); | |
| for (const r of rows) process.stdout.write(`${r.id} ${r.updated_at} ${r.name}\n`); | |
| } | |
| async function cmdPrint(arg) { | |
| const file = resolveTarget(arg); | |
| const id = idFromFilename(path.basename(file)); | |
| const meta = id ? loadIndex().get(id) : null; | |
| const pairs = await extractPairs(file); | |
| process.stdout.write(renderThread(meta, file, pairs) + "\n"); | |
| process.stderr.write(`(${pairs.length} turns from ${path.basename(file)})\n`); | |
| } | |
| async function main() { | |
| const args = process.argv.slice(2); | |
| if (args.length === 0 || args[0] === "--help" || args[0] === "-h") { | |
| process.stderr.write( | |
| "Usage: codex-thread <id-or-path>\n" + | |
| " codex-thread --list\n" + | |
| "\n" + | |
| "Examples:\n" + | |
| " codex-thread --list\n" + | |
| " codex-thread 019dea12 > out.md\n" + | |
| " codex-thread 019deb3a >> out.md\n" | |
| ); | |
| process.exit(args.length === 0 ? 1 : 0); | |
| } | |
| if (args[0] === "--list" || args[0] === "-l") return cmdList(); | |
| return cmdPrint(args[0]); | |
| } | |
| main().catch((e) => die(String(e?.stack || e))); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment