243 lines
8.0 KiB
JavaScript
243 lines
8.0 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/、archived_sessions/ 与索引复制到目标目录下的 codex-sessions-backup/
|
||
function backupAll(destDir, log = () => {}) {
|
||
const out = path.join(destDir, "codex-sessions-backup");
|
||
fs.mkdirSync(out, { recursive: true });
|
||
let n = 0;
|
||
for (const [name, dir] of [["sessions", SESSIONS_DIR], ["archived_sessions", ARCHIVED_SESSIONS_DIR]]) {
|
||
const files = findRolloutFiles(dir);
|
||
for (const f of files) {
|
||
const rel = path.relative(dir, f);
|
||
const dst = path.join(out, name, 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/ 与 archived_sessions/(已存在则跳过)
|
||
function importSessions(srcDir, log = () => {}) {
|
||
// 支持传入 backup 根目录或其中的 sessions/ 子目录
|
||
let added = 0;
|
||
let skipped = 0;
|
||
const roots = fs.existsSync(path.join(srcDir, "sessions"))
|
||
? [
|
||
{ base: path.join(srcDir, "sessions"), dest: SESSIONS_DIR },
|
||
{ base: path.join(srcDir, "archived_sessions"), dest: ARCHIVED_SESSIONS_DIR },
|
||
]
|
||
: [{ base: srcDir, dest: SESSIONS_DIR }];
|
||
for (const root of roots) {
|
||
const files = findRolloutFiles(root.base);
|
||
for (const f of files) {
|
||
const rel = path.relative(root.base, f);
|
||
const dst = path.join(root.dest, 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,
|
||
};
|