// sessions.js —— 读取 / 解析 / 导出 / 备份 / 导入 Codex 会话记录。 // 直接读 ~/.codex/sessions / archived_sessions 下的 rollout-*.jsonl,不依赖数据库,跨平台。 const fs = require("fs"); const path = require("path"); const os = require("os"); const CODEX_HOME = path.join(os.homedir(), ".codex"); const SESSIONS_DIR = path.join(CODEX_HOME, "sessions"); const ARCHIVED_SESSIONS_DIR = path.join(CODEX_HOME, "archived_sessions"); const SESSION_INDEX = path.join(CODEX_HOME, "session_index.jsonl"); // 递归找出所有 rollout-*.jsonl function findRolloutFiles(base) { const out = []; function walk(dir) { let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const ent of ents) { const full = path.join(dir, ent.name); if (ent.isDirectory()) walk(full); else if (ent.isFile() && ent.name.startsWith("rollout-") && ent.name.endsWith(".jsonl")) out.push(full); } } walk(base); return out; } // 读取 session_index.jsonl,得到 {会话id: Codex 生成的标题} function loadIndexTitles() { const map = {}; try { const txt = fs.readFileSync(SESSION_INDEX, "utf-8"); for (const line of txt.split("\n")) { if (!line.trim()) continue; try { const d = JSON.parse(line); if (d.id && d.thread_name) map[d.id] = d.thread_name; } catch {} } } catch {} return map; } // 只读文件开头一段,快速取元信息与首条用户消息(避免整读大文件) function peekMeta(file) { let head = ""; try { const fd = fs.openSync(file, "r"); const buf = Buffer.alloc(262144); // 256KB:足够越过超长系统提示找到首条用户消息 const n = fs.readSync(fd, buf, 0, buf.length, 0); fs.closeSync(fd); head = buf.slice(0, n).toString("utf-8"); } catch { return null; } const meta = { id: "", cwd: "", ts: "", source: "", threadSource: "", provider: "", title: "" }; const lines = head.split("\n"); for (const line of lines) { if (!line.trim()) continue; let d; try { d = JSON.parse(line); } catch { continue; // 可能是被截断的最后一行 } const p = d.payload || {}; if (d.type === "session_meta") { meta.id = p.session_id || p.id || ""; meta.cwd = p.cwd || ""; meta.ts = p.timestamp || d.timestamp || ""; meta.source = p.source || ""; meta.threadSource = p.thread_source || ""; meta.provider = p.model_provider || ""; } else if (d.type === "event_msg" && p.type === "user_message" && !meta.title) { meta.title = String(p.message || "").replace(/\s+/g, " ").trim().slice(0, 60); } if (meta.id && meta.title) break; } return meta; } function normalizeListOptions(opts) { if (typeof opts === "boolean") return { includeSubagent: opts, includeArchived: false }; return { includeSubagent: !!(opts && opts.includeSubagent), includeArchived: !!(opts && opts.includeArchived), }; } // 列出会话(默认只列当前 sessions,并排除子 agent 派生线程;全量模式会带 archived_sessions) function listConversations(opts = {}) { const { includeSubagent, includeArchived } = normalizeListOptions(opts); const roots = [{ dir: SESSIONS_DIR, archived: false }]; if (includeArchived) roots.push({ dir: ARCHIVED_SESSIONS_DIR, archived: true }); const titles = loadIndexTitles(); const items = []; for (const root of roots) { const files = findRolloutFiles(root.dir); for (const file of files) { const m = peekMeta(file); if (!m) continue; if (!includeSubagent && m.threadSource === "subagent") continue; let size = 0; try { size = fs.statSync(file).size; } catch {} items.push({ id: m.id, title: titles[m.id] || m.title || "(无标题对话)", project: m.cwd ? path.basename(m.cwd) : "", cwd: m.cwd, ts: m.ts, source: m.source, threadSource: m.threadSource, provider: m.provider, path: file, size, archived: root.archived, }); } } items.sort((a, b) => (b.ts || "").localeCompare(a.ts || "")); return items; } // 解析单个会话文件为可读消息列表 function readConversation(file) { const messages = []; let meta = {}; let content; try { content = fs.readFileSync(file, "utf-8"); } catch (e) { return { meta, messages, error: String(e) }; } for (const line of content.split("\n")) { if (!line.trim()) continue; let d; try { d = JSON.parse(line); } catch { continue; } const p = d.payload || {}; const ts = d.timestamp || ""; if (d.type === "session_meta") { meta = { id: p.session_id || p.id || "", cwd: p.cwd || "", ts: p.timestamp || ts, provider: p.model_provider || "" }; } else if (d.type === "event_msg" && p.type === "user_message") { messages.push({ role: "user", text: String(p.message || ""), ts }); } else if (d.type === "event_msg" && p.type === "agent_message") { messages.push({ role: "assistant", text: String(p.message || ""), ts }); } else if (d.type === "response_item" && p.type === "function_call") { const name = p.name || "tool"; messages.push({ role: "tool", text: `调用工具:${name}`, ts, collapsed: true }); } } return { meta, messages }; } // 导出为 Markdown 文本 function toMarkdown(item, conv) { const lines = []; lines.push(`# ${item.title || "Codex 会话记录"}`); lines.push(""); if (item.project) lines.push(`- 项目:${item.project}`); if (item.cwd) lines.push(`- 目录:${item.cwd}`); if (item.ts) lines.push(`- 时间:${item.ts}`); if (item.provider) lines.push(`- Provider:${item.provider}`); lines.push(""); lines.push("---"); lines.push(""); for (const m of conv.messages) { if (m.role === "user") lines.push(`## 👤 用户\n\n${m.text}\n`); else if (m.role === "assistant") lines.push(`## 🤖 Codex\n\n${m.text}\n`); else if (m.role === "tool") lines.push(`> 🔧 ${m.text}\n`); } return lines.join("\n"); } // 备份:把整个 sessions/ 与索引复制到目标目录下的 codex-sessions-backup/ function backupAll(destDir, log = () => {}) { const out = path.join(destDir, "codex-sessions-backup"); fs.mkdirSync(out, { recursive: true }); const files = findRolloutFiles(SESSIONS_DIR); let n = 0; for (const f of files) { const rel = path.relative(SESSIONS_DIR, f); const dst = path.join(out, "sessions", rel); fs.mkdirSync(path.dirname(dst), { recursive: true }); fs.copyFileSync(f, dst); n++; } if (fs.existsSync(SESSION_INDEX)) fs.copyFileSync(SESSION_INDEX, path.join(out, "session_index.jsonl")); log(`已备份 ${n} 个会话文件到 ${out}`); return { path: out, count: n }; } // 导入:把外部备份目录里的 rollout-*.jsonl 合并进本机 sessions/(按原年月日结构,已存在则跳过) function importSessions(srcDir, log = () => {}) { // 支持传入 backup 根目录或其中的 sessions/ 子目录 let base = srcDir; if (fs.existsSync(path.join(srcDir, "sessions"))) base = path.join(srcDir, "sessions"); const files = findRolloutFiles(base); let added = 0; let skipped = 0; for (const f of files) { const rel = path.relative(base, f); const dst = path.join(SESSIONS_DIR, rel); if (fs.existsSync(dst)) { skipped++; continue; } fs.mkdirSync(path.dirname(dst), { recursive: true }); fs.copyFileSync(f, dst); added++; } log(`导入完成:新增 ${added} 个,跳过 ${skipped} 个(已存在)`); return { added, skipped }; } module.exports = { CODEX_HOME, SESSIONS_DIR, ARCHIVED_SESSIONS_DIR, listConversations, readConversation, toMarkdown, backupAll, importSessions, };