// 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; 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") { 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 = 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; } } 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 }; return { includeSubagent: !!(opts && opts.includeSubagent), includeArchived: !!(opts && opts.includeArchived), }; } // 列出会话(默认只列当前 sessions,并排除子 agent 派生线程;全量模式会带 archived_sessions) function listConversations(opts = {}) { const { includeSubagent, includeArchived } = 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; } // 解析单个会话文件为可读消息列表 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: 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, findConversationById, repairSkillMentionTitles, };