1060 lines
35 KiB
TypeScript
1060 lines
35 KiB
TypeScript
/* eslint-disable max-lines -- Why: Codex discovery, incremental parsing, attribution, and aggregation all depend on the same event-normalization rules. Keeping them together makes the duplicate-snapshot logic easier to audit when usage totals look wrong. */
|
|
import { basename, join, win32, posix } from 'path'
|
|
import { createReadStream } from 'fs'
|
|
import { realpath, readdir, stat } from 'fs/promises'
|
|
import { createInterface } from 'readline'
|
|
import type { Repo } from '../../shared/types'
|
|
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
|
|
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths'
|
|
import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge'
|
|
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
|
|
import type {
|
|
CodexUsageAttributedEvent,
|
|
CodexUsageDailyAggregate,
|
|
CodexUsageLocationBreakdown,
|
|
CodexUsageLocationModelBreakdown,
|
|
CodexUsageModelBreakdown,
|
|
CodexUsageParsedEvent,
|
|
CodexUsagePersistedFile,
|
|
CodexUsageProcessedFile,
|
|
CodexUsageSession
|
|
} from './types'
|
|
|
|
export type CodexUsageWorktreeRef = {
|
|
repoId: string
|
|
worktreeId: string
|
|
path: string
|
|
displayName: string
|
|
}
|
|
|
|
type CodexUsageRawRecord = {
|
|
timestamp?: string
|
|
type?: string
|
|
payload?: Record<string, unknown>
|
|
}
|
|
|
|
type CodexUsageRawUsage = {
|
|
inputTokens: number
|
|
cachedInputTokens: number
|
|
outputTokens: number
|
|
reasoningOutputTokens: number
|
|
totalTokens: number
|
|
}
|
|
|
|
type CodexUsageParseContext = {
|
|
sessionId: string
|
|
sessionCwd: string | null
|
|
currentCwd: string | null
|
|
currentModel: string | null
|
|
previousTotals: CodexUsageRawUsage | null
|
|
totalOnlyBaselinePending?: boolean
|
|
}
|
|
|
|
type CodexUsageDeltaResolution =
|
|
| { kind: 'event'; delta: CodexUsageRawUsage; nextTotals: CodexUsageRawUsage | null }
|
|
| { kind: 'baseline'; nextTotals: CodexUsageRawUsage }
|
|
|
|
const YIELD_EVERY_FILES = 10
|
|
|
|
function ensureNumber(value: unknown): number {
|
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
}
|
|
|
|
function normalizeComparablePath(pathValue: string, platform = process.platform): string {
|
|
const normalized = pathValue.replace(/\\/g, '/')
|
|
return platform === 'win32' || looksLikeWindowsPath(pathValue)
|
|
? normalized.toLowerCase()
|
|
: normalized
|
|
}
|
|
|
|
function normalizeFsPath(pathValue: string, platform = process.platform): string {
|
|
if (platform === 'win32' || looksLikeWindowsPath(pathValue)) {
|
|
return win32.normalize(win32.resolve(pathValue))
|
|
}
|
|
return posix.normalize(posix.resolve(pathValue))
|
|
}
|
|
|
|
function looksLikeWindowsPath(pathValue: string): boolean {
|
|
return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\')
|
|
}
|
|
|
|
async function canonicalizePath(pathValue: string): Promise<string> {
|
|
try {
|
|
const resolved = await realpath(pathValue)
|
|
return normalizeFsPath(resolved)
|
|
} catch {
|
|
return normalizeFsPath(pathValue)
|
|
}
|
|
}
|
|
|
|
async function yieldToEventLoop(): Promise<void> {
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
}
|
|
|
|
async function walkJsonlFiles(dirPath: string): Promise<string[]> {
|
|
const entries = await readdir(dirPath, { withFileTypes: true })
|
|
const files: string[] = []
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = join(dirPath, entry.name)
|
|
if (entry.isDirectory()) {
|
|
appendDiscoveredFiles(files, await walkJsonlFiles(fullPath))
|
|
continue
|
|
}
|
|
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
files.push(fullPath)
|
|
}
|
|
}
|
|
|
|
return files
|
|
}
|
|
|
|
function appendDiscoveredFiles(target: string[], source: readonly string[]): void {
|
|
// Why: large session directories can exceed V8's argument limit if child
|
|
// file arrays are spread into push().
|
|
for (const filePath of source) {
|
|
target.push(filePath)
|
|
}
|
|
}
|
|
|
|
export function getCodexSessionsDirectory(): string {
|
|
// Why: Orca-launched Codex processes receive an Orca-owned CODEX_HOME, so
|
|
// callers that need the primary runtime path should not consult ambient
|
|
// shell CODEX_HOME.
|
|
return join(getOrcaManagedCodexHomePath(), 'sessions')
|
|
}
|
|
|
|
export function getCodexSessionDirectories(): string[] {
|
|
// Why: upgraded users still have ordinary Codex history under ~/.codex, while
|
|
// new Orca-launched sessions are written under Orca's managed runtime home.
|
|
return [getCodexSessionsDirectory(), join(getSystemCodexHomePath(), 'sessions')].filter(
|
|
(dirPath, index, allDirPaths) => allDirPaths.indexOf(dirPath) === index
|
|
)
|
|
}
|
|
|
|
export async function listCodexSessionFiles(): Promise<string[]> {
|
|
const files: string[] = []
|
|
for (const dirPath of getCodexSessionDirectories()) {
|
|
try {
|
|
appendDiscoveredFiles(files, await walkJsonlFiles(dirPath))
|
|
} catch {
|
|
// Missing or unreadable history in one home should not hide the other.
|
|
}
|
|
}
|
|
return dedupeCodexSessionFileAliases(files)
|
|
}
|
|
|
|
async function dedupeCodexSessionFileAliases(files: string[]): Promise<string[]> {
|
|
const excludedAliases = new Set<string>()
|
|
for (const filePath of files) {
|
|
const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath)
|
|
if (!legacyCopyBridge) {
|
|
continue
|
|
}
|
|
if (legacyCopyBridge.sourceSkipBytes !== null) {
|
|
continue
|
|
}
|
|
excludedAliases.add(
|
|
await getPhysicalFileAliasKey(
|
|
legacyCopyBridge.preferManagedCopy ? legacyCopyBridge.sourcePath : filePath
|
|
)
|
|
)
|
|
}
|
|
|
|
const seenAliases = new Set<string>()
|
|
const uniqueFiles: string[] = []
|
|
for (const filePath of [...new Set(files)].sort()) {
|
|
const aliasKey = await getCodexSessionFileAliasKey(filePath)
|
|
if (excludedAliases.has(aliasKey)) {
|
|
continue
|
|
}
|
|
if (seenAliases.has(aliasKey)) {
|
|
continue
|
|
}
|
|
seenAliases.add(aliasKey)
|
|
uniqueFiles.push(filePath)
|
|
}
|
|
return uniqueFiles
|
|
}
|
|
|
|
async function getCodexSessionFileAliasKey(filePath: string): Promise<string> {
|
|
return getPhysicalFileAliasKey(filePath)
|
|
}
|
|
|
|
async function getPhysicalFileAliasKey(filePath: string): Promise<string> {
|
|
try {
|
|
const fileStat = await stat(filePath)
|
|
if (fileStat.ino !== 0) {
|
|
return `${fileStat.dev}:${fileStat.ino}`
|
|
}
|
|
} catch {}
|
|
return `path:${await canonicalizePath(filePath)}`
|
|
}
|
|
|
|
function getLegacySourceSkipBytesByPath(files: string[]): Map<string, number> {
|
|
const sourceSkipBytesByPath = new Map<string, number>()
|
|
for (const filePath of files) {
|
|
const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath)
|
|
if (!legacyCopyBridge || legacyCopyBridge.sourceSkipBytes === null) {
|
|
continue
|
|
}
|
|
const existing = sourceSkipBytesByPath.get(legacyCopyBridge.sourcePath) ?? 0
|
|
sourceSkipBytesByPath.set(
|
|
legacyCopyBridge.sourcePath,
|
|
Math.max(existing, legacyCopyBridge.sourceSkipBytes)
|
|
)
|
|
}
|
|
return sourceSkipBytesByPath
|
|
}
|
|
|
|
export async function getProcessedFileInfo(filePath: string): Promise<CodexUsageProcessedFile> {
|
|
const fileStat = await stat(filePath)
|
|
return {
|
|
path: filePath,
|
|
mtimeMs: fileStat.mtimeMs,
|
|
size: fileStat.size
|
|
}
|
|
}
|
|
|
|
function normalizeRawUsage(value: unknown): CodexUsageRawUsage | null {
|
|
if (value == null || typeof value !== 'object') {
|
|
return null
|
|
}
|
|
|
|
const record = value as Record<string, unknown>
|
|
const inputTokens = ensureNumber(record.input_tokens)
|
|
const cachedInputTokens = ensureNumber(
|
|
record.cached_input_tokens ?? record.cache_read_input_tokens
|
|
)
|
|
const outputTokens = ensureNumber(record.output_tokens)
|
|
const reasoningOutputTokens = ensureNumber(record.reasoning_output_tokens)
|
|
const totalTokens = ensureNumber(record.total_tokens)
|
|
|
|
return {
|
|
inputTokens,
|
|
cachedInputTokens,
|
|
outputTokens,
|
|
reasoningOutputTokens,
|
|
// Why: legacy Codex logs can omit total_tokens. Reasoning is already billed
|
|
// inside output, so synthesizing input+output matches Codex pricing instead
|
|
// of double-counting reasoning as another billable bucket.
|
|
totalTokens: totalTokens > 0 ? totalTokens : inputTokens + outputTokens
|
|
}
|
|
}
|
|
|
|
function subtractRawUsage(
|
|
current: CodexUsageRawUsage,
|
|
previous: CodexUsageRawUsage | null
|
|
): CodexUsageRawUsage {
|
|
return {
|
|
inputTokens: Math.max(current.inputTokens - (previous?.inputTokens ?? 0), 0),
|
|
cachedInputTokens: Math.max(current.cachedInputTokens - (previous?.cachedInputTokens ?? 0), 0),
|
|
outputTokens: Math.max(current.outputTokens - (previous?.outputTokens ?? 0), 0),
|
|
reasoningOutputTokens: Math.max(
|
|
current.reasoningOutputTokens - (previous?.reasoningOutputTokens ?? 0),
|
|
0
|
|
),
|
|
totalTokens: Math.max(current.totalTokens - (previous?.totalTokens ?? 0), 0)
|
|
}
|
|
}
|
|
|
|
function addRawUsage(left: CodexUsageRawUsage, right: CodexUsageRawUsage): CodexUsageRawUsage {
|
|
return {
|
|
inputTokens: left.inputTokens + right.inputTokens,
|
|
cachedInputTokens: left.cachedInputTokens + right.cachedInputTokens,
|
|
outputTokens: left.outputTokens + right.outputTokens,
|
|
reasoningOutputTokens: left.reasoningOutputTokens + right.reasoningOutputTokens,
|
|
totalTokens: left.totalTokens + right.totalTokens
|
|
}
|
|
}
|
|
|
|
function rawUsageEquals(left: CodexUsageRawUsage, right: CodexUsageRawUsage): boolean {
|
|
return (
|
|
left.inputTokens === right.inputTokens &&
|
|
left.cachedInputTokens === right.cachedInputTokens &&
|
|
left.outputTokens === right.outputTokens &&
|
|
left.reasoningOutputTokens === right.reasoningOutputTokens
|
|
)
|
|
}
|
|
|
|
function rawUsageIsMonotonic(current: CodexUsageRawUsage, previous: CodexUsageRawUsage): boolean {
|
|
return (
|
|
current.inputTokens >= previous.inputTokens &&
|
|
current.cachedInputTokens >= previous.cachedInputTokens &&
|
|
current.outputTokens >= previous.outputTokens &&
|
|
current.reasoningOutputTokens >= previous.reasoningOutputTokens
|
|
)
|
|
}
|
|
|
|
function rawUsageMagnitude(usage: CodexUsageRawUsage): number {
|
|
return (
|
|
usage.inputTokens + usage.cachedInputTokens + usage.outputTokens + usage.reasoningOutputTokens
|
|
)
|
|
}
|
|
|
|
function looksLikeStaleRegression(
|
|
current: CodexUsageRawUsage,
|
|
previous: CodexUsageRawUsage,
|
|
last: CodexUsageRawUsage
|
|
): boolean {
|
|
const previousTotal = rawUsageMagnitude(previous)
|
|
const currentTotal = rawUsageMagnitude(current)
|
|
const lastTotal = rawUsageMagnitude(last)
|
|
if (previousTotal <= 0 || currentTotal <= 0 || lastTotal <= 0) {
|
|
return false
|
|
}
|
|
return currentTotal * 100 >= previousTotal * 98 || currentTotal + lastTotal * 2 >= previousTotal
|
|
}
|
|
|
|
function resolveCodexUsageDelta(
|
|
totalUsage: CodexUsageRawUsage | null,
|
|
lastUsage: CodexUsageRawUsage | null,
|
|
previousTotals: CodexUsageRawUsage | null
|
|
): CodexUsageDeltaResolution | null {
|
|
if (totalUsage && lastUsage && previousTotals) {
|
|
if (rawUsageEquals(totalUsage, previousTotals)) {
|
|
return null
|
|
}
|
|
if (
|
|
!rawUsageIsMonotonic(totalUsage, previousTotals) &&
|
|
looksLikeStaleRegression(totalUsage, previousTotals, lastUsage)
|
|
) {
|
|
return null
|
|
}
|
|
// Why: Codex totals are mutable snapshots after compaction/resume. The
|
|
// last_token_usage payload is the billable increment; totals are the baseline.
|
|
return { kind: 'event', delta: lastUsage, nextTotals: totalUsage }
|
|
}
|
|
|
|
if (totalUsage && lastUsage) {
|
|
return { kind: 'event', delta: lastUsage, nextTotals: totalUsage }
|
|
}
|
|
|
|
if (totalUsage && previousTotals) {
|
|
if (rawUsageEquals(totalUsage, previousTotals)) {
|
|
return null
|
|
}
|
|
if (!rawUsageIsMonotonic(totalUsage, previousTotals)) {
|
|
return { kind: 'baseline', nextTotals: totalUsage }
|
|
}
|
|
return {
|
|
kind: 'event',
|
|
delta: subtractRawUsage(totalUsage, previousTotals),
|
|
nextTotals: totalUsage
|
|
}
|
|
}
|
|
|
|
if (totalUsage) {
|
|
return { kind: 'event', delta: totalUsage, nextTotals: totalUsage }
|
|
}
|
|
|
|
if (lastUsage && previousTotals) {
|
|
return { kind: 'event', delta: lastUsage, nextTotals: addRawUsage(previousTotals, lastUsage) }
|
|
}
|
|
|
|
if (lastUsage) {
|
|
return { kind: 'event', delta: lastUsage, nextTotals: null }
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function extractString(value: unknown): string | null {
|
|
if (typeof value !== 'string') {
|
|
return null
|
|
}
|
|
const trimmed = value.trim()
|
|
return trimmed.length > 0 ? trimmed : null
|
|
}
|
|
|
|
function extractModel(value: unknown): string | null {
|
|
if (value == null || typeof value !== 'object') {
|
|
return null
|
|
}
|
|
|
|
const record = value as Record<string, unknown>
|
|
const direct = [extractString(record.model), extractString(record.model_name)].find(
|
|
(candidate) => candidate !== null
|
|
)
|
|
if (direct) {
|
|
return direct
|
|
}
|
|
|
|
if (record.info && typeof record.info === 'object') {
|
|
const info = record.info as Record<string, unknown>
|
|
const infoDirect = [extractString(info.model), extractString(info.model_name)].find(
|
|
(candidate) => candidate !== null
|
|
)
|
|
if (infoDirect) {
|
|
return infoDirect
|
|
}
|
|
if (info.metadata && typeof info.metadata === 'object') {
|
|
const metadata = info.metadata as Record<string, unknown>
|
|
const metadataModel = extractString(metadata.model)
|
|
if (metadataModel) {
|
|
return metadataModel
|
|
}
|
|
}
|
|
}
|
|
|
|
if (record.metadata && typeof record.metadata === 'object') {
|
|
const metadata = record.metadata as Record<string, unknown>
|
|
return extractString(metadata.model)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function getDefaultProjectLabel(cwd: string | null): string {
|
|
if (!cwd) {
|
|
return 'Unknown location'
|
|
}
|
|
const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean)
|
|
if (parts.length >= 2) {
|
|
return parts.slice(-2).join('/')
|
|
}
|
|
return parts.at(-1) ?? cwd
|
|
}
|
|
|
|
function localDayFromTimestamp(timestamp: string): string | null {
|
|
const parsed = new Date(timestamp)
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return null
|
|
}
|
|
const year = parsed.getFullYear()
|
|
const month = String(parsed.getMonth() + 1).padStart(2, '0')
|
|
const day = String(parsed.getDate()).padStart(2, '0')
|
|
return `${year}-${month}-${day}`
|
|
}
|
|
|
|
async function buildWorktreesWithCanonicalPaths(
|
|
worktrees: CodexUsageWorktreeRef[]
|
|
): Promise<(CodexUsageWorktreeRef & { canonicalPath: string })[]> {
|
|
return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath)
|
|
}
|
|
|
|
function isContainingPath(candidatePath: string, targetPath: string): boolean {
|
|
const useWin32 = looksLikeWindowsPath(candidatePath) || looksLikeWindowsPath(targetPath)
|
|
const relativePath = useWin32
|
|
? win32.relative(candidatePath, targetPath)
|
|
: posix.relative(candidatePath, targetPath)
|
|
if (!relativePath) {
|
|
return true
|
|
}
|
|
// Why: on Windows, `path.relative('C:\\repo', 'D:\\other')` returns an
|
|
// absolute `D:\\other` path instead of a `..`-prefixed relative. Treating
|
|
// that as "contained" would attribute off-drive Codex usage to the wrong
|
|
// Orca worktree.
|
|
const isAbsoluteRelative = useWin32
|
|
? win32.isAbsolute(relativePath)
|
|
: posix.isAbsolute(relativePath)
|
|
const parentPrefix = useWin32 ? `..${win32.sep}` : `..${posix.sep}`
|
|
// Why: `..name` is a valid child path; only `..` and `../...` escape.
|
|
return (
|
|
!isAbsoluteRelative &&
|
|
relativePath !== '..' &&
|
|
!relativePath.startsWith(parentPrefix) &&
|
|
relativePath !== '.'
|
|
)
|
|
}
|
|
|
|
function findContainingWorktree(
|
|
cwd: string,
|
|
worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[]
|
|
): CodexUsageWorktreeRef | null {
|
|
const normalizedCwd = normalizeFsPath(cwd)
|
|
for (const worktree of worktrees) {
|
|
if (areWorktreePathsEqual(worktree.canonicalPath, normalizedCwd)) {
|
|
return worktree
|
|
}
|
|
if (isContainingPath(worktree.canonicalPath, normalizedCwd)) {
|
|
return worktree
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
export async function attributeCodexUsageEvent(
|
|
event: CodexUsageParsedEvent,
|
|
worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[]
|
|
): Promise<CodexUsageAttributedEvent | null> {
|
|
const day = localDayFromTimestamp(event.timestamp)
|
|
if (!day) {
|
|
return null
|
|
}
|
|
|
|
let repoId: string | null = null
|
|
let worktreeId: string | null = null
|
|
let projectKey = 'unscoped'
|
|
let projectLabel = getDefaultProjectLabel(event.cwd)
|
|
|
|
if (event.cwd) {
|
|
const worktree = findContainingWorktree(event.cwd, worktrees)
|
|
if (worktree) {
|
|
repoId = worktree.repoId
|
|
worktreeId = worktree.worktreeId
|
|
projectKey = `worktree:${worktree.worktreeId}`
|
|
projectLabel = worktree.displayName
|
|
} else {
|
|
// Why: all-local mode should still collapse repeated off-Orca sessions by
|
|
// location, but those keys must normalize slash/case differences so the
|
|
// same folder does not fragment into multiple "projects" across platforms.
|
|
projectKey = `cwd:${normalizeComparablePath(event.cwd)}`
|
|
}
|
|
}
|
|
|
|
return {
|
|
...event,
|
|
day,
|
|
projectKey,
|
|
projectLabel,
|
|
repoId,
|
|
worktreeId
|
|
}
|
|
}
|
|
|
|
function createEmptySession(event: CodexUsageAttributedEvent): CodexUsageSession {
|
|
return {
|
|
sessionId: event.sessionId,
|
|
firstTimestamp: event.timestamp,
|
|
lastTimestamp: event.timestamp,
|
|
primaryModel: event.model,
|
|
hasMixedModels: false,
|
|
primaryProjectLabel: event.projectLabel,
|
|
hasMixedLocations: false,
|
|
primaryWorktreeId: event.worktreeId,
|
|
primaryRepoId: event.repoId,
|
|
eventCount: 0,
|
|
totalInputTokens: 0,
|
|
totalCachedInputTokens: 0,
|
|
totalOutputTokens: 0,
|
|
totalReasoningOutputTokens: 0,
|
|
totalTokens: 0,
|
|
hasInferredPricing: false,
|
|
locationBreakdown: [],
|
|
modelBreakdown: [],
|
|
locationModelBreakdown: []
|
|
}
|
|
}
|
|
|
|
function createEmptyDailyAggregate(event: CodexUsageAttributedEvent): CodexUsageDailyAggregate {
|
|
return {
|
|
day: event.day,
|
|
model: event.model,
|
|
projectKey: event.projectKey,
|
|
projectLabel: event.projectLabel,
|
|
repoId: event.repoId,
|
|
worktreeId: event.worktreeId,
|
|
eventCount: 0,
|
|
inputTokens: 0,
|
|
cachedInputTokens: 0,
|
|
outputTokens: 0,
|
|
reasoningOutputTokens: 0,
|
|
totalTokens: 0,
|
|
hasInferredPricing: false
|
|
}
|
|
}
|
|
|
|
function mergeLocationBreakdown(
|
|
target: CodexUsageLocationBreakdown[],
|
|
event: CodexUsageAttributedEvent
|
|
): void {
|
|
const existing = target.find((entry) => entry.locationKey === event.projectKey) ?? null
|
|
if (existing) {
|
|
existing.eventCount++
|
|
existing.inputTokens += event.inputTokens
|
|
existing.cachedInputTokens += event.cachedInputTokens
|
|
existing.outputTokens += event.outputTokens
|
|
existing.reasoningOutputTokens += event.reasoningOutputTokens
|
|
existing.totalTokens += event.totalTokens
|
|
existing.hasInferredPricing ||= event.hasInferredPricing
|
|
return
|
|
}
|
|
|
|
target.push({
|
|
locationKey: event.projectKey,
|
|
projectLabel: event.projectLabel,
|
|
repoId: event.repoId,
|
|
worktreeId: event.worktreeId,
|
|
eventCount: 1,
|
|
inputTokens: event.inputTokens,
|
|
cachedInputTokens: event.cachedInputTokens,
|
|
outputTokens: event.outputTokens,
|
|
reasoningOutputTokens: event.reasoningOutputTokens,
|
|
totalTokens: event.totalTokens,
|
|
hasInferredPricing: event.hasInferredPricing
|
|
})
|
|
}
|
|
|
|
function mergeModelBreakdown(
|
|
target: CodexUsageModelBreakdown[],
|
|
event: CodexUsageAttributedEvent
|
|
): void {
|
|
const key = event.model ?? 'unknown'
|
|
const existing = target.find((entry) => entry.modelKey === key) ?? null
|
|
if (existing) {
|
|
existing.eventCount++
|
|
existing.inputTokens += event.inputTokens
|
|
existing.cachedInputTokens += event.cachedInputTokens
|
|
existing.outputTokens += event.outputTokens
|
|
existing.reasoningOutputTokens += event.reasoningOutputTokens
|
|
existing.totalTokens += event.totalTokens
|
|
existing.hasInferredPricing ||= event.hasInferredPricing
|
|
return
|
|
}
|
|
|
|
target.push({
|
|
modelKey: key,
|
|
modelLabel: event.model ?? 'Unknown model',
|
|
eventCount: 1,
|
|
inputTokens: event.inputTokens,
|
|
cachedInputTokens: event.cachedInputTokens,
|
|
outputTokens: event.outputTokens,
|
|
reasoningOutputTokens: event.reasoningOutputTokens,
|
|
totalTokens: event.totalTokens,
|
|
hasInferredPricing: event.hasInferredPricing
|
|
})
|
|
}
|
|
|
|
function mergeLocationModelBreakdown(
|
|
target: CodexUsageLocationModelBreakdown[],
|
|
event: CodexUsageAttributedEvent
|
|
): void {
|
|
const modelKey = event.model ?? 'unknown'
|
|
const existing =
|
|
target.find((entry) => entry.locationKey === event.projectKey && entry.modelKey === modelKey) ??
|
|
null
|
|
if (existing) {
|
|
existing.eventCount++
|
|
existing.inputTokens += event.inputTokens
|
|
existing.cachedInputTokens += event.cachedInputTokens
|
|
existing.outputTokens += event.outputTokens
|
|
existing.reasoningOutputTokens += event.reasoningOutputTokens
|
|
existing.totalTokens += event.totalTokens
|
|
existing.hasInferredPricing ||= event.hasInferredPricing
|
|
return
|
|
}
|
|
|
|
target.push({
|
|
locationKey: event.projectKey,
|
|
modelKey,
|
|
modelLabel: event.model ?? 'Unknown model',
|
|
repoId: event.repoId,
|
|
worktreeId: event.worktreeId,
|
|
eventCount: 1,
|
|
inputTokens: event.inputTokens,
|
|
cachedInputTokens: event.cachedInputTokens,
|
|
outputTokens: event.outputTokens,
|
|
reasoningOutputTokens: event.reasoningOutputTokens,
|
|
totalTokens: event.totalTokens,
|
|
hasInferredPricing: event.hasInferredPricing
|
|
})
|
|
}
|
|
|
|
function aggregateCodexUsage(events: CodexUsageAttributedEvent[]): {
|
|
sessions: CodexUsageSession[]
|
|
dailyAggregates: CodexUsageDailyAggregate[]
|
|
} {
|
|
const sessionsById = new Map<string, CodexUsageSession>()
|
|
const dailyByKey = new Map<string, CodexUsageDailyAggregate>()
|
|
|
|
for (const event of events) {
|
|
const session = sessionsById.get(event.sessionId) ?? createEmptySession(event)
|
|
if (!sessionsById.has(event.sessionId)) {
|
|
sessionsById.set(event.sessionId, session)
|
|
}
|
|
if (event.timestamp < session.firstTimestamp) {
|
|
session.firstTimestamp = event.timestamp
|
|
}
|
|
if (event.timestamp >= session.lastTimestamp) {
|
|
session.lastTimestamp = event.timestamp
|
|
}
|
|
session.eventCount++
|
|
session.totalInputTokens += event.inputTokens
|
|
session.totalCachedInputTokens += event.cachedInputTokens
|
|
session.totalOutputTokens += event.outputTokens
|
|
session.totalReasoningOutputTokens += event.reasoningOutputTokens
|
|
session.totalTokens += event.totalTokens
|
|
session.hasInferredPricing ||= event.hasInferredPricing
|
|
mergeLocationBreakdown(session.locationBreakdown, event)
|
|
mergeModelBreakdown(session.modelBreakdown, event)
|
|
mergeLocationModelBreakdown(session.locationModelBreakdown, event)
|
|
|
|
const dailyKey = [event.day, event.model ?? 'unknown', event.projectKey].join('::')
|
|
const daily = dailyByKey.get(dailyKey) ?? createEmptyDailyAggregate(event)
|
|
if (!dailyByKey.has(dailyKey)) {
|
|
dailyByKey.set(dailyKey, daily)
|
|
}
|
|
daily.eventCount++
|
|
daily.inputTokens += event.inputTokens
|
|
daily.cachedInputTokens += event.cachedInputTokens
|
|
daily.outputTokens += event.outputTokens
|
|
daily.reasoningOutputTokens += event.reasoningOutputTokens
|
|
daily.totalTokens += event.totalTokens
|
|
daily.hasInferredPricing ||= event.hasInferredPricing
|
|
}
|
|
|
|
return {
|
|
sessions: finalizeSessions(sessionsById),
|
|
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
|
|
left.day === right.day
|
|
? left.projectLabel.localeCompare(right.projectLabel)
|
|
: left.day.localeCompare(right.day)
|
|
)
|
|
}
|
|
}
|
|
|
|
function finalizeSessions(sessionsById: Map<string, CodexUsageSession>): CodexUsageSession[] {
|
|
for (const session of sessionsById.values()) {
|
|
session.locationBreakdown.sort((left, right) => right.totalTokens - left.totalTokens)
|
|
session.modelBreakdown.sort((left, right) => right.totalTokens - left.totalTokens)
|
|
const primaryLocation = session.locationBreakdown[0] ?? null
|
|
const primaryModel = session.modelBreakdown[0] ?? null
|
|
session.primaryProjectLabel =
|
|
session.locationBreakdown.length <= 1
|
|
? (primaryLocation?.projectLabel ?? 'Unknown location')
|
|
: 'Multiple locations'
|
|
session.hasMixedLocations = session.locationBreakdown.length > 1
|
|
session.primaryWorktreeId = primaryLocation?.worktreeId ?? null
|
|
session.primaryRepoId = primaryLocation?.repoId ?? null
|
|
session.primaryModel =
|
|
session.modelBreakdown.length <= 1 ? (primaryModel?.modelLabel ?? null) : 'Mixed models'
|
|
session.hasMixedModels = session.modelBreakdown.length > 1
|
|
}
|
|
|
|
return [...sessionsById.values()].sort((left, right) =>
|
|
right.lastTimestamp.localeCompare(left.lastTimestamp)
|
|
)
|
|
}
|
|
|
|
function mergeSessions(
|
|
target: Map<string, CodexUsageSession>,
|
|
sessions: CodexUsageSession[]
|
|
): void {
|
|
for (const session of sessions) {
|
|
const existing = target.get(session.sessionId)
|
|
if (!existing) {
|
|
target.set(session.sessionId, structuredClone(session))
|
|
continue
|
|
}
|
|
|
|
existing.firstTimestamp =
|
|
session.firstTimestamp < existing.firstTimestamp
|
|
? session.firstTimestamp
|
|
: existing.firstTimestamp
|
|
existing.lastTimestamp =
|
|
session.lastTimestamp > existing.lastTimestamp
|
|
? session.lastTimestamp
|
|
: existing.lastTimestamp
|
|
existing.eventCount += session.eventCount
|
|
existing.totalInputTokens += session.totalInputTokens
|
|
existing.totalCachedInputTokens += session.totalCachedInputTokens
|
|
existing.totalOutputTokens += session.totalOutputTokens
|
|
existing.totalReasoningOutputTokens += session.totalReasoningOutputTokens
|
|
existing.totalTokens += session.totalTokens
|
|
existing.hasInferredPricing ||= session.hasInferredPricing
|
|
|
|
for (const location of session.locationBreakdown) {
|
|
const existingLocation =
|
|
existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ??
|
|
null
|
|
if (existingLocation) {
|
|
existingLocation.eventCount += location.eventCount
|
|
existingLocation.inputTokens += location.inputTokens
|
|
existingLocation.cachedInputTokens += location.cachedInputTokens
|
|
existingLocation.outputTokens += location.outputTokens
|
|
existingLocation.reasoningOutputTokens += location.reasoningOutputTokens
|
|
existingLocation.totalTokens += location.totalTokens
|
|
existingLocation.hasInferredPricing ||= location.hasInferredPricing
|
|
} else {
|
|
existing.locationBreakdown.push({ ...location })
|
|
}
|
|
}
|
|
|
|
for (const model of session.modelBreakdown) {
|
|
const existingModel =
|
|
existing.modelBreakdown.find((entry) => entry.modelKey === model.modelKey) ?? null
|
|
if (existingModel) {
|
|
existingModel.eventCount += model.eventCount
|
|
existingModel.inputTokens += model.inputTokens
|
|
existingModel.cachedInputTokens += model.cachedInputTokens
|
|
existingModel.outputTokens += model.outputTokens
|
|
existingModel.reasoningOutputTokens += model.reasoningOutputTokens
|
|
existingModel.totalTokens += model.totalTokens
|
|
existingModel.hasInferredPricing ||= model.hasInferredPricing
|
|
} else {
|
|
existing.modelBreakdown.push({ ...model })
|
|
}
|
|
}
|
|
|
|
for (const locationModel of session.locationModelBreakdown) {
|
|
const existingLocationModel =
|
|
existing.locationModelBreakdown.find(
|
|
(entry) =>
|
|
entry.locationKey === locationModel.locationKey &&
|
|
entry.modelKey === locationModel.modelKey
|
|
) ?? null
|
|
if (existingLocationModel) {
|
|
existingLocationModel.eventCount += locationModel.eventCount
|
|
existingLocationModel.inputTokens += locationModel.inputTokens
|
|
existingLocationModel.cachedInputTokens += locationModel.cachedInputTokens
|
|
existingLocationModel.outputTokens += locationModel.outputTokens
|
|
existingLocationModel.reasoningOutputTokens += locationModel.reasoningOutputTokens
|
|
existingLocationModel.totalTokens += locationModel.totalTokens
|
|
existingLocationModel.hasInferredPricing ||= locationModel.hasInferredPricing
|
|
} else {
|
|
existing.locationModelBreakdown.push({ ...locationModel })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function mergeDailyAggregates(
|
|
target: Map<string, CodexUsageDailyAggregate>,
|
|
dailyAggregates: CodexUsageDailyAggregate[]
|
|
): void {
|
|
for (const aggregate of dailyAggregates) {
|
|
const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::')
|
|
const existing = target.get(key)
|
|
if (!existing) {
|
|
target.set(key, { ...aggregate })
|
|
continue
|
|
}
|
|
existing.eventCount += aggregate.eventCount
|
|
existing.inputTokens += aggregate.inputTokens
|
|
existing.cachedInputTokens += aggregate.cachedInputTokens
|
|
existing.outputTokens += aggregate.outputTokens
|
|
existing.reasoningOutputTokens += aggregate.reasoningOutputTokens
|
|
existing.totalTokens += aggregate.totalTokens
|
|
existing.hasInferredPricing ||= aggregate.hasInferredPricing
|
|
}
|
|
}
|
|
|
|
export function parseCodexUsageRecord(
|
|
line: string,
|
|
context: CodexUsageParseContext
|
|
): CodexUsageParsedEvent | null {
|
|
let parsed: CodexUsageRawRecord
|
|
try {
|
|
parsed = JSON.parse(line) as CodexUsageRawRecord
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
if (!parsed.type || !parsed.payload) {
|
|
return null
|
|
}
|
|
|
|
if (parsed.type === 'session_meta') {
|
|
context.sessionId = extractString(parsed.payload.id) ?? context.sessionId
|
|
context.sessionCwd = extractString(parsed.payload.cwd)
|
|
if (!context.currentCwd && context.sessionCwd) {
|
|
context.currentCwd = context.sessionCwd
|
|
}
|
|
return null
|
|
}
|
|
|
|
if (parsed.type === 'turn_context') {
|
|
context.currentCwd =
|
|
extractString(parsed.payload.cwd) ?? context.currentCwd ?? context.sessionCwd
|
|
context.currentModel = extractModel(parsed.payload) ?? context.currentModel
|
|
return null
|
|
}
|
|
|
|
if (parsed.type !== 'event_msg' || parsed.payload.type !== 'token_count' || !parsed.timestamp) {
|
|
return null
|
|
}
|
|
|
|
const info = parsed.payload.info
|
|
if (info == null || typeof info !== 'object') {
|
|
// Why: Codex emits token_count snapshots with null info for rate-limit
|
|
// updates. Treating them as malformed usage would make active sessions look
|
|
// flaky and create false scan errors for perfectly valid logs.
|
|
return null
|
|
}
|
|
|
|
const record = info as Record<string, unknown>
|
|
const totalUsage = normalizeRawUsage(record.total_token_usage)
|
|
const lastUsage = normalizeRawUsage(record.last_token_usage)
|
|
if (context.totalOnlyBaselinePending) {
|
|
context.totalOnlyBaselinePending = false
|
|
if (totalUsage && !lastUsage && !context.previousTotals) {
|
|
context.previousTotals = totalUsage
|
|
return null
|
|
}
|
|
}
|
|
const resolvedUsage = resolveCodexUsageDelta(totalUsage, lastUsage, context.previousTotals)
|
|
if (!resolvedUsage) {
|
|
return null
|
|
}
|
|
if (resolvedUsage.kind === 'baseline') {
|
|
context.previousTotals = resolvedUsage.nextTotals
|
|
return null
|
|
}
|
|
|
|
let delta = {
|
|
...resolvedUsage.delta,
|
|
cachedInputTokens: Math.min(
|
|
resolvedUsage.delta.cachedInputTokens,
|
|
resolvedUsage.delta.inputTokens
|
|
)
|
|
}
|
|
|
|
if (
|
|
delta.inputTokens === 0 &&
|
|
delta.cachedInputTokens === 0 &&
|
|
delta.outputTokens === 0 &&
|
|
delta.reasoningOutputTokens === 0 &&
|
|
delta.totalTokens === 0
|
|
) {
|
|
return null
|
|
}
|
|
|
|
context.previousTotals = resolvedUsage.nextTotals
|
|
|
|
const resolvedModel = extractModel(parsed.payload) ?? context.currentModel
|
|
const hasInferredPricing = resolvedModel === null
|
|
|
|
return {
|
|
sessionId: context.sessionId,
|
|
timestamp: parsed.timestamp,
|
|
cwd: context.currentCwd ?? context.sessionCwd,
|
|
model: resolvedModel,
|
|
hasInferredPricing,
|
|
inputTokens: delta.inputTokens,
|
|
cachedInputTokens: delta.cachedInputTokens,
|
|
outputTokens: delta.outputTokens,
|
|
reasoningOutputTokens: delta.reasoningOutputTokens,
|
|
totalTokens: delta.totalTokens
|
|
}
|
|
}
|
|
|
|
export async function parseCodexUsageFile(
|
|
filePath: string,
|
|
worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[],
|
|
options: { skipInitialBytes?: number } = {}
|
|
): Promise<CodexUsagePersistedFile> {
|
|
const processedFile = await getProcessedFileInfo(filePath)
|
|
const lines = createInterface({
|
|
input: createReadStream(filePath, {
|
|
encoding: 'utf-8',
|
|
start: options.skipInitialBytes ?? 0
|
|
}),
|
|
crlfDelay: Infinity
|
|
})
|
|
const events: CodexUsageAttributedEvent[] = []
|
|
const context: CodexUsageParseContext = {
|
|
sessionId: basename(filePath, '.jsonl'),
|
|
sessionCwd: null,
|
|
currentCwd: null,
|
|
currentModel: null,
|
|
previousTotals: null,
|
|
// Why: suffix-only legacy copy parsing lacks the copied prefix context. A
|
|
// leading total-only snapshot is a baseline, not the suffix's billable delta.
|
|
totalOnlyBaselinePending: (options.skipInitialBytes ?? 0) > 0
|
|
}
|
|
|
|
for await (const line of lines) {
|
|
const parsed = parseCodexUsageRecord(line, context)
|
|
if (!parsed) {
|
|
continue
|
|
}
|
|
const attributed = await attributeCodexUsageEvent(parsed, worktrees)
|
|
if (attributed) {
|
|
events.push(attributed)
|
|
}
|
|
}
|
|
|
|
return {
|
|
...processedFile,
|
|
...aggregateCodexUsage(events)
|
|
}
|
|
}
|
|
|
|
export async function scanCodexUsageFiles(
|
|
worktrees: CodexUsageWorktreeRef[],
|
|
previousProcessedFiles: CodexUsagePersistedFile[]
|
|
): Promise<{
|
|
processedFiles: CodexUsagePersistedFile[]
|
|
sessions: CodexUsageSession[]
|
|
dailyAggregates: CodexUsageDailyAggregate[]
|
|
}> {
|
|
const files = await listCodexSessionFiles()
|
|
const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file]))
|
|
const processedFiles: CodexUsagePersistedFile[] = []
|
|
const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees)
|
|
const legacySourceSkipBytesByPath = getLegacySourceSkipBytesByPath(files)
|
|
const sessionsById = new Map<string, CodexUsageSession>()
|
|
const dailyByKey = new Map<string, CodexUsageDailyAggregate>()
|
|
|
|
for (const [index, filePath] of files.entries()) {
|
|
const legacySourceSkipBytes = legacySourceSkipBytesByPath.get(filePath) ?? 0
|
|
const fileInfo = await getProcessedFileInfo(filePath)
|
|
const previous = previousByPath.get(filePath)
|
|
const canReuse =
|
|
legacySourceSkipBytes === 0 &&
|
|
previous &&
|
|
previous.mtimeMs === fileInfo.mtimeMs &&
|
|
previous.size === fileInfo.size
|
|
|
|
const processed = canReuse
|
|
? previous
|
|
: await parseCodexUsageFile(filePath, worktreesWithCanonicalPaths, {
|
|
skipInitialBytes: legacySourceSkipBytes
|
|
})
|
|
|
|
processedFiles.push(processed)
|
|
mergeSessions(sessionsById, processed.sessions)
|
|
mergeDailyAggregates(dailyByKey, processed.dailyAggregates)
|
|
|
|
// Why: Codex session history can grow large, and scans run on the Electron
|
|
// main process. Yield regularly so opening Settings does not stall while
|
|
// a background refresh walks old JSONL files.
|
|
if ((index + 1) % YIELD_EVERY_FILES === 0) {
|
|
await yieldToEventLoop()
|
|
}
|
|
}
|
|
|
|
return {
|
|
processedFiles,
|
|
sessions: finalizeSessions(sessionsById),
|
|
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
|
|
left.day === right.day
|
|
? left.projectLabel.localeCompare(right.projectLabel)
|
|
: left.day.localeCompare(right.day)
|
|
)
|
|
}
|
|
}
|
|
|
|
export function createWorktreeRefs(
|
|
repos: Repo[],
|
|
worktreesByRepo: Map<string, { path: string; worktreeId: string; displayName: string }[]>
|
|
): CodexUsageWorktreeRef[] {
|
|
const refs: CodexUsageWorktreeRef[] = []
|
|
for (const repo of repos) {
|
|
for (const worktree of worktreesByRepo.get(repo.id) ?? []) {
|
|
refs.push({
|
|
repoId: repo.id,
|
|
worktreeId: worktree.worktreeId,
|
|
path: worktree.path,
|
|
displayName: worktree.displayName
|
|
})
|
|
}
|
|
}
|
|
return refs
|
|
}
|
|
|
|
export function getDefaultWorktreeLabel(pathValue: string): string {
|
|
return basename(pathValue)
|
|
}
|
|
|
|
export function getSessionProjectLabel(locationBreakdown: CodexUsageLocationBreakdown[]): string {
|
|
if (locationBreakdown.length === 0) {
|
|
return 'Unknown location'
|
|
}
|
|
if (locationBreakdown.length === 1) {
|
|
return locationBreakdown[0].projectLabel
|
|
}
|
|
return 'Multiple locations'
|
|
}
|