diff --git a/README.md b/README.md index da863255..60b51436 100644 --- a/README.md +++ b/README.md @@ -612,7 +612,7 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta |----------|---------------|-------| | **Claude Code** | `~/.claude/projects//.jsonl` | Each assistant entry carries model name, token usage (input, output, cache read, cache write), `tool_use` blocks, and timestamps. | | **Claude (multiple config dirs)** | Set via `CLAUDE_CONFIG_DIRS` (e.g. `~/.claude-work:~/.claude-personal`) | Scans every listed directory and merges sessions into one row per project so totals reflect all your Claude usage. Use `:` on POSIX, `;` on Windows; overrides `CLAUDE_CONFIG_DIR`. Missing or unreadable directories are skipped. | -| **Codex (OpenAI)** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | Reads `token_count` events (per-call and cumulative usage) and `function_call` entries for tool tracking; attributes cost by project working directory. `codeburn report --provider codex` views Codex alone. | +| **Codex (OpenAI)** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, `~/.codex/archived_sessions/rollout-*.jsonl` | Reads `token_count` events (per-call and cumulative usage) and `function_call` entries for tool tracking; attributes cost by project working directory. `codeburn report --provider codex` views Codex alone. | | **Cursor** | SQLite `state.vscdb` under `globalStorage`: macOS `~/Library/Application Support/Cursor/User/globalStorage/`, Linux `~/.config/Cursor/User/globalStorage/`, Windows `%APPDATA%/Cursor/User/globalStorage/`; results cached at `~/.cache/codeburn/cursor-results.json` | Input tokens come from Cursor's own per-conversation context meter (`composerData.promptTokenBreakdown`), credited once per conversation on a stable anchor; tool calls and shell commands are read from the agent stream (`agentKv`), and Composer house models are priced from Cursor's published rates. Output is a reply-text estimate and cache tokens are server-side only, so figures are marked estimated and undercount the Cursor admin console for long conversations. The cache auto-invalidates when the database changes; first run on a large database can take a minute. | | **OpenCode** | SQLite `~/.local/share/opencode/opencode*.db` (respects `XDG_DATA_HOME`) | Queries `session`, `message`, and `part` read-only and recalculates cost via LiteLLM (falling back to OpenCode's own cost field for unpriced models). Subtask sessions (`parent_id IS NOT NULL`) are excluded to avoid double counting; multiple channel databases are supported. | | **Gemini CLI** | `~/.gemini/tmp//chats/session-*.json` | One JSON file per session with real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Input is reported inclusive of cached, so CodeBurn subtracts cached before pricing to avoid double charging. | diff --git a/docs/providers/codex.md b/docs/providers/codex.md index 268fd35b..505b3089 100644 --- a/docs/providers/codex.md +++ b/docs/providers/codex.md @@ -8,13 +8,19 @@ OpenAI Codex CLI. ## Where it reads from -`$CODEX_HOME` if set, otherwise `~/.codex`. Sessions are nested by date: +`$CODEX_HOME` if set, otherwise `~/.codex`. Active sessions are nested by date: ``` ~/.codex/sessions///
/rollout-*.jsonl ``` -The discovery walk uses strict regex (`^\d{4}$`, `^\d{2}$`) on each path component. +Archived sessions are stored in a flat directory and are included in usage reports: + +``` +~/.codex/archived_sessions/rollout-*.jsonl +``` + +The active-session discovery walk uses strict regex (`^\d{4}$`, `^\d{2}$`) on each path component. ## Storage format diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 0917f66f..288811fb 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -281,16 +281,27 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null { return entry } +async function discoverSessionFile(filePath: string): Promise { + const s = await stat(filePath).catch(() => null) + if (!s?.isFile()) return null + + const cachedProject = await getCachedCodexProject(filePath) + if (cachedProject) { + return { path: filePath, project: cachedProject, provider: 'codex' } + } + + const { valid, meta } = await isValidCodexSession(filePath) + if (!valid || !meta) return null + + const cwd = meta.payload?.cwd ?? 'unknown' + return { path: filePath, project: sanitizeProject(cwd), provider: 'codex' } +} + async function discoverSessionsInDir(codexDir: string): Promise { - const sessionsDir = join(codexDir, 'sessions') const sources: SessionSource[] = [] + const sessionsDir = join(codexDir, 'sessions') - let years: string[] - try { - years = await readdir(sessionsDir) - } catch { - return sources - } + const years = await readdir(sessionsDir).catch(() => [] as string[]) for (const year of years) { if (!/^\d{4}$/.test(year)) continue @@ -310,25 +321,23 @@ async function discoverSessionsInDir(codexDir: string): Promise for (const file of files) { if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue const filePath = join(dayDir, file) - const s = await stat(filePath).catch(() => null) - if (!s?.isFile()) continue - - const cachedProject = await getCachedCodexProject(filePath) - if (cachedProject) { - sources.push({ path: filePath, project: cachedProject, provider: 'codex' }) - continue - } - - const { valid, meta } = await isValidCodexSession(filePath) - if (!valid || !meta) continue - - const cwd = meta.payload?.cwd ?? 'unknown' - sources.push({ path: filePath, project: sanitizeProject(cwd), provider: 'codex' }) + const source = await discoverSessionFile(filePath) + if (source) sources.push(source) } } } } + // Codex moves archived sessions into a flat directory. Keep them in usage + // reports so archiving a conversation does not erase its historical usage. + const archivedDir = join(codexDir, 'archived_sessions') + const archivedFiles = await readdir(archivedDir).catch(() => [] as string[]) + for (const file of archivedFiles) { + if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue + const source = await discoverSessionFile(join(archivedDir, file)) + if (source) sources.push(source) + } + return sources } diff --git a/tests/providers/codex.test.ts b/tests/providers/codex.test.ts index 86f1176a..9595e356 100644 --- a/tests/providers/codex.test.ts +++ b/tests/providers/codex.test.ts @@ -105,6 +105,14 @@ async function writeSession(dir: string, date: string, filename: string, lines: return filePath } +async function writeArchivedSession(dir: string, filename: string, lines: string[]) { + const archivedDir = join(dir, 'archived_sessions') + await mkdir(archivedDir, { recursive: true }) + const filePath = join(archivedDir, filename) + await writeFile(filePath, lines.join('\n') + '\n') + return filePath +} + describe('codex provider - model display names', () => { it('maps gpt-5.3-codex-spark to its own label', () => { const provider = createCodexProvider(tmpDir) @@ -136,6 +144,22 @@ describe('codex provider - session discovery', () => { expect(sessions[0]!.path).toContain('rollout-abc123.jsonl') }) + it('discovers sessions moved to the flat archived_sessions directory', async () => { + const filePath = await writeArchivedSession(tmpDir, 'rollout-archived.jsonl', [ + sessionMeta({ cwd: '/Users/test/archived' }), + tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toEqual([{ + path: filePath, + project: 'Users-test-archived', + provider: 'codex', + }]) + }) + it('returns empty for non-existent directory', async () => { const provider = createCodexProvider('/nonexistent/path/that/does/not/exist') const sessions = await provider.discoverSessions()