codeburn/src/context-tree-codex.ts
Resham Joshi 9b20dfd7dc
feat(context): per-session context token tree across CLI, TUI, and dashboard (#592)
* feat(context): add experimental context command with per-session token tree

Reconstructs context breakdown for a Claude Code session
from its transcript: messages and tokens by role, block type, and tool,
split into full-session history vs the live window since the last
compaction (following preservedSegment.headUuid).

Block tokens are chars/4 estimates, images estimated from sniffed PNG or
JPEG dimensions, reasoning derived per message from output_tokens minus
visible output since transcripts strip thinking text. The exact context
size comes from the last assistant message's API usage, and the gap to
the estimate is shown as derived system prompt and tool overhead.

* feat(context): show session titles in context --list

Reads the latest ai-title entry (summary entry as fallback for older
sessions) from one tail and one head chunk of each transcript, so the
list stays fast on 100MB files.

* feat(dash): context explorer page with Claude Code and Codex session trees

Adds a Context page to the web dashboard: a navbar toggle, a provider
picker (Claude Code / Codex), the 15 most recent sessions with titles,
and per-session expandable details showing the context token tree, the
exact live context vs window, and a live/full-history scope toggle.

Server side adds /api/context/sessions and /api/context/tree with an
mtime-keyed tree cache, plus a Codex rollout builder: response items
feed the tree, compacted entries (with replacement_history) split live
window from full history, reasoning comes exact from cumulative
token_count totals, and model_context_window gives the real window.

* feat(context): interactive TUI for the context command

codeburn context with no arguments now opens an ink TUI: arrow keys to
move, enter to expand a session's token tree inline (with a spinner
while the transcript reads), tab to switch between Claude Code and
Codex, f to toggle live window vs full history, q to quit. Flag and id
forms keep the plain output for scripts, and non-TTY runs fall back to
the static list. Tree rows are shared with the static renderer via
snapshotRows.

* fix(context): review fixes across window detection, codex parsing, and surfaces

Window sizes are no longer guessed from token counts alone: opus-4-8 and
[1m] model ids map to the 1M window (a 190K opus-4-8 session used to
render as 95% of a 200K window), and Codex omits the percentage instead
of borrowing Anthropic constants when the rollout lacks
model_context_window.

Codex compaction accounting now reads the encrypted compaction item and
developer messages from replacement_history, so post-compaction windows
stop undercounting. Both builders stream with largeLineAsBuffer so one
oversized line cannot silently truncate the walk.

The tree API stops sending session.filePath, serves the flattened rows
so the dash renders the same tree as the CLI and TUI, resolves ids
directly instead of re-scanning every session file per request, and the
tree cache evicts LRU. Session discovery honors CLAUDE_CONFIG_DIRS,
CLAUDE_CONFIG_DIR, and CODEX_HOME, stats files in parallel, and id
lookups stat only matching files.

CLI gains --provider codex and machine-readable --list --json; --full
now opens the TUI in full scope instead of bypassing it. The TUI shows
build errors instead of a frozen spinner and drops the ref-plus-counter
repaint for plain state. The dashboard scopes the usage error banner and
device sidebar to the Usage page.
2026-07-02 05:00:03 +02:00

341 lines
12 KiB
TypeScript

import { readdir, stat } from 'fs/promises'
import { existsSync } from 'fs'
import { basename, join } from 'path'
import { homedir } from 'os'
import { readSessionLines } from './fs-utils.js'
import {
add,
estimateTokens,
IMAGE_TOKEN_FALLBACK,
lineToText,
newAcc,
readChunk,
snapshot,
type Acc,
type ContextTreeResult,
type SessionRef,
type TitledSessionRef,
} from './context-tree.js'
// Codex rollout counterpart of the Claude Code context tree. Rollouts carry
// full response items plus token_count events with exact totals: the last
// token_count gives the live context size and model_context_window, and the
// cumulative reasoning_output_tokens total prices reasoning exactly (reasoning
// item text is encrypted). `compacted` entries mark compactions and include
// the replacement_history the next window starts from.
type CodexItem = {
type?: string
role?: string
content?: unknown
name?: string
arguments?: unknown
input?: unknown
action?: unknown
output?: unknown
}
type CodexEntry = {
type?: string
payload?: {
type?: string
role?: string
model?: string
cwd?: string
id?: string
base_instructions?: { text?: unknown } | null
message?: unknown
replacement_history?: unknown
info?: {
total_token_usage?: { reasoning_output_tokens?: number }
last_token_usage?: { input_tokens?: number; output_tokens?: number; total_tokens?: number }
model_context_window?: number
} | null
} & CodexItem
}
// Injected harness content: any tag-shaped block that isn't an image marker,
// plus the AGENTS.md / mentioned-files preambles Codex prepends to turns.
function isCodexMetaText(text: string): boolean {
const t = text.trimStart()
if (t.startsWith('<')) return !t.startsWith('<image')
return t.startsWith('# AGENTS.md') || t.startsWith('# Files mentioned')
}
function addCodexItem(accs: Acc[], item: CodexItem): void {
if (item.type === 'message' && item.role === 'assistant') {
for (const acc of accs) {
acc.assistantCount += 1
acc.messages += 1
}
if (!Array.isArray(item.content)) return
for (const block of item.content) {
if (block == null || typeof block !== 'object') continue
const b = block as { type?: string; text?: unknown }
if ((b.type === 'output_text' || b.type === 'text') && typeof b.text === 'string') {
for (const acc of accs) add(acc.assistantText, estimateTokens(b.text))
}
}
} else if (item.type === 'message' && item.role === 'user') {
for (const acc of accs) {
acc.userCount += 1
acc.messages += 1
}
if (!Array.isArray(item.content)) return
for (const block of item.content) {
if (block == null || typeof block !== 'object') continue
const b = block as { type?: string; text?: unknown }
if (b.type === 'input_image') {
for (const acc of accs) add(acc.userImage, IMAGE_TOKEN_FALLBACK)
} else if ((b.type === 'input_text' || b.type === 'text') && typeof b.text === 'string') {
// Rollouts reference images as short "<image name=...>" markers; the
// pixels never hit the file, so charge a flat estimate per marker.
if (b.text.trimStart().startsWith('<image')) {
for (const acc of accs) add(acc.userImage, IMAGE_TOKEN_FALLBACK)
} else if (isCodexMetaText(b.text)) {
for (const acc of accs) add(acc.userMeta, estimateTokens(b.text))
} else {
for (const acc of accs) add(acc.userText, estimateTokens(b.text))
}
}
}
} else if (item.type === 'message' && item.role === 'developer') {
// Injected per-turn instructions (permissions, harness rules), not user text.
if (!Array.isArray(item.content)) return
for (const block of item.content) {
if (block == null || typeof block !== 'object') continue
const b = block as { type?: string; text?: unknown }
if ((b.type === 'input_text' || b.type === 'text') && typeof b.text === 'string') {
for (const acc of accs) add(acc.userMeta, estimateTokens(b.text))
}
}
} else if (item.type === 'compaction') {
// The compaction summary ships encrypted; base64 is ~4/3 of the plaintext,
// so estimate from the decoded size.
const encrypted = (item as { encrypted_content?: unknown }).encrypted_content
const chars = typeof encrypted === 'string' ? encrypted.length * 0.75 : 0
for (const acc of accs) add(acc.userCompactSummary, Math.ceil(chars / 4))
} else if (item.type === 'reasoning') {
// Tokens are patched from cumulative usage after the walk.
for (const acc of accs) acc.assistantReasoning.count += 1
} else if (item.type === 'function_call' || item.type === 'custom_tool_call' || item.type === 'local_shell_call' || item.type === 'web_search_call') {
const tool =
typeof item.name === 'string' && item.name
? item.name
: item.type === 'local_shell_call'
? 'shell'
: item.type === 'web_search_call'
? 'web_search'
: 'unknown'
let argText = ''
if (typeof item.arguments === 'string') argText = item.arguments
else if (typeof item.input === 'string') argText = item.input
else {
try {
argText = JSON.stringify(item.arguments ?? item.input ?? item.action ?? {})
} catch {
argText = ''
}
}
const tokens = estimateTokens(argText)
for (const acc of accs) {
add(acc.toolCall, tokens)
const stat = acc.byTool.get(tool) ?? { count: 0, tokens: 0 }
add(stat, tokens)
acc.byTool.set(tool, stat)
}
} else if (item.type === 'function_call_output' || item.type === 'custom_tool_call_output') {
let out = ''
if (typeof item.output === 'string') out = item.output
else {
try {
out = JSON.stringify(item.output ?? '')
} catch {
out = ''
}
}
const tokens = estimateTokens(out)
for (const acc of accs) add(acc.toolResult, tokens)
}
}
export async function buildCodexContextTree(session: SessionRef): Promise<ContextTreeResult> {
const full = newAcc()
let segment = newAcc()
let compactions = 0
let model = 'unknown'
let systemTokens = 0
let contextWindow: number | null = null
let lastTotalReasoning = 0
let segmentStartReasoning = 0
let lastUsage: { input_tokens?: number; output_tokens?: number; total_tokens?: number } | null = null
for await (const line of readSessionLines(session.filePath, undefined, { largeLineAsBuffer: true })) {
const text = lineToText(line)
if (!text || text.charCodeAt(0) !== 123) continue
let entry: CodexEntry
try {
entry = JSON.parse(text) as CodexEntry
} catch {
continue
}
const payload = entry.payload
if (!payload) continue
if (entry.type === 'session_meta') {
const instructions = payload.base_instructions?.text
if (typeof instructions === 'string') systemTokens = estimateTokens(instructions)
} else if (entry.type === 'turn_context') {
if (typeof payload.model === 'string' && payload.model) model = payload.model
} else if (entry.type === 'compacted') {
compactions += 1
segment = newAcc()
segmentStartReasoning = lastTotalReasoning
if (typeof payload.message === 'string' && payload.message) {
add(segment.userCompactSummary, estimateTokens(payload.message))
}
// The originals already landed in `full`, so the replacement history
// seeds only the new live window.
if (Array.isArray(payload.replacement_history)) {
for (const item of payload.replacement_history) {
if (item != null && typeof item === 'object') addCodexItem([segment], item as CodexItem)
}
}
} else if (entry.type === 'response_item') {
addCodexItem([full, segment], payload)
} else if (entry.type === 'event_msg' && payload.type === 'token_count') {
const info = payload.info
const totalReasoning = info?.total_token_usage?.reasoning_output_tokens
if (typeof totalReasoning === 'number') lastTotalReasoning = totalReasoning
if (info?.last_token_usage) lastUsage = info.last_token_usage
if (typeof info?.model_context_window === 'number' && info.model_context_window > 0) {
contextWindow = info.model_context_window
}
}
}
full.assistantReasoning.tokens = lastTotalReasoning
segment.assistantReasoning.tokens = Math.max(0, lastTotalReasoning - segmentStartReasoning)
if (systemTokens > 0) {
add(full.system, systemTokens)
add(segment.system, systemTokens)
}
let reported: ContextTreeResult['reported'] = null
if (lastUsage) {
const context = lastUsage.total_tokens ?? (lastUsage.input_tokens ?? 0) + (lastUsage.output_tokens ?? 0)
if (context > 0) {
// No guessing for OpenAI windows: without model_context_window the
// percentage is omitted rather than computed against a wrong constant.
reported = { context, window: contextWindow }
}
}
return {
session,
model,
compactions,
reported,
effective: snapshot(segment),
full: snapshot(full),
}
}
const ROLLOUT_RE = /^rollout-.{19}-(.+)\.jsonl$/
// Mirrors the CODEX_HOME handling of providers/codex.ts.
function codexSessionsRoot(): string {
return join(process.env['CODEX_HOME'] ?? join(homedir(), '.codex'), 'sessions')
}
type RolloutFile = { filePath: string; sessionId: string }
async function listRolloutFiles(): Promise<RolloutFile[]> {
const root = codexSessionsRoot()
if (!existsSync(root)) return []
let files: string[]
try {
files = await readdir(root, { recursive: true })
} catch {
return []
}
const rollouts: RolloutFile[] = []
for (const rel of files) {
const match = ROLLOUT_RE.exec(basename(rel))
if (match) rollouts.push({ filePath: join(root, rel), sessionId: match[1] })
}
return rollouts
}
async function statRef(file: RolloutFile): Promise<SessionRef | null> {
try {
const info = await stat(file.filePath)
if (!info.isFile() || info.size === 0) return null
return { ...file, project: '', mtimeMs: info.mtimeMs, sizeBytes: info.size }
} catch {
return null
}
}
function newestFirst(refs: Array<SessionRef | null>): SessionRef[] {
return refs.filter((r): r is SessionRef => r !== null).sort((a, b) => b.mtimeMs - a.mtimeMs)
}
export async function listCodexSessionRefs(): Promise<SessionRef[]> {
const files = await listRolloutFiles()
return newestFirst(await Promise.all(files.map(statRef)))
}
// Id lookups match filenames directly so only the matching files get stated.
export async function findCodexSession(idPrefix: string): Promise<SessionRef | null> {
const matches = (await listRolloutFiles()).filter((f) => f.sessionId.startsWith(idPrefix))
return newestFirst(await Promise.all(matches.map(statRef)))[0] ?? null
}
// Codex stores no session name; use the head chunk for the cwd (project) and
// the first real user message as a stand-in title.
async function readCodexHeadInfo(ref: SessionRef): Promise<{ project: string; title: string }> {
let chunk: string
try {
chunk = await readChunk(ref.filePath, 0, 262_144)
} catch {
return { project: '', title: '' }
}
let project = ''
let title = ''
for (const line of chunk.split('\n')) {
if (project && title) break
let entry: CodexEntry
try {
entry = JSON.parse(line) as CodexEntry
} catch {
continue
}
const payload = entry.payload
if (!payload) continue
if (!project && entry.type === 'session_meta' && typeof payload.cwd === 'string' && payload.cwd) {
project = basename(payload.cwd)
}
if (!title && entry.type === 'response_item' && payload.type === 'message' && payload.role === 'user' && Array.isArray(payload.content)) {
for (const block of payload.content) {
const b = block as { type?: string; text?: unknown }
if ((b.type === 'input_text' || b.type === 'text') && typeof b.text === 'string' && b.text.trim() && !isCodexMetaText(b.text)) {
title = b.text.replace(/\s+/g, ' ').trim().slice(0, 80)
break
}
}
}
}
return { project, title }
}
export async function listRecentCodexSessions(limit = 15): Promise<TitledSessionRef[]> {
const refs = (await listCodexSessionRefs()).slice(0, limit)
return Promise.all(
refs.map(async (ref) => {
const info = await readCodexHeadInfo(ref)
return { ...ref, project: info.project, title: info.title }
}),
)
}