// Read Codex's own cumulative token snapshots from ~/.codex/sessions. "use strict"; const fs = require("fs"); const os = require("os"); const path = require("path"); const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), ".codex"); const SESSIONS_ROOT = path.join(CODEX_HOME, "sessions"); const RESET_PATH = path.join(CODEX_HOME, ".tokendun-token-reset.json"); const fileCache = new Map(); const fsp = fs.promises; // 正在增长(流式生成中)的会话文件每次变化都会导致全量重读,阻塞主进程事件循环。 // 给同一文件加最小重读间隔:5 秒内即使变了也复用上次结果,token 统计允许秒级延迟。 const MIN_REREAD_MS = 5000; function clean(n) { n = Number(n); return Number.isFinite(n) && n > 0 ? Math.round(n) : 0; } function dayKey(date = new Date()) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); return `${y}-${m}-${d}`; } function blank(date = dayKey()) { return { available: false, date, total: 0, input: 0, output: 0, cached: 0, reasoning: 0, unknown: 0, lastSource: "session", }; } function snapshotUsage(info) { const u = info && typeof info === "object" ? info : {}; const input = clean(u.input_tokens || u.prompt_tokens || u.inputTokenCount || u.promptTokenCount); const cached = clean( u.cached_input_tokens || u.cache_read_input_tokens || u.cached_tokens || (u.input_tokens_details && u.input_tokens_details.cached_tokens) ); const output = clean(u.output_tokens || u.completion_tokens || u.candidatesTokenCount); const reasoning = clean(u.reasoning_output_tokens || u.reasoning_tokens || u.thoughtsTokenCount); const total = clean(u.total_tokens || u.totalTokenCount || u.total) || input + output; return { total, input, output, cached, reasoning }; } function snapshotAt(line) { let item; try { item = JSON.parse(line); } catch { return null; } if (item?.type !== "event_msg" || item?.payload?.type !== "token_count") return null; const timestamp = new Date(item.timestamp); if (!Number.isFinite(timestamp.getTime())) return null; const usage = snapshotUsage(item.payload.info?.total_token_usage); return usage.total > 0 ? { timestamp, usage } : null; } async function parseSessionFile(file) { try { const stat = await fsp.stat(file); const cached = fileCache.get(file); if (cached) { // 未变化 → 直接用缓存 if (cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.value; // 变化了但距上次读太近 → 复用缓存,避免流式生成期间反复全量读盘阻塞主进程 if (Date.now() - cached.readAt < MIN_REREAD_MS) return cached.value; } const text = await fsp.readFile(file, "utf8"); const snapshots = []; for (const line of text.split("\n")) { const snapshot = snapshotAt(line); if (snapshot) snapshots.push(snapshot); } const value = { snapshots }; fileCache.set(file, { size: stat.size, mtimeMs: stat.mtimeMs, value, readAt: Date.now() }); return value; } catch { return { snapshots: [] }; } } function utcDatePath(date) { return path.join( SESSIONS_ROOT, String(date.getUTCFullYear()).padStart(4, "0"), String(date.getUTCMonth() + 1).padStart(2, "0"), String(date.getUTCDate()).padStart(2, "0") ); } function sessionFilesForDay(start, end) { const dirs = new Set(); for (const cursor = new Date(start); cursor < end; cursor.setDate(cursor.getDate() + 1)) { dirs.add(utcDatePath(cursor)); } dirs.add(utcDatePath(new Date(end.getTime() - 1))); const files = []; for (const dir of dirs) { try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.isFile() && entry.name.endsWith(".jsonl")) files.push(path.join(dir, entry.name)); } } catch {} } return files; } function delta(current, before) { return Math.max(0, clean(current) - clean(before)); } function readReset() { try { const value = JSON.parse(fs.readFileSync(RESET_PATH, "utf8")); return value && typeof value === "object" ? value : null; } catch { return null; } } function applyReset(value) { const reset = readReset(); if (!reset || reset.date !== value.date) return value; return { ...value, total: delta(value.total, reset.total), input: delta(value.input, reset.input), output: delta(value.output, reset.output), cached: delta(value.cached, reset.cached), reasoning: delta(value.reasoning, reset.reasoning), }; } async function readDailyRaw(now = new Date()) { const date = dayKey(now); const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); const result = blank(date); result.available = fs.existsSync(SESSIONS_ROOT); for (const file of sessionFilesForDay(start, end)) { const snapshots = (await parseSessionFile(file)).snapshots; let before = null; let current = null; for (const snapshot of snapshots) { if (snapshot.timestamp < start) before = snapshot; else if (snapshot.timestamp < end && (!current || snapshot.timestamp > current.timestamp)) current = snapshot; } if (!current) continue; const b = before?.usage || {}; const u = current.usage; result.total += delta(u.total, b.total); result.input += delta(u.input, b.input); result.output += delta(u.output, b.output); result.cached += delta(u.cached, b.cached); result.reasoning += delta(u.reasoning, b.reasoning); } return result; } async function readDaily(now = new Date()) { return applyReset(await readDailyRaw(now)); } async function reset(now = new Date()) { const value = await readDailyRaw(now); fs.mkdirSync(path.dirname(RESET_PATH), { recursive: true }); fs.writeFileSync(RESET_PATH, JSON.stringify({ ...value, date: dayKey(now) }), "utf8"); return blank(dayKey(now)); } module.exports = { readDaily, reset, _snapshotUsage: snapshotUsage, _sessionFilesForDay: sessionFilesForDay, };