Files
2026-07-13 12:26:02 +08:00

342 lines
12 KiB
TypeScript

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