mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
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
bc5fc016ac
commit
caecc8379e
8 changed files with 283 additions and 201 deletions
|
|
@ -551,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 && (
|
||||
|
|
@ -582,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>
|
||||
|
|
@ -669,7 +673,7 @@ export function App() {
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import {
|
|||
fetchContextSessions,
|
||||
fetchContextTree,
|
||||
type ContextProvider,
|
||||
type ContextRow,
|
||||
type ContextSessionInfo,
|
||||
type ContextSnapshot,
|
||||
} from '@/lib/api'
|
||||
import { cn, fmtNum, fmtTokens, label } from '@/lib/utils'
|
||||
import { Card } from '@/components/ui/card'
|
||||
|
|
@ -24,47 +24,26 @@ function ago(mtimeMs: number): string {
|
|||
return `${Math.round(mins / (60 * 24))}d ago`
|
||||
}
|
||||
|
||||
type TreeRow = { depth: number; name: string; count: number; tokens: number; section?: boolean }
|
||||
|
||||
function treeRows(view: ContextSnapshot): TreeRow[] {
|
||||
const rows: TreeRow[] = []
|
||||
rows.push({ depth: 0, name: 'assistant', count: view.assistant.count, tokens: view.assistant.tokens, section: true })
|
||||
rows.push({ depth: 1, name: 'text', count: view.assistant.text.count, tokens: view.assistant.text.tokens })
|
||||
if (view.assistant.reasoning.count > 0) rows.push({ depth: 1, name: 'reasoning', count: view.assistant.reasoning.count, tokens: view.assistant.reasoning.tokens })
|
||||
rows.push({ depth: 1, name: 'tool-call', count: view.assistant.toolCall.count, tokens: view.assistant.toolCall.tokens })
|
||||
for (const t of view.assistant.byTool) rows.push({ depth: 2, name: t.tool, count: t.count, tokens: t.tokens })
|
||||
rows.push({ depth: 0, name: 'user', count: view.user.count, tokens: view.user.tokens, section: true })
|
||||
rows.push({ depth: 1, name: 'text', count: view.user.text.count, tokens: view.user.text.tokens })
|
||||
if (view.user.image.count > 0) rows.push({ depth: 1, name: 'image', count: view.user.image.count, tokens: view.user.image.tokens })
|
||||
if (view.user.compactSummary.count > 0) rows.push({ depth: 1, name: 'compact-summary', count: view.user.compactSummary.count, tokens: view.user.compactSummary.tokens })
|
||||
if (view.user.meta.count > 0) rows.push({ depth: 1, name: 'meta', count: view.user.meta.count, tokens: view.user.meta.tokens })
|
||||
rows.push({ depth: 0, name: 'tool', count: view.toolResult.count, tokens: view.toolResult.tokens, section: true })
|
||||
rows.push({ depth: 1, name: 'tool-result', count: view.toolResult.count, tokens: view.toolResult.tokens })
|
||||
if (view.system.count > 0) rows.push({ depth: 0, name: 'system', count: view.system.count, tokens: view.system.tokens, section: true })
|
||||
return rows
|
||||
}
|
||||
|
||||
function TreeTable({ view }: { view: ContextSnapshot }) {
|
||||
const rows = treeRows(view)
|
||||
const max = Math.max(1, ...rows.filter((r) => !r.section).map((r) => r.tokens))
|
||||
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.section && i > 0 && 'mt-2')}>
|
||||
{!r.section && (
|
||||
<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.section ? 'font-semibold text-foreground' : 'text-muted-foreground')}
|
||||
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.name}
|
||||
{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.section ? 'font-semibold text-foreground' : 'text-foreground')}>
|
||||
<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>
|
||||
|
|
@ -104,7 +83,9 @@ function SessionDetails({ provider, id }: { provider: ContextProvider; id: strin
|
|||
}
|
||||
|
||||
const view = scope === 'full' ? data.full : data.effective
|
||||
const pct = data.reported ? Math.min(100, Math.round((data.reported.context / data.reported.window) * 100)) : null
|
||||
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">
|
||||
|
|
@ -113,7 +94,7 @@ function SessionDetails({ provider, id }: { provider: ContextProvider; id: strin
|
|||
<Chip label="Est. tokens" value={fmtTokens(view.tokens)} />
|
||||
<Chip
|
||||
label="Context (exact)"
|
||||
value={data.reported ? `${fmtTokens(data.reported.context)} / ${fmtTokens(data.reported.window)}` : '—'}
|
||||
value={data.reported ? (window ? `${fmtTokens(data.reported.context)} / ${fmtTokens(window)}` : fmtTokens(data.reported.context)) : '—'}
|
||||
/>
|
||||
<Chip label="Compactions" value={fmtNum(data.compactions)} />
|
||||
</div>
|
||||
|
|
@ -149,7 +130,7 @@ function SessionDetails({ provider, id }: { provider: ContextProvider; id: strin
|
|||
<span className="text-[11px] text-tertiary-foreground">token counts are estimates; “Context (exact)” comes from API usage</span>
|
||||
</div>
|
||||
|
||||
<TreeTable view={view} />
|
||||
<TreeTable rows={rows} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -202,13 +202,17 @@ export type ContextSnapshot = {
|
|||
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
|
||||
reported: { context: number; window: number | null } | null
|
||||
effective: ContextSnapshot
|
||||
full: ContextSnapshot
|
||||
effectiveRows: ContextRow[]
|
||||
fullRows: ContextRow[]
|
||||
}
|
||||
|
||||
export async function fetchContextSessions(provider: ContextProvider): Promise<ContextSessionInfo[]> {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { open, readdir, stat } from 'fs/promises'
|
||||
import { readdir, stat } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { basename, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
|
@ -8,11 +8,14 @@ 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
|
||||
|
|
@ -97,6 +100,22 @@ function addCodexItem(accs: Acc[], item: CodexItem): void {
|
|||
}
|
||||
}
|
||||
}
|
||||
} 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
|
||||
|
|
@ -152,8 +171,8 @@ export async function buildCodexContextTree(session: SessionRef): Promise<Contex
|
|||
let segmentStartReasoning = 0
|
||||
let lastUsage: { input_tokens?: number; output_tokens?: number; total_tokens?: number } | null = null
|
||||
|
||||
for await (const line of readSessionLines(session.filePath)) {
|
||||
const text = line as string
|
||||
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 {
|
||||
|
|
@ -207,7 +226,9 @@ export async function buildCodexContextTree(session: SessionRef): Promise<Contex
|
|||
if (lastUsage) {
|
||||
const context = lastUsage.total_tokens ?? (lastUsage.input_tokens ?? 0) + (lastUsage.output_tokens ?? 0)
|
||||
if (context > 0) {
|
||||
reported = { context, window: contextWindow ?? (context > 220_000 ? 1_000_000 : 200_000) }
|
||||
// No guessing for OpenAI windows: without model_context_window the
|
||||
// percentage is omitted rather than computed against a wrong constant.
|
||||
reported = { context, window: contextWindow }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -223,8 +244,15 @@ export async function buildCodexContextTree(session: SessionRef): Promise<Contex
|
|||
|
||||
const ROLLOUT_RE = /^rollout-.{19}-(.+)\.jsonl$/
|
||||
|
||||
export async function listCodexSessionRefs(): Promise<SessionRef[]> {
|
||||
const root = join(homedir(), '.codex', 'sessions')
|
||||
// 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 {
|
||||
|
|
@ -232,28 +260,37 @@ export async function listCodexSessionRefs(): Promise<SessionRef[]> {
|
|||
} catch {
|
||||
return []
|
||||
}
|
||||
const refs: SessionRef[] = []
|
||||
const rollouts: RolloutFile[] = []
|
||||
for (const rel of files) {
|
||||
const base = basename(rel)
|
||||
const match = ROLLOUT_RE.exec(base)
|
||||
if (!match) continue
|
||||
const filePath = join(root, rel)
|
||||
try {
|
||||
const info = await stat(filePath)
|
||||
if (!info.isFile() || info.size === 0) continue
|
||||
refs.push({
|
||||
filePath,
|
||||
sessionId: match[1],
|
||||
project: '',
|
||||
mtimeMs: info.mtimeMs,
|
||||
sizeBytes: info.size,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const match = ROLLOUT_RE.exec(basename(rel))
|
||||
if (match) rollouts.push({ filePath: join(root, rel), sessionId: match[1] })
|
||||
}
|
||||
refs.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
return refs
|
||||
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
|
||||
|
|
@ -261,14 +298,7 @@ export async function listCodexSessionRefs(): Promise<SessionRef[]> {
|
|||
async function readCodexHeadInfo(ref: SessionRef): Promise<{ project: string; title: string }> {
|
||||
let chunk: string
|
||||
try {
|
||||
const fd = await open(ref.filePath, 'r')
|
||||
try {
|
||||
const buf = Buffer.alloc(262_144)
|
||||
const { bytesRead } = await fd.read(buf, 0, buf.length, 0)
|
||||
chunk = buf.subarray(0, bytesRead).toString('utf-8')
|
||||
} finally {
|
||||
await fd.close()
|
||||
}
|
||||
chunk = await readChunk(ref.filePath, 0, 262_144)
|
||||
} catch {
|
||||
return { project: '', title: '' }
|
||||
}
|
||||
|
|
@ -300,7 +330,7 @@ async function readCodexHeadInfo(ref: SessionRef): Promise<{ project: string; ti
|
|||
return { project, title }
|
||||
}
|
||||
|
||||
export async function listRecentCodexSessions(limit = 15): Promise<Array<SessionRef & { title: string }>> {
|
||||
export async function listRecentCodexSessions(limit = 15): Promise<TitledSessionRef[]> {
|
||||
const refs = (await listCodexSessionRefs()).slice(0, limit)
|
||||
return Promise.all(
|
||||
refs.map(async (ref) => {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
import { open, readdir, stat } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { delimiter, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import chalk from 'chalk'
|
||||
|
||||
import { readSessionLines } from './fs-utils.js'
|
||||
import { readSessionLines, type SessionLine } from './fs-utils.js'
|
||||
import { formatTokens } from './format.js'
|
||||
|
||||
// zaly-style context breakdown for a single Claude Code session: what is in
|
||||
// the model's context window right now, split by role, block type, and tool.
|
||||
// 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
|
||||
|
|
@ -54,11 +52,25 @@ export type ContextTreeResult = {
|
|||
session: SessionRef
|
||||
model: string
|
||||
compactions: number
|
||||
reported: { context: number; window: number } | null
|
||||
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
|
||||
|
|
@ -369,45 +381,44 @@ const skipFileSnapshots = (head: string): boolean => head.includes('"type":"file
|
|||
// segment's head (messages Claude Code carried across the compaction), not at
|
||||
// the boundary itself.
|
||||
async function findLastBoundary(filePath: string): Promise<{
|
||||
boundaryIndex: number
|
||||
headUuid: string | null
|
||||
compactions: number
|
||||
maxPreTokens: number
|
||||
}> {
|
||||
let boundaryIndex = -1
|
||||
let headUuid: string | null = null
|
||||
let compactions = 0
|
||||
let maxPreTokens = 0
|
||||
let index = -1
|
||||
for await (const line of readSessionLines(filePath, skipFileSnapshots)) {
|
||||
index += 1
|
||||
const text = line as string
|
||||
if (!text.includes('"subtype":"compact_boundary"')) continue
|
||||
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(text) as RawEntry
|
||||
entry = JSON.parse(line) as RawEntry
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (entry.type !== 'system' || entry.subtype !== 'compact_boundary') continue
|
||||
compactions += 1
|
||||
boundaryIndex = index
|
||||
headUuid = entry.compactMetadata?.preservedSegment?.headUuid ?? null
|
||||
maxPreTokens = Math.max(maxPreTokens, entry.compactMetadata?.preTokens ?? 0)
|
||||
}
|
||||
return { boundaryIndex, headUuid, compactions, maxPreTokens }
|
||||
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 index = -1
|
||||
let boundariesSeen = 0
|
||||
let inPreservedSegment = false
|
||||
for await (const line of readSessionLines(session.filePath, skipFileSnapshots)) {
|
||||
index += 1
|
||||
const text = line as string
|
||||
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 {
|
||||
|
|
@ -416,9 +427,12 @@ export async function buildContextTree(session: SessionRef): Promise<ContextTree
|
|||
continue
|
||||
}
|
||||
if (entry.isSidechain === true) continue
|
||||
if (entry.type === 'system' && entry.subtype === 'compact_boundary') continue
|
||||
if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
|
||||
boundariesSeen += 1
|
||||
continue
|
||||
}
|
||||
if (boundary.headUuid && entry.uuid === boundary.headUuid) inPreservedSegment = true
|
||||
const effective = boundary.boundaryIndex === -1 || index > boundary.boundaryIndex || inPreservedSegment
|
||||
const effective = boundariesSeen >= boundary.compactions || inPreservedSegment
|
||||
builder.addEntry(entry, effective)
|
||||
}
|
||||
builder.flushReasoning()
|
||||
|
|
@ -431,7 +445,8 @@ export async function buildContextTree(session: SessionRef): Promise<ContextTree
|
|||
(builder.lastUsage.cache_creation_input_tokens ?? 0) +
|
||||
(builder.lastUsage.output_tokens ?? 0)
|
||||
builder.maxSeenTokens = Math.max(builder.maxSeenTokens, context)
|
||||
reported = { context, window: builder.maxSeenTokens > 220_000 ? 1_000_000 : 200_000 }
|
||||
const million = MILLION_WINDOW_RE.test(builder.model) || builder.maxSeenTokens > 220_000
|
||||
reported = { context, window: million ? 1_000_000 : 200_000 }
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -444,44 +459,69 @@ export async function buildContextTree(session: SessionRef): Promise<ContextTree
|
|||
}
|
||||
}
|
||||
|
||||
export async function listRecentSessions(limit = 15): Promise<SessionRef[]> {
|
||||
const root = join(homedir(), '.claude', 'projects')
|
||||
if (!existsSync(root)) return []
|
||||
const refs: SessionRef[] = []
|
||||
let projectDirs: string[]
|
||||
try {
|
||||
projectDirs = await readdir(root)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
for (const dir of projectDirs) {
|
||||
const projectPath = join(root, dir)
|
||||
let files: string[]
|
||||
// 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 {
|
||||
files = await readdir(projectPath)
|
||||
projectDirs = await readdir(root)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.jsonl')) continue
|
||||
const filePath = join(projectPath, file)
|
||||
for (const dir of projectDirs) {
|
||||
let names: string[]
|
||||
try {
|
||||
const info = await stat(filePath)
|
||||
if (!info.isFile() || info.size === 0) continue
|
||||
refs.push({
|
||||
filePath,
|
||||
sessionId: file.slice(0, -'.jsonl'.length),
|
||||
project: dir.split('-').filter(Boolean).pop() ?? dir,
|
||||
mtimeMs: info.mtimeMs,
|
||||
sizeBytes: info.size,
|
||||
})
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
refs.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
return refs.slice(0, limit)
|
||||
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
|
||||
|
|
@ -513,7 +553,7 @@ function titleFromChunk(chunk: string): string {
|
|||
return title || summary
|
||||
}
|
||||
|
||||
async function readChunk(filePath: string, start: number, length: number): Promise<string> {
|
||||
export async function readChunk(filePath: string, start: number, length: number): Promise<string> {
|
||||
const fd = await open(filePath, 'r')
|
||||
try {
|
||||
const buf = Buffer.alloc(length)
|
||||
|
|
@ -535,7 +575,7 @@ export async function readSessionTitle(ref: SessionRef): Promise<string> {
|
|||
}
|
||||
}
|
||||
|
||||
async function resolveSession(arg: string | undefined): Promise<SessionRef | null> {
|
||||
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)
|
||||
|
|
@ -548,16 +588,20 @@ async function resolveSession(arg: string | undefined): Promise<SessionRef | nul
|
|||
sizeBytes: info.size,
|
||||
}
|
||||
}
|
||||
const recent = await listRecentSessions(5000)
|
||||
if (!arg) return recent[0] ?? null
|
||||
return recent.find((r) => r.sessionId.startsWith(arg)) ?? null
|
||||
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')
|
||||
}
|
||||
|
||||
function relativeAge(mtimeMs: number): string {
|
||||
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`
|
||||
|
|
@ -566,7 +610,6 @@ function relativeAge(mtimeMs: number): string {
|
|||
|
||||
export type ContextRow = { depth: number; label: string; count: number; tokens: number; bold?: boolean }
|
||||
|
||||
// The tree flattened to rows, shared by the static CLI renderer and the TUI.
|
||||
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 })
|
||||
|
|
@ -622,8 +665,9 @@ export function renderContextTree(result: ContextTreeResult, opts: { full?: bool
|
|||
lines.push(` ${chalk.dim('◦')} ${formatTokens(result.effective.tokens)} ${chalk.dim(`effective (${pct}%)`)}`)
|
||||
}
|
||||
if (result.reported) {
|
||||
const pct = Math.round((result.reported.context / result.reported.window) * 100)
|
||||
lines.push(` context (exact, last turn): ${chalk.bold(formatTokens(result.reported.context))} ${chalk.dim(`of ${formatTokens(result.reported.window)} window (${pct}%)`)}`)
|
||||
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)')}`)
|
||||
|
|
@ -641,42 +685,57 @@ export function renderContextTree(result: ContextTreeResult, opts: { full?: bool
|
|||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function renderSessionList(refs: SessionRef[], titles: string[]): string {
|
||||
const lines = ['', ` ${chalk.bold('Recent Claude Code sessions')}`, '']
|
||||
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 [i, ref] of refs.entries()) {
|
||||
for (const ref of refs) {
|
||||
const sizeMb = (ref.sizeBytes / 1024 / 1024).toFixed(1).padStart(6)
|
||||
const title = titles[i] ?? ''
|
||||
const shortTitle = title.length > 48 ? `${title.slice(0, 47)}…` : title
|
||||
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(' codeburn context <id> to inspect one'))
|
||||
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 },
|
||||
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 = await listRecentSessions(15)
|
||||
const refs =
|
||||
provider === 'codex' ? await (await import('./context-tree-codex.js')).listRecentCodexSessions(15) : await listRecentTitledSessions(15)
|
||||
if (refs.length === 0) {
|
||||
console.log('No Claude Code sessions found under ~/.claude/projects.')
|
||||
console.log(provider === 'codex' ? 'No Codex sessions found.' : 'No Claude Code sessions found.')
|
||||
return
|
||||
}
|
||||
const titles = await Promise.all(refs.map(readSessionTitle))
|
||||
console.log(renderSessionList(refs, titles))
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ sessions: refs }, null, 2))
|
||||
return
|
||||
}
|
||||
console.log(renderSessionList(refs, provider))
|
||||
return
|
||||
}
|
||||
const session = await resolveSession(sessionArg)
|
||||
const session = await resolveSession(sessionArg, provider)
|
||||
if (!session) {
|
||||
console.error(sessionArg ? `No session matching "${sessionArg}".` : 'No Claude Code sessions found under ~/.claude/projects.')
|
||||
console.error(sessionArg ? `No ${provider} session matching "${sessionArg}".` : `No ${provider} sessions found.`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
const result = await buildContextTree(session)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import React, { useEffect, useRef, useState } from 'react'
|
||||
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,
|
||||
listRecentSessions,
|
||||
readSessionTitle,
|
||||
listRecentTitledSessions,
|
||||
relativeAge,
|
||||
snapshotRows,
|
||||
type ContextTreeResult,
|
||||
type SessionRef,
|
||||
type TitledSessionRef,
|
||||
} from './context-tree.js'
|
||||
import { buildCodexContextTree, listRecentCodexSessions } from './context-tree-codex.js'
|
||||
|
||||
type Provider = 'claude' | 'codex'
|
||||
type SessionRow = SessionRef & { title: string }
|
||||
type Scope = 'effective' | 'full'
|
||||
|
||||
const ORANGE = '#FF8C42'
|
||||
const DIM = '#555555'
|
||||
|
|
@ -24,25 +24,15 @@ const PROVIDERS: Array<{ key: Provider; label: string }> = [
|
|||
{ 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`
|
||||
if (mins < 60 * 24) return `${Math.round(mins / 60)}h`
|
||||
return `${Math.round(mins / (60 * 24))}d`
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text
|
||||
}
|
||||
|
||||
async function loadSessions(provider: Provider): Promise<SessionRow[]> {
|
||||
if (provider === 'codex') return listRecentCodexSessions(15)
|
||||
const refs = await listRecentSessions(15)
|
||||
const titles = await Promise.all(refs.map(readSessionTitle))
|
||||
return refs.map((r, i) => ({ ...r, title: titles[i] ?? '' }))
|
||||
async function loadSessions(provider: Provider): Promise<TitledSessionRef[]> {
|
||||
return provider === 'codex' ? listRecentCodexSessions(15) : listRecentTitledSessions(15)
|
||||
}
|
||||
|
||||
function TreeDetails({ tree, scope }: { tree: ContextTreeResult; scope: 'effective' | 'full' }) {
|
||||
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
|
||||
|
|
@ -51,8 +41,8 @@ function TreeDetails({ tree, scope }: { tree: ContextTreeResult; scope: 'effecti
|
|||
|
||||
const headline: string[] = [`model ${tree.model}`, `messages ${view.messages.toLocaleString('en-US')}`, `est ${formatTokens(view.tokens)}`]
|
||||
if (tree.reported) {
|
||||
const pct = Math.round((tree.reported.context / tree.reported.window) * 100)
|
||||
headline.push(`context ${formatTokens(tree.reported.context)} / ${formatTokens(tree.reported.window)} (${pct}%)`)
|
||||
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'}`)
|
||||
|
||||
|
|
@ -81,17 +71,17 @@ function TreeDetails({ tree, scope }: { tree: ContextTreeResult; scope: 'effecti
|
|||
)
|
||||
}
|
||||
|
||||
function ContextTuiApp() {
|
||||
function ContextTuiApp({ initialScope }: { initialScope: Scope }) {
|
||||
const { exit } = useApp()
|
||||
const [provider, setProvider] = useState<Provider>('claude')
|
||||
const [sessions, setSessions] = useState<SessionRow[] | null>(null)
|
||||
const [sessions, setSessions] = useState<TitledSessionRef[] | null>(null)
|
||||
const [cursor, setCursor] = useState(0)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [scope, setScope] = useState<'effective' | 'full'>('effective')
|
||||
const [scope, setScope] = useState<Scope>(initialScope)
|
||||
const [building, setBuilding] = useState(false)
|
||||
const [frame, setFrame] = useState(0)
|
||||
const [, forceRender] = useState(0)
|
||||
const trees = useRef(new Map<string, ContextTreeResult>())
|
||||
const [trees, setTrees] = useState<Record<string, ContextTreeResult>>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
|
|
@ -112,22 +102,20 @@ function ContextTuiApp() {
|
|||
return () => clearInterval(t)
|
||||
}, [building])
|
||||
|
||||
const toggleExpand = (session: SessionRow) => {
|
||||
const toggleExpand = (session: TitledSessionRef) => {
|
||||
if (expandedId === session.sessionId) {
|
||||
setExpandedId(null)
|
||||
return
|
||||
}
|
||||
setExpandedId(session.sessionId)
|
||||
const key = `${provider}:${session.sessionId}:${session.mtimeMs}`
|
||||
if (trees.current.has(key)) return
|
||||
if (trees[key]) return
|
||||
setBuilding(true)
|
||||
setErrors((e) => ({ ...e, [key]: '' }))
|
||||
const build = provider === 'claude' ? buildContextTree(session) : buildCodexContextTree(session)
|
||||
void build
|
||||
.then((tree) => {
|
||||
trees.current.set(key, tree)
|
||||
forceRender((n) => n + 1)
|
||||
})
|
||||
.catch(() => {})
|
||||
.then((tree) => setTrees((t) => ({ ...t, [key]: tree })))
|
||||
.catch((err: unknown) => setErrors((e) => ({ ...e, [key]: err instanceof Error ? err.message : String(err) })))
|
||||
.finally(() => setBuilding(false))
|
||||
}
|
||||
|
||||
|
|
@ -170,7 +158,8 @@ function ContextTuiApp() {
|
|||
const selected = i === cursor
|
||||
const expanded = expandedId === s.sessionId
|
||||
const key = `${provider}:${s.sessionId}:${s.mtimeMs}`
|
||||
const tree = trees.current.get(key)
|
||||
const tree = trees[key]
|
||||
const error = errors[key]
|
||||
return (
|
||||
<Box key={s.filePath} flexDirection="column">
|
||||
<Text>
|
||||
|
|
@ -182,10 +171,15 @@ function ContextTuiApp() {
|
|||
</Text>
|
||||
<Text color={DIM}>
|
||||
{' '}
|
||||
{truncate(s.project, 12).padEnd(12)} {ago(s.mtimeMs).padStart(4)} {`${(s.sizeBytes / 1024 / 1024).toFixed(1)}MB`.padStart(8)}
|
||||
{truncate(s.project, 12).padEnd(12)} {relativeAge(s.mtimeMs).padStart(8)} {`${(s.sizeBytes / 1024 / 1024).toFixed(1)}MB`.padStart(8)}
|
||||
</Text>
|
||||
</Text>
|
||||
{expanded && !tree && (
|
||||
{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>
|
||||
|
|
@ -202,8 +196,8 @@ function ContextTuiApp() {
|
|||
)
|
||||
}
|
||||
|
||||
export async function runContextTui(): Promise<void> {
|
||||
export async function runContextTui(opts: { initialScope?: Scope } = {}): Promise<void> {
|
||||
patchStdoutForWindows()
|
||||
const instance = render(<ContextTuiApp />)
|
||||
const instance = render(<ContextTuiApp initialScope={opts.initialScope ?? 'effective'} />)
|
||||
await instance.waitUntilExit()
|
||||
}
|
||||
|
|
|
|||
14
src/main.ts
14
src/main.ts
|
|
@ -1338,14 +1338,20 @@ program
|
|||
|
||||
program
|
||||
.command('context [session]')
|
||||
.description('Context token breakdown per session: what fills the window, by role, block type, and tool (experimental). No arguments opens an interactive browser.')
|
||||
.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')
|
||||
.action(async (session: string | undefined, opts: { list?: boolean; full?: boolean; json?: boolean }) => {
|
||||
if (!session && !opts.list && !opts.json && !opts.full && process.stdout.isTTY && process.stdin.isTTY) {
|
||||
.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()
|
||||
await runContextTui({ initialScope: opts.full ? 'full' : 'effective' })
|
||||
return
|
||||
}
|
||||
await runContextCommand(session, opts)
|
||||
|
|
|
|||
|
|
@ -19,8 +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, listRecentSessions, readSessionTitle, type ContextTreeResult, type SessionRef } from './context-tree.js'
|
||||
import { buildCodexContextTree, listCodexSessionRefs, listRecentCodexSessions } from './context-tree-codex.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) => {
|
||||
|
|
@ -135,7 +135,12 @@ export async function runWebDashboard(opts: {
|
|||
const getContextTree = (provider: 'claude' | 'codex', ref: SessionRef): Promise<ContextTreeResult> => {
|
||||
const key = `${provider}:${ref.sessionId}:${ref.mtimeMs}`
|
||||
const hit = contextTreeCache.get(key)
|
||||
if (hit) return hit
|
||||
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))
|
||||
|
|
@ -295,23 +300,17 @@ export async function runWebDashboard(opts: {
|
|||
res.end(JSON.stringify(await share.status()))
|
||||
return
|
||||
}
|
||||
// Context explorer: recent sessions per provider, and the per-session
|
||||
// token tree. Session ids are resolved against the discovered session
|
||||
// files only, never joined into a path from user input.
|
||||
// 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
|
||||
}
|
||||
let sessions
|
||||
if (provider === 'claude') {
|
||||
const refs = await listRecentSessions(15)
|
||||
const titles = await Promise.all(refs.map(readSessionTitle))
|
||||
sessions = refs.map((r, i) => ({ provider, sessionId: r.sessionId, project: r.project, title: titles[i] ?? '', mtimeMs: r.mtimeMs, sizeBytes: r.sizeBytes }))
|
||||
} else {
|
||||
sessions = (await listRecentCodexSessions(15)).map((r) => ({ provider, sessionId: r.sessionId, project: r.project, title: r.title, mtimeMs: r.mtimeMs, sizeBytes: r.sizeBytes }))
|
||||
}
|
||||
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
|
||||
|
|
@ -323,15 +322,20 @@ export async function runWebDashboard(opts: {
|
|||
writeJsonError(res, 400, 'provider (claude|codex) and id are required')
|
||||
return
|
||||
}
|
||||
const refs = provider === 'claude' ? await listRecentSessions(5000) : await listCodexSessionRefs()
|
||||
const ref = refs.find((r) => r.sessionId === id)
|
||||
if (!ref) {
|
||||
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(tree))
|
||||
res.end(JSON.stringify(payload))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue