codeburn/src/menubar-json.ts
2026-05-11 19:39:35 +03:00

207 lines
5.8 KiB
TypeScript

/// Rollup of one time window (today / 7 days / 30 days / month / all) used as the canonical
/// input to the menubar payload. Built inside the CLI and also consumed by the day-aggregator
/// when hydrating per-day cache entries.
export type PeriodData = {
label: string
cost: number
calls: number
sessions: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
categories: Array<{
name: string
cost: number
turns: number
editTurns: number
oneShotTurns: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
}>
models: Array<{ name: string; cost: number; calls: number }>
}
export type ProviderCost = {
name: string
cost: number
}
import type { OptimizeResult } from './optimize.js'
const TOP_ACTIVITIES_LIMIT = 20
const TOP_MODELS_LIMIT = 20
const TOP_FINDINGS_LIMIT = 10
const HISTORY_DAYS_LIMIT = 365
const SYNTHETIC_MODEL_NAME = '<synthetic>'
export type DailyModelBreakdown = {
name: string
cost: number
calls: number
inputTokens: number
outputTokens: number
}
export type DailyHistoryEntry = {
date: string
cost: number
calls: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
topModels: DailyModelBreakdown[]
}
export type MenubarPayload = {
generated: string
current: {
label: string
cost: number
calls: number
sessions: number
oneShotRate: number | null
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
cacheHitPercent: number
topActivities: Array<{
name: string
cost: number
turns: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
oneShotRate: number | null
}>
topModels: Array<{
name: string
cost: number
calls: number
}>
providers: Record<string, number>
}
optimize: {
findingCount: number
savingsUSD: number
topFindings: Array<{
title: string
impact: 'high' | 'medium' | 'low'
savingsUSD: number
}>
}
history: {
daily: DailyHistoryEntry[]
}
}
function oneShotRateFor(editTurns: number, oneShotTurns: number): number | null {
if (editTurns === 0) return null
return oneShotTurns / editTurns
}
function aggregateOneShotRate(categories: PeriodData['categories']): number | null {
let edits = 0
let oneShots = 0
for (const cat of categories) {
edits += cat.editTurns
oneShots += cat.oneShotTurns
}
if (edits === 0) return null
return oneShots / edits
}
function cacheHitPercent(inputTokens: number, cacheReadTokens: number): number {
const denom = inputTokens + cacheReadTokens
if (denom === 0) return 0
return (cacheReadTokens / denom) * 100
}
function buildTopActivities(categories: PeriodData['categories']): MenubarPayload['current']['topActivities'] {
// The CLI supplies categories sorted by cost. There are fewer than 20 known
// task categories today, so the macOS token-mode resort still receives every
// category while keeping this payload compact if the taxonomy grows later.
return categories.slice(0, TOP_ACTIVITIES_LIMIT).map(cat => ({
name: cat.name,
cost: cat.cost,
turns: cat.turns,
inputTokens: cat.inputTokens,
outputTokens: cat.outputTokens,
cacheReadTokens: cat.cacheReadTokens,
cacheWriteTokens: cat.cacheWriteTokens,
oneShotRate: oneShotRateFor(cat.editTurns, cat.oneShotTurns),
}))
}
function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] {
return models
.filter(m => m.name !== SYNTHETIC_MODEL_NAME)
.slice(0, TOP_MODELS_LIMIT)
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls }))
}
function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] {
if (!optimize || optimize.findings.length === 0) {
return { findingCount: 0, savingsUSD: 0, topFindings: [] }
}
const { findings, costRate } = optimize
const totalSavingsUSD = findings.reduce((s, f) => s + f.tokensSaved * costRate, 0)
const topFindings = findings.slice(0, TOP_FINDINGS_LIMIT).map(f => ({
title: f.title,
impact: f.impact,
savingsUSD: f.tokensSaved * costRate,
}))
return {
findingCount: findings.length,
savingsUSD: totalSavingsUSD,
topFindings,
}
}
function buildProviders(providers: ProviderCost[]): Record<string, number> {
const map: Record<string, number> = {}
for (const p of providers) {
if (p.cost < 0) continue
map[p.name.toLowerCase()] = p.cost
}
return map
}
function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['history'] {
if (!daily || daily.length === 0) return { daily: [] }
const sorted = [...daily].sort((a, b) => a.date.localeCompare(b.date))
const trimmed = sorted.slice(-HISTORY_DAYS_LIMIT)
return { daily: trimmed }
}
export function buildMenubarPayload(
current: PeriodData,
providers: ProviderCost[],
optimize: OptimizeResult | null,
dailyHistory?: DailyHistoryEntry[],
): MenubarPayload {
return {
generated: new Date().toISOString(),
current: {
label: current.label,
cost: current.cost,
calls: current.calls,
sessions: current.sessions,
oneShotRate: aggregateOneShotRate(current.categories),
inputTokens: current.inputTokens,
outputTokens: current.outputTokens,
cacheReadTokens: current.cacheReadTokens,
cacheWriteTokens: current.cacheWriteTokens,
cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens),
topActivities: buildTopActivities(current.categories),
topModels: buildTopModels(current.models),
providers: buildProviders(providers),
},
optimize: buildOptimize(optimize),
history: buildHistory(dailyHistory),
}
}