codeburn/src/context-tree.ts
Aditya Vikram Singh 35b8f0519e
Extract shared token estimation helper, migrate all CHARS_PER_TOKEN sites (#644) (#677)
Co-authored-by: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
2026-07-16 11:58:22 -07:00

744 lines
28 KiB
TypeScript

import { open, readdir, stat } from 'fs/promises'
import { existsSync } from 'fs'
import { delimiter, join } from 'path'
import { homedir } from 'os'
import chalk from 'chalk'
import { readSessionLines, type SessionLine } from './fs-utils.js'
import { formatTokens } from './format.js'
import { estimateTokensFromChars } from './token-estimate.js'
// Block token counts are chars/4 estimates; the "context (exact)" line comes
// from the last assistant message's API usage. Transcripts store thinking
// blocks with their text stripped, so reasoning is derived per message as
// output_tokens minus the estimated visible output.
export const IMAGE_TOKEN_FALLBACK = 1600
export type BlockStat = { count: number; tokens: number }
export type ContextSnapshot = {
messages: number
tokens: number
assistant: {
count: number
tokens: number
text: BlockStat
reasoning: BlockStat
toolCall: BlockStat
byTool: Array<{ tool: string; count: number; tokens: number }>
}
user: {
count: number
tokens: number
text: BlockStat
image: BlockStat
compactSummary: BlockStat
meta: BlockStat
}
toolResult: BlockStat
system: BlockStat
}
export type SessionRef = {
filePath: string
sessionId: string
project: string
mtimeMs: number
sizeBytes: number
}
export type ContextTreeResult = {
session: SessionRef
model: string
compactions: number
reported: { context: number; window: number | null } | null
effective: ContextSnapshot
full: ContextSnapshot
}
// A single line above this decodes to a string near V8's limit; skip it
// instead of letting toString abort the whole walk.
const MAX_LINE_BYTES = 256 * 1024 * 1024
export function lineToText(line: SessionLine): string | null {
if (typeof line === 'string') return line
if (line.length > MAX_LINE_BYTES) return null
try {
return line.toString('utf-8')
} catch {
return null
}
}
export type Acc = {
messages: number
assistantCount: number
assistantText: BlockStat
assistantReasoning: BlockStat
toolCall: BlockStat
byTool: Map<string, BlockStat>
userCount: number
userText: BlockStat
userImage: BlockStat
userCompactSummary: BlockStat
userMeta: BlockStat
toolResult: BlockStat
system: BlockStat
}
type RawUsage = {
input_tokens?: number
output_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
}
type RawEntry = {
type?: string
subtype?: string
uuid?: string
isSidechain?: boolean
isMeta?: boolean
isCompactSummary?: boolean
content?: unknown
attachment?: unknown
compactMetadata?: { preTokens?: number; preservedSegment?: { headUuid?: string } }
message?: {
id?: string
role?: string
model?: string
content?: unknown
usage?: RawUsage
}
}
// Streamed assistant messages arrive as several transcript entries sharing one
// message id. Reasoning can only be settled once the whole message has been
// seen, so per-message state is buffered here and flushed at end of file.
type PendingAssistant = {
effective: boolean
visibleEstTokens: number
thinkingCount: number
outputTokens: number
}
function newBlockStat(): BlockStat {
return { count: 0, tokens: 0 }
}
export function newAcc(): Acc {
return {
messages: 0,
assistantCount: 0,
assistantText: newBlockStat(),
assistantReasoning: newBlockStat(),
toolCall: newBlockStat(),
byTool: new Map(),
userCount: 0,
userText: newBlockStat(),
userImage: newBlockStat(),
userCompactSummary: newBlockStat(),
userMeta: newBlockStat(),
toolResult: newBlockStat(),
system: newBlockStat(),
}
}
export function estimateTokens(text: string): number {
return estimateTokensFromChars(text.length)
}
export function add(stat: BlockStat, tokens: number): void {
stat.count += 1
stat.tokens += tokens
}
// Injected harness content (slash-command wrappers, system reminders, hook
// output) rather than something the user typed.
const META_TEXT_RE = /^\s*<(command-name|command-message|command-args|command-contents|local-command-stdout|local-command-stderr|system-reminder|task-notification)/
function pngDims(buf: Buffer): [number, number] | null {
if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47) return null
return [buf.readUInt32BE(16), buf.readUInt32BE(20)]
}
function jpegDims(buf: Buffer): [number, number] | null {
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null
let i = 2
while (i + 9 < buf.length) {
if (buf[i] !== 0xff) {
i++
continue
}
const marker = buf[i + 1]
const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc
if (isSof) return [buf.readUInt16BE(i + 7), buf.readUInt16BE(i + 5)]
const len = buf.readUInt16BE(i + 2)
if (len < 2) return null
i += 2 + len
}
return null
}
// Anthropic vision pricing: ~(w*h)/750 tokens after the API downscales to fit
// 1568px on the long edge / ~1.15MP total.
function imageTokens(source: unknown): number {
const data = (source as { data?: unknown } | undefined)?.data
if (typeof data !== 'string' || data.length === 0) return IMAGE_TOKEN_FALLBACK
let buf: Buffer
try {
buf = Buffer.from(data.slice(0, 262144), 'base64')
} catch {
return IMAGE_TOKEN_FALLBACK
}
const dims = pngDims(buf) ?? jpegDims(buf)
if (!dims) return IMAGE_TOKEN_FALLBACK
const [w, h] = dims
if (!(w > 0) || !(h > 0)) return IMAGE_TOKEN_FALLBACK
const scale = Math.min(1, 1568 / Math.max(w, h), Math.sqrt(1_150_000 / (w * h)))
return Math.max(1, Math.min(IMAGE_TOKEN_FALLBACK, Math.round((w * scale * h * scale) / 750)))
}
function toolResultTokens(content: unknown): number {
if (typeof content === 'string') return estimateTokens(content)
if (!Array.isArray(content)) return 0
let tokens = 0
for (const block of content) {
if (block == null || typeof block !== 'object') continue
const b = block as { type?: string; text?: unknown; source?: unknown }
if (b.type === 'text' && typeof b.text === 'string') tokens += estimateTokens(b.text)
else if (b.type === 'image') tokens += imageTokens(b.source)
}
return tokens
}
class TreeBuilder {
full = newAcc()
effective = newAcc()
pending = new Map<string, PendingAssistant>()
model = 'unknown'
lastUsage: RawUsage | null = null
maxSeenTokens = 0
private accs(effective: boolean): Acc[] {
return effective ? [this.full, this.effective] : [this.full]
}
addEntry(entry: RawEntry, effective: boolean): void {
const role = entry.message?.role
if (entry.type === 'assistant' && role === 'assistant') {
this.addAssistant(entry, effective)
} else if (entry.type === 'user' && role === 'user') {
this.addUser(entry, effective)
} else if (entry.type === 'system') {
const tokens = typeof entry.content === 'string' ? estimateTokens(entry.content) : 0
for (const acc of this.accs(effective)) add(acc.system, tokens)
} else if (entry.type === 'attachment') {
let tokens = 0
try {
tokens = entry.attachment == null ? 0 : estimateTokens(JSON.stringify(entry.attachment))
} catch {
tokens = 0
}
for (const acc of this.accs(effective)) add(acc.userMeta, tokens)
}
}
private addAssistant(entry: RawEntry, effective: boolean): void {
const msg = entry.message
if (!msg) return
if (typeof msg.model === 'string' && msg.model && msg.model !== '<synthetic>') this.model = msg.model
const usage = msg.usage
if (usage && ((usage.input_tokens ?? 0) > 0 || (usage.cache_read_input_tokens ?? 0) > 0)) {
this.lastUsage = usage
}
const id = msg.id ?? entry.uuid ?? ''
let pending = this.pending.get(id)
if (!pending) {
pending = { effective, visibleEstTokens: 0, thinkingCount: 0, outputTokens: 0 }
this.pending.set(id, pending)
for (const acc of this.accs(effective)) {
acc.assistantCount += 1
acc.messages += 1
}
}
if (usage?.output_tokens !== undefined) pending.outputTokens = usage.output_tokens
const content = msg.content
if (!Array.isArray(content)) return
for (const block of content) {
if (block == null || typeof block !== 'object') continue
const b = block as { type?: string; text?: unknown; name?: unknown; input?: unknown; content?: unknown }
if (b.type === 'text' && typeof b.text === 'string') {
const tokens = estimateTokens(b.text)
pending.visibleEstTokens += tokens
for (const acc of this.accs(pending.effective)) add(acc.assistantText, tokens)
} else if (b.type === 'thinking' || b.type === 'redacted_thinking') {
pending.thinkingCount += 1
} else if (b.type === 'tool_use' || b.type === 'server_tool_use') {
let tokens = 0
try {
tokens = estimateTokens(JSON.stringify(b.input ?? {}))
} catch {
tokens = 0
}
pending.visibleEstTokens += tokens
const tool = typeof b.name === 'string' && b.name ? b.name : 'unknown'
for (const acc of this.accs(pending.effective)) {
add(acc.toolCall, tokens)
const stat = acc.byTool.get(tool) ?? newBlockStat()
add(stat, tokens)
acc.byTool.set(tool, stat)
}
} else if (b.type === 'web_search_tool_result' || b.type === 'web_fetch_tool_result') {
for (const acc of this.accs(pending.effective)) add(acc.toolResult, toolResultTokens(b.content))
}
}
}
private addUser(entry: RawEntry, effective: boolean): void {
for (const acc of this.accs(effective)) {
acc.userCount += 1
acc.messages += 1
}
const content = entry.message?.content
const bucketFor = (acc: Acc, text: string): BlockStat => {
if (entry.isCompactSummary) return acc.userCompactSummary
if (entry.isMeta || META_TEXT_RE.test(text)) return acc.userMeta
return acc.userText
}
if (typeof content === 'string') {
for (const acc of this.accs(effective)) add(bucketFor(acc, content), estimateTokens(content))
return
}
if (!Array.isArray(content)) return
for (const block of content) {
if (block == null || typeof block !== 'object') continue
const b = block as { type?: string; text?: unknown; source?: unknown; content?: unknown }
if (b.type === 'text' && typeof b.text === 'string') {
for (const acc of this.accs(effective)) add(bucketFor(acc, b.text), estimateTokens(b.text))
} else if (b.type === 'image') {
const tokens = imageTokens(b.source)
for (const acc of this.accs(effective)) add(acc.userImage, tokens)
} else if (b.type === 'tool_result') {
const tokens = toolResultTokens(b.content)
for (const acc of this.accs(effective)) add(acc.toolResult, tokens)
}
}
}
// Transcripts strip thinking text, so estimate reasoning as the message's
// output_tokens minus its estimated visible output. Only messages that
// actually contained thinking blocks get a reasoning row; the remainder for
// other messages is chars/4 drift, not reasoning.
flushReasoning(): void {
for (const pending of this.pending.values()) {
if (pending.thinkingCount === 0) continue
const tokens = Math.max(0, pending.outputTokens - pending.visibleEstTokens)
for (const acc of this.accs(pending.effective)) {
acc.assistantReasoning.count += pending.thinkingCount
acc.assistantReasoning.tokens += tokens
}
}
}
}
export function snapshot(acc: Acc): ContextSnapshot {
const assistantTokens = acc.assistantText.tokens + acc.assistantReasoning.tokens + acc.toolCall.tokens
const userTokens = acc.userText.tokens + acc.userImage.tokens + acc.userCompactSummary.tokens + acc.userMeta.tokens
const byTool = [...acc.byTool.entries()]
.map(([tool, stat]) => ({ tool, count: stat.count, tokens: stat.tokens }))
.sort((a, b) => b.tokens - a.tokens)
return {
messages: acc.messages,
tokens: assistantTokens + userTokens + acc.toolResult.tokens + acc.system.tokens,
assistant: {
count: acc.assistantCount,
tokens: assistantTokens,
text: acc.assistantText,
reasoning: acc.assistantReasoning,
toolCall: acc.toolCall,
byTool,
},
user: {
count: acc.userCount,
tokens: userTokens,
text: acc.userText,
image: acc.userImage,
compactSummary: acc.userCompactSummary,
meta: acc.userMeta,
},
toolResult: acc.toolResult,
system: acc.system,
}
}
const skipFileSnapshots = (head: string): boolean => head.includes('"type":"file-history-snapshot"')
// Pass 1: locate the last compaction. The live window starts at the preserved
// segment's head (messages Claude Code carried across the compaction), not at
// the boundary itself.
async function findLastBoundary(filePath: string): Promise<{
headUuid: string | null
compactions: number
maxPreTokens: number
}> {
let headUuid: string | null = null
let compactions = 0
let maxPreTokens = 0
for await (const line of readSessionLines(filePath, skipFileSnapshots, { largeLineAsBuffer: true })) {
if (typeof line !== 'string') continue
if (!line.includes('"subtype":"compact_boundary"')) continue
let entry: RawEntry
try {
entry = JSON.parse(line) as RawEntry
} catch {
continue
}
if (entry.type !== 'system' || entry.subtype !== 'compact_boundary') continue
compactions += 1
headUuid = entry.compactMetadata?.preservedSegment?.headUuid ?? null
maxPreTokens = Math.max(maxPreTokens, entry.compactMetadata?.preTokens ?? 0)
}
return { headUuid, compactions, maxPreTokens }
}
// Claude models with a 1M window: opus-4-8 (auto-compactions on disk show
// ~1.0M preTokens) and the "[1m]" long-context variants. Others default to
// 200K unless the session itself proves bigger.
const MILLION_WINDOW_RE = /opus-4-8|\[1m\]/
export async function buildContextTree(session: SessionRef): Promise<ContextTreeResult> {
const boundary = await findLastBoundary(session.filePath)
const builder = new TreeBuilder()
builder.maxSeenTokens = boundary.maxPreTokens
let boundariesSeen = 0
let inPreservedSegment = false
for await (const line of readSessionLines(session.filePath, skipFileSnapshots, { largeLineAsBuffer: true })) {
const text = lineToText(line)
if (!text || text.charCodeAt(0) !== 123) continue
let entry: RawEntry
try {
entry = JSON.parse(text) as RawEntry
} catch {
continue
}
if (entry.isSidechain === true) continue
if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
boundariesSeen += 1
continue
}
if (boundary.headUuid && entry.uuid === boundary.headUuid) inPreservedSegment = true
const effective = boundariesSeen >= boundary.compactions || inPreservedSegment
builder.addEntry(entry, effective)
}
builder.flushReasoning()
let reported: ContextTreeResult['reported'] = null
if (builder.lastUsage) {
const context =
(builder.lastUsage.input_tokens ?? 0) +
(builder.lastUsage.cache_read_input_tokens ?? 0) +
(builder.lastUsage.cache_creation_input_tokens ?? 0) +
(builder.lastUsage.output_tokens ?? 0)
builder.maxSeenTokens = Math.max(builder.maxSeenTokens, context)
const million = MILLION_WINDOW_RE.test(builder.model) || builder.maxSeenTokens > 220_000
reported = { context, window: million ? 1_000_000 : 200_000 }
}
return {
session,
model: builder.model,
compactions: boundary.compactions,
reported,
effective: snapshot(builder.effective),
full: snapshot(builder.full),
}
}
// Mirrors the env handling of providers/claude.ts so the context views cover
// the same session roots as usage tracking.
function claudeProjectRoots(): string[] {
const dirsEnv = process.env['CLAUDE_CONFIG_DIRS']
const dirs = dirsEnv ? dirsEnv.split(delimiter).filter(Boolean) : [process.env['CLAUDE_CONFIG_DIR'] ?? join(homedir(), '.claude')]
return dirs.map((d) => join(d, 'projects'))
}
type SessionFile = { filePath: string; sessionId: string; project: string }
async function listSessionFiles(): Promise<SessionFile[]> {
const files: SessionFile[] = []
for (const root of claudeProjectRoots()) {
if (!existsSync(root)) continue
let projectDirs: string[]
try {
projectDirs = await readdir(root)
} catch {
continue
}
for (const dir of projectDirs) {
let names: string[]
try {
names = await readdir(join(root, dir))
} catch {
continue
}
for (const name of names) {
if (!name.endsWith('.jsonl')) continue
files.push({
filePath: join(root, dir, name),
sessionId: name.slice(0, -'.jsonl'.length),
project: dir.split('-').filter(Boolean).pop() ?? dir,
})
}
}
}
return files
}
async function statRef(file: SessionFile): Promise<SessionRef | null> {
try {
const info = await stat(file.filePath)
if (!info.isFile() || info.size === 0) return null
return { ...file, 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 listRecentSessions(limit = 15): Promise<SessionRef[]> {
const files = await listSessionFiles()
return newestFirst(await Promise.all(files.map(statRef))).slice(0, limit)
}
// Id lookups match filenames directly so only the matching files get stated.
export async function findClaudeSession(idPrefix: string): Promise<SessionRef | null> {
const matches = (await listSessionFiles()).filter((f) => f.sessionId.startsWith(idPrefix))
return newestFirst(await Promise.all(matches.map(statRef)))[0] ?? null
}
// Claude Code stores an AI-generated session name as "ai-title" entries (the
// last one is current; sessions get re-titled) and, in older sessions, as
// "summary" entries near the top. Scanning one tail and one head chunk finds
// it without reading a potentially 100MB transcript.
const TITLE_CHUNK_BYTES = 262_144
function titleFromChunk(chunk: string): string {
let title = ''
let summary = ''
for (const line of chunk.split('\n')) {
if (line.includes('"type":"ai-title"')) {
try {
const t = (JSON.parse(line) as { aiTitle?: unknown }).aiTitle
if (typeof t === 'string' && t) title = t
} catch {
continue
}
} else if (!summary && line.includes('"type":"summary"')) {
try {
const t = (JSON.parse(line) as { summary?: unknown }).summary
if (typeof t === 'string' && t) summary = t
} catch {
continue
}
}
}
return title || summary
}
export async function readChunk(filePath: string, start: number, length: number): Promise<string> {
const fd = await open(filePath, 'r')
try {
const buf = Buffer.alloc(length)
const { bytesRead } = await fd.read(buf, 0, length, start)
return buf.subarray(0, bytesRead).toString('utf-8')
} finally {
await fd.close()
}
}
export async function readSessionTitle(ref: SessionRef): Promise<string> {
try {
const tailStart = Math.max(0, ref.sizeBytes - TITLE_CHUNK_BYTES)
let title = titleFromChunk(await readChunk(ref.filePath, tailStart, TITLE_CHUNK_BYTES))
if (!title && tailStart > 0) title = titleFromChunk(await readChunk(ref.filePath, 0, TITLE_CHUNK_BYTES))
return title.replace(/\s+/g, ' ').trim()
} catch {
return ''
}
}
async function resolveSession(arg: string | undefined, provider: 'claude' | 'codex'): Promise<SessionRef | null> {
if (arg && (arg.endsWith('.jsonl') || arg.includes('/'))) {
if (!existsSync(arg)) return null
const info = await stat(arg)
const base = arg.split('/').pop() ?? arg
return {
filePath: arg,
sessionId: base.replace(/\.jsonl$/, ''),
project: '',
mtimeMs: info.mtimeMs,
sizeBytes: info.size,
}
}
if (provider === 'codex') {
const codex = await import('./context-tree-codex.js')
if (!arg) return (await codex.listRecentCodexSessions(1))[0] ?? null
return codex.findCodexSession(arg)
}
if (!arg) return (await listRecentSessions(1))[0] ?? null
return findClaudeSession(arg)
}
function num(n: number): string {
return n.toLocaleString('en-US')
}
export function relativeAge(mtimeMs: number): string {
const mins = Math.max(0, Math.round((Date.now() - mtimeMs) / 60_000))
if (mins < 60) return `${mins}m ago`
if (mins < 60 * 24) return `${Math.round(mins / 60)}h ago`
return `${Math.round(mins / (60 * 24))}d ago`
}
export type ContextRow = { depth: number; label: string; count: number; tokens: number; bold?: boolean }
export function snapshotRows(view: ContextSnapshot): ContextRow[] {
const rows: ContextRow[] = []
rows.push({ depth: 0, label: 'assistant', count: view.assistant.count, tokens: view.assistant.tokens, bold: true })
rows.push({ depth: 1, label: 'text', count: view.assistant.text.count, tokens: view.assistant.text.tokens })
if (view.assistant.reasoning.count > 0) rows.push({ depth: 1, label: 'reasoning', count: view.assistant.reasoning.count, tokens: view.assistant.reasoning.tokens })
rows.push({ depth: 1, label: 'tool-call', count: view.assistant.toolCall.count, tokens: view.assistant.toolCall.tokens })
for (const t of view.assistant.byTool) rows.push({ depth: 2, label: t.tool, count: t.count, tokens: t.tokens })
rows.push({ depth: 0, label: 'user', count: view.user.count, tokens: view.user.tokens, bold: true })
rows.push({ depth: 1, label: 'text', count: view.user.text.count, tokens: view.user.text.tokens })
if (view.user.image.count > 0) rows.push({ depth: 1, label: 'image', count: view.user.image.count, tokens: view.user.image.tokens })
if (view.user.compactSummary.count > 0) rows.push({ depth: 1, label: 'compact-summary', count: view.user.compactSummary.count, tokens: view.user.compactSummary.tokens })
if (view.user.meta.count > 0) rows.push({ depth: 1, label: 'meta', count: view.user.meta.count, tokens: view.user.meta.tokens })
rows.push({ depth: 0, label: 'tool', count: view.toolResult.count, tokens: view.toolResult.tokens, bold: true })
rows.push({ depth: 1, label: 'tool-result', count: view.toolResult.count, tokens: view.toolResult.tokens })
if (view.system.count > 0) rows.push({ depth: 0, label: 'system', count: view.system.count, tokens: view.system.tokens, bold: true })
return rows
}
function renderRows(rows: ContextRow[]): string[] {
const leftLen = (r: ContextRow): number => r.depth * 2 + (r.depth > 0 ? 2 : 0) + r.label.length
const labelWidth = Math.max(...rows.map(leftLen)) + 2
const countWidth = Math.max(...rows.map((r) => num(r.count).length)) + 1
const tokenWidth = Math.max(...rows.map((r) => num(r.tokens).length))
return rows.map((r) => {
const indent = ' '.repeat(r.depth)
const bullet = r.depth > 0 ? chalk.dim('◦ ') : ''
const label = r.depth === 0 ? chalk.bold(r.label) : r.label
const pad = ' '.repeat(labelWidth - leftLen(r))
const count = chalk.dim(`${num(r.count)}x`.padStart(countWidth + 1))
const tokens = (r.bold ? chalk.cyan.bold : chalk.cyan)(num(r.tokens).padStart(tokenWidth + 2))
return ` ${indent}${bullet}${label}${pad}${count}${tokens} ${chalk.dim('tokens')}`
})
}
export function renderContextTree(result: ContextTreeResult, opts: { full?: boolean } = {}): string {
const view = opts.full ? result.full : result.effective
const lines: string[] = []
const scopeLabel = opts.full ? 'full session' : 'effective'
lines.push('')
lines.push(` ${chalk.bold('Context Token Usage')} ${chalk.dim(`(${scopeLabel})`)}`)
const sizeMb = (result.session.sizeBytes / 1024 / 1024).toFixed(1)
const project = result.session.project ? `${result.session.project} · ` : ''
lines.push(chalk.dim(` session ${result.session.sessionId.slice(0, 8)} · ${project}${result.model} · ${relativeAge(result.session.mtimeMs)} · ${sizeMb}MB on disk`))
lines.push('')
const masked = Math.max(0, result.full.tokens - result.effective.tokens)
lines.push(` messages: ${chalk.bold(num(view.messages))}`)
lines.push(` tokens: ${chalk.bold(formatTokens(result.full.tokens))} ${chalk.dim('estimated across the session')}`)
if (result.compactions > 0) {
const pct = result.full.tokens > 0 ? Math.round((result.effective.tokens / result.full.tokens) * 100) : 0
lines.push(` ${chalk.dim('◦')} ${formatTokens(masked)} ${chalk.dim(`compacted away (${num(result.compactions)} compaction${result.compactions === 1 ? '' : 's'})`)}`)
lines.push(` ${chalk.dim('◦')} ${formatTokens(result.effective.tokens)} ${chalk.dim(`effective (${pct}%)`)}`)
}
if (result.reported) {
const { context, window } = result.reported
const windowPart = window ? ` ${chalk.dim(`of ${formatTokens(window)} window (${Math.round((context / window) * 100)}%)`)}` : ''
lines.push(` context (exact, last turn): ${chalk.bold(formatTokens(context))}${windowPart}`)
const overhead = result.reported.context - result.effective.tokens
if (overhead >= 0) {
lines.push(` ${chalk.dim('◦')} ${formatTokens(overhead)} ${chalk.dim('system prompt, tools & memory (derived)')}`)
}
}
lines.push('')
lines.push(...renderRows(snapshotRows(view)))
lines.push('')
lines.push(chalk.dim(' block tokens are estimated (chars/4, images by pixel count, reasoning from per-message usage);'))
lines.push(chalk.dim(' "context (exact)" comes from API usage.'))
if (!opts.full && result.compactions > 0) lines.push(chalk.dim(' showing the live window since the last compaction; use --full for the whole session.'))
lines.push('')
return lines.join('\n')
}
export type TitledSessionRef = SessionRef & { title: string }
function renderSessionList(refs: TitledSessionRef[], provider: 'claude' | 'codex'): string {
const heading = provider === 'codex' ? 'Recent Codex sessions' : 'Recent Claude Code sessions'
const hint = provider === 'codex' ? 'codeburn context <id> --provider codex to inspect one' : 'codeburn context <id> to inspect one'
const lines = ['', ` ${chalk.bold(heading)}`, '']
const projectWidth = Math.max(...refs.map((r) => r.project.length))
for (const ref of refs) {
const sizeMb = (ref.sizeBytes / 1024 / 1024).toFixed(1).padStart(6)
const shortTitle = ref.title.length > 48 ? `${ref.title.slice(0, 47)}` : ref.title
lines.push(` ${chalk.cyan(ref.sessionId.slice(0, 8))} ${chalk.dim(`${sizeMb}MB`)} ${relativeAge(ref.mtimeMs).padStart(7)} ${chalk.dim(ref.project.padEnd(projectWidth))} ${shortTitle}`)
}
lines.push('')
lines.push(chalk.dim(` ${hint}`))
lines.push('')
return lines.join('\n')
}
export async function listRecentTitledSessions(limit = 15): Promise<TitledSessionRef[]> {
const refs = await listRecentSessions(limit)
const titles = await Promise.all(refs.map(readSessionTitle))
return refs.map((r, i) => ({ ...r, title: titles[i] ?? '' }))
}
export async function runContextCommand(
sessionArg: string | undefined,
opts: { list?: boolean; full?: boolean; json?: boolean; provider?: string },
): Promise<void> {
const provider: 'claude' | 'codex' = opts.provider === 'codex' ? 'codex' : 'claude'
if (opts.list) {
const refs =
provider === 'codex' ? await (await import('./context-tree-codex.js')).listRecentCodexSessions(15) : await listRecentTitledSessions(15)
if (refs.length === 0) {
console.log(provider === 'codex' ? 'No Codex sessions found.' : 'No Claude Code sessions found.')
return
}
if (opts.json) {
console.log(JSON.stringify({ sessions: refs }, null, 2))
return
}
console.log(renderSessionList(refs, provider))
return
}
const session = await resolveSession(sessionArg, provider)
if (!session) {
console.error(sessionArg ? `No ${provider} session matching "${sessionArg}".` : `No ${provider} sessions found.`)
process.exitCode = 1
return
}
const result =
provider === 'codex' ? await (await import('./context-tree-codex.js')).buildCodexContextTree(session) : await buildContextTree(session)
if (opts.json) {
console.log(JSON.stringify(result, null, 2))
return
}
console.log(renderContextTree(result, { full: opts.full }))
}