import os from 'node:os'; import { CODEX_SESSIONS_DIR, readCodexConfig, readCodexWorkspaceState } from './codex-config.js'; import { readMobileSessionIndex, readMobileSessions, renameMobileSession } from './mobile-session-index.js'; import { hideSessionInMobile, readHiddenSessionIds } from './codex-data-hidden-state.js'; import { normalizeComparablePath, parseFilteredSessionMetadata, projectIdFor, readSessionNameIndex, renameSessionNameIndexRow, toPublicProject, upsertProject, walkJsonlFiles } from './codex-data-parser.js'; import { readSessionMessagesFromCache } from './codex-data-messages.js'; import { mergeMobileOnlySessions } from './codex-data-mobile-sessions.js'; const DEFAULT_SESSION_SCAN_CONCURRENCY = 16; const MIN_SESSION_SCAN_CONCURRENCY = 1; const SESSION_SCAN_CONCURRENCY_ENV = 'CODEXMOBILE_SESSION_SCAN_CONCURRENCY'; function resolveSessionScanConcurrency(env = process.env) { const configured = Number(env[SESSION_SCAN_CONCURRENCY_ENV] || DEFAULT_SESSION_SCAN_CONCURRENCY); if (!Number.isFinite(configured)) { return DEFAULT_SESSION_SCAN_CONCURRENCY; } return Math.max(MIN_SESSION_SCAN_CONCURRENCY, Math.floor(configured)); } // Session scans are mostly JSONL file I/O, so the default allows moderate parallel reads while staying tunable. const SESSION_SCAN_CONCURRENCY = resolveSessionScanConcurrency(); function retainProjectSessions({ sessionsByProject, sessionById }, projectById) { const retainedSessionsByProject = new Map(); const retainedSessionById = new Map(); for (const [projectId, sessions] of sessionsByProject.entries()) { if (!projectById.has(projectId)) { continue; } const retained = sessions.filter((session) => session?.projectId === projectId); if (!retained.length) { continue; } retainedSessionsByProject.set(projectId, retained); for (const session of retained) { retainedSessionById.set(session.id, sessionById.get(session.id) || session); } } return { sessionsByProject: retainedSessionsByProject, sessionById: retainedSessionById }; } let cache = { syncedAt: null, config: null, projects: [], projectById: new Map(), sessionsByProject: new Map(), sessionById: new Map() }; async function loadVisibleProjectCache() { const config = await readCodexConfig(); const workspaceState = await readCodexWorkspaceState(); const projectById = new Map(); const visibleProjects = workspaceState.projects.length ? workspaceState.projects.map((project) => ({ path: project.path, trustLevel: config.projects.find( (entry) => normalizeComparablePath(entry.path) === normalizeComparablePath(project.path) )?.trustLevel || 'trusted', label: project.label })) : config.projects.map((project) => ({ ...project, label: null })); const visibleProjectIds = new Set(); for (const project of visibleProjects) { const entry = upsertProject(projectById, project.path, project.trustLevel, project.label); if (entry) { visibleProjectIds.add(entry.id); } } return { config, visibleProjects, visibleProjectIds, projectById }; } export async function refreshProjectCache() { const { config, visibleProjects, projectById } = await loadVisibleProjectCache(); const projectOrder = new Map(visibleProjects.map((project, index) => [projectIdFor(project.path), index])); const retained = retainProjectSessions(cache, projectById); cache = { ...cache, config, projects: [...projectById.values()].sort((a, b) => { const orderA = projectOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; const orderB = projectOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; return orderA - orderB || a.name.localeCompare(b.name, 'zh-Hans-CN'); }), projectById, sessionsByProject: retained.sessionsByProject, sessionById: retained.sessionById }; return getCacheSnapshot(); } async function mapWithConcurrency(items, limit, mapper) { if (!Number.isInteger(limit) || limit < MIN_SESSION_SCAN_CONCURRENCY) { throw new TypeError('Concurrency limit must be a positive integer.'); } if (!items.length) { return []; } const results = new Array(items.length); let nextIndex = 0; const worker = async () => { while (nextIndex < items.length) { const index = nextIndex; nextIndex += 1; results[index] = await mapper(items[index], index); } }; const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker()); await Promise.all(workers); return results; } export async function refreshCodexCache() { const { config, visibleProjects, visibleProjectIds, projectById } = await loadVisibleProjectCache(); const sessionIndex = await readSessionNameIndex(); const mobileSessionIndex = await readMobileSessionIndex(); const mobileSessions = await readMobileSessions(); const hiddenSessionIds = await readHiddenSessionIds(); const sessionsByProject = new Map(); const sessionById = new Map(); const files = await walkJsonlFiles(CODEX_SESSIONS_DIR); const sessions = await mapWithConcurrency(files, SESSION_SCAN_CONCURRENCY, async (file) => { try { const session = await parseFilteredSessionMetadata({ filePath: file, sessionIndex, mobileSessionIndex, includeIdentity: (identity) => ( !hiddenSessionIds.has(identity.id) && visibleProjectIds.has(identity.projectId) ) }); if (!session) { return null; } if (hiddenSessionIds.has(session.id)) { return null; } if (!visibleProjectIds.has(session.projectId)) { return null; } return session; } catch (error) { console.warn(`[codex-data] skip unreadable session file=${file} message=${error.message || error}`); return null; } }); for (const session of sessions) { if (!session) { continue; } const project = projectById.get(session.projectId); if (!project) { continue; } if (!sessionsByProject.has(project.id)) { sessionsByProject.set(project.id, []); } sessionsByProject.get(project.id).push(session); sessionById.set(session.id, session); } mergeMobileOnlySessions({ mobileSessions, hiddenSessionIds, visibleProjectIds, projectById, sessionsByProject, sessionById }); for (const [projectId, sessions] of sessionsByProject.entries()) { sessions.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0)); const project = projectById.get(projectId); if (project) { project.sessionCount = sessions.length; project.updatedAt = sessions[0]?.updatedAt || project.updatedAt; } } const projectOrder = new Map(visibleProjects.map((project, index) => [projectIdFor(project.path), index])); const projects = [...projectById.values()].sort((a, b) => { const orderA = projectOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER; const orderB = projectOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER; return orderA - orderB || a.name.localeCompare(b.name, 'zh-Hans-CN'); }); cache = { syncedAt: new Date().toISOString(), config, projects, projectById, sessionsByProject, sessionById }; return getCacheSnapshot(); } export function getCacheSnapshot() { return { syncedAt: cache.syncedAt, config: cache.config, projects: cache.projects.map(toPublicProject) }; } export function listProjects() { return cache.projects.map(toPublicProject); } export function getProject(projectId) { return cache.projectById.get(projectId) || null; } export function listProjectSessions(projectId) { return (cache.sessionsByProject.get(projectId) || []).map((session) => ({ id: session.id, title: session.title, summary: session.summary, model: session.model, provider: session.provider, source: session.source, messageCount: session.messageCount, updatedAt: session.updatedAt })); } export function getSession(sessionId) { return cache.sessionById.get(sessionId) || null; } export async function renameSession(sessionId, projectId, title) { const session = getSession(sessionId); if (!session) { const error = new Error('Session not found'); error.statusCode = 404; throw error; } if (projectId && session.projectId !== projectId) { const error = new Error('Session not found in project'); error.statusCode = 404; throw error; } const nextTitle = String(title || '').trim().slice(0, 52); if (!nextTitle) { const error = new Error('Title is required'); error.statusCode = 400; throw error; } if (session.filePath) { await renameSessionNameIndexRow(session.id, nextTitle, session.updatedAt); } await renameMobileSession({ id: session.id, projectPath: session.cwd, title: nextTitle, updatedAt: session.updatedAt }); return { ...session, title: nextTitle }; } export async function deleteSession(sessionId, projectId) { const session = getSession(sessionId); if (!session) { const error = new Error('Session not found'); error.statusCode = 404; throw error; } if (projectId && session.projectId !== projectId) { const error = new Error('Session not found in project'); error.statusCode = 404; throw error; } const hidden = await hideSessionInMobile(session); return { deletedSessionId: sessionId, projectId: session.projectId, hiddenOnly: true, hiddenAt: hidden.hiddenAt, deletedFile: false, deletedIndexRows: false, deletedMobileRecord: false }; } export async function readSessionMessages(sessionId, options = {}) { return readSessionMessagesFromCache(cache, sessionId, options); } export function getHostName() { return os.hostname(); }