mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-27 18:05:12 +00:00
Compact one-row strip: correction rate with count, median time to first edit, and the top reworked file, plus one coaching note derived locally with the same thresholds and copy as the CLI's workflow-insights buildCoachingNotes. Decodes two new optional payload blocks (current.workflow and current.topReworkedFiles), so payloads from older CLIs still parse. The section hides entirely when there is no signal and individual stats never render as zero placeholders. It reads the store's current payload, so it follows the selected agent tab automatically. Adds unit tests for the duration formatter, note selection, model derivation, and payload decoding. Co-authored-by: reviewer <review@local>
496 lines
17 KiB
Swift
496 lines
17 KiB
Swift
import Foundation
|
|
|
|
/// Shape of `codeburn status --format menubar-json --period <period>`.
|
|
/// `current` is scoped to the requested period; the whole payload reflects that slice.
|
|
struct MenubarPayload: Codable, Sendable {
|
|
let generated: String
|
|
let current: CurrentBlock
|
|
let optimize: OptimizeBlock
|
|
let history: HistoryBlock
|
|
let combined: CombinedUsage?
|
|
let claudeConfigs: ClaudeConfigSelector?
|
|
|
|
init(generated: String,
|
|
current: CurrentBlock,
|
|
optimize: OptimizeBlock,
|
|
history: HistoryBlock,
|
|
combined: CombinedUsage?,
|
|
claudeConfigs: ClaudeConfigSelector? = nil) {
|
|
self.generated = generated
|
|
self.current = current
|
|
self.optimize = optimize
|
|
self.history = history
|
|
self.combined = combined
|
|
self.claudeConfigs = claudeConfigs
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case generated, current, optimize, history, combined, claudeConfigs
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
generated = try c.decode(String.self, forKey: .generated)
|
|
current = try c.decode(CurrentBlock.self, forKey: .current)
|
|
optimize = try c.decode(OptimizeBlock.self, forKey: .optimize)
|
|
history = try c.decode(HistoryBlock.self, forKey: .history)
|
|
combined = try c.decodeIfPresent(CombinedUsage.self, forKey: .combined)
|
|
claudeConfigs = try c.decodeIfPresent(ClaudeConfigSelector.self, forKey: .claudeConfigs)
|
|
}
|
|
}
|
|
|
|
struct ClaudeConfigSelector: Codable, Sendable {
|
|
let selectedId: String?
|
|
let options: [ClaudeConfigOption]
|
|
}
|
|
|
|
struct ClaudeConfigOption: Codable, Identifiable, Hashable, Sendable {
|
|
let id: String
|
|
let label: String
|
|
let path: String
|
|
}
|
|
|
|
struct CombinedUsage: Codable, Sendable {
|
|
let perDevice: [CombinedDeviceUsage]
|
|
let combined: CombinedUsageTotals
|
|
}
|
|
|
|
struct CombinedDeviceUsage: Codable, Sendable {
|
|
let id: String
|
|
let name: String
|
|
let local: Bool
|
|
let error: String?
|
|
let cost: Double
|
|
let calls: Int
|
|
let sessions: Int
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
let cacheCreateTokens: Int
|
|
let cacheReadTokens: Int
|
|
let totalTokens: Int
|
|
}
|
|
|
|
struct CombinedUsageTotals: Codable, Sendable {
|
|
let cost: Double
|
|
let calls: Int
|
|
let sessions: Int
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
let cacheCreateTokens: Int
|
|
let cacheReadTokens: Int
|
|
let totalTokens: Int
|
|
let deviceCount: Int
|
|
let reachableCount: Int
|
|
}
|
|
|
|
struct HistoryBlock: Codable, Sendable {
|
|
let daily: [DailyHistoryEntry]
|
|
}
|
|
|
|
struct DailyModelBreakdown: Codable, Sendable {
|
|
let name: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let calls: Int
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
|
|
var totalTokens: Int { inputTokens + outputTokens }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name, cost, savingsUSD, calls, inputTokens, outputTokens
|
|
}
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
calls = try c.decode(Int.self, forKey: .calls)
|
|
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
|
|
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
|
|
}
|
|
}
|
|
|
|
struct DailyHistoryEntry: Codable, Sendable {
|
|
let date: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let calls: Int
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
let cacheReadTokens: Int
|
|
let cacheWriteTokens: Int
|
|
let topModels: [DailyModelBreakdown]
|
|
|
|
/// Pricing-ratio prior: input + 5x output + cache_creation + 0.1x cache_read.
|
|
/// Matches Anthropic's published per-token pricing on Sonnet/Opus closely enough to be a useful proxy.
|
|
var effectiveTokens: Double {
|
|
Double(inputTokens) + 5.0 * Double(outputTokens) + Double(cacheWriteTokens) + 0.1 * Double(cacheReadTokens)
|
|
}
|
|
}
|
|
|
|
extension DailyHistoryEntry {
|
|
/// Required for legacy payloads (no topModels emitted yet).
|
|
enum CodingKeys: String, CodingKey {
|
|
case date, cost, savingsUSD, calls, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, topModels
|
|
}
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
date = try c.decode(String.self, forKey: .date)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
calls = try c.decode(Int.self, forKey: .calls)
|
|
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
|
|
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
|
|
cacheReadTokens = try c.decode(Int.self, forKey: .cacheReadTokens)
|
|
cacheWriteTokens = try c.decode(Int.self, forKey: .cacheWriteTokens)
|
|
topModels = try c.decodeIfPresent([DailyModelBreakdown].self, forKey: .topModels) ?? []
|
|
}
|
|
}
|
|
|
|
struct RetryTaxModelEntry: Codable, Sendable {
|
|
let name: String
|
|
let taxUSD: Double
|
|
let retries: Int
|
|
let retriesPerEdit: Double?
|
|
}
|
|
|
|
struct RetryTax: Codable, Sendable {
|
|
let totalUSD: Double
|
|
let retries: Int
|
|
let editTurns: Int
|
|
let byModel: [RetryTaxModelEntry]
|
|
}
|
|
|
|
struct RoutingWasteModelEntry: Codable, Sendable {
|
|
let name: String
|
|
let costPerEdit: Double
|
|
let editTurns: Int
|
|
let actualUSD: Double
|
|
let counterfactualUSD: Double
|
|
let savingsUSD: Double
|
|
}
|
|
|
|
struct RoutingWaste: Codable, Sendable {
|
|
let totalSavingsUSD: Double
|
|
let baselineModel: String
|
|
let baselineCostPerEdit: Double
|
|
let byModel: [RoutingWasteModelEntry]
|
|
}
|
|
|
|
/// Workflow-intelligence rollup for the period. `correctionRate` and
|
|
/// `medianTimeToFirstEditMs` are null when not computable (no user turns / no
|
|
/// session ever edited), so both are optional.
|
|
struct WorkflowBlock: Codable, Sendable {
|
|
let corrections: Int
|
|
let correctionRate: Double?
|
|
let medianTimeToFirstEditMs: Double?
|
|
}
|
|
|
|
/// One entry of `topReworkedFiles`. `path` is basename-only (the CLI trims it
|
|
/// for privacy before the payload can leave the machine); `sessions` is the
|
|
/// distinct-session count and `edits` the total edit-family calls.
|
|
struct ReworkedFileEntry: Codable, Sendable {
|
|
let path: String
|
|
let sessions: Int
|
|
let edits: Int
|
|
}
|
|
|
|
struct CurrentBlock: Codable, Sendable {
|
|
let label: String
|
|
let cost: Double
|
|
let calls: Int
|
|
let sessions: Int
|
|
let oneShotRate: Double?
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
let cacheHitPercent: Double
|
|
/// Codex credits consumed in the period (nil on payloads from older builds).
|
|
let codexCredits: Double?
|
|
let topActivities: [ActivityEntry]
|
|
let topModels: [ModelEntry]
|
|
let localModelSavings: LocalModelSavings
|
|
let providers: [String: Double]
|
|
let topProjects: [ProjectEntry]
|
|
let modelEfficiency: [ModelEfficiencyEntry]
|
|
let topSessions: [TopSessionEntry]
|
|
let retryTax: RetryTax
|
|
let routingWaste: RoutingWaste
|
|
let tools: [ToolEntry]
|
|
let skills: [SkillEntry]
|
|
let subagents: [SubagentEntry]
|
|
let mcpServers: [McpServerEntry]
|
|
/// Workflow-intelligence rollup. Optional so payloads from older CLIs
|
|
/// (which never emit it) still decode; absent -> the Workflow strip hides.
|
|
/// Declared last with a default so the memberwise initializer stays
|
|
/// backward-compatible for existing construction sites.
|
|
var workflow: WorkflowBlock? = nil
|
|
/// Files most reworked by edit-family calls. Empty on older CLIs.
|
|
var topReworkedFiles: [ReworkedFileEntry] = []
|
|
}
|
|
|
|
extension CurrentBlock {
|
|
enum CodingKeys: String, CodingKey {
|
|
case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens,
|
|
cacheHitPercent, codexCredits, topActivities, topModels, localModelSavings, providers, topProjects,
|
|
modelEfficiency, topSessions, retryTax, routingWaste,
|
|
tools, skills, subagents, mcpServers,
|
|
workflow, topReworkedFiles
|
|
}
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
label = try c.decode(String.self, forKey: .label)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
calls = try c.decode(Int.self, forKey: .calls)
|
|
sessions = try c.decode(Int.self, forKey: .sessions)
|
|
oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate)
|
|
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
|
|
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
|
|
cacheHitPercent = try c.decodeIfPresent(Double.self, forKey: .cacheHitPercent) ?? 0
|
|
codexCredits = try c.decodeIfPresent(Double.self, forKey: .codexCredits)
|
|
topActivities = try c.decodeIfPresent([ActivityEntry].self, forKey: .topActivities) ?? []
|
|
topModels = try c.decodeIfPresent([ModelEntry].self, forKey: .topModels) ?? []
|
|
localModelSavings = try c.decodeIfPresent(LocalModelSavings.self, forKey: .localModelSavings) ?? LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: [])
|
|
providers = try c.decodeIfPresent([String: Double].self, forKey: .providers) ?? [:]
|
|
topProjects = try c.decodeIfPresent([ProjectEntry].self, forKey: .topProjects) ?? []
|
|
modelEfficiency = try c.decodeIfPresent([ModelEfficiencyEntry].self, forKey: .modelEfficiency) ?? []
|
|
topSessions = try c.decodeIfPresent([TopSessionEntry].self, forKey: .topSessions) ?? []
|
|
retryTax = try c.decodeIfPresent(RetryTax.self, forKey: .retryTax) ?? RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: [])
|
|
routingWaste = try c.decodeIfPresent(RoutingWaste.self, forKey: .routingWaste) ?? RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: [])
|
|
tools = try c.decodeIfPresent([ToolEntry].self, forKey: .tools) ?? []
|
|
skills = try c.decodeIfPresent([SkillEntry].self, forKey: .skills) ?? []
|
|
subagents = try c.decodeIfPresent([SubagentEntry].self, forKey: .subagents) ?? []
|
|
mcpServers = try c.decodeIfPresent([McpServerEntry].self, forKey: .mcpServers) ?? []
|
|
workflow = try c.decodeIfPresent(WorkflowBlock.self, forKey: .workflow)
|
|
topReworkedFiles = try c.decodeIfPresent([ReworkedFileEntry].self, forKey: .topReworkedFiles) ?? []
|
|
}
|
|
}
|
|
|
|
struct LocalModelSavingsByModel: Codable, Sendable {
|
|
let name: String
|
|
let calls: Int
|
|
let actualUSD: Double
|
|
let savingsUSD: Double
|
|
let baselineModel: String
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
}
|
|
|
|
struct LocalModelSavingsByProvider: Codable, Sendable {
|
|
let name: String
|
|
let calls: Int
|
|
let savingsUSD: Double
|
|
}
|
|
|
|
struct LocalModelSavings: Codable, Sendable {
|
|
let totalUSD: Double
|
|
let calls: Int
|
|
let byModel: [LocalModelSavingsByModel]
|
|
let byProvider: [LocalModelSavingsByProvider]
|
|
}
|
|
|
|
struct ActivityEntry: Codable, Sendable {
|
|
let name: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let turns: Int
|
|
let oneShotRate: Double?
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
turns = try c.decode(Int.self, forKey: .turns)
|
|
oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate)
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case name, cost, savingsUSD, turns, oneShotRate
|
|
}
|
|
}
|
|
|
|
struct ModelEntry: Codable, Sendable {
|
|
let name: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let savingsBaselineModel: String
|
|
let calls: Int
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
savingsBaselineModel = try c.decodeIfPresent(String.self, forKey: .savingsBaselineModel) ?? ""
|
|
calls = try c.decode(Int.self, forKey: .calls)
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case name, cost, savingsUSD, savingsBaselineModel, calls
|
|
}
|
|
}
|
|
|
|
struct SessionModelEntry: Codable, Sendable {
|
|
let name: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case name, cost, savingsUSD
|
|
}
|
|
}
|
|
|
|
struct SessionDetailEntry: Codable, Sendable {
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let calls: Int
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
let date: String
|
|
let models: [SessionModelEntry]
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
calls = try c.decode(Int.self, forKey: .calls)
|
|
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
|
|
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
|
|
date = try c.decode(String.self, forKey: .date)
|
|
models = try c.decodeIfPresent([SessionModelEntry].self, forKey: .models) ?? []
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case cost, savingsUSD, calls, inputTokens, outputTokens, date, models
|
|
}
|
|
}
|
|
|
|
struct ProjectEntry: Codable, Sendable {
|
|
let name: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let sessions: Int
|
|
let avgCostPerSession: Double
|
|
let sessionDetails: [SessionDetailEntry]
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
sessions = try c.decode(Int.self, forKey: .sessions)
|
|
avgCostPerSession = try c.decode(Double.self, forKey: .avgCostPerSession)
|
|
sessionDetails = try c.decodeIfPresent([SessionDetailEntry].self, forKey: .sessionDetails) ?? []
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case name, cost, savingsUSD, sessions, avgCostPerSession, sessionDetails
|
|
}
|
|
}
|
|
|
|
struct ModelEfficiencyEntry: Codable, Sendable {
|
|
let name: String
|
|
let costPerEdit: Double?
|
|
let oneShotRate: Double?
|
|
}
|
|
|
|
struct TopSessionEntry: Codable, Sendable {
|
|
let project: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let calls: Int
|
|
let date: String
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
project = try c.decode(String.self, forKey: .project)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
calls = try c.decode(Int.self, forKey: .calls)
|
|
date = try c.decode(String.self, forKey: .date)
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case project, cost, savingsUSD, calls, date
|
|
}
|
|
}
|
|
|
|
struct ToolEntry: Codable, Sendable {
|
|
let name: String
|
|
let calls: Int
|
|
}
|
|
|
|
struct SkillEntry: Codable, Sendable {
|
|
let name: String
|
|
let turns: Int
|
|
let cost: Double
|
|
}
|
|
|
|
struct SubagentEntry: Codable, Sendable {
|
|
let name: String
|
|
let calls: Int
|
|
let cost: Double
|
|
}
|
|
|
|
struct McpServerEntry: Codable, Sendable {
|
|
let name: String
|
|
let calls: Int
|
|
}
|
|
|
|
struct OptimizeBlock: Codable, Sendable {
|
|
let findingCount: Int
|
|
let savingsUSD: Double
|
|
let topFindings: [FindingEntry]
|
|
}
|
|
|
|
struct FindingEntry: Codable, Sendable {
|
|
let title: String
|
|
let impact: String
|
|
let savingsUSD: Double
|
|
}
|
|
|
|
// MARK: - Empty fallback
|
|
|
|
extension MenubarPayload {
|
|
/// Strictly-empty payload. Used as the fallback before real data arrives, so no
|
|
/// plausible-looking fake numbers leak into the UI.
|
|
static let empty = MenubarPayload(
|
|
generated: "",
|
|
current: CurrentBlock(
|
|
label: "",
|
|
cost: 0,
|
|
calls: 0,
|
|
sessions: 0,
|
|
oneShotRate: nil,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheHitPercent: 0,
|
|
codexCredits: nil,
|
|
topActivities: [],
|
|
topModels: [],
|
|
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
|
|
providers: [:],
|
|
topProjects: [],
|
|
modelEfficiency: [],
|
|
topSessions: [],
|
|
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
|
|
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []),
|
|
tools: [],
|
|
skills: [],
|
|
subagents: [],
|
|
mcpServers: []
|
|
),
|
|
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
|
|
history: HistoryBlock(daily: []),
|
|
combined: nil,
|
|
claudeConfigs: nil
|
|
)
|
|
}
|