mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-09 17:19:15 +00:00
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.
This commit is contained in:
parent
f1a4e8cc4f
commit
9b20dfd7dc
8 changed files with 1690 additions and 4 deletions
|
|
@ -20,6 +20,7 @@ import { BarList, type BarItem } from '@/components/BarList'
|
|||
import { DataTable } from '@/components/DataTable'
|
||||
import { UsageChart, DeviceUsageChart, type Unit } from '@/components/UsageChart'
|
||||
import { DeviceSearchModal } from '@/components/DeviceSearchModal'
|
||||
import { ContextExplorer } from '@/components/ContextExplorer'
|
||||
|
||||
const n = (v: number | undefined): number => v ?? 0
|
||||
|
||||
|
|
@ -345,6 +346,7 @@ function CombinedView({ devices, unit }: { devices: DeviceUsage[]; unit: Unit })
|
|||
}
|
||||
|
||||
export function App() {
|
||||
const [page, setPage] = useState<'usage' | 'context'>('usage')
|
||||
const [period, setPeriod] = useState<Period>('today')
|
||||
const [provider, setProvider] = useState('all')
|
||||
const [view, setView] = useState<string>('all')
|
||||
|
|
@ -454,7 +456,25 @@ export function App() {
|
|||
<span className="ml-1 text-[11px] font-light uppercase tracking-[0.14em] text-tertiary-foreground max-sm:hidden">usage</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-6 flex rounded-md border border-border bg-interactive-secondary p-0.5 max-md:ml-2 max-md:shrink-0">
|
||||
{(['usage', 'context'] as const).map((pg) => (
|
||||
<button
|
||||
key={pg}
|
||||
type="button"
|
||||
onClick={() => setPage(pg)}
|
||||
className={cn(
|
||||
'rounded-[5px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
page === pg ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{pg === 'usage' ? 'Usage' : 'Context'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 max-md:min-w-0 max-md:overflow-x-auto max-md:[-ms-overflow-style:none] max-md:[scrollbar-width:none] max-md:[&::-webkit-scrollbar]:hidden">
|
||||
{page === 'usage' && (
|
||||
<>
|
||||
<div className="flex rounded-md border border-border bg-interactive-secondary p-0.5 max-md:shrink-0">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
|
|
@ -497,6 +517,8 @@ export function App() {
|
|||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -529,6 +551,8 @@ export function App() {
|
|||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
{page === 'usage' && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-heading">Devices</p>
|
||||
{multi && (
|
||||
|
|
@ -560,6 +584,8 @@ export function App() {
|
|||
</svg>
|
||||
Search local devices
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="mb-2 px-2.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-heading">Share</p>
|
||||
|
|
@ -635,17 +661,19 @@ export function App() {
|
|||
|
||||
<main className="min-w-0 flex-1 overflow-y-auto pr-0.5">
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h1 className="font-display text-xl tracking-tight text-foreground">{viewTitle}</h1>
|
||||
<span className="text-xs text-tertiary-foreground">{label}</span>
|
||||
<h1 className="font-display text-xl tracking-tight text-foreground">{page === 'context' ? 'Context' : viewTitle}</h1>
|
||||
<span className="text-xs text-tertiary-foreground">{page === 'usage' ? label : ''}</span>
|
||||
</div>
|
||||
|
||||
{showCombined ? (
|
||||
{page === 'context' ? (
|
||||
<ContextExplorer />
|
||||
) : showCombined ? (
|
||||
<CombinedView devices={devices} unit={unit} />
|
||||
) : (
|
||||
<DeviceView payload={primary?.payload} isRemote={!!viewing && !viewing.local} unit={unit} />
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
{page === 'usage' && isError && (
|
||||
<div className="mt-4 text-sm text-tertiary-foreground">Failed to load: {String((error as Error)?.message)}</div>
|
||||
)}
|
||||
</main>
|
||||
|
|
|
|||
222
dash/src/components/ContextExplorer.tsx
Normal file
222
dash/src/components/ContextExplorer.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
fetchContextSessions,
|
||||
fetchContextTree,
|
||||
type ContextProvider,
|
||||
type ContextRow,
|
||||
type ContextSessionInfo,
|
||||
} from '@/lib/api'
|
||||
import { cn, fmtNum, fmtTokens, label } from '@/lib/utils'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
const PROVIDERS: Array<{ key: ContextProvider; label: string }> = [
|
||||
{ key: 'claude', label: 'Claude Code' },
|
||||
{ key: 'codex', label: 'Codex' },
|
||||
]
|
||||
|
||||
function ago(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`
|
||||
}
|
||||
|
||||
function TreeTable({ rows }: { rows: ContextRow[] }) {
|
||||
const max = Math.max(1, ...rows.filter((r) => !r.bold).map((r) => r.tokens))
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className={cn('relative flex items-center gap-3 rounded-sm px-2 py-[3px]', r.bold && i > 0 && 'mt-2')}>
|
||||
{!r.bold && (
|
||||
<span
|
||||
className="absolute inset-y-[3px] left-0 rounded-sm bg-primary/[0.07]"
|
||||
style={{ width: `${Math.max(1, (r.tokens / max) * 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn('relative min-w-0 flex-1 truncate text-[13px]', r.bold ? 'font-semibold text-foreground' : 'text-muted-foreground')}
|
||||
style={{ paddingLeft: r.depth * 16 }}
|
||||
>
|
||||
{r.label}
|
||||
</span>
|
||||
<span className="relative w-16 shrink-0 text-right text-xs tabular-nums text-tertiary-foreground">{fmtNum(r.count)}x</span>
|
||||
<span className={cn('relative w-20 shrink-0 text-right text-[13px] tabular-nums', r.bold ? 'font-semibold text-foreground' : 'text-foreground')}>
|
||||
{fmtTokens(r.tokens)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ label: lbl, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-interactive-secondary px-3 py-2">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.1em] text-tertiary-foreground">{lbl}</div>
|
||||
<div className="mt-0.5 text-sm font-medium tabular-nums text-foreground">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionDetails({ provider, id }: { provider: ContextProvider; id: string }) {
|
||||
const [scope, setScope] = useState<'effective' | 'full'>('effective')
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['context-tree', provider, id],
|
||||
queryFn: () => fetchContextTree(provider, id),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 py-4">
|
||||
<Skeleton className="h-14" />
|
||||
<Skeleton className="h-40" />
|
||||
<p className="text-xs text-tertiary-foreground">Reading the whole transcript, large sessions take a few seconds…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError || !data) {
|
||||
return <p className="px-4 py-4 text-sm text-tertiary-foreground">Failed to load: {String((error as Error)?.message ?? 'unknown')}</p>
|
||||
}
|
||||
|
||||
const view = scope === 'full' ? data.full : data.effective
|
||||
const rows = scope === 'full' ? data.fullRows : data.effectiveRows
|
||||
const window = data.reported?.window ?? null
|
||||
const pct = data.reported && window ? Math.min(100, Math.round((data.reported.context / window) * 100)) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<Chip label="Messages" value={fmtNum(view.messages)} />
|
||||
<Chip label="Est. tokens" value={fmtTokens(view.tokens)} />
|
||||
<Chip
|
||||
label="Context (exact)"
|
||||
value={data.reported ? (window ? `${fmtTokens(data.reported.context)} / ${fmtTokens(window)}` : fmtTokens(data.reported.context)) : '—'}
|
||||
/>
|
||||
<Chip label="Compactions" value={fmtNum(data.compactions)} />
|
||||
</div>
|
||||
|
||||
{pct !== null && (
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-[11px] text-tertiary-foreground">
|
||||
<span>{label(data.model)} · live context window</span>
|
||||
<span className="tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-interactive-secondary">
|
||||
<div className={cn('h-full rounded-full', pct >= 80 ? 'bg-[#c8541f]' : 'bg-primary')} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex rounded-md border border-border bg-interactive-secondary p-0.5">
|
||||
{(['effective', 'full'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={cn(
|
||||
'rounded-[5px] px-2.5 py-1 text-[11px] font-medium transition-colors',
|
||||
scope === s ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{s === 'effective' ? 'Live window' : 'Full history'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] text-tertiary-foreground">token counts are estimates; “Context (exact)” comes from API usage</span>
|
||||
</div>
|
||||
|
||||
<TreeTable rows={rows} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRow({ s, open, onToggle }: { s: ContextSessionInfo; open: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div className={cn('border-t border-border first:border-t-0', open && 'bg-interactive-secondary/30')}>
|
||||
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-interactive-secondary/50">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="10"
|
||||
height="10"
|
||||
className={cn('shrink-0 text-tertiary-foreground transition-transform', open && 'rotate-90')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M6 3l5 5-5 5" />
|
||||
</svg>
|
||||
<span className="shrink-0 font-mono text-xs text-primary">{s.sessionId.slice(0, 8)}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
|
||||
{s.title || <span className="text-tertiary-foreground">untitled session</span>}
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-xs text-tertiary-foreground sm:block">{s.project}</span>
|
||||
<span className="w-16 shrink-0 text-right text-xs tabular-nums text-tertiary-foreground">{ago(s.mtimeMs)}</span>
|
||||
<span className="w-16 shrink-0 text-right text-xs tabular-nums text-tertiary-foreground">{(s.sizeBytes / 1024 / 1024).toFixed(1)}MB</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-border">
|
||||
<SessionDetails provider={s.provider} id={s.sessionId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextExplorer() {
|
||||
const [provider, setProvider] = useState<ContextProvider>('claude')
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['context-sessions', provider],
|
||||
queryFn: () => fetchContextSessions(provider),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{PROVIDERS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProvider(p.key)
|
||||
setOpenId(null)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border px-3.5 py-1.5 text-xs font-medium transition-colors',
|
||||
provider === p.key
|
||||
? 'border-primary/40 bg-primary/10 text-foreground'
|
||||
: 'border-border bg-card text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto text-xs text-tertiary-foreground">what fills each session’s context window, block by block</span>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isError && <p className="px-4 py-6 text-sm text-tertiary-foreground">Failed to load sessions: {String((error as Error)?.message)}</p>}
|
||||
{data && data.length === 0 && <p className="px-4 py-6 text-sm text-tertiary-foreground">No sessions found for this provider.</p>}
|
||||
{data?.map((s) => (
|
||||
<SessionRow key={s.sessionId} s={s} open={openId === s.sessionId} onToggle={() => setOpenId(openId === s.sessionId ? null : s.sessionId)} />
|
||||
))}
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -166,6 +166,68 @@ export async function pairDevice(d: DiscoveredDevice): Promise<{ ok: boolean; na
|
|||
return res.json() as Promise<{ ok: boolean; name?: string; error?: string }>
|
||||
}
|
||||
|
||||
export type ContextProvider = 'claude' | 'codex'
|
||||
|
||||
export type ContextSessionInfo = {
|
||||
provider: ContextProvider
|
||||
sessionId: string
|
||||
project: string
|
||||
title: string
|
||||
mtimeMs: number
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
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 ContextRow = { depth: number; label: string; count: number; tokens: number; bold?: boolean }
|
||||
|
||||
export type ContextTree = {
|
||||
session: { sessionId: string; project: string; mtimeMs: number; sizeBytes: number }
|
||||
model: string
|
||||
compactions: number
|
||||
reported: { context: number; window: number | null } | null
|
||||
effective: ContextSnapshot
|
||||
full: ContextSnapshot
|
||||
effectiveRows: ContextRow[]
|
||||
fullRows: ContextRow[]
|
||||
}
|
||||
|
||||
export async function fetchContextSessions(provider: ContextProvider): Promise<ContextSessionInfo[]> {
|
||||
const res = await fetch(`/api/context/sessions?provider=${encodeURIComponent(provider)}`)
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`)
|
||||
const json = (await res.json()) as { sessions: ContextSessionInfo[] }
|
||||
return json.sessions ?? []
|
||||
}
|
||||
|
||||
export async function fetchContextTree(provider: ContextProvider, id: string): Promise<ContextTree> {
|
||||
const res = await fetch(`/api/context/tree?provider=${encodeURIComponent(provider)}&id=${encodeURIComponent(id)}`)
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`)
|
||||
return res.json() as Promise<ContextTree>
|
||||
}
|
||||
|
||||
export type PendingPairing = { id: string; name: string; code: string }
|
||||
export type ShareStatus = {
|
||||
sharing: boolean
|
||||
|
|
|
|||
341
src/context-tree-codex.ts
Normal file
341
src/context-tree-codex.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
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 }
|
||||
}),
|
||||
)
|
||||
}
|
||||
744
src/context-tree.ts
Normal file
744
src/context-tree.ts
Normal file
|
|
@ -0,0 +1,744 @@
|
|||
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'
|
||||
|
||||
// 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.
|
||||
|
||||
const CHARS_PER_TOKEN = 4
|
||||
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 Math.ceil(text.length / CHARS_PER_TOKEN)
|
||||
}
|
||||
|
||||
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 }))
|
||||
}
|
||||
203
src/context-tui.tsx
Normal file
203
src/context-tui.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { render, Box, Text, useApp, useInput } from 'ink'
|
||||
|
||||
import { formatTokens } from './format.js'
|
||||
import { patchStdoutForWindows } from './ink-win.js'
|
||||
import {
|
||||
buildContextTree,
|
||||
listRecentTitledSessions,
|
||||
relativeAge,
|
||||
snapshotRows,
|
||||
type ContextTreeResult,
|
||||
type TitledSessionRef,
|
||||
} from './context-tree.js'
|
||||
import { buildCodexContextTree, listRecentCodexSessions } from './context-tree-codex.js'
|
||||
|
||||
type Provider = 'claude' | 'codex'
|
||||
type Scope = 'effective' | 'full'
|
||||
|
||||
const ORANGE = '#FF8C42'
|
||||
const DIM = '#555555'
|
||||
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
const PROVIDERS: Array<{ key: Provider; label: string }> = [
|
||||
{ key: 'claude', label: 'Claude Code' },
|
||||
{ key: 'codex', label: 'Codex' },
|
||||
]
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text
|
||||
}
|
||||
|
||||
async function loadSessions(provider: Provider): Promise<TitledSessionRef[]> {
|
||||
return provider === 'codex' ? listRecentCodexSessions(15) : listRecentTitledSessions(15)
|
||||
}
|
||||
|
||||
function TreeDetails({ tree, scope }: { tree: ContextTreeResult; scope: Scope }) {
|
||||
const view = scope === 'full' ? tree.full : tree.effective
|
||||
const rows = snapshotRows(view)
|
||||
const labelWidth = Math.max(...rows.map((r) => r.depth * 2 + r.label.length)) + 2
|
||||
const countWidth = Math.max(...rows.map((r) => `${r.count}x`.length)) + 1
|
||||
const tokenWidth = Math.max(...rows.map((r) => formatTokens(r.tokens).length)) + 2
|
||||
|
||||
const headline: string[] = [`model ${tree.model}`, `messages ${view.messages.toLocaleString('en-US')}`, `est ${formatTokens(view.tokens)}`]
|
||||
if (tree.reported) {
|
||||
const { context, window } = tree.reported
|
||||
headline.push(window ? `context ${formatTokens(context)} / ${formatTokens(window)} (${Math.round((context / window) * 100)}%)` : `context ${formatTokens(context)} (exact)`)
|
||||
}
|
||||
if (tree.compactions > 0) headline.push(`${tree.compactions} compaction${tree.compactions === 1 ? '' : 's'}`)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginLeft={4} marginBottom={1} paddingLeft={1} borderStyle="round" borderColor={DIM} width={72}>
|
||||
<Text color={DIM}>
|
||||
{headline.join(' · ')}
|
||||
</Text>
|
||||
<Text color={DIM}>
|
||||
showing <Text color={ORANGE}>{scope === 'effective' ? 'live window' : 'full history'}</Text> · press f to switch
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
{rows.map((r, i) => (
|
||||
<Text key={i}>
|
||||
{' '.repeat(r.depth * 2)}
|
||||
<Text bold={r.bold} color={r.bold ? undefined : DIM}>
|
||||
{(r.label + ' ').padEnd(labelWidth - r.depth * 2, r.bold ? ' ' : '·')}
|
||||
</Text>
|
||||
<Text color={DIM}>{`${r.count.toLocaleString('en-US')}x`.padStart(countWidth)}</Text>
|
||||
<Text color={ORANGE} bold={r.bold}>
|
||||
{formatTokens(r.tokens).padStart(tokenWidth)}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextTuiApp({ initialScope }: { initialScope: Scope }) {
|
||||
const { exit } = useApp()
|
||||
const [provider, setProvider] = useState<Provider>('claude')
|
||||
const [sessions, setSessions] = useState<TitledSessionRef[] | null>(null)
|
||||
const [cursor, setCursor] = useState(0)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [scope, setScope] = useState<Scope>(initialScope)
|
||||
const [building, setBuilding] = useState(false)
|
||||
const [frame, setFrame] = useState(0)
|
||||
const [trees, setTrees] = useState<Record<string, ContextTreeResult>>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setSessions(null)
|
||||
setCursor(0)
|
||||
setExpandedId(null)
|
||||
void loadSessions(provider).then((rows) => {
|
||||
if (alive) setSessions(rows)
|
||||
})
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [provider])
|
||||
|
||||
useEffect(() => {
|
||||
if (!building) return
|
||||
const t = setInterval(() => setFrame((f) => f + 1), 100)
|
||||
return () => clearInterval(t)
|
||||
}, [building])
|
||||
|
||||
const toggleExpand = (session: TitledSessionRef) => {
|
||||
if (expandedId === session.sessionId) {
|
||||
setExpandedId(null)
|
||||
return
|
||||
}
|
||||
setExpandedId(session.sessionId)
|
||||
const key = `${provider}:${session.sessionId}:${session.mtimeMs}`
|
||||
if (trees[key]) return
|
||||
setBuilding(true)
|
||||
setErrors((e) => ({ ...e, [key]: '' }))
|
||||
const build = provider === 'claude' ? buildContextTree(session) : buildCodexContextTree(session)
|
||||
void build
|
||||
.then((tree) => setTrees((t) => ({ ...t, [key]: tree })))
|
||||
.catch((err: unknown) => setErrors((e) => ({ ...e, [key]: err instanceof Error ? err.message : String(err) })))
|
||||
.finally(() => setBuilding(false))
|
||||
}
|
||||
|
||||
useInput((input, key) => {
|
||||
if (input === 'q' || key.escape) {
|
||||
exit()
|
||||
return
|
||||
}
|
||||
if (key.upArrow || input === 'k') setCursor((c) => Math.max(0, c - 1))
|
||||
if (key.downArrow || input === 'j') setCursor((c) => Math.min((sessions?.length ?? 1) - 1, c + 1))
|
||||
if (key.tab || key.leftArrow || key.rightArrow) setProvider((p) => (p === 'claude' ? 'codex' : 'claude'))
|
||||
if (input === 'f') setScope((s) => (s === 'effective' ? 'full' : 'effective'))
|
||||
if ((key.return || input === ' ') && sessions && sessions[cursor]) toggleExpand(sessions[cursor])
|
||||
})
|
||||
|
||||
const titleWidth = 46
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1} paddingY={1}>
|
||||
<Box>
|
||||
<Text bold color={ORANGE}>
|
||||
Context{' '}
|
||||
</Text>
|
||||
{PROVIDERS.map((p) => (
|
||||
<Text key={p.key}>
|
||||
{' '}
|
||||
<Text bold={provider === p.key} color={provider === p.key ? undefined : DIM} inverse={provider === p.key}>
|
||||
{` ${p.label} `}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
<Text color={DIM}>{' ↑↓ move · enter expand · tab provider · f scope · q quit'}</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
|
||||
{!sessions && <Text color={DIM}>Loading sessions…</Text>}
|
||||
{sessions && sessions.length === 0 && <Text color={DIM}>No sessions found for this provider.</Text>}
|
||||
|
||||
{sessions?.map((s, i) => {
|
||||
const selected = i === cursor
|
||||
const expanded = expandedId === s.sessionId
|
||||
const key = `${provider}:${s.sessionId}:${s.mtimeMs}`
|
||||
const tree = trees[key]
|
||||
const error = errors[key]
|
||||
return (
|
||||
<Box key={s.filePath} flexDirection="column">
|
||||
<Text>
|
||||
<Text color={ORANGE}>{selected ? '❯ ' : ' '}</Text>
|
||||
<Text color={selected ? ORANGE : undefined}>{s.sessionId.slice(0, 8)}</Text>
|
||||
<Text color={expanded || selected ? undefined : DIM}>
|
||||
{' '}
|
||||
{truncate(s.title || 'untitled session', titleWidth).padEnd(titleWidth)}
|
||||
</Text>
|
||||
<Text color={DIM}>
|
||||
{' '}
|
||||
{truncate(s.project, 12).padEnd(12)} {relativeAge(s.mtimeMs).padStart(8)} {`${(s.sizeBytes / 1024 / 1024).toFixed(1)}MB`.padStart(8)}
|
||||
</Text>
|
||||
</Text>
|
||||
{expanded && error && (
|
||||
<Box marginLeft={4} marginBottom={1}>
|
||||
<Text color="red">could not read this session: {error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{expanded && !tree && !error && (
|
||||
<Box marginLeft={4} marginBottom={1}>
|
||||
<Text color={ORANGE}>{SPINNER[frame % SPINNER.length]} </Text>
|
||||
<Text color={DIM}>reading transcript ({(s.sizeBytes / 1024 / 1024).toFixed(0)}MB)…</Text>
|
||||
</Box>
|
||||
)}
|
||||
{expanded && tree && <TreeDetails tree={tree} scope={scope} />}
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
|
||||
<Box height={1} />
|
||||
<Text color={DIM}>block tokens are estimates; context (exact) comes from API usage</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export async function runContextTui(opts: { initialScope?: Scope } = {}): Promise<void> {
|
||||
patchStdoutForWindows()
|
||||
const instance = render(<ContextTuiApp initialScope={opts.initialScope ?? 'effective'} />)
|
||||
await instance.waitUntilExit()
|
||||
}
|
||||
22
src/main.ts
22
src/main.ts
|
|
@ -24,6 +24,7 @@ import { loadRemotes, saveRemotes } from './sharing/store.js'
|
|||
import type { UsageQuery } from './sharing/share-server.js'
|
||||
import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js'
|
||||
import { runOptimize } from './optimize.js'
|
||||
import { runContextCommand } from './context-tree.js'
|
||||
import { renderCompare } from './compare.js'
|
||||
import {
|
||||
installAntigravityStatusLineHook,
|
||||
|
|
@ -1335,6 +1336,27 @@ program
|
|||
await runOptimize(projects, label, range, { format: opts.format })
|
||||
})
|
||||
|
||||
program
|
||||
.command('context [session]')
|
||||
.description('Context token breakdown per session: what fills the window, by role, block type, and tool (experimental). No session argument opens an interactive browser.')
|
||||
.option('--list', 'List recent sessions to pick from')
|
||||
.option('--full', 'Cover the whole session history instead of the live (post-compaction) window')
|
||||
.option('--json', 'JSON output')
|
||||
.option('--provider <provider>', 'Session source: claude or codex', 'claude')
|
||||
.action(async (session: string | undefined, opts: { list?: boolean; full?: boolean; json?: boolean; provider?: string }) => {
|
||||
if (opts.provider !== 'claude' && opts.provider !== 'codex') {
|
||||
console.error('context: --provider must be claude or codex')
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
if (!session && !opts.list && !opts.json && process.stdout.isTTY && process.stdin.isTTY) {
|
||||
const { runContextTui } = await import('./context-tui.js')
|
||||
await runContextTui({ initialScope: opts.full ? 'full' : 'effective' })
|
||||
return
|
||||
}
|
||||
await runContextCommand(session, opts)
|
||||
})
|
||||
|
||||
program
|
||||
.command('compare')
|
||||
.description('Compare two AI models side-by-side')
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import { pairingCode } from './sharing/pairing.js'
|
|||
import { getSharingDir, loadRemotes, loadShareAlways, saveShareAlways } from './sharing/store.js'
|
||||
import { ShareController } from './sharing/share-controller.js'
|
||||
import { sanitizeForSharing } from './sharing/sanitize.js'
|
||||
import { buildContextTree, findClaudeSession, listRecentTitledSessions, snapshotRows, type ContextTreeResult, type SessionRef } from './context-tree.js'
|
||||
import { buildCodexContextTree, findCodexSession, listRecentCodexSessions } from './context-tree-codex.js'
|
||||
|
||||
function readBody(req: import('http').IncomingMessage): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -127,6 +129,29 @@ export async function runWebDashboard(opts: {
|
|||
return payload
|
||||
}
|
||||
|
||||
// Context trees re-read a whole transcript (up to 100MB), so cache each by
|
||||
// file version. Keyed on mtime: an active session invalidates itself.
|
||||
const contextTreeCache = new Map<string, Promise<ContextTreeResult>>()
|
||||
const getContextTree = (provider: 'claude' | 'codex', ref: SessionRef): Promise<ContextTreeResult> => {
|
||||
const key = `${provider}:${ref.sessionId}:${ref.mtimeMs}`
|
||||
const hit = contextTreeCache.get(key)
|
||||
if (hit) {
|
||||
// Re-insert so eviction is LRU rather than insertion order.
|
||||
contextTreeCache.delete(key)
|
||||
contextTreeCache.set(key, hit)
|
||||
return hit
|
||||
}
|
||||
const tree = provider === 'claude' ? buildContextTree(ref) : buildCodexContextTree(ref)
|
||||
contextTreeCache.set(key, tree)
|
||||
void tree.catch(() => contextTreeCache.delete(key))
|
||||
while (contextTreeCache.size > 12) {
|
||||
const oldest = contextTreeCache.keys().next().value
|
||||
if (oldest === undefined) break
|
||||
contextTreeCache.delete(oldest)
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
// Embed this machine's prewarmed payload in index.html for an instant first
|
||||
// paint with no data round-trip. Only the local device is inlined: no remote
|
||||
// network wait, and paired devices stream in via the live fetch right after.
|
||||
|
|
@ -275,6 +300,45 @@ export async function runWebDashboard(opts: {
|
|||
res.end(JSON.stringify(await share.status()))
|
||||
return
|
||||
}
|
||||
// Session ids are resolved against the discovered session files only,
|
||||
// never joined into a path from user input, and responses never carry
|
||||
// local file paths.
|
||||
if (url.pathname === '/api/context/sessions') {
|
||||
const provider = url.searchParams.get('provider') ?? 'claude'
|
||||
if (provider !== 'claude' && provider !== 'codex') {
|
||||
writeJsonError(res, 400, 'provider must be claude or codex')
|
||||
return
|
||||
}
|
||||
const refs = provider === 'claude' ? await listRecentTitledSessions(15) : await listRecentCodexSessions(15)
|
||||
const sessions = refs.map((r) => ({ provider, sessionId: r.sessionId, project: r.project, title: r.title, mtimeMs: r.mtimeMs, sizeBytes: r.sizeBytes }))
|
||||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
||||
res.end(JSON.stringify({ sessions }))
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/api/context/tree') {
|
||||
const provider = url.searchParams.get('provider') ?? 'claude'
|
||||
const id = url.searchParams.get('id') ?? ''
|
||||
if ((provider !== 'claude' && provider !== 'codex') || !id) {
|
||||
writeJsonError(res, 400, 'provider (claude|codex) and id are required')
|
||||
return
|
||||
}
|
||||
const ref = provider === 'claude' ? await findClaudeSession(id) : await findCodexSession(id)
|
||||
if (!ref || ref.sessionId !== id) {
|
||||
writeJsonError(res, 404, `no ${provider} session ${id}`)
|
||||
return
|
||||
}
|
||||
const tree = await getContextTree(provider, ref)
|
||||
const payload = {
|
||||
...tree,
|
||||
session: { sessionId: tree.session.sessionId, project: tree.session.project, mtimeMs: tree.session.mtimeMs, sizeBytes: tree.session.sizeBytes },
|
||||
effectiveRows: snapshotRows(tree.effective),
|
||||
fullRows: snapshotRows(tree.full),
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
||||
res.end(JSON.stringify(payload))
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/share/approve' && req.method === 'POST') {
|
||||
const body = JSON.parse((await readBody(req)) || '{}') as { id?: string; approve?: boolean }
|
||||
const ok = typeof body.id === 'string' && share.resolvePending(body.id, !!body.approve)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue