2026-07-15 18:01:56 +08:00
|
|
|
|
// sessions.js —— 读取 / 解析 / 导出 / 备份 / 导入 Codex 会话记录。
|
|
|
|
|
|
// 直接读 ~/.codex/sessions / archived_sessions 下的 rollout-*.jsonl,不依赖数据库,跨平台。
|
|
|
|
|
|
const fs = require("fs");
|
|
|
|
|
|
const path = require("path");
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const { spawnSync } = require("child_process");
|
|
|
|
|
|
const initSqlJs = require("sql.js");
|
|
|
|
|
|
const { cleanThreadTitle, cleanUserVisibleText, stripInternalThoughtBlocks, looksLikeSkillMentionTitle } = require("./title_clean");
|
|
|
|
|
|
const codexDataDir = require("./codex_data_dir");
|
2026-07-15 18:01:56 +08:00
|
|
|
|
|
2026-08-16 12:45:32 +08:00
|
|
|
|
let permanentDeleteBusy = false;
|
2026-09-11 09:06:41 +08:00
|
|
|
|
let clearAllBusy = false;
|
|
|
|
|
|
const DEFAULT_LIST_LIMIT = 500;
|
|
|
|
|
|
const ALL_LIST_LIMIT = 5000;
|
2026-08-16 12:45:32 +08:00
|
|
|
|
|
|
|
|
|
|
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";
|
|
|
|
|
|
}
|
2026-07-15 18:01:56 +08:00
|
|
|
|
|
|
|
|
|
|
// 递归找出所有 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 {
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const txt = fs.readFileSync(paths().sessionIndex, "utf-8");
|
2026-07-15 18:01:56 +08:00
|
|
|
|
for (const line of txt.split("\n")) {
|
|
|
|
|
|
if (!line.trim()) continue;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const d = JSON.parse(line);
|
2026-08-16 12:45:32 +08:00
|
|
|
|
if (d.id && d.thread_name) map[d.id] = cleanThreadTitle(d.thread_name, "", 80);
|
2026-07-15 18:01:56 +08:00
|
|
|
|
} 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") {
|
2026-09-11 09:06:41 +08:00
|
|
|
|
// 新版 subagent 的 session_id 指向父线程,id 才是当前子线程。
|
|
|
|
|
|
meta.id = p.id || p.session_id || "";
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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) {
|
2026-08-16 12:45:32 +08:00
|
|
|
|
meta.title = cleanThreadTitle(p.message || "", "", 200);
|
2026-07-15 18:01:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (meta.id && meta.title) break;
|
|
|
|
|
|
}
|
|
|
|
|
|
return meta;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 12:45:32 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-11 09:06:41 +08:00
|
|
|
|
// 清空全部 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 12:45:32 +08:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 18:01:56 +08:00
|
|
|
|
function normalizeListOptions(opts) {
|
2026-09-11 09:06:41 +08:00
|
|
|
|
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;
|
2026-07-15 18:01:56 +08:00
|
|
|
|
return {
|
|
|
|
|
|
includeSubagent: !!(opts && opts.includeSubagent),
|
|
|
|
|
|
includeArchived: !!(opts && opts.includeArchived),
|
2026-09-11 09:06:41 +08:00
|
|
|
|
limit,
|
2026-07-15 18:01:56 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 列出会话(默认只列当前 sessions,并排除子 agent 派生线程;全量模式会带 archived_sessions)
|
|
|
|
|
|
function listConversations(opts = {}) {
|
2026-09-11 09:06:41 +08:00
|
|
|
|
const { includeSubagent, includeArchived, limit } = normalizeListOptions(opts);
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const { sessionsDir, archivedSessionsDir } = paths();
|
|
|
|
|
|
const roots = [{ dir: sessionsDir, archived: false }];
|
|
|
|
|
|
if (includeArchived) roots.push({ dir: archivedSessionsDir, archived: true });
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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,
|
2026-08-16 12:45:32 +08:00
|
|
|
|
title: cleanThreadTitle(titles[m.id] || "", m.title, 80) || "(无标题对话)",
|
2026-07-15 18:01:56 +08:00
|
|
|
|
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 || ""));
|
2026-09-11 09:06:41 +08:00
|
|
|
|
return items.slice(0, limit);
|
2026-07-15 18:01:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 解析单个会话文件为可读消息列表
|
|
|
|
|
|
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") {
|
2026-09-11 09:06:41 +08:00
|
|
|
|
meta = { id: p.id || p.session_id || "", cwd: p.cwd || "", ts: p.timestamp || ts, provider: p.model_provider || "" };
|
2026-07-15 18:01:56 +08:00
|
|
|
|
} else if (d.type === "event_msg" && p.type === "user_message") {
|
2026-08-16 12:45:32 +08:00
|
|
|
|
messages.push({ role: "user", text: cleanUserVisibleText(String(p.message || "")), ts });
|
2026-07-15 18:01:56 +08:00
|
|
|
|
} else if (d.type === "event_msg" && p.type === "agent_message") {
|
2026-08-16 12:45:32 +08:00
|
|
|
|
messages.push({ role: "assistant", text: stripInternalThoughtBlocks(String(p.message || "")), ts });
|
2026-07-15 18:01:56 +08:00
|
|
|
|
} 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");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 18:45:40 +08:00
|
|
|
|
// 备份:把 sessions/、archived_sessions/ 与索引复制到目标目录下的 codex-sessions-backup/
|
2026-07-15 18:01:56 +08:00
|
|
|
|
function backupAll(destDir, log = () => {}) {
|
|
|
|
|
|
const out = path.join(destDir, "codex-sessions-backup");
|
|
|
|
|
|
fs.mkdirSync(out, { recursive: true });
|
|
|
|
|
|
let n = 0;
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const { sessionsDir, archivedSessionsDir, sessionIndex } = paths();
|
|
|
|
|
|
for (const [name, dir] of [["sessions", sessionsDir], ["archived_sessions", archivedSessionsDir]]) {
|
2026-07-15 18:45:40 +08:00
|
|
|
|
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++;
|
|
|
|
|
|
}
|
2026-07-15 18:01:56 +08:00
|
|
|
|
}
|
2026-08-16 12:45:32 +08:00
|
|
|
|
if (fs.existsSync(sessionIndex)) fs.copyFileSync(sessionIndex, path.join(out, "session_index.jsonl"));
|
2026-07-15 18:01:56 +08:00
|
|
|
|
log(`已备份 ${n} 个会话文件到 ${out}`);
|
|
|
|
|
|
return { path: out, count: n };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-16 12:45:32 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-15 18:45:40 +08:00
|
|
|
|
// 导入:把外部备份目录里的 rollout-*.jsonl 合并进本机 sessions/ 与 archived_sessions/(已存在则跳过)
|
2026-07-15 18:01:56 +08:00
|
|
|
|
function importSessions(srcDir, log = () => {}) {
|
|
|
|
|
|
// 支持传入 backup 根目录或其中的 sessions/ 子目录
|
|
|
|
|
|
let added = 0;
|
|
|
|
|
|
let skipped = 0;
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const sourceRoot = resolveImportRoot(srcDir);
|
|
|
|
|
|
const { sessionsDir, archivedSessionsDir } = paths();
|
|
|
|
|
|
const roots = fs.existsSync(path.join(sourceRoot, "sessions"))
|
2026-07-15 18:45:40 +08:00
|
|
|
|
? [
|
2026-08-16 12:45:32 +08:00
|
|
|
|
{ base: path.join(sourceRoot, "sessions"), dest: sessionsDir },
|
|
|
|
|
|
{ base: path.join(sourceRoot, "archived_sessions"), dest: archivedSessionsDir },
|
2026-07-15 18:45:40 +08:00
|
|
|
|
]
|
2026-08-16 12:45:32 +08:00
|
|
|
|
: [{ base: sourceRoot, dest: sessionsDir }];
|
2026-07-15 18:45:40 +08:00
|
|
|
|
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++;
|
2026-07-15 18:01:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-16 12:45:32 +08:00
|
|
|
|
const indexMerged = mergeSessionIndex(path.join(sourceRoot, "session_index.jsonl"), paths().sessionIndex);
|
|
|
|
|
|
log(`导入完成:新增 ${added} 个,跳过 ${skipped} 个(已存在),合并索引 ${indexMerged} 条`);
|
|
|
|
|
|
return { added, skipped, indexMerged };
|
2026-07-15 18:01:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
module.exports = {
|
2026-08-16 12:45:32 +08:00
|
|
|
|
getCodexHome: () => paths().codexHome,
|
|
|
|
|
|
getSessionsDir: () => paths().sessionsDir,
|
|
|
|
|
|
getArchivedSessionsDir: () => paths().archivedSessionsDir,
|
2026-07-15 18:01:56 +08:00
|
|
|
|
listConversations,
|
|
|
|
|
|
readConversation,
|
|
|
|
|
|
toMarkdown,
|
|
|
|
|
|
backupAll,
|
|
|
|
|
|
importSessions,
|
2026-08-16 12:45:32 +08:00
|
|
|
|
deleteConversation,
|
2026-09-11 09:06:41 +08:00
|
|
|
|
clearAllConversations,
|
2026-08-16 12:45:32 +08:00
|
|
|
|
findConversationById,
|
|
|
|
|
|
repairSkillMentionTitles,
|
2026-07-15 18:01:56 +08:00
|
|
|
|
};
|