235 lines
7.7 KiB
JavaScript
235 lines
7.7 KiB
JavaScript
|
|
// 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,
|
|||
|
|
};
|