Files
codex-market-app/electron/lib/sessions.js
T
2026-07-15 18:01:56 +08:00

235 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};