feat: add desktop app hot update channel
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
// 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,
|
||||
};
|
||||
Reference in New Issue
Block a user