From ae69412d6c3e5dcf5acc27b7ef26d1372ff90a14 Mon Sep 17 00:00:00 2001 From: Eohan G Date: Tue, 23 Jun 2026 23:22:37 +0800 Subject: [PATCH] feat(insights): /usage-style profile + cost attribution; scanner perf overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Insights tab — new all-time Profile section - 2×4 stat grid: sessions / messages / total tokens / active days, current & longest streak, peak hour, favorite model. - 13-week GitHub-style contribution calendar (blue palette). - Fun-fact callout ("~N× Harry Potter") tied to lifetime tokens. - Replaces the older weekday × hour heatmap on the Insights tab. Cost tab — new attribution sections - By context size: ≤50k / 50–150k / >150k bands. Claude-only (Codex records are delta-of-cumulative, not absolute prompt size). - Usage attribution tables: Skills / Subagents / Plugins / MCP servers, read straight from Claude's `attribution*` fields on each assistant line — so percentages are an exact group-and-sum. - Both follow the right-pill range (today / 7d / 30d) and the cost/tokens metric, like the existing model/project breakdowns. Scanner perf — boot peak RSS ~1.3 GB → ~75 MB on this 711 MB corpus - Per-line autoreleasepool in both scanners so JSONSerialization's NSDictionary/NSString tree drains immediately instead of piling up across files (Aggregator.run runs in a detached Task with no natural pool drain). - Shared Scanner across Claude + Codex so the on-disk cache is decoded once per Aggregator.run() instead of twice. - Cache format JSON → binary property list. PropertyListDecoder reads directly into the Swift struct without an intermediate NSDictionary tree, and the on-disk file shrinks ~62% (18 MB → 6.9 MB here). cacheVersion 4 → 5 (forces one full rescan on next launch). Privacy boundary unchanged: every new data point is derived locally from already-scanned records. No new I/O sources, no new network paths. Co-Authored-By: Claude Opus 4.7 --- .../CodingBar/Views/Panel/PanelCharts.swift | 127 +++++++++++ Sources/CodingBar/Views/Panel/PanelTabs.swift | 210 +++++++++++++++++- Sources/CodingBarCore/Aggregator.swift | 62 +++++- Sources/CodingBarCore/ClaudeScanner.swift | 33 ++- Sources/CodingBarCore/CodexScanner.swift | 10 +- Sources/CodingBarCore/Models.swift | 110 ++++++++- Sources/CodingBarCore/Profile.swift | 126 +++++++++++ Sources/CodingBarCore/Sample.swift | 35 ++- Sources/CodingBarCore/Scanner.swift | 42 +++- Tests/CodingBarCoreTests/SmokeTests.swift | 78 +++++++ 10 files changed, 806 insertions(+), 27 deletions(-) create mode 100644 Sources/CodingBarCore/Profile.swift diff --git a/Sources/CodingBar/Views/Panel/PanelCharts.swift b/Sources/CodingBar/Views/Panel/PanelCharts.swift index 2360deb..155bfe7 100644 --- a/Sources/CodingBar/Views/Panel/PanelCharts.swift +++ b/Sources/CodingBar/Views/Panel/PanelCharts.swift @@ -145,6 +145,133 @@ struct DCHeatGrid: View { } } +// MARK: - Profile stat-card grid (2 cols × 4 rows), modeled on Claude Desktop's Overview + +struct DCStatGrid: View { + @Environment(\.dc) private var dc + let items: [(label: String, value: String)] // expects 8 (Sessions … Favorite model) + + var body: some View { + // 2 per row; an odd tail keeps a balanced empty slot so widths don't jump. + let rows = stride(from: 0, to: items.count, by: 2).map { Array(items[$0.. some View { + VStack(alignment: .leading, spacing: 2) { + Text(it.label).font(.system(size: 8.5)).foregroundStyle(dc.fg3).lineLimit(1) + Text(it.value).font(.system(size: 15, weight: .bold)).monospacedDigit() + .foregroundStyle(dc.fg).lineLimit(1).minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 9).padding(.vertical, 8) + .background(RoundedRectangle(cornerRadius: 8).fill(dc.elev)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(dc.sep2, lineWidth: 0.5)) + } +} + +// MARK: - Contribution calendar — GitHub-style blue, 7 rows (Mon…Sun) × week columns + +struct DCContribCalendar: View { + @Environment(\.dc) private var dc + @Environment(\.lang) private var lang + @Environment(\.colorScheme) private var scheme + let cells: [[Double]] // 7 rows × N week cols; -1 = outside window (blank) + + // GitHub shows only every other weekday to fit the gutter — Mon / Wed / Fri / Sun. + private var weekdays: [String] { + lang.t("M T W T F S S", "一 二 三 四 五 六 日").split(separator: " ").map(String.init) + } + private let labelW: CGFloat = 14 + + private func level(_ v: Double) -> Color { + if v < 0 { return .clear } // outside the window → blank gap + if v < 0.06 { return dc.track } // in-window day with no activity + let light = ["#bcd2f7", "#7da9ef", "#4a86e8", "#2f6ad0"] + let dark = ["#1d3a63", "#2b5aa0", "#3f7bd6", "#5b9bff"] + let pal = scheme == .dark ? dark : light + let i = v < 0.30 ? 0 : (v < 0.55 ? 1 : (v < 0.80 ? 2 : 3)) + return Color(hex: pal[i]) + } + + var body: some View { + let cols = cells.first?.count ?? 0 + VStack(spacing: 3) { + ForEach(0.. Double { metric == .cost ? r.cost : Double(r.tokens) } + private var sorted: [AttributionRow] { rows.sorted { val($0) > val($1) } } + private var shown: [AttributionRow] { expanded ? sorted : Array(sorted.prefix(cap)) } + + var body: some View { + if !rows.isEmpty { + VStack(alignment: .leading, spacing: 7) { + Text(title).font(.system(size: 9.5)).foregroundStyle(dc.fg3) + ForEach(shown) { row($0) } + if sorted.count > cap { + Button { expanded.toggle() } label: { + Text(expanded ? lang.t("Collapse", "收起") + : lang.t("… \(sorted.count - cap) more", "… 还有 \(sorted.count - cap) 项")) + .font(.system(size: 10, weight: .medium)).foregroundStyle(dc.accent) + } + .buttonStyle(.plain).focusEffectDisabled() + } + } + } + } + + private func row(_ r: AttributionRow) -> some View { + let share = total > 0 ? val(r) / total : 0 + let pct = share * 100 + let pctText = (pct > 0 && pct < 0.5) ? "<1%" : "\(Int(pct.rounded()))%" + return HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 2).fill(dot).frame(width: 6, height: 6) + Text(r.name).font(.system(size: 11)).foregroundStyle(dc.fg) + .lineLimit(1).truncationMode(.tail) + Spacer(minLength: 6) + Text(metric == .cost ? Panel.usd(r.cost) : Panel.tok(r.tokens)) + .font(.system(size: 9.5)).monospacedDigit().foregroundStyle(dc.fg3) + Text(pctText).font(.system(size: 11, weight: .semibold)).monospacedDigit() + .foregroundStyle(dc.fg).frame(width: 36, alignment: .trailing) + } + } +} + // MARK: - Wrapping flow layout (legend chips) struct DCFlow: Layout { diff --git a/Sources/CodingBar/Views/Panel/PanelTabs.swift b/Sources/CodingBar/Views/Panel/PanelTabs.swift index 6859623..8395ba8 100644 --- a/Sources/CodingBar/Views/Panel/PanelTabs.swift +++ b/Sources/CodingBar/Views/Panel/PanelTabs.swift @@ -478,9 +478,136 @@ struct CostTab: View { } } } + contextSection + attributionSection } } + // MARK: 用量归因 — Skills / Subagents / Plugins / MCP servers (`/usage` "% of usage"). + // Claude-only (Codex carries no attribution tags); follows the range pill + metric. + + @ViewBuilder + private var attributionSection: some View { + let a = ov.attribution + let total = metric == .cost ? a.totalCost : Double(a.totalTokens) + if !a.isEmpty && total > 0 { + DCSection { + VStack(alignment: .leading, spacing: 15) { + HStack { + DCLabel(lang.t("Usage attribution", "用量归因")) + Spacer() + Text(lang.t("Claude · approx", "Claude · 近似")).font(.system(size: 9.5)).foregroundStyle(dc.fg3) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(RoundedRectangle(cornerRadius: 5).fill(dc.hover)) + } + AttributionTable(title: lang.t("Skills · % of usage", "Skill · 占用量"), + rows: a.skills, metric: metric, total: total, dot: dc.accent) + AttributionTable(title: lang.t("Subagents · % of usage", "子代理 · 占用量"), + rows: a.subagents, metric: metric, total: total, dot: dc.good) + AttributionTable(title: lang.t("Plugins · % of usage", "插件 · 占用量"), + rows: a.plugins, metric: metric, total: total, dot: dc.warn) + AttributionTable(title: lang.t("MCP servers · % of usage", "MCP 服务 · 占用量"), + rows: a.mcpServers, metric: metric, total: total, dot: dc.codex) + } + } + } + } + + // MARK: 按上下文体量 — the `/usage` "what's contributing to your usage" lens. + // Claude-only (Codex tokens are deltas, not absolute context); follows the range pill + // and the cost/tokens metric like the rest of this tab. + + private func ctxMetric(_ b: ContextBucket) -> Double { metric == .cost ? b.cost : Double(b.tokens) } + private var ctxTotal: Double { max(metric == .cost ? ov.contextSpend.totalCost : Double(ov.contextSpend.totalTokens), 1e-9) } + + @ViewBuilder + private var contextSection: some View { + let cs = ov.contextSpend + let total = metric == .cost ? cs.totalCost : Double(cs.totalTokens) + if total > 0 { + DCSection { + VStack(alignment: .leading, spacing: 0) { + HStack { + DCLabel(metric == .cost ? lang.t("By context size · cost", "按上下文体量 · 花费") + : lang.t("By context size · tokens", "按上下文体量 · Token")) + Spacer() + Text(lang.t("Claude · approx", "Claude · 近似")).font(.system(size: 9.5)).foregroundStyle(dc.fg3) + .padding(.horizontal, 6).padding(.vertical, 1) + .background(RoundedRectangle(cornerRadius: 5).fill(dc.hover)) + } + .padding(.bottom, 11) + + // Stacked share bar (small → mid → large), colored by severity. + GeometryReader { g in + HStack(spacing: 1) { + seg(ctxMetric(cs.small), dc.good, g.size.width) + seg(ctxMetric(cs.mid), dc.warn, g.size.width) + seg(ctxMetric(cs.large), dc.bad, g.size.width) + } + } + .frame(height: 8).clipShape(RoundedRectangle(cornerRadius: 4)) + .padding(.bottom, 12) + + VStack(alignment: .leading, spacing: 9) { + ctxRow(lang.t("≤50k ctx", "≤5万 上下文"), cs.small, dc.good) + ctxRow(lang.t("50–150k ctx", "5–15万 上下文"), cs.mid, dc.warn) + ctxRow(lang.t(">150k ctx", ">15万 上下文"), cs.large, dc.bad) + } + + // Actionable callout only when large-context spend is non-trivial. + let largeShare = ctxMetric(cs.large) / ctxTotal + if largeShare >= 0.10 { contextTip(largeShare).padding(.top, 12) } + } + } + } + } + + private func seg(_ v: Double, _ color: Color, _ width: CGFloat) -> some View { + Rectangle().fill(color).frame(width: width * (v / ctxTotal)) + } + + private func ctxRow(_ label: String, _ b: ContextBucket, _ color: Color) -> some View { + let share = ctxMetric(b) / ctxTotal + return HStack(spacing: 9) { + RoundedRectangle(cornerRadius: 2).fill(color).frame(width: 7, height: 7) + Text(label).font(.system(size: 11, weight: .medium)).foregroundStyle(dc.fg) + .frame(width: 86, alignment: .leading) + GeometryReader { g in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2).fill(dc.track) + RoundedRectangle(cornerRadius: 2).fill(color.opacity(0.55)).frame(width: g.size.width * share) + } + } + .frame(height: 4) + Text(metric == .cost ? Panel.usd(b.cost) : Panel.tok(b.tokens)) + .font(.system(size: 11, weight: .semibold)).monospacedDigit().foregroundStyle(dc.fg) + .frame(width: 56, alignment: .trailing) + Text("\(Int((share * 100).rounded()))%").font(.system(size: 10.5)).monospacedDigit() + .foregroundStyle(dc.fg3).frame(width: 32, alignment: .trailing) + } + } + + private func contextTip(_ share: Double) -> some View { + let pct = Int((share * 100).rounded()) + let text = metric == .cost + ? lang.t("\(pct)% of spend came from >150k-context turns — /compact mid-task, /clear when switching tasks.", + "\(pct)% 的花费来自 >15万 上下文的会话 —— 任务中途 /compact、换任务时 /clear 可省。") + : lang.t("\(pct)% of tokens came from >150k-context turns — /compact mid-task, /clear when switching tasks.", + "\(pct)% 的 Token 来自 >15万 上下文的会话 —— 任务中途 /compact、换任务时 /clear 可省。") + return HStack(alignment: .top, spacing: 9) { + Text("◔").font(.system(size: 11, weight: .bold)).foregroundStyle(dc.warn) + .frame(width: 18, height: 18) + .background(RoundedRectangle(cornerRadius: 5).fill(dc.warn.opacity(0.16))) + Text(text).font(.system(size: 11)).foregroundStyle(dc.fg) + .lineSpacing(3).fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(.horizontal, 11).padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(RoundedRectangle(cornerRadius: 9).fill(dc.warnBg)) + .overlay(RoundedRectangle(cornerRadius: 9).stroke(dc.warnBorder, lineWidth: 0.5)) + } + // Empty-state copy needs its own English wording: "No spend in Today" / "in Last 7d" // are ungrammatical, so it diverges from the range label used in the section header. private var emptyHintText: String { @@ -589,14 +716,90 @@ struct InsightsTab: View { private var ov: Overview { snap.overviews.first { $0.range == range } ?? snap.overview } private var rangeLabel: String { switch range { case .today: lang.t("Today", "今日"); case .week: lang.t("Last 7d", "近 7 天"); case .month: lang.t("Last 30d", "近 30 天") } } + private var p: ProfileStats { snap.profile } + var body: some View { VStack(spacing: 0) { + profileBlock codeOutput habitsBlock coachBlock } } + // MARK: 档案 — all-time stat-card grid + contribution calendar (Claude-Desktop-style) + + private var profileBlock: some View { + DCSection { + VStack(alignment: .leading, spacing: 0) { + HStack { + DCLabel(lang.t("Profile", "档案")); Spacer() + Text(lang.t("all-time", "累计")).font(.system(size: 10)).foregroundStyle(dc.fg3) + } + .padding(.bottom, 10) + + DCStatGrid(items: statItems) + + HStack { + Text(lang.t("Activity · last 90d", "活跃日历 · 近 90 天")).font(.system(size: 9.5)).foregroundStyle(dc.fg3) + Spacer() + if p.peakHour >= 0 { + Text(lang.t("Peak \(peakHourLabel)", "高峰 \(peakHourLabel)")).font(.system(size: 9.5)).foregroundStyle(dc.fg2) + } + } + .padding(.top, 14).padding(.bottom, 7) + DCContribCalendar(cells: p.calendar) + + if let fun = funFact { + HStack(alignment: .top, spacing: 6) { + Text("✦").font(.system(size: 10)).foregroundStyle(dc.accent) + Text(fun).font(.system(size: 10.5)).foregroundStyle(dc.fg2) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 11) + } + } + } + } + + private var statItems: [(label: String, value: String)] { + [ + (lang.t("Sessions", "会话"), Panel.int(p.sessions)), + (lang.t("Messages", "消息"), Panel.int(p.messages)), + (lang.t("Total tokens", "总 Token"), Panel.tok(p.totalTokens)), + (lang.t("Active days", "活跃天数"), Panel.int(p.activeDays)), + (lang.t("Current streak", "当前连续"), streakText(p.currentStreak)), + (lang.t("Longest streak", "最长连续"), streakText(p.longestStreak)), + (lang.t("Peak hour", "高峰时段"), p.peakHour >= 0 ? peakHourLabel : "—"), + (lang.t("Favorite model", "最爱模型"), favoriteModelText), + ] + } + + private func streakText(_ n: Int) -> String { lang.t("\(n)d", "\(n) 天") } + + // EN reads 12-hour (4 PM); ZH reads 24-hour (16:00), matching each locale's habit. + private var peakHourLabel: String { + let h = p.peakHour + guard h >= 0 else { return "—" } + let h12 = h % 12 == 0 ? 12 : h % 12 + return lang.t("\(h12) \(h < 12 ? "AM" : "PM")", String(format: "%02d:00", h)) + } + + private var favoriteModelText: String { + p.favoriteModel.isEmpty ? "—" : Pricing.displayName(forCanonicalKey: p.favoriteModel) + } + + // Flourish from Claude Desktop's Overview. ~100K tokens ≈ the text of the first + // Harry Potter (~77K words). Purely cosmetic, computed from the all-time total. + private var funFact: String? { + let t = p.totalTokens + guard t > 0 else { return nil } + let books = Double(t) / 100_000.0 + let mult = books >= 1 ? Panel.int(Int(books.rounded())) : String(format: "%.1f", books) + return lang.t("You've burned ~\(mult)× the text of Harry Potter and the Philosopher's Stone.", + "你已经烧掉约 \(mult) 本《哈利·波特与魔法石》的文字量。") + } + private var codeOutput: some View { DCSection { VStack(alignment: .leading, spacing: 0) { @@ -621,13 +824,6 @@ struct InsightsTab: View { VStack(alignment: .leading, spacing: 0) { Text(lang.t("Tool usage mix", "工具使用占比")).font(.system(size: 9.5)).foregroundStyle(dc.fg3).padding(.bottom, 5) DCToolMix(mix: snap.habits.toolMix) - HStack { - Text(lang.t("Activity heatmap · 7d", "活跃热力 · 7 天")).font(.system(size: 9.5)).foregroundStyle(dc.fg3) - Spacer() - Text(lang.t("Peak \(snap.habits.heatmap.peakLabel)", "高峰 \(snap.habits.heatmap.peakLabel)")).font(.system(size: 9.5)).foregroundStyle(dc.fg2) - } - .padding(.top, 13).padding(.bottom, 6) - DCHeatGrid(cells: snap.habits.heatmap.cells) } } } diff --git a/Sources/CodingBarCore/Aggregator.swift b/Sources/CodingBarCore/Aggregator.swift index 6d960e4..f708e97 100644 --- a/Sources/CodingBarCore/Aggregator.swift +++ b/Sources/CodingBarCore/Aggregator.swift @@ -10,9 +10,13 @@ public enum Aggregator { language: AppLanguage = .en) -> Snapshot { let cal = Calendar.current + // One shared Scanner so the on-disk cache is loaded (and saved) once per run, + // not once per provider — the cache decode is one of the heaviest steps in + // boot-time memory because it goes through `JSONSerialization`. + let scanner = Scanner() // token/cost/behavior aggregation is 100% local - let (claudeRecords, _) = ClaudeScanner.scan() - let codexRecords = CodexScanner.scan() + let (claudeRecords, _) = ClaudeScanner.scan(scanner: scanner) + let codexRecords = CodexScanner.scan(scanner: scanner) let allRecords = claudeRecords + codexRecords let todayStart = cal.startOfDay(for: now) @@ -146,6 +150,48 @@ public enum Aggregator { return (models, projects) } + // Claude spend by context-window size (the `/usage` "what's contributing" lens). + // Codex is excluded: its per-record tokens are deltas of a running total, not the + // absolute prompt size, so they can't be bucketed by context. Range-aware via the + // record set passed in (each overview supplies its own range's records). + func contextAttribution(from records: [RawRecord]) -> ContextAttribution { + var small = ContextBucket(), mid = ContextBucket(), large = ContextBucket() + for r in records where r.provider == .claude { + // A Claude `usage` block is the request's absolute prompt size. + let ctx = r.tokens.input + r.tokens.cacheRead + r.tokens.cacheWrite + let b = ContextBucket(cost: Pricing.cost(model: r.model, tokens: r.tokens), tokens: r.tokens.total) + if ctx > ContextAttribution.largeThreshold { large = large + b } + else if ctx > ContextAttribution.midThreshold { mid = mid + b } + else { small = small + b } + } + return ContextAttribution(small: small, mid: mid, large: large) + } + + // Claude usage attributed to skills / subagents / plugins / MCP servers — the + // `/usage` "what's contributing" tables. Each turn already carries Claude Code's + // own `attribution*` tags, so this is an exact group-and-sum (no inference). Codex + // is excluded (no such tags). `totalCost/Tokens` is the Claude total = the "% of + // usage" denominator, so a category's share is its cost / all Claude spend. + func usageAttribution(from records: [RawRecord]) -> UsageAttribution { + var skill: [String: ContextBucket] = [:], agent: [String: ContextBucket] = [:] + var plugin: [String: ContextBucket] = [:], mcp: [String: ContextBucket] = [:] + var totalCost = 0.0, totalTokens = 0 + for r in records where r.provider == .claude { + let b = ContextBucket(cost: Pricing.cost(model: r.model, tokens: r.tokens), tokens: r.tokens.total) + totalCost += b.cost; totalTokens += b.tokens + if let s = r.attribution.skill { skill[s] = (skill[s] ?? .init()) + b } + if let a = r.attribution.agent { agent[a] = (agent[a] ?? .init()) + b } + if let p = r.attribution.plugin { plugin[p] = (plugin[p] ?? .init()) + b } + if let m = r.attribution.mcpServer { mcp[m] = (mcp[m] ?? .init()) + b } + } + func rows(_ map: [String: ContextBucket]) -> [AttributionRow] { + map.map { AttributionRow(name: $0.key, cost: $0.value.cost, tokens: $0.value.tokens) } + .sorted { $0.cost > $1.cost } + } + return UsageAttribution(skills: rows(skill), subagents: rows(agent), plugins: rows(plugin), + mcpServers: rows(mcp), totalCost: totalCost, totalTokens: totalTokens) + } + let (models, projects) = breakdown(from: allRecords) // cache stats are Claude only @@ -190,6 +236,9 @@ public enum Aggregator { // Pillar ③ — Habits (tool mix, rhythm, heatmap) let habits = Behavior.build(from: allRecords, todayStart: todayStart, now: now) + // Insights profile — all-time stat-card grid + contribution calendar (offline). + let profile = ProfileBuilder.build(from: allRecords, now: now) + // Pillar ② — Fuel gauge + active/throughput let (fuelGauge, isActive, throughput) = FuelCalculator.build(from: claudeRecords, now: now) @@ -225,6 +274,8 @@ public enum Aggregator { let deltaTok: Double? = prevTok > 0 ? Double(s.tokens.total - prevTok) / Double(prevTok) * 100 : nil let rangeRecords = allRecords.filter { $0.timestamp >= start && $0.timestamp <= now } let bd = breakdown(from: rangeRecords) + let ctxAttr = contextAttribution(from: rangeRecords) + let usageAttr = usageAttribution(from: rangeRecords) let trend: [DayPoint] switch range { case .today: trend = trendSeries(hourBucketsToday()) @@ -239,7 +290,9 @@ public enum Aggregator { deltaTokensPct: deltaTok, trend: trend, models: bd.models, - projects: bd.projects + projects: bd.projects, + contextSpend: ctxAttr, + attribution: usageAttr ) } let overviewToday = makeOverview(.today, start: todayStart, output: gitRanges.today) @@ -264,7 +317,8 @@ public enum Aggregator { liveSessions: liveSessions, burnPerMin: burnPerMin, quotaForecast: quotaForecast, - quotaFetchedAt: quota.isEmpty ? nil : now + quotaFetchedAt: quota.isEmpty ? nil : now, + profile: profile ) } } diff --git a/Sources/CodingBarCore/ClaudeScanner.swift b/Sources/CodingBarCore/ClaudeScanner.swift index 5f02764..aaf7f33 100644 --- a/Sources/CodingBarCore/ClaudeScanner.swift +++ b/Sources/CodingBarCore/ClaudeScanner.swift @@ -2,7 +2,12 @@ import Foundation public enum ClaudeScanner { - static func scan() -> (records: [RawRecord], seenIds: Set) { + /// Accepts a pre-created `Scanner` so the same on-disk cache is loaded ONCE per + /// Aggregator.run() and shared with the Codex scan, instead of each provider + /// instantiating its own Scanner and re-decoding the same cache file twice (a real + /// peak-memory cost — the cache decode goes through `JSONSerialization`, which keeps + /// a full NSDictionary tree alive alongside the Swift struct). + static func scan(scanner: Scanner) -> (records: [RawRecord], seenIds: Set) { let home = FileManager.default.homeDirectoryForCurrentUser let projectsDir = home .appendingPathComponent(".claude") @@ -12,7 +17,6 @@ public enum ClaudeScanner { return ([], []) } - let scanner = Scanner() var seenIds = Set() var allRecords: [RawRecord] = [] @@ -43,7 +47,13 @@ public enum ClaudeScanner { var records: [RawRecord] = [] + // Drain JSONSerialization's autoreleased NSDictionary/NSString tree per line. + // Without this, every parsed line's NSObject tree piles up in the pool until the + // run loop drains it — and Aggregator.run() runs in a detached Task with no + // natural drain point, so a single big file would inflate RSS by hundreds of MB + // of "virtual" autoreleased intermediates before any of them are reclaimed. data.forEachLine { line in + autoreleasepool { let trimmed = line.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, let lineData = trimmed.data(using: .utf8), @@ -87,6 +97,21 @@ public enum ClaudeScanner { } let toolName = allToolNames.first + // Usage attribution: Claude Code tags each assistant line (top-level, alongside + // `usage`) with what drove the turn — a skill, subagent, plugin, or MCP server. + // These power the `/usage`-style "what's contributing" breakdowns. Absent on + // plain coding turns, so most records carry an empty Attribution. + func attr(_ key: String) -> String? { + guard let v = obj[key] as? String, !v.isEmpty else { return nil } + return v + } + let attribution = Attribution( + skill: attr("attributionSkill"), + agent: attr("attributionAgent"), + plugin: attr("attributionPlugin"), + mcpServer: attr("attributionMcpServer") + ) + let tokens = TokenBreakdown( input: inputTokens, output: outputTokens, @@ -105,9 +130,11 @@ public enum ClaudeScanner { toolNames: allToolNames, messageId: messageId, sessionKey: sessionKey, - hasInterrupt: trimmed.contains("[Request interrupted") + hasInterrupt: trimmed.contains("[Request interrupted"), + attribution: attribution ) records.append(record) + } } return records diff --git a/Sources/CodingBarCore/CodexScanner.swift b/Sources/CodingBarCore/CodexScanner.swift index d6bb816..9a6dc3f 100644 --- a/Sources/CodingBarCore/CodexScanner.swift +++ b/Sources/CodingBarCore/CodexScanner.swift @@ -5,7 +5,9 @@ public enum CodexScanner { /// Token usage records only. Codex *quota* now comes from the live usage API /// (see `CodexQuotaFetcher`), not from the `rate_limits` snapshots embedded in /// the rollout logs, so this no longer does the second rate-limit pass. - static func scan() -> [RawRecord] { + /// Accepts a pre-created `Scanner` shared with `ClaudeScanner` so the on-disk cache + /// is loaded once per Aggregator.run() — see ClaudeScanner.scan for the rationale. + static func scan(scanner: Scanner) -> [RawRecord] { let home = FileManager.default.homeDirectoryForCurrentUser let sessionsDir = home .appendingPathComponent(".codex") @@ -15,7 +17,6 @@ public enum CodexScanner { return [] } - let scanner = Scanner() return scanner.scan(directory: sessionsDir) { fileURL in parseFile(fileURL) } @@ -47,7 +48,11 @@ public enum CodexScanner { // next emitted record so the habits tool-mix counts Codex, not just Claude. var pendingTools: [String] = [] + // Drain JSONSerialization's autoreleased NSDictionary/NSString tree per line — + // see ClaudeScanner for the rationale. Rollouts are commonly the largest jsonl + // files in the corpus, so this is even more important here than for Claude. data.forEachLine { line in + autoreleasepool { let trimmed = line.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, let lineData = trimmed.data(using: .utf8), @@ -154,6 +159,7 @@ public enum CodexScanner { default: break } + } } return records diff --git a/Sources/CodingBarCore/Models.swift b/Sources/CodingBarCore/Models.swift index 764c5f6..66fb6a7 100644 --- a/Sources/CodingBarCore/Models.swift +++ b/Sources/CodingBarCore/Models.swift @@ -174,6 +174,39 @@ public struct Heatmap: Codable, Sendable { public init(cells: [[Double]] = [], peakLabel: String = "") { self.cells = cells; self.peakLabel = peakLabel } } +/// All-time "profile" stats — the Insights tab's stat-card grid + GitHub-style +/// contribution calendar, modeled on Claude Desktop's Overview. Deliberately +/// independent of the panel's today/week/month range pill: streaks, active days and +/// the favorite model only read meaningfully over a lifetime window. Everything here +/// is still derived 100% locally from the same scanned `RawRecord`s — no new source. +public struct ProfileStats: Codable, Sendable, Equatable { + public var sessions: Int // distinct session log files, all-time + public var messages: Int // assistant turns (one RawRecord ≈ one reply) + public var totalTokens: Int // all-time total across every provider + public var activeDays: Int // distinct calendar days with any activity + public var currentStreak: Int // consecutive active days ending today (or yesterday) + public var longestStreak: Int // longest run of consecutive active days, all-time + public var peakHour: Int // 0...23 hour-of-day with the most tokens; -1 = no data + public var favoriteModel: String // canonical pricing key of the most-used model; "" = none + public var favoriteModelProvider: Provider + /// 7 rows (Mon…Sun) × N week columns of normalized 0...1 daily intensity. A cell is + /// `-1` for days outside the window (before the first column / future days this week) + /// so the view can render those as blank gaps rather than zero-intensity squares. + public var calendar: [[Double]] + + public init(sessions: Int = 0, messages: Int = 0, totalTokens: Int = 0, activeDays: Int = 0, + currentStreak: Int = 0, longestStreak: Int = 0, peakHour: Int = -1, + favoriteModel: String = "", favoriteModelProvider: Provider = .claude, + calendar: [[Double]] = []) { + self.sessions = sessions; self.messages = messages; self.totalTokens = totalTokens + self.activeDays = activeDays; self.currentStreak = currentStreak; self.longestStreak = longestStreak + self.peakHour = peakHour; self.favoriteModel = favoriteModel + self.favoriteModelProvider = favoriteModelProvider; self.calendar = calendar + } + + public static let empty = ProfileStats() +} + /// Pillar ①: output correlated from local git. public struct OutputStat: Codable, Sendable { public var added: Int @@ -194,6 +227,68 @@ public struct PeriodTotals: Codable, Sendable { } } +/// Spend + tokens for one context-window size band. +public struct ContextBucket: Codable, Sendable, Equatable { + public var cost: Double + public var tokens: Int + public init(cost: Double = 0, tokens: Int = 0) { self.cost = cost; self.tokens = tokens } + public static func + (l: ContextBucket, r: ContextBucket) -> ContextBucket { + ContextBucket(cost: l.cost + r.cost, tokens: l.tokens + r.tokens) + } +} + +/// Claude spend grouped by the prompt (context-window) size at each turn — the +/// "what's contributing to your usage" lens from Claude Code's `/usage`. Buckets by total +/// prompt tokens: small ≤50k, mid 50–150k, large >150k. Range-aware (each Overview +/// computes its own, so it follows the panel's range pill). **Claude-only**: a Claude +/// `usage` block is the request's absolute prompt size (input + cache_read + +/// cache_creation), which IS the context; Codex records are per-turn deltas of a running +/// total, so their per-record tokens aren't an absolute context measure — they're excluded. +public struct ContextAttribution: Codable, Sendable, Equatable { + public var small: ContextBucket + public var mid: ContextBucket + public var large: ContextBucket + public init(small: ContextBucket = .init(), mid: ContextBucket = .init(), large: ContextBucket = .init()) { + self.small = small; self.mid = mid; self.large = large + } + public var totalCost: Double { small.cost + mid.cost + large.cost } + public var totalTokens: Int { small.tokens + mid.tokens + large.tokens } + + /// Bucket lower bounds (prompt tokens). A turn lands in `mid` above `midThreshold` + /// and in `large` above `largeThreshold`; the UI labels the bands from these. + public static let midThreshold = 50_000 + public static let largeThreshold = 150_000 +} + +/// One named consumer in a usage-attribution table (e.g. the `hunt` skill). +public struct AttributionRow: Codable, Sendable, Equatable, Identifiable { + public var id: String { name } + public var name: String + public var cost: Double + public var tokens: Int + public init(name: String, cost: Double, tokens: Int) { self.name = name; self.cost = cost; self.tokens = tokens } +} + +/// Claude spend attributed to what drove each turn — the `/usage` "what's contributing to +/// your usage" tables. Each list is a share of the **Claude total** (`totalCost`/ +/// `totalTokens`), so percentages are independent and don't sum to 100 (most turns carry +/// no attribution at all). Range-aware; **Claude-only** (Codex logs lack these tags). +public struct UsageAttribution: Codable, Sendable, Equatable { + public var skills: [AttributionRow] + public var subagents: [AttributionRow] + public var plugins: [AttributionRow] + public var mcpServers: [AttributionRow] + public var totalCost: Double // Claude total in range — the "% of usage" denominator + public var totalTokens: Int + public init(skills: [AttributionRow] = [], subagents: [AttributionRow] = [], + plugins: [AttributionRow] = [], mcpServers: [AttributionRow] = [], + totalCost: Double = 0, totalTokens: Int = 0) { + self.skills = skills; self.subagents = subagents; self.plugins = plugins + self.mcpServers = mcpServers; self.totalCost = totalCost; self.totalTokens = totalTokens + } + public var isEmpty: Bool { skills.isEmpty && subagents.isEmpty && plugins.isEmpty && mcpServers.isEmpty } +} + public enum Range: String, Codable, Sendable, CaseIterable { case today, week, month } public struct Overview: Codable, Sendable { @@ -211,12 +306,18 @@ public struct Overview: Codable, Sendable { /// instead of always showing all-time totals. public var models: [ModelStat] public var projects: [ProjectStat] + /// Spend grouped by context-window size at each Claude turn (Claude-only, range-aware). + public var contextSpend: ContextAttribution + /// Spend attributed to skills / subagents / plugins / MCP servers (Claude-only, range-aware). + public var attribution: UsageAttribution public init(range: Range, spend: PeriodTotals, output: OutputStat, deltaVsPrevPct: Double?, deltaTokensPct: Double? = nil, trend: [DayPoint], - models: [ModelStat] = [], projects: [ProjectStat] = []) { + models: [ModelStat] = [], projects: [ProjectStat] = [], + contextSpend: ContextAttribution = .init(), attribution: UsageAttribution = .init()) { self.range = range; self.spend = spend; self.output = output self.deltaVsPrevPct = deltaVsPrevPct; self.deltaTokensPct = deltaTokensPct; self.trend = trend - self.models = models; self.projects = projects + self.models = models; self.projects = projects; self.contextSpend = contextSpend + self.attribution = attribution } /// Period-over-period change for the given display metric (nil = hide pill). @@ -264,6 +365,8 @@ public struct Snapshot: Codable, Sendable { /// instantly and consistently. The UI picks by the user's selected range. public var overviews: [Overview] public var habits: Habits + /// All-time profile stats (Insights tab stat-card grid + contribution calendar). + public var profile: ProfileStats public var projects: [ProjectStat] public var models: [ModelStat] public var cache: CacheStat @@ -291,8 +394,9 @@ public struct Snapshot: Codable, Sendable { quotaNotes: [String] = [], overviews: [Overview] = [], liveSessions: [LiveSession] = [], burnPerMin: Double = 0, quotaForecast: [String: String] = [:], quotaFetchedAt: Date? = nil, - quotaFetchedByProvider: [String: Date] = [:]) { + quotaFetchedByProvider: [String: Date] = [:], profile: ProfileStats = .empty) { self.generatedAt = generatedAt; self.menu = menu; self.overview = overview; self.habits = habits + self.profile = profile self.projects = projects; self.models = models; self.cache = cache; self.quota = quota self.coach = coach; self.fuel = fuel; self.quotaNotes = quotaNotes self.overviews = overviews.isEmpty ? [overview] : overviews diff --git a/Sources/CodingBarCore/Profile.swift b/Sources/CodingBarCore/Profile.swift new file mode 100644 index 0000000..202f014 --- /dev/null +++ b/Sources/CodingBarCore/Profile.swift @@ -0,0 +1,126 @@ +import Foundation + +/// All-time profile stats for the Insights tab — the Claude-Desktop-style stat-card +/// grid plus a GitHub contribution calendar. Every value is derived locally from the +/// already-scanned `RawRecord`s; this adds no new data source and no network path. +enum ProfileBuilder { + + /// Week columns in the contribution calendar. 13 weeks ≈ 90 days keeps the squares + /// legible inside the 340pt popover instead of shrinking a full year to dust. + static let calendarWeeks = 13 + + static func build(from records: [RawRecord], now: Date) -> ProfileStats { + guard !records.isEmpty else { return .empty } + let cal = Calendar.current + + var sessionKeys = Set() + // Messages = assistant turns. One RawRecord is one reply; dedup by (file, messageId) + // so a streamed/replayed line can't inflate the count. Records lacking an id (Codex + // turns are keyed by token_count events, not message ids) each count once. + var seenMessages = Set() + var messagesWithoutId = 0 + var totalTokens = 0 + var activeDaySet = Set() + var hourTokens = [Int](repeating: 0, count: 24) + // Favorite = most-frequently used model, not most tokens, so one heavy session + // doesn't crown a model the user rarely picks. + var modelCounts: [String: Int] = [:] + var dayTokens: [Date: Int] = [:] + + for r in records { + sessionKeys.insert(r.sessionKey) + if let mid = r.messageId, !mid.isEmpty { + seenMessages.insert(r.sessionKey + "·" + mid) + } else { + messagesWithoutId += 1 + } + totalTokens += r.tokens.total + let day = cal.startOfDay(for: r.timestamp) + activeDaySet.insert(day) + dayTokens[day, default: 0] += r.tokens.total + hourTokens[cal.component(.hour, from: r.timestamp)] += r.tokens.total + modelCounts[Pricing.normalize(model: r.model), default: 0] += 1 + } + + let peakHour: Int = { + guard let mx = hourTokens.max(), mx > 0 else { return -1 } + return hourTokens.firstIndex(of: mx) ?? -1 + }() + + // Stable on ties: most uses wins, then the lexicographically smaller key. + let favorite = modelCounts.max { + $0.value != $1.value ? $0.value < $1.value : $0.key > $1.key + }?.key ?? "" + let favoriteProvider = favorite.isEmpty ? Provider.claude : Pricing.provider(forCanonicalKey: favorite) + + let (current, longest) = streaks(activeDays: activeDaySet, now: now, cal: cal) + let calendar = contributionCalendar(dayTokens: dayTokens, now: now, cal: cal) + + return ProfileStats( + sessions: sessionKeys.count, + messages: seenMessages.count + messagesWithoutId, + totalTokens: totalTokens, + activeDays: activeDaySet.count, + currentStreak: current, + longestStreak: longest, + peakHour: peakHour, + favoriteModel: favorite, + favoriteModelProvider: favoriteProvider, + calendar: calendar + ) + } + + /// (currentStreak, longestStreak) over a set of active day-starts. The current + /// streak counts back from today; if today is inactive but yesterday is active it + /// anchors on yesterday, so the streak doesn't read 0 first thing in the morning. + private static func streaks(activeDays: Set, now: Date, cal: Calendar) -> (Int, Int) { + guard !activeDays.isEmpty else { return (0, 0) } + let sorted = activeDays.sorted() + + var longest = 1, run = 1 + for i in 1.. [[Double]] { + let today = cal.startOfDay(for: now) + // Monday of the current week. weekday Sun=1..Sat=7 → Mon-index 0..6. + let weekdayIdx = (cal.component(.weekday, from: today) + 5) % 7 + guard let thisMonday = cal.date(byAdding: .day, value: -weekdayIdx, to: today), + let startMonday = cal.date(byAdding: .day, value: -7 * (calendarWeeks - 1), to: thisMonday) else { + return [] + } + let maxVal = dayTokens.values.max() ?? 0 + var cells = [[Double]](repeating: [Double](repeating: -1, count: calendarWeeks), count: 7) + for col in 0.. today { continue } + let v = dayTokens[day] ?? 0 + cells[row][col] = maxVal > 0 ? min(1.0, Double(v) / Double(maxVal)) : 0 + } + } + return cells + } +} diff --git a/Sources/CodingBarCore/Sample.swift b/Sources/CodingBarCore/Sample.swift index 95c7e64..8333752 100644 --- a/Sources/CodingBarCore/Sample.swift +++ b/Sources/CodingBarCore/Sample.swift @@ -20,6 +20,16 @@ public extension Snapshot { return min(0.95, base + jit - 0.06) } } + // 7 rows (Mon…Sun) × 13 week columns. Recent weeks denser, weekends lighter, + // the tail of the current (last) week left blank (-1) to mirror real output. + let profileCal: [[Double]] = (0..<7).map { row in + (0..<13).map { col -> Double in + if col == 12 && row >= 4 { return -1 } // future days this week + let seed = Double((row * 31 + col * 17) % 11) / 11.0 + let wk = row >= 5 ? seed * 0.5 : seed + return col >= 9 ? min(1.0, wk + 0.25) : max(0, wk - 0.08) + } + } return Snapshot( generatedAt: now, menu: MenuSummary(metric: .tokens, primaryText: "1.2M", quotaPercent: 0.26, active: true, throughput: 1400), @@ -29,7 +39,23 @@ public extension Snapshot { output: OutputStat(added: 1240, removed: 180, commits: 3, files: 18), deltaVsPrevPct: 12, deltaTokensPct: 9, - trend: trend), + trend: trend, + contextSpend: ContextAttribution( + small: ContextBucket(cost: 0.52, tokens: 190_000), + mid: ContextBucket(cost: 1.28, tokens: 430_000), + large: ContextBucket(cost: 2.40, tokens: 640_000)), + attribution: UsageAttribution( + skills: [AttributionRow(name: "orchestration", cost: 0.42, tokens: 120_000), + AttributionRow(name: "superpowers:brainstorming", cost: 0.13, tokens: 36_000), + AttributionRow(name: "hunt", cost: 0.08, tokens: 22_000), + AttributionRow(name: "check", cost: 0.05, tokens: 14_000)], + subagents: [AttributionRow(name: "workflow-subagent", cost: 0.30, tokens: 88_000), + AttributionRow(name: "general-purpose", cost: 0.12, tokens: 34_000)], + plugins: [AttributionRow(name: "superpowers", cost: 0.21, tokens: 60_000), + AttributionRow(name: "skill-creator", cost: 0.05, tokens: 14_000)], + mcpServers: [AttributionRow(name: "playwright", cost: 0.29, tokens: 84_000), + AttributionRow(name: "happy", cost: 0.04, tokens: 11_000)], + totalCost: 4.20, totalTokens: 1_260_000)), habits: Habits( toolMix: ToolMix(write: 52, read: 28, run: 14, search: 6), rhythm: Rhythm(turnsPerSession: 11, avgMinutes: 22, interruptRate: 0.18), @@ -67,7 +93,12 @@ public extension Snapshot { "claude": "Claude weekly quota runs out Wed 15:12", "codex": "Codex weekly quota runs out tomorrow 08:30", ], - quotaFetchedAt: now.addingTimeInterval(-46) + quotaFetchedAt: now.addingTimeInterval(-46), + profile: ProfileStats( + sessions: 202, messages: 31_256, totalTokens: 66_800_000, activeDays: 28, + currentStreak: 22, longestStreak: 22, peakHour: 16, + favoriteModel: "anthropic/claude-opus-4-8", favoriteModelProvider: .claude, + calendar: profileCal) ) } } diff --git a/Sources/CodingBarCore/Scanner.swift b/Sources/CodingBarCore/Scanner.swift index 8ebd290..7b8fdd6 100644 --- a/Sources/CodingBarCore/Scanner.swift +++ b/Sources/CodingBarCore/Scanner.swift @@ -2,6 +2,18 @@ import Foundation // MARK: - RawRecord +/// What drove a turn, as tagged by Claude Code itself (the `attribution*` fields it +/// writes on each assistant line with usage). Claude-only — Codex logs carry none of +/// these, so every field is nil for Codex records. Drives the `/usage`-style "what's +/// contributing to your usage" breakdowns (Skills / Subagents / Plugins / MCP servers). +struct Attribution: Codable, Equatable { + var skill: String? + var agent: String? + var plugin: String? + var mcpServer: String? + var isEmpty: Bool { skill == nil && agent == nil && plugin == nil && mcpServer == nil } +} + struct RawRecord { var provider: Provider var model: String @@ -13,6 +25,7 @@ struct RawRecord { var messageId: String? var sessionKey: String // file path stem, used for session counting var hasInterrupt: Bool // true if the raw line contained "[Request interrupted" + var attribution = Attribution() // Claude-only usage attribution (nil-filled for Codex) } // MARK: - Robust log timestamp parsing @@ -57,8 +70,11 @@ final class Scanner { /// whenever a scanner's output for an unchanged file changes; a version mismatch is /// ignored (→ one full rescan with the new parser). v2: Codex switched from summing /// `last_token_usage` to the delta of `total_token_usage`. v3: Codex records now - /// carry tool names parsed from `function_call` items. - private static let cacheVersion = 3 + /// carry tool names parsed from `function_call` items. v4: Claude records now carry + /// the `attribution*` fields (skill / agent / plugin / MCP server). v5: cache moved + /// from JSON to binary property list (smaller, decodes without an NSDictionary tree + /// intermediate — see loadCache for the peak-memory rationale). + private static let cacheVersion = 5 private struct CacheFile: Codable { var version: Int @@ -81,6 +97,7 @@ final class Scanner { var messageId: String? var sessionKey: String var hasInterrupt: Bool + var attribution: Attribution? } // MARK: State @@ -151,7 +168,8 @@ final class Scanner { toolNames: cached.toolNames, messageId: cached.messageId, sessionKey: cached.sessionKey, - hasInterrupt: cached.hasInterrupt + hasInterrupt: cached.hasInterrupt, + attribution: cached.attribution ?? Attribution() ) } @@ -170,15 +188,25 @@ final class Scanner { toolNames: raw.toolNames, messageId: raw.messageId, sessionKey: raw.sessionKey, - hasInterrupt: raw.hasInterrupt + hasInterrupt: raw.hasInterrupt, + // Most turns carry no attribution (plain coding lines). Persist nil instead of + // `{skill:null,agent:null,plugin:null,mcpServer:null}` so the cache JSON omits + // the field entirely — ~50 bytes per empty record across tens of thousands. + attribution: raw.attribution.isEmpty ? nil : raw.attribution ) } // MARK: Persistence private func loadCache() { + // Binary property list, not JSON. JSONDecoder on Apple platforms goes through + // `JSONSerialization`, which first materializes a full NSDictionary/NSString tree + // of the whole file before walking it to construct the Swift struct — at peak + // both representations are alive (~140–180 MB for an 18 MB cache here). The + // binary plist decoder reads directly into the Swift struct: smaller file, no + // intermediate object tree, ~50% lower peak memory. guard let data = try? Data(contentsOf: cacheURL), - let decoded = try? JSONDecoder().decode(CacheFile.self, from: data), + let decoded = try? PropertyListDecoder().decode(CacheFile.self, from: data), decoded.version == Scanner.cacheVersion else { return // missing, unreadable, or stale-version cache → full rescan } @@ -189,7 +217,9 @@ final class Scanner { let dir = cacheURL.deletingLastPathComponent() try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let file = CacheFile(version: Scanner.cacheVersion, entries: cache) - guard let data = try? JSONEncoder().encode(file) else { return } + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary // default is .xml — bigger than JSON + guard let data = try? encoder.encode(file) else { return } try? data.write(to: cacheURL) } } diff --git a/Tests/CodingBarCoreTests/SmokeTests.swift b/Tests/CodingBarCoreTests/SmokeTests.swift index a96dcef..bdd2ddb 100644 --- a/Tests/CodingBarCoreTests/SmokeTests.swift +++ b/Tests/CodingBarCoreTests/SmokeTests.swift @@ -75,6 +75,49 @@ final class SmokeTests: XCTestCase { let back = try JSONDecoder().decode(Snapshot.self, from: data) XCTAssertEqual(back.overview.spend.sessions, 7) XCTAssertEqual(back.menu.primaryText, "1.2M") + // The additive ProfileStats field must round-trip too. + XCTAssertEqual(back.profile.sessions, 202) + XCTAssertEqual(back.profile.currentStreak, 22) + XCTAssertEqual(back.profile.calendar.count, 7) + } + + /// ProfileBuilder derives all-time stats from raw records: distinct sessions, + /// active days, current/longest streak (current anchors on today/yesterday), + /// peak hour by tokens, and the most-frequently-used model. + func testProfileBuilderStatsAndStreaks() { + let cal = Calendar.current + let now = cal.date(from: DateComponents(year: 2026, month: 3, day: 15, hour: 20))! // a Sunday + let base = cal.startOfDay(for: now) + func rec(_ dayOff: Int, _ hour: Int, _ model: String, _ session: String, _ tok: Int, _ mid: String) -> RawRecord { + let day = cal.date(byAdding: .day, value: dayOff, to: base)! + let ts = cal.date(byAdding: .hour, value: hour, to: day)! + return RawRecord(provider: .claude, model: model, timestamp: ts, cwd: "/p", + tokens: TokenBreakdown(input: tok), toolName: nil, toolNames: [], + messageId: mid, sessionKey: session, hasInterrupt: false) + } + let records = [ + // current run (today, -1, -2) → streak 3; peak hour 14 (900 tok vs 200) + rec(0, 14, "claude-opus-4-8", "s1", 500, "m1"), + rec(-1, 14, "claude-opus-4-8", "s1", 300, "m2"), + rec(-2, 14, "claude-opus-4-8", "s2", 100, "m3"), + // older 4-day run (-10…-13) → longest 4; one sonnet keeps opus the favorite + rec(-10, 9, "claude-opus-4-8", "s2", 50, "m4"), + rec(-11, 9, "claude-opus-4-8", "s3", 50, "m5"), + rec(-12, 9, "claude-sonnet-4-6", "s3", 50, "m6"), + rec(-13, 9, "claude-opus-4-8", "s3", 50, "m7"), + ] + let p = ProfileBuilder.build(from: records, now: now) + XCTAssertEqual(p.sessions, 3) + XCTAssertEqual(p.messages, 7) + XCTAssertEqual(p.activeDays, 7) + XCTAssertEqual(p.currentStreak, 3) + XCTAssertEqual(p.longestStreak, 4) + XCTAssertEqual(p.peakHour, 14) + XCTAssertEqual(p.favoriteModel, "anthropic/claude-opus-4-8") + XCTAssertEqual(p.favoriteModelProvider, .claude) + XCTAssertEqual(p.calendar.count, 7) + XCTAssertEqual(p.calendar.first?.count, ProfileBuilder.calendarWeeks) + XCTAssertEqual(p.calendar.flatMap { $0 }.max(), 1.0) // the busiest day normalizes to 1 } /// Codex `token_count` events are cumulative snapshots; replayed/duplicate events @@ -142,6 +185,41 @@ final class SmokeTests: XCTestCase { XCTAssertEqual(Behavior.bucket(toolName: "view_image"), \ToolMix.read) } + /// Claude tags each assistant line with `attribution*` fields (skill / agent / plugin / + /// MCP server) that drive the `/usage`-style breakdowns. The scanner must lift them onto + /// the record, and leave a plain turn's attribution empty. + func testClaudeScannerParsesUsageAttribution() throws { + func line(_ o: [String: Any]) -> String { String(data: try! JSONSerialization.data(withJSONObject: o), encoding: .utf8)! } + func asst(_ id: String, _ extra: [String: Any]) -> [String: Any] { + var o: [String: Any] = [ + "type": "assistant", "timestamp": "2026-06-18T13:00:00.000Z", "cwd": "/p", + "message": ["id": id, "model": "claude-opus-4-8", + "usage": ["input_tokens": 100, "output_tokens": 10, + "cache_read_input_tokens": 0, "cache_creation_input_tokens": 0], + "content": []], + ] + for (k, v) in extra { o[k] = v } + return o + } + let lines = [ + asst("m1", ["attributionSkill": "hunt", "attributionPlugin": "superpowers"]), + asst("m2", ["attributionMcpServer": "playwright", "attributionMcpTool": "browser_click"]), + asst("m3", ["attributionAgent": "general-purpose"]), + asst("m4", [:]), // plain turn → empty attribution + ].map(line) + let url = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).jsonl") + try lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8) + defer { try? FileManager.default.removeItem(at: url) } + + let recs = ClaudeScanner.parseFile(url) + XCTAssertEqual(recs.count, 4) + XCTAssertEqual(recs[0].attribution.skill, "hunt") + XCTAssertEqual(recs[0].attribution.plugin, "superpowers") + XCTAssertEqual(recs[1].attribution.mcpServer, "playwright") + XCTAssertEqual(recs[2].attribution.agent, "general-purpose") + XCTAssertTrue(recs[3].attribution.isEmpty) + } + /// A Codex session that switches model mid-stream (`/model`) must attribute each /// turn to the model in effect AT that turn, not freeze on the session's first one /// (the `model == "unknown"` guard used to ignore every later turn_context).