mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-04-28 15:09:43 +00:00
Introduces mac/ with a native SwiftUI menubar app that replaces the previous SwiftBar plugin entirely. Install via `npx codeburn menubar`, which downloads the .app from GitHub Releases, strips Gatekeeper quarantine, and drops it into ~/Applications. Highlights - mac/ SwiftUI app: agent tabs, Today/7/30/Month/All period switcher, Trend/Forecast/Pulse/Stats/Plan insights, activity + model breakdowns, optimize findings, CSV/JSON export, Star-on-GitHub banner, live 60s refresh, instant currency switching with offline FX cache. - Security: CodeburnCLI argv-based spawn (no shell interpretation), SafeFile symlink guards + O_NOFOLLOW writes, FX rate clamping to [0.0001, 1_000_000], keychain filtered to account == "default", removed byte-window credential log, in-flight refresh guard, POSIX flock on config.json writes, TerminalLauncher validates argv before AppleScript interpolation. - Performance: shared static NumberFormatter (thousands of allocations per popover redraw eliminated), concurrent pipe drain with 20 MB cap + 60s timeout in DataClient, Observation-tracked reactive UI, 5-min payload cache keyed on (period, provider). - CLI: new `codeburn menubar` subcommand that downloads + installs + launches the .app (no clone, no build). New `status --format menubar-json` payload builder. `export` rewritten to produce a folder of one-table-per-file CSVs with a `.codeburn-export` marker so arbitrary -o paths cannot be silently deleted. - Removed: src/menubar.ts (SwiftBar plugin generator), install-menubar / uninstall-menubar subcommands, `status --format menubar` directive output, tests/menubar.test.ts, tests/security/menubar-injection.test.ts. - Release: .github/workflows/release-menubar.yml builds universal binary, assembles .app, ad-hoc signs, zips, uploads on mac-v* tag push. Runs on the free macos-latest runner. Tests - 230 TypeScript tests pass - 10 Swift CapacityEstimator tests pass - TypeScript typecheck clean - Swift release build clean
182 lines
5 KiB
TypeScript
182 lines
5 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 }>
|
|
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
|
|
cacheHitPercent: number
|
|
topActivities: Array<{
|
|
name: string
|
|
cost: number
|
|
turns: 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'] {
|
|
return categories.slice(0, TOP_ACTIVITIES_LIMIT).map(cat => ({
|
|
name: cat.name,
|
|
cost: cat.cost,
|
|
turns: cat.turns,
|
|
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,
|
|
cacheHitPercent: cacheHitPercent(current.inputTokens, current.cacheReadTokens),
|
|
topActivities: buildTopActivities(current.categories),
|
|
topModels: buildTopModels(current.models),
|
|
providers: buildProviders(providers),
|
|
},
|
|
optimize: buildOptimize(optimize),
|
|
history: buildHistory(dailyHistory),
|
|
}
|
|
}
|