Files

858 lines
32 KiB
JavaScript
Raw Permalink 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 { spawnSync } = require("child_process");
const initSqlJs = require("sql.js");
const { cleanThreadTitle, cleanUserVisibleText, stripInternalThoughtBlocks, looksLikeSkillMentionTitle } = require("./title_clean");
const codexDataDir = require("./codex_data_dir");
let permanentDeleteBusy = false;
let clearAllBusy = false;
const DEFAULT_LIST_LIMIT = 500;
const ALL_LIST_LIMIT = 5000;
function paths() {
const codexHome = codexDataDir.get();
return {
codexHome,
sessionsDir: path.join(codexHome, "sessions"),
archivedSessionsDir: path.join(codexHome, "archived_sessions"),
sessionIndex: path.join(codexHome, "session_index.jsonl"),
titleCleanMarker: path.join(codexHome, ".td-title-clean-v1.json"),
};
}
function isInside(base, file) {
const rel = path.relative(path.resolve(base), path.resolve(file));
return rel && !rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel);
}
function sessionRoots() {
const { sessionsDir, archivedSessionsDir } = paths();
return [sessionsDir, archivedSessionsDir];
}
function sessionFileAllowed(file) {
return sessionRoots().some((root) => isInside(root, file)) &&
path.basename(file).startsWith("rollout-") && path.extname(file) === ".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(paths().sessionIndex, "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] = cleanThreadTitle(d.thread_name, "", 80);
} 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") {
// 新版 subagent 的 session_id 指向父线程,id 才是当前子线程。
meta.id = p.id || p.session_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 = cleanThreadTitle(p.message || "", "", 200);
}
if (meta.id && meta.title) break;
}
return meta;
}
function sqlite3Available() {
const r = spawnSync("sqlite3", ["--version"], { encoding: "utf-8" });
return !r.error && r.status === 0;
}
function runSqlite3(dbPath, sql) {
const r = spawnSync("sqlite3", ["-json", dbPath, sql], {
encoding: "utf-8",
maxBuffer: 20 * 1024 * 1024,
});
if (r.error || r.status !== 0) {
throw new Error((r.stderr || (r.error && r.error.message) || "sqlite3 执行失败").trim());
}
return r.stdout || "";
}
function quoteIdent(value) {
return "\"" + String(value || "").replace(/"/g, "\"\"") + "\"";
}
function sqliteTables(dbPath) {
try {
return JSON.parse(runSqlite3(dbPath, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';") || "[]");
} catch {
return [];
}
}
function sqliteTableInfo(dbPath, table) {
try {
return JSON.parse(runSqlite3(dbPath, "PRAGMA table_info(" + quoteIdent(table) + ");") || "[]");
} catch {
return [];
}
}
function sqliteForeignKeys(dbPath, table) {
try {
return JSON.parse(runSqlite3(dbPath, "PRAGMA foreign_key_list(" + quoteIdent(table) + ");") || "[]");
} catch {
return [];
}
}
function sqlJsRows(db, statement) {
const result = db.exec(statement);
if (!result.length) return [];
const columns = result[0].columns;
return result[0].values.map((values) => Object.fromEntries(columns.map((name, index) => [name, values[index]])));
}
function sqliteBufferWithCommittedWal(dbPath) {
let main = fs.readFileSync(dbPath);
const walPath = dbPath + "-wal";
if (!fs.existsSync(walPath)) return main;
const wal = fs.readFileSync(walPath);
if (wal.length < 32) return main;
const magic = wal.readUInt32BE(0);
if (magic !== 0x377f0682 && magic !== 0x377f0683) throw new Error("SQLite WAL 头无效,已停止删除");
let pageSize = wal.readUInt32BE(8);
if (pageSize === 1) pageSize = 65536;
if (!pageSize || (pageSize & (pageSize - 1)) !== 0) throw new Error("SQLite WAL 页大小无效,已停止删除");
const frameSize = 24 + pageSize;
const salt1 = wal.readUInt32BE(16);
const salt2 = wal.readUInt32BE(20);
const littleEndianChecksum = magic === 0x377f0682;
const checksum = (buffer, start, end, sums) => {
let [s1, s2] = sums;
for (let offset = start; offset < end; offset += 8) {
const a = littleEndianChecksum ? buffer.readUInt32LE(offset) : buffer.readUInt32BE(offset);
const b = littleEndianChecksum ? buffer.readUInt32LE(offset + 4) : buffer.readUInt32BE(offset + 4);
s1 = (s1 + a + s2) >>> 0;
s2 = (s2 + b + s1) >>> 0;
}
return [s1, s2];
};
let sums = checksum(wal, 0, 24, [0, 0]);
if (sums[0] !== wal.readUInt32BE(24) || sums[1] !== wal.readUInt32BE(28)) {
throw new Error("SQLite WAL 校验失败,已停止删除");
}
let lastCommitOffset = -1;
let committedPages = 0;
for (let offset = 32; offset + frameSize <= wal.length; offset += frameSize) {
if (wal.readUInt32BE(offset + 8) !== salt1 || wal.readUInt32BE(offset + 12) !== salt2) break;
sums = checksum(wal, offset, offset + 8, sums);
sums = checksum(wal, offset + 24, offset + 24 + pageSize, sums);
if (sums[0] !== wal.readUInt32BE(offset + 16) || sums[1] !== wal.readUInt32BE(offset + 20)) break;
const dbSize = wal.readUInt32BE(offset + 4);
if (dbSize > 0) {
lastCommitOffset = offset;
committedPages = dbSize;
}
}
if (lastCommitOffset < 0) return main;
const merged = Buffer.alloc(committedPages * pageSize);
main.copy(merged, 0, 0, Math.min(main.length, merged.length));
for (let offset = 32; offset <= lastCommitOffset; offset += frameSize) {
const pageNo = wal.readUInt32BE(offset);
if (!pageNo || pageNo > committedPages) continue;
wal.copy(merged, (pageNo - 1) * pageSize, offset + 24, offset + 24 + pageSize);
}
return merged;
}
function replaceStateDb(dbPath, data) {
const tmp = dbPath + ".td-delete-" + process.pid + ".tmp";
const old = dbPath + ".td-delete-" + process.pid + ".old";
const oldWal = old + "-wal";
const oldShm = old + "-shm";
fs.rmSync(old, { force: true });
fs.rmSync(oldWal, { force: true });
fs.rmSync(oldShm, { force: true });
const fd = fs.openSync(tmp, "w");
try {
fs.writeFileSync(fd, data);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
let movedMain = false;
let movedWal = false;
let movedShm = false;
let installed = false;
try {
fs.renameSync(dbPath, old);
movedMain = true;
if (fs.existsSync(dbPath + "-wal")) { fs.renameSync(dbPath + "-wal", oldWal); movedWal = true; }
if (fs.existsSync(dbPath + "-shm")) { fs.renameSync(dbPath + "-shm", oldShm); movedShm = true; }
fs.renameSync(tmp, dbPath);
installed = true;
} catch (e) {
if (installed) { try { fs.rmSync(dbPath, { force: true }); } catch {} }
if (movedMain && fs.existsSync(old) && !fs.existsSync(dbPath)) fs.renameSync(old, dbPath);
if (movedWal && fs.existsSync(oldWal) && !fs.existsSync(dbPath + "-wal")) fs.renameSync(oldWal, dbPath + "-wal");
if (movedShm && fs.existsSync(oldShm) && !fs.existsSync(dbPath + "-shm")) fs.renameSync(oldShm, dbPath + "-shm");
throw e;
} finally {
fs.rmSync(tmp, { force: true });
}
try { fs.rmSync(old, { force: true }); } catch {}
try { fs.rmSync(oldWal, { force: true }); } catch {}
try { fs.rmSync(oldShm, { force: true }); } catch {}
}
async function deleteSessionFromStateDb(SQL, dbPath, id) {
let db;
try {
db = new SQL.Database(sqliteBufferWithCommittedWal(dbPath));
const tables = sqlJsRows(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").map((row) => row.name).filter(Boolean);
if (!tables.includes("threads")) return false;
db.run("PRAGMA foreign_keys=ON");
db.run("PRAGMA secure_delete=ON");
db.run("BEGIN");
let changed = false;
for (const table of tables) {
if (table === "threads") continue;
const columns = sqlJsRows(db, "PRAGMA table_info(" + quoteIdent(table) + ")").map((row) => String(row.name || ""));
const fks = sqlJsRows(db, "PRAGMA foreign_key_list(" + quoteIdent(table) + ")");
const direct = columns.filter((name) => /(^|_)thread_id$/i.test(name));
const referencesThread = fks.filter((fk) => String(fk.table || "").toLowerCase() === "threads").map((fk) => fk.from).filter(Boolean);
for (const column of Array.from(new Set(direct.concat(referencesThread)))) {
db.run("DELETE FROM " + quoteIdent(table) + " WHERE " + quoteIdent(column) + "=?", [id]);
changed = db.getRowsModified() > 0 || changed;
}
}
db.run("DELETE FROM " + quoteIdent("threads") + " WHERE " + quoteIdent("id") + "=?", [id]);
changed = db.getRowsModified() > 0 || changed;
db.run("COMMIT");
if (changed) {
db.run("VACUUM");
const fkErrors = sqlJsRows(db, "PRAGMA foreign_key_check");
const integrity = sqlJsRows(db, "PRAGMA integrity_check");
if (fkErrors.length || !integrity.length || integrity.some((row) => String(Object.values(row)[0] || "").toLowerCase() !== "ok")) {
throw new Error("SQLite 删除后完整性校验失败,已停止替换");
}
for (const table of tables) {
const columns = sqlJsRows(db, "PRAGMA table_info(" + quoteIdent(table) + ")").map((row) => String(row.name || ""));
for (const column of columns.filter((name) => /(^|_)thread_id$/i.test(name))) {
const hit = sqlJsRows(db, "SELECT 1 AS hit FROM " + quoteIdent(table) + " WHERE " + quoteIdent(column) + "=" + sql(id) + " LIMIT 1");
if (hit.length) throw new Error("SQLite 仍有会话关联记录,已停止替换");
}
}
replaceStateDb(dbPath, Buffer.from(db.export()));
}
return changed;
} catch (e) {
if (db) try { db.run("ROLLBACK"); } catch {}
throw e;
} finally {
if (db) try { db.close(); } catch {}
}
}
function removeSessionIndexEntry(id) {
const { sessionIndex } = paths();
if (!fs.existsSync(sessionIndex)) return false;
const lines = fs.readFileSync(sessionIndex, "utf-8").split("\n");
const kept = lines.filter((line) => {
if (!line.trim()) return true;
try { return JSON.parse(line).id !== id; } catch { return true; }
});
if (kept.length === lines.length) return false;
const tmp = sessionIndex + ".td-delete-" + process.pid + ".tmp";
const old = sessionIndex + ".td-delete-" + process.pid + ".old";
fs.rmSync(old, { force: true });
const fd = fs.openSync(tmp, "w");
try {
fs.writeFileSync(fd, kept.join("\n"), "utf-8");
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
try {
fs.renameSync(sessionIndex, old);
try { fs.renameSync(tmp, sessionIndex); } catch (e) { fs.renameSync(old, sessionIndex); throw e; }
try { fs.rmSync(old, { force: true }); } catch {}
} finally { fs.rmSync(tmp, { force: true }); }
return true;
}
// 永久删除一个会话:正文、归档正文、Codex 标题/状态索引一起清理。
// 只接受本机 sessions/archived_sessions 下的 rollout 文件,禁止借此 IPC 删除任意路径。
async function deleteConversation(filePath) {
if (permanentDeleteBusy) throw new Error("已有永久删除任务正在执行,请稍后重试");
permanentDeleteBusy = true;
try {
const requested = path.resolve(String(filePath || ""));
if (!sessionFileAllowed(requested) || !fs.existsSync(requested)) throw new Error("无效的会话文件");
const meta = peekMeta(requested);
if (!meta || !meta.id) throw new Error("无法识别会话 ID,已停止删除");
const files = [];
for (const root of sessionRoots()) {
for (const file of findRolloutFiles(root)) {
const m = peekMeta(file);
if (m && m.id === meta.id) files.push(file);
}
}
const { codexHome } = paths();
const stateDbs = fs.readdirSync(codexHome).filter((name) => /^state_.*\.sqlite$/.test(name));
const SQL = await initSqlJs({
locateFile: (file) => path.join(global.__BUNDLE_ROOT__ || path.join(__dirname, "..", ".."), "node_modules", "sql.js", "dist", file),
});
let stateRowsRemoved = 0;
for (const name of stateDbs) {
if (await deleteSessionFromStateDb(SQL, path.join(codexHome, name), meta.id)) stateRowsRemoved++;
}
const indexRemoved = removeSessionIndexEntry(meta.id);
for (const file of files) fs.rmSync(file, { force: true });
return { id: meta.id, cwd: meta.cwd || "", filesRemoved: files.length, indexRemoved, stateDbs: stateDbs.length, stateRowsRemoved };
} finally {
permanentDeleteBusy = false;
}
}
// 清空全部 Codex 会话:删除 rollout 正文、状态库线程关联记录、桌面侧栏目录和会话索引。
// 配置、登录态、项目、插件、自动化任务均保留;执行前由调用方保证 Codex 已完全退出。
function deleteAllThreadLinkedRows(SQL, dbPath, { requireThreads = false } = {}) {
if (!fs.existsSync(dbPath)) return { changed: false, rows: 0 };
let db;
try {
db = new SQL.Database(sqliteBufferWithCommittedWal(dbPath));
const tables = sqlJsRows(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.map((row) => row.name).filter(Boolean);
if (requireThreads && !tables.includes("threads")) return { changed: false, rows: 0 };
db.run("PRAGMA foreign_keys=ON");
db.run("PRAGMA secure_delete=ON");
db.run("BEGIN");
let rows = 0;
const deferred = [];
for (const table of tables) {
// 自动化定义不是会话正文;即使它记录了 target_thread_id,也必须保留。
if (table === "automations") continue;
const columns = sqlJsRows(db, "PRAGMA table_info(" + quoteIdent(table) + ")").map((row) => String(row.name || ""));
const fks = sqlJsRows(db, "PRAGMA foreign_key_list(" + quoteIdent(table) + ")");
const linked = columns.filter((name) => /(^|_)(thread_id|parent_thread_id|child_thread_id)$/i.test(name));
const referencesThreads = fks
.filter((fk) => String(fk.table || "").toLowerCase() === "threads")
.map((fk) => String(fk.from || ""));
const targets = Array.from(new Set(linked.concat(referencesThreads))).filter(Boolean);
if (table === "threads") {
deferred.push(table);
continue;
}
for (const column of targets) {
db.run("DELETE FROM " + quoteIdent(table) + " WHERE " + quoteIdent(column) + " IS NOT NULL");
rows += db.getRowsModified();
}
}
if (tables.includes("threads")) {
db.run("DELETE FROM " + quoteIdent("threads"));
rows += db.getRowsModified();
}
db.run("COMMIT");
if (!rows) return { changed: false, rows: 0 };
db.run("VACUUM");
const fkErrors = sqlJsRows(db, "PRAGMA foreign_key_check");
const integrity = sqlJsRows(db, "PRAGMA integrity_check");
if (fkErrors.length || !integrity.length || integrity.some((row) => String(Object.values(row)[0] || "").toLowerCase() !== "ok")) {
throw new Error("SQLite 清空会话后完整性校验失败,已停止替换");
}
replaceStateDb(dbPath, Buffer.from(db.export()));
return { changed: true, rows };
} catch (e) {
if (db) try { db.run("ROLLBACK"); } catch {}
throw e;
} finally {
if (db) try { db.close(); } catch {}
}
}
function copyIfExists(source, target) {
if (!fs.existsSync(source)) return false;
fs.mkdirSync(path.dirname(target), { recursive: true });
const stat = fs.statSync(source);
if (stat.isDirectory()) {
fs.cpSync(source, target, { recursive: true, force: true });
} else {
fs.copyFileSync(source, target);
}
return true;
}
function pruneEmptyDirs(root) {
if (!fs.existsSync(root)) return;
let entries;
try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
if (entry.isDirectory()) pruneEmptyDirs(path.join(root, entry.name));
}
try {
if (!fs.readdirSync(root).length) fs.rmdirSync(root);
} catch {}
}
async function clearAllConversations() {
if (clearAllBusy) throw new Error("已有清空会话任务正在执行,请稍后重试");
clearAllBusy = true;
let backup = "";
const stagedDirs = [];
const copiedFiles = [];
try {
const { codexHome, sessionsDir, archivedSessionsDir, sessionIndex } = paths();
const rolloutFiles = sessionRoots().flatMap((root) => findRolloutFiles(root));
const timestamp = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
backup = path.join(codexHome, "backups_state", "session-clear", timestamp);
fs.mkdirSync(backup, { recursive: true });
// 会话正文用同盘 rename 暂存,不复制 700+ 个大 JSONL;清空成功后保留备份,便于恢复。
for (const [name, source] of [["sessions", sessionsDir], ["archived_sessions", archivedSessionsDir]]) {
if (!fs.existsSync(source)) continue;
const target = path.join(backup, name);
fs.renameSync(source, target);
stagedDirs.push({ source, target });
fs.mkdirSync(source, { recursive: true });
}
if (fs.existsSync(sessionIndex)) {
copyIfExists(sessionIndex, path.join(backup, "session_index.jsonl"));
copiedFiles.push([sessionIndex, path.join(backup, "session_index.jsonl")]);
fs.writeFileSync(sessionIndex, "", "utf-8");
}
for (const name of fs.readdirSync(codexHome)) {
if (!/^state_.*\.sqlite(?:-(?:wal|shm))?$/.test(name)) continue;
const source = path.join(codexHome, name);
const target = path.join(backup, name);
copyIfExists(source, target);
copiedFiles.push([source, target]);
}
const catalogDb = path.join(codexHome, "sqlite", "codex-dev.db");
for (const suffix of ["", "-wal", "-shm"]) {
const source = catalogDb + suffix;
if (!fs.existsSync(source)) continue;
const target = path.join(backup, "codex-dev.db" + suffix);
copyIfExists(source, target);
copiedFiles.push([source, target]);
}
const indexLines = copiedFiles.some(([source]) => source === sessionIndex)
? fs.readFileSync(path.join(backup, "session_index.jsonl"), "utf-8").split("\n").filter((line) => line.trim()).length
: 0;
const SQL = await initSqlJs({
locateFile: (file) => path.join(global.__BUNDLE_ROOT__ || path.join(__dirname, "..", ".."), "node_modules", "sql.js", "dist", file),
});
let stateRowsRemoved = 0;
for (const name of fs.readdirSync(codexHome).filter((item) => /^state_.*\.sqlite$/.test(item))) {
const result = deleteAllThreadLinkedRows(SQL, path.join(codexHome, name), { requireThreads: true });
stateRowsRemoved += result.rows;
}
const catalog = deleteAllThreadLinkedRows(SQL, catalogDb);
return {
ok: true,
filesRemoved: rolloutFiles.length,
indexEntriesRemoved: indexLines,
stateRowsRemoved,
catalogRowsRemoved: catalog.rows,
backup,
};
} catch (error) {
// 任一 SQLite/文件步骤失败,恢复正文目录、索引和数据库备份,避免半清空状态。
for (const [source, target] of copiedFiles) {
try {
if (fs.existsSync(target)) {
fs.mkdirSync(path.dirname(source), { recursive: true });
fs.copyFileSync(target, source);
}
} catch {}
}
for (const { source, target } of stagedDirs.reverse()) {
try {
fs.rmSync(source, { recursive: true, force: true });
if (fs.existsSync(target)) fs.renameSync(target, source);
} catch {}
}
throw error;
} finally {
clearAllBusy = false;
}
}
function findConversationById(id) {
const target = String(id || "").trim();
if (!target || target.length > 160) return null;
for (const root of sessionRoots()) {
for (const file of findRolloutFiles(root)) {
const meta = peekMeta(file);
if (meta && meta.id === target) return { ...meta, path: file };
}
}
return null;
}
function sql(v) {
return "'" + String(v == null ? "" : v).replace(/'/g, "''") + "'";
}
function findFallbackTitlesById() {
const map = {};
for (const root of sessionRoots()) {
for (const file of findRolloutFiles(root)) {
const m = peekMeta(file);
if (m && m.id && m.title) map[m.id] = m.title;
}
}
return map;
}
function repairStateDbTitles(fallbackTitles, log = () => {}) {
if (!sqlite3Available()) return 0;
let fixed = 0;
let files = [];
const { codexHome } = paths();
try {
files = fs.readdirSync(codexHome).filter((f) => /^state_.*\.sqlite$/.test(f));
} catch {
return 0;
}
for (const name of files) {
const dbPath = path.join(codexHome, name);
try {
const out = runSqlite3(dbPath, "SELECT id,title,first_user_message FROM threads WHERE title LIKE '[$%' OR title LIKE '$%';");
const rows = JSON.parse(out || "[]");
const updates = [];
for (const row of rows) {
const next = cleanThreadTitle(row.title, row.first_user_message || fallbackTitles[row.id] || "", 80);
if (next && next !== row.title) updates.push({ id: row.id, title: next });
}
if (!updates.length) continue;
const statements = ["BEGIN;"].concat(
updates.map((u) => `UPDATE threads SET title=${sql(u.title)} WHERE id=${sql(u.id)};`),
["COMMIT;"]
).join("\n");
runSqlite3(dbPath, statements);
fixed += updates.length;
} catch (e) {
log(`标题清洗跳过 ${name}:${e.message || e}`);
}
}
return fixed;
}
function repairSkillMentionTitles(log = () => {}) {
let raw = "";
let indexMtimeMs = 0;
const { sessionIndex, titleCleanMarker } = paths();
try {
indexMtimeMs = fs.statSync(sessionIndex).mtimeMs;
const marker = JSON.parse(fs.readFileSync(titleCleanMarker, "utf-8"));
if (marker && Number(marker.indexMtimeMs) >= indexMtimeMs) return { indexFixed: 0, dbFixed: 0, skipped: true };
} catch {}
try {
raw = fs.readFileSync(sessionIndex, "utf-8");
} catch {
return { indexFixed: 0, dbFixed: 0 };
}
const fallbackTitles = findFallbackTitlesById();
let indexFixed = 0;
const lines = raw.split("\n").map((line) => {
if (!line.trim()) return line;
try {
const d = JSON.parse(line);
if (!d || !d.id || !d.thread_name || !looksLikeSkillMentionTitle(d.thread_name)) return line;
const next = cleanThreadTitle(d.thread_name, fallbackTitles[d.id] || "", 80);
if (!next || next === d.thread_name) return line;
d.thread_name = next;
indexFixed++;
return JSON.stringify(d);
} catch {
return line;
}
});
if (indexFixed) {
const backup = sessionIndex + ".bak-title-clean-" + new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14);
try {
fs.copyFileSync(sessionIndex, backup);
} catch {}
fs.writeFileSync(sessionIndex, lines.join("\n"), "utf-8");
}
const dbFixed = repairStateDbTitles(fallbackTitles, log);
try {
fs.writeFileSync(titleCleanMarker, JSON.stringify({ indexMtimeMs, checkedAt: new Date().toISOString() }, null, 2), "utf-8");
} catch {}
return { indexFixed, dbFixed };
}
function normalizeListOptions(opts) {
if (typeof opts === "boolean") return { includeSubagent: opts, includeArchived: false, limit: DEFAULT_LIST_LIMIT };
const requestedLimit = Number(opts && opts.limit);
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
? Math.min(Math.floor(requestedLimit), ALL_LIST_LIMIT)
: DEFAULT_LIST_LIMIT;
return {
includeSubagent: !!(opts && opts.includeSubagent),
includeArchived: !!(opts && opts.includeArchived),
limit,
};
}
// 列出会话(默认只列当前 sessions,并排除子 agent 派生线程;全量模式会带 archived_sessions)
function listConversations(opts = {}) {
const { includeSubagent, includeArchived, limit } = normalizeListOptions(opts);
const { sessionsDir, archivedSessionsDir } = paths();
const roots = [{ dir: sessionsDir, archived: false }];
if (includeArchived) roots.push({ dir: archivedSessionsDir, 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: cleanThreadTitle(titles[m.id] || "", m.title, 80) || "(无标题对话)",
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.slice(0, limit);
}
// 解析单个会话文件为可读消息列表
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.id || p.session_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: cleanUserVisibleText(String(p.message || "")), ts });
} else if (d.type === "event_msg" && p.type === "agent_message") {
messages.push({ role: "assistant", text: stripInternalThoughtBlocks(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;
const { sessionsDir, archivedSessionsDir, sessionIndex } = paths();
for (const [name, dir] of [["sessions", sessionsDir], ["archived_sessions", archivedSessionsDir]]) {
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(sessionIndex)) fs.copyFileSync(sessionIndex, path.join(out, "session_index.jsonl"));
log(`已备份 ${n} 个会话文件到 ${out}`);
return { path: out, count: n };
}
function resolveImportRoot(srcDir) {
const root = path.resolve(String(srcDir || ""));
if (fs.existsSync(path.join(root, "sessions"))) return root;
const nested = path.join(root, "codex-sessions-backup");
if (fs.existsSync(path.join(nested, "sessions"))) return nested;
return root;
}
function mergeSessionIndex(srcIndex, dstIndex) {
if (!fs.existsSync(srcIndex)) return 0;
const entries = new Map();
const read = (file) => {
try {
for (const line of fs.readFileSync(file, "utf-8").split("\n")) {
if (!line.trim()) continue;
try {
const value = JSON.parse(line);
if (value && value.id) entries.set(String(value.id), value);
} catch {}
}
} catch {}
};
// 目标机器已有的重命名优先,备份只补缺失条目。
read(srcIndex);
const imported = new Map(entries);
entries.clear();
read(dstIndex);
const existing = new Map(entries);
for (const [id, value] of imported) if (!existing.has(id)) existing.set(id, value);
if (!existing.size) return 0;
fs.mkdirSync(path.dirname(dstIndex), { recursive: true });
const backup = dstIndex + ".bak-import-" + Date.now();
try { if (fs.existsSync(dstIndex)) fs.copyFileSync(dstIndex, backup); } catch {}
const tmp = dstIndex + ".tmp-import-" + process.pid;
try {
fs.writeFileSync(tmp, [...existing.values()].map((value) => JSON.stringify(value)).join("\n") + "\n", "utf-8");
try { fs.renameSync(tmp, dstIndex); }
catch {
fs.rmSync(dstIndex, { force: true });
fs.renameSync(tmp, dstIndex);
}
} finally {
try { fs.rmSync(tmp, { force: true }); } catch {}
}
return imported.size;
}
// 导入:把外部备份目录里的 rollout-*.jsonl 合并进本机 sessions/ 与 archived_sessions/(已存在则跳过)
function importSessions(srcDir, log = () => {}) {
// 支持传入 backup 根目录或其中的 sessions/ 子目录
let added = 0;
let skipped = 0;
const sourceRoot = resolveImportRoot(srcDir);
const { sessionsDir, archivedSessionsDir } = paths();
const roots = fs.existsSync(path.join(sourceRoot, "sessions"))
? [
{ base: path.join(sourceRoot, "sessions"), dest: sessionsDir },
{ base: path.join(sourceRoot, "archived_sessions"), dest: archivedSessionsDir },
]
: [{ base: sourceRoot, dest: sessionsDir }];
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++;
}
}
const indexMerged = mergeSessionIndex(path.join(sourceRoot, "session_index.jsonl"), paths().sessionIndex);
log(`导入完成:新增 ${added} 个,跳过 ${skipped} 个(已存在),合并索引 ${indexMerged} 条`);
return { added, skipped, indexMerged };
}
module.exports = {
getCodexHome: () => paths().codexHome,
getSessionsDir: () => paths().sessionsDir,
getArchivedSessionsDir: () => paths().archivedSessionsDir,
listConversations,
readConversation,
toMarkdown,
backupAll,
importSessions,
deleteConversation,
clearAllConversations,
findConversationById,
repairSkillMentionTitles,
};