195 lines
6.9 KiB
JavaScript
195 lines
6.9 KiB
JavaScript
// provider_sync.js —— 一键把历史会话同步到「当前账号」的 provider,使其在 Codex 侧栏重新显示。
|
||
// 复刻 CodexPlusPlus 的 provider-sync:改 rollout 首行 provider + state DB(provider/has_user_event/cwd)。
|
||
// 必须在 Codex 已退出时运行。数据库用纯 JS 的 sql.js(免原生编译,跨平台)。
|
||
const fs = require("fs");
|
||
const path = require("path");
|
||
const os = require("os");
|
||
const { execFileSync } = require("child_process");
|
||
const initSqlJs = require("sql.js");
|
||
|
||
const CODEX_HOME = path.join(os.homedir(), ".codex");
|
||
|
||
// 检测 Codex 是否在运行(尽力而为,跨平台)
|
||
function codexRunning() {
|
||
try {
|
||
if (process.platform === "darwin") {
|
||
return execFileSync("pgrep", ["-f", "Codex.app/Contents/MacOS/Codex"], { encoding: "utf-8" }).trim().length > 0;
|
||
}
|
||
if (process.platform === "win32") {
|
||
return /Codex\.exe/i.test(execFileSync("tasklist", [], { encoding: "utf-8" }));
|
||
}
|
||
} catch {
|
||
/* pgrep 无匹配时退出码非 0,视为未运行 */
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// 目标 provider = config.toml 根级 model_provider,缺省 openai
|
||
function targetProvider() {
|
||
try {
|
||
const head = fs.readFileSync(path.join(CODEX_HOME, "config.toml"), "utf-8").split("\n[")[0];
|
||
const m = head.match(/^\s*model_provider\s*=\s*["']([^"']+)/m);
|
||
if (m) return m[1];
|
||
} catch {}
|
||
return "openai";
|
||
}
|
||
|
||
function findRollouts() {
|
||
const out = [];
|
||
for (const root of [path.join(CODEX_HOME, "sessions"), path.join(CODEX_HOME, "archived_sessions")]) {
|
||
const walk = (dir) => {
|
||
let ents;
|
||
try {
|
||
ents = fs.readdirSync(dir, { withFileTypes: true });
|
||
} catch {
|
||
return;
|
||
}
|
||
for (const e of ents) {
|
||
const full = path.join(dir, e.name);
|
||
if (e.isDirectory()) walk(full);
|
||
else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) out.push(full);
|
||
}
|
||
};
|
||
walk(root);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function ts() {
|
||
const d = new Date();
|
||
const p = (n) => String(n).padStart(2, "0");
|
||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||
}
|
||
|
||
async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
|
||
if (codexRunning()) throw new Error("检测到 Codex 正在运行,请先完全退出 Codex 再同步。");
|
||
const target = targetProvider();
|
||
log(`目标 provider:${target}`);
|
||
onProgress({ phase: "备份中", done: 0, total: 0 });
|
||
|
||
// 1) 备份
|
||
const backup = path.join(CODEX_HOME, "backups_state", "provider-sync", ts());
|
||
fs.mkdirSync(backup, { recursive: true });
|
||
for (const name of ["config.toml", ".codex-global-state.json", ".codex-global-state.json.bak"]) {
|
||
const src = path.join(CODEX_HOME, name);
|
||
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(backup, name));
|
||
}
|
||
for (const db of fs.readdirSync(CODEX_HOME).filter((f) => /^state_.*\.sqlite/.test(f))) {
|
||
fs.copyFileSync(path.join(CODEX_HOME, db), path.join(backup, db));
|
||
}
|
||
log(`已备份到 ${backup}`);
|
||
|
||
// 2) 遍历 rollout:改首行 provider,收集 thread_id / cwd / has_user_event
|
||
const userEventIds = new Set();
|
||
const cwdById = {};
|
||
let changedFiles = 0;
|
||
const rolloutFiles = findRollouts();
|
||
const rtotal = rolloutFiles.length;
|
||
let ri = 0;
|
||
for (const file of rolloutFiles) {
|
||
ri++;
|
||
if (ri % 5 === 0 || ri === rtotal) onProgress({ phase: "改写会话文件", done: ri, total: rtotal });
|
||
let lines;
|
||
try {
|
||
lines = fs.readFileSync(file, "utf-8").split(/(?<=\n)/); // 保留换行
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (!lines.length) continue;
|
||
const blob = lines.join("");
|
||
const hasUE = blob.includes('"user_message"') || blob.includes('"user_input"');
|
||
let tid = null;
|
||
let rewrote = false;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
if (!line.trim()) continue;
|
||
let rec;
|
||
try {
|
||
rec = JSON.parse(line);
|
||
} catch {
|
||
continue;
|
||
}
|
||
if (rec.type === "session_meta" && rec.payload && typeof rec.payload === "object") {
|
||
tid = rec.payload.id || rec.payload.session_id || null;
|
||
const cwd = rec.payload.cwd;
|
||
if (tid && cwd) cwdById[tid] = cwd;
|
||
if (rec.payload.model_provider !== target) {
|
||
rec.payload.model_provider = target;
|
||
lines[i] = JSON.stringify(rec) + "\n";
|
||
rewrote = true;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
if (rewrote) {
|
||
fs.writeFileSync(file, lines.join(""), "utf-8");
|
||
changedFiles++;
|
||
}
|
||
if (tid && hasUE) userEventIds.add(tid);
|
||
}
|
||
log(`改写 rollout 文件:${changedFiles} 个;含用户消息的会话:${userEventIds.size} 条`);
|
||
|
||
// 3) 更新所有 state_*.sqlite
|
||
onProgress({ phase: "更新数据库", done: 0, total: 0 });
|
||
const SQL = await initSqlJs({
|
||
// wasm 恒在【包内】node_modules(热更包不含 node_modules),故用 BUNDLE_ROOT 而非 __dirname
|
||
locateFile: (f) =>
|
||
path.join(global.__BUNDLE_ROOT__ || path.join(__dirname, "..", ".."),
|
||
"node_modules", "sql.js", "dist", f),
|
||
});
|
||
let provRows = 0;
|
||
let ueRows = 0;
|
||
let cwdRows = 0;
|
||
for (const name of fs.readdirSync(CODEX_HOME).filter((f) => /^state_.*\.sqlite$/.test(f))) {
|
||
const dbPath = path.join(CODEX_HOME, name);
|
||
let db;
|
||
try {
|
||
db = new SQL.Database(fs.readFileSync(dbPath));
|
||
const tbl = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='threads'");
|
||
if (!tbl.length) {
|
||
db.close();
|
||
continue;
|
||
}
|
||
const cols = new Set(db.exec("PRAGMA table_info(threads)")[0].values.map((r) => r[1]));
|
||
if (!cols.has("model_provider")) {
|
||
db.close();
|
||
continue;
|
||
}
|
||
db.run("UPDATE threads SET model_provider=? WHERE COALESCE(model_provider,'')<>?", [target, target]);
|
||
provRows += db.getRowsModified();
|
||
if (cols.has("has_user_event")) {
|
||
for (const tid of userEventIds) {
|
||
db.run("UPDATE threads SET has_user_event=1 WHERE id=? AND COALESCE(has_user_event,0)<>1", [tid]);
|
||
ueRows += db.getRowsModified();
|
||
}
|
||
}
|
||
if (cols.has("cwd")) {
|
||
for (const tid of Object.keys(cwdById)) {
|
||
db.run("UPDATE threads SET cwd=? WHERE id=? AND COALESCE(cwd,'')<>?", [cwdById[tid], tid, cwdById[tid]]);
|
||
cwdRows += db.getRowsModified();
|
||
}
|
||
}
|
||
fs.writeFileSync(dbPath, Buffer.from(db.export()));
|
||
db.close();
|
||
} catch (e) {
|
||
if (db) try { db.close(); } catch {}
|
||
log(` 数据库跳过 ${name}:${e.message || e}`);
|
||
}
|
||
}
|
||
log(`数据库更新:provider ${provRows} 行、has_user_event ${ueRows} 行、cwd ${cwdRows} 行`);
|
||
onProgress({ phase: "完成", done: 0, total: 0 });
|
||
|
||
return {
|
||
target,
|
||
totalConversations: rtotal,
|
||
changedFiles,
|
||
userEvents: userEventIds.size,
|
||
provRows,
|
||
ueRows,
|
||
cwdRows,
|
||
backup,
|
||
};
|
||
}
|
||
|
||
module.exports = { syncProviderToCurrent, codexRunning, targetProvider };
|