Compare commits

...

9 commits
v0.9.9 ... main

Author SHA1 Message Date
René Lachmann
3542407f8f
fix: handle # compound-path separator in fingerprintFile (#358)
Some checks are pending
CI / semgrep (push) Waiting to run
The Cursor provider encodes workspace context into source paths using a
`#cursor-ws=<tag>` suffix (e.g. `state.vscdb#cursor-ws=__orphan__`).
`fingerprintFile` only had a fallback for `:` separators (OpenCode
sessions), so Cursor sources silently returned null on macOS/Linux where
paths contain no colons, causing them to be skipped entirely.

Add a `#` fallback before the existing `:` check. The first `stat()`
on the full path still succeeds for real files containing `#`, so there
is no regression for legitimate paths.

Includes 4 new test cases covering both separators, the combined case,
and the null case for non-existent base files.
2026-05-19 04:21:17 -07:00
ozymandiashh
c9487e7b0a
Keep OpenCode router calls without usage (#342)
Some checks are pending
CI / semgrep (push) Waiting to run
2026-05-18 16:48:03 -07:00
Resham Joshi
06f69484f3
Fix one-shot rate detection for all non-Claude providers (#355)
Some checks are pending
CI / semgrep (push) Waiting to run
* Add CodeBurn Pro Mac App Store app

SwiftUI MenuBarExtra with litellm-snapshot pricing, Claude/Codex/Copilot
parsers, session discovery, auto-refresh timer, and dashboard UI matching
the real menubar design.

* Add appstore/ to .gitignore

Private Mac App Store build, not for the public repo.

* Fix one-shot rate detection for Gemini, Vibe, Kiro, and Goose

Gemini and Mistral Vibe now emit per-assistant-message calls grouped
by user turn via turnId, so the classifier sees Edit->Bash->Edit as
retries instead of independent one-shot turns. Kiro and Goose record
per-message tool ordering via toolSequence for the same effect on
aggregated sessions.

Vibe now prefers meta.json.stats.session_cost over price-derived
estimates. Session cache bumped to v2. Insight pill switcher scrolls
horizontally instead of wrapping.

Closes #351.

* Remove dead geminiOrdinal variable and add toolSequence cache validation

* Restore geminiOrdinal for idx fallback dedup key
2026-05-18 15:56:14 -07:00
Resham Joshi
7cea9efb31
Add Optimize tab, token display modes, daily budget alerts, and project drill-down (#349)
* Add CodeBurn Pro Mac App Store app

SwiftUI MenuBarExtra with litellm-snapshot pricing, Claude/Codex/Copilot
parsers, session discovery, auto-refresh timer, and dashboard UI matching
the real menubar design.

* Add appstore/ to .gitignore

Private Mac App Store build, not for the public repo.

* Add Optimize tab, token display modes, daily budget alerts, and project drill-down

- New Optimize insight tab with Retry Tax and Routing Waste computations
  (pure math from session data, no LLM required)
- Retry tax: shows money wasted on failed edit retries, per-model breakdown
- Routing waste: counterfactual savings vs cheapest reliable model, per-model
- Token display modes: Cost ($), Tokens (up/down split), Total Tokens
- Daily budget alert: configurable threshold, flame turns yellow when exceeded
- Project drill-down: click project rows to see per-session cost, tokens, models
- Period-aware top sessions: 30-day view now shows 30-day costliest sessions
- Friendly project names: show directory name instead of full path, Home for ~
- Session cache TTL bumped to 180s to prevent re-parsing on non-today periods
- Audit fixes: array mutation, percentage rounding, ForEach ID collision,
  baseline minimum threshold, editTurns scoping, first-of-month edge case

* Fix model badge ForEach ID collision and budget warning provider filter

- SessionDetailsList model badges used id: \.name which silently drops
  duplicate model entries. Switched to enumerated offset-based ID.
- Hero budget warning compared against provider-filtered payload instead
  of todayPayload (all providers). Now matches the menubar flame tint.
2026-05-18 14:51:15 -07:00
ozymandiashh
5a837c94e9
Track OpenCode child sessions (#343)
Some checks are pending
CI / semgrep (push) Waiting to run
2026-05-18 05:51:08 -07:00
ozymandiashh
2013ecbfd9
Track agent calls across providers (#340) 2026-05-18 05:51:01 -07:00
Resham Joshi
303c9458cb
Fix OpenCode/Goose returning 0 sessions on fresh install (#347)
* Add CodeBurn Pro Mac App Store app

SwiftUI MenuBarExtra with litellm-snapshot pricing, Claude/Codex/Copilot
parsers, session discovery, auto-refresh timer, and dashboard UI matching
the real menubar design.

* Add appstore/ to .gitignore

Private Mac App Store build, not for the public repo.

* Fix OpenCode/Goose returning 0 sessions on fresh install

SQLite-based providers use compound paths (db.path:sessionId) which
caused fingerprintFile to fail stat() and silently skip all sessions.
Fall back to stat on the DB file when the full compound path fails.

Fixes #346
2026-05-18 05:45:20 -07:00
iamtoruk
1317af2acf Bump to 0.9.10, fix per-provider chart history for today period
Some checks are pending
CI / semgrep (push) Waiting to run
The fallback daily history path (used when rangeStartStr = todayStr)
was missing cache-based historical data, showing only today's bar in
the trend chart. Now includes allCacheDays filtered by provider.
2026-05-17 14:06:13 -07:00
Resham Joshi
58ccf84f02
Fix menubar per-provider performance (24s → 2s) and session cache safety (#344)
Some checks are pending
CI / semgrep (push) Waiting to run
Per-provider menubar calls now use loadDailyCache() instead of hydrateCache(),
splitting history into cache-based and fallback paths. Fixes session cache wipe
when scanProjectDirs/parseProviderSources receive empty dirs. Strips _dirty flag
before session cache serialization to prevent unnecessary 132MB rewrites. Removes
dead keychain code from both credential stores.
2026-05-17 13:54:38 -07:00
36 changed files with 2276 additions and 403 deletions

3
.gitignore vendored
View file

@ -41,5 +41,8 @@ assets/discord-*.png
# Desktop app experiments
desktop/
# Mac App Store app (private)
appstore/
# WIP / not ready
src/summit.ts

View file

@ -3,6 +3,15 @@
## Unreleased
### Added (CLI)
- **Agent and subagent tracking coverage.** Gemini sessions now emit one
provider call per assistant message with token usage instead of one aggregate
call per session, preserving per-message tools, bash commands, timestamps,
and nearest user prompts. Existing cached aggregate Gemini entries are
reparsed so the new per-message shape takes effect, and per-tool counts may
increase because repeated tools are now attributed to the specific Gemini
message that used them. Claude discovery also scans direct project-level
`subagents/*.jsonl` files, and Codex agent tool normalization is covered by
regression tests. Addresses #336.
- **Multiple subscription plans can be tracked at the same time.**
`codeburn plan set` now stores plans in a provider-keyed `plans` map, so
setting a Codex custom plan no longer overwrites an existing Claude plan.
@ -28,6 +37,18 @@
`Shell`, `ReadFile`, and `WriteFile`, and maps hidden managed Kimi Code
model aliases to priced Kimi K2 entries.
### Fixed (CLI)
- **OpenCode child sessions are attributed to their root session.** The
OpenCode parser now walks the unarchived `session.parent_id` subtree so
child and grandchild agent sessions contribute token and tool usage under
the discovered root session while still excluding child sessions from
top-level discovery to avoid double counting.
- **OpenCode router sessions with missing usage are still reported.**
Some OpenCode router/provider combinations can persist assistant messages
with text or tool activity but zero token and cost fields. The OpenCode
parser now keeps those turns as zero-cost calls instead of dropping the
session entirely. Closes #341.
## 0.9.9 - 2026-05-15
### Added (CLI)
@ -36,6 +57,13 @@
workspace-based project names from session data. Closes #248.
### Fixed (CLI)
- **One-shot rate detection for non-Claude providers.** Gemini and Mistral Vibe
now emit per-assistant-message calls grouped by user turn, so retry detection
sees multi-message `Edit -> Bash -> Edit` flows instead of counting each
message as an independent one-shot turn. Kiro and Goose record per-message
tool ordering via `toolSequence` for the same effect on aggregated sessions.
Vibe prefers `meta.json.stats.session_cost` over price-derived estimates when
available. Session cache bumped to v2. Closes #351.
- **Reduced Claude parser OOM risk.** Large Claude JSONL sessions retained
full entry objects (text, thinking blocks, tool results) in memory during
parsing, causing V8 heap exhaustion on heavy usage months. Entries are now

View file

@ -21,7 +21,11 @@ Subagent traces are stored under a parent session's `agents/` folder with the sa
## Caching
None.
Current Vibe local logs do not expose cache-read/cache-write token fields, so
CodeBurn reports cache token counts as `0`. When `meta.json.stats.session_cost`
is present, CodeBurn uses that session total instead of re-estimating from
prompt/completion token prices because it is the best cache-aware cost signal
available in the local log shape.
## Deduplication
@ -29,8 +33,8 @@ Per `mistral-vibe:<session_id>`.
## Quirks
- **Usage is cumulative per session.** Vibe does not write per-assistant-message token usage into `messages.jsonl`; token counts come from `meta.json.stats.session_prompt_tokens` and `session_completion_tokens`. CodeBurn emits one usage record per Vibe session.
- **Cost prefers Vibe's own model prices.** `meta.json.stats.input_price_per_million` and `output_price_per_million` are used first, with the active model config as a fallback. LiteLLM pricing is only used when Vibe provides no price data.
- **Usage is cumulative per session.** Vibe does not write per-assistant-message token usage into `messages.jsonl`; token counts come from `meta.json.stats.session_prompt_tokens` and `session_completion_tokens`. CodeBurn splits assistant-message tools into their user turns for classification and distributes the cumulative token/cost totals across those assistant calls so session totals remain unchanged.
- **Cost prefers Vibe's own session total.** `meta.json.stats.session_cost` is used first. If it is missing, `meta.json.stats.input_price_per_million` and `output_price_per_million` are used with the active model config as a fallback. LiteLLM pricing is only used when Vibe provides no price data.
- **Project names come from metadata.** Discovery uses `meta.json.environment.working_directory` and falls back to the session directory name if that field is missing.
- **Tool calls come from messages.** Assistant `tool_calls[*].function.name` is normalized to the standard CodeBurn names (`bash` to `Bash`, `search_replace` to `Edit`, etc.). Bash commands are extracted from `function.arguments.command`.

View file

@ -26,8 +26,15 @@ Per `<sessionId>:<messageId>`.
- **Schema validation is loud.** When a required table is missing, the parser logs an actionable warning telling the user which table is gone and what version of OpenCode it expects. This is the right behavior; do not silently swallow these.
- Source paths are encoded as `<dbPath>:<sessionId>`.
- Discovery only emits root sessions (`parent_id IS NULL`) to avoid double
counting. Parsing a root session walks the unarchived `session.parent_id`
subtree, so child and grandchild agent sessions contribute their message,
token, and tool usage back to the root session.
- Each message's `parts` are indexed; preserving the order matters for reasoning-token correctness.
- Tokens are reported across `input`, `output`, `reasoning`, `cache.read`, and `cache.write`. Anthropic semantics.
- Assistant messages with missing router usage are kept as zero-cost calls
when their parts contain non-empty text or tool activity. Empty zero-usage
assistant placeholders are still skipped.
- External MCP tools are stored as `<server>_<tool>` names (for example
`clickup_clickup_get_task`). The provider normalizes those to CodeBurn's
canonical `mcp__<server>__<tool>` names before aggregation so shared MCP

View file

@ -26,6 +26,12 @@ final class AppStore {
}
var showingAccentPicker: Bool = false
var currency: String = "USD"
var displayMetric: DisplayMetric = DisplayMetric(rawValue: UserDefaults.standard.string(forKey: "CodeBurnDisplayMetric") ?? "") ?? .cost {
didSet { UserDefaults.standard.set(displayMetric.rawValue, forKey: "CodeBurnDisplayMetric") }
}
var dailyBudget: Double = UserDefaults.standard.double(forKey: "CodeBurnDailyBudget") {
didSet { UserDefaults.standard.set(dailyBudget, forKey: "CodeBurnDailyBudget") }
}
var isLoading: Bool { loadingCountsByKey.values.contains { $0 > 0 } }
var isCurrentKeyLoading: Bool { loadingCountsByKey[currentKey, default: 0] > 0 }
var hasAttemptedCurrentKeyLoad: Bool { attemptedKeys.contains(currentKey) }
@ -934,12 +940,17 @@ enum SubscriptionLoadState: Sendable, Equatable {
case transientFailure(retryAt: Date?) // 429 / network blip; backing off automatically
}
enum DisplayMetric: String {
case cost, tokens, totalTokens
}
enum InsightMode: String, CaseIterable, Identifiable {
case plan = "Plan"
case trend = "Trend"
case forecast = "Forecast"
case pulse = "Pulse"
case stats = "Stats"
case optimize = "Optimize"
var id: String { rawValue }
}

View file

@ -624,6 +624,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
// Track currency so the menubar title catches up immediately on
// currency switch instead of waiting for the next 30s payload tick.
_ = self.store.currency
_ = self.store.displayMetric
_ = self.store.dailyBudget
// Track the live-quota state too so the flame icon re-tints on
// every subscription / codex usage update, not just every 30s.
_ = self.store.subscription
@ -709,7 +711,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
// warning/critical/danger override with a fixed palette color so the
// user gets a glanceable signal even when the menu bar is busy.
let aggregate = store.aggregateQuotaStatus
let tint = Self.flameTint(for: aggregate.severity)
var tint = Self.flameTint(for: aggregate.severity)
if tint == nil, store.dailyBudget > 0,
let todayCost = store.todayPayload?.current.cost, todayCost >= store.dailyBudget {
tint = NSColor.systemYellow
}
let flameConfig: NSImage.SymbolConfiguration
if let tint {
flameConfig = baseConfig.applying(.init(paletteColors: [tint]))
@ -728,11 +734,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
let hasPayload = store.todayPayload != nil
let compact = isCompact
let fallback = compact ? "$-" : "$—"
let formatted = store.todayPayload?.current.cost
let valueText = compact
? (formatted?.asCompactCurrencyWhole() ?? fallback)
: " " + (formatted?.asCompactCurrency() ?? fallback)
let valueText: String
if store.displayMetric == .tokens, let p = store.todayPayload?.current {
let out = formatTokensMenubar(Double(p.outputTokens))
let inp = formatTokensMenubar(Double(p.inputTokens))
valueText = compact ? "\(out)\(inp)" : "\(out)\(inp)"
} else if store.displayMetric == .totalTokens, let p = store.todayPayload?.current {
let total = formatTokensMenubar(Double(p.inputTokens + p.outputTokens))
valueText = compact ? total : " \(total) tok"
} else {
let fallback = compact ? "$-" : "$—"
let formatted = store.todayPayload?.current.cost
valueText = compact
? (formatted?.asCompactCurrencyWhole() ?? fallback)
: " " + (formatted?.asCompactCurrency() ?? fallback)
}
var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0]
if !hasPayload {
@ -745,6 +761,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
button.attributedTitle = composed
}
private func formatTokensMenubar(_ n: Double) -> String {
if n >= 1_000_000_000 { return String(format: "%.1fB", n / 1_000_000_000) }
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
return String(format: "%.0f", n)
}
// MARK: - Popover
private func setupPopover() {

View file

@ -37,10 +37,7 @@ enum ClaudeCredentialStore {
private static let maxCredentialBytes = 64 * 1024
/// Legacy local cache file. New writes use the macOS Keychain; this path is
/// read once for migration and then removed.
private static let cacheFilename = "claude-credentials.v1.json"
private static let ourKeychainService = "org.agentseal.codeburn.menubar.claude.oauth.v1"
private static let ourKeychainAccount = "default"
private static let lock = NSLock()
private nonisolated(unsafe) static var memoryCache: CachedRecord?
@ -279,13 +276,6 @@ enum ClaudeCredentialStore {
}
private static func readOurCache() throws -> CredentialRecord? {
// Migrate: if credentials exist in keychain from a previous build, move to file.
if let keychainRecord = try? readOurKeychainCache() {
try? writeOurFileCache(record: keychainRecord)
deleteOurKeychainCache()
return keychainRecord
}
let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
@ -304,63 +294,10 @@ enum ClaudeCredentialStore {
try data.write(to: url, options: [.atomic, .completeFileProtection])
}
private static func readOurKeychainCache() throws -> CredentialRecord? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: ourKeychainService,
kSecAttrAccount as String: ourKeychainAccount,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw StoreError.keychainReadFailed(status)
}
return try? JSONDecoder().decode(CredentialRecord.self, from: data)
}
private static func writeOurKeychainCache(record: CredentialRecord) throws {
let url = cacheFileURL()
let data = try JSONEncoder().encode(record)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: ourKeychainService,
kSecAttrAccount as String: ourKeychainAccount,
]
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
var add = query
add.merge(attributes) { _, new in new }
let addStatus = SecItemAdd(add as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw StoreError.keychainWriteFailed(addStatus)
}
} else if status != errSecSuccess {
throw StoreError.keychainWriteFailed(status)
}
try? FileManager.default.removeItem(at: url)
}
private static func deleteOurCache() {
deleteOurKeychainCache()
try? FileManager.default.removeItem(at: cacheFileURL())
}
private static func deleteOurKeychainCache() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: ourKeychainService,
kSecAttrAccount as String: ourKeychainAccount,
]
SecItemDelete(query as CFDictionary)
}
private static func cacheInMemory(_ record: CredentialRecord) {
lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) }
}

View file

@ -18,8 +18,6 @@ enum CodexCredentialStore {
private static let maxCredentialBytes = 64 * 1024
private static let cacheFilename = "codex-credentials.v1.json"
private static let ourKeychainService = "org.agentseal.codeburn.menubar.codex.oauth.v1"
private static let ourKeychainAccount = "default"
private static let lock = NSLock()
private nonisolated(unsafe) static var memoryCache: CachedRecord?
@ -201,12 +199,6 @@ enum CodexCredentialStore {
}
private static func readOurCache() throws -> CredentialRecord? {
if let keychainRecord = try? readOurKeychainCache() {
try? writeOurFileCache(record: keychainRecord)
deleteOurKeychainCache()
return keychainRecord
}
let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
@ -225,63 +217,10 @@ enum CodexCredentialStore {
try data.write(to: url, options: [.atomic, .completeFileProtection])
}
private static func readOurKeychainCache() throws -> CredentialRecord? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: ourKeychainService,
kSecAttrAccount as String: ourKeychainAccount,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw StoreError.fileWriteFailed("keychain read failed with status \(status)")
}
return try? JSONDecoder().decode(CredentialRecord.self, from: data)
}
private static func writeOurKeychainCache(record: CredentialRecord) throws {
let url = cacheFileURL()
let data = try JSONEncoder().encode(record)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: ourKeychainService,
kSecAttrAccount as String: ourKeychainAccount,
]
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound {
var add = query
add.merge(attributes) { _, new in new }
let addStatus = SecItemAdd(add as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw StoreError.fileWriteFailed("keychain write failed with status \(addStatus)")
}
} else if status != errSecSuccess {
throw StoreError.fileWriteFailed("keychain update failed with status \(status)")
}
try? FileManager.default.removeItem(at: url)
}
private static func deleteOurCache() {
deleteOurKeychainCache()
try? FileManager.default.removeItem(at: cacheFileURL())
}
private static func deleteOurKeychainCache() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: ourKeychainService,
kSecAttrAccount as String: ourKeychainAccount,
]
SecItemDelete(query as CFDictionary)
}
private static func cacheInMemory(_ record: CredentialRecord) {
lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) }
}

View file

@ -58,6 +58,36 @@ extension DailyHistoryEntry {
}
}
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]
}
struct CurrentBlock: Codable, Sendable {
let label: String
let cost: Double
@ -70,6 +100,38 @@ struct CurrentBlock: Codable, Sendable {
let topActivities: [ActivityEntry]
let topModels: [ModelEntry]
let providers: [String: Double]
let topProjects: [ProjectEntry]
let modelEfficiency: [ModelEfficiencyEntry]
let topSessions: [TopSessionEntry]
let retryTax: RetryTax
let routingWaste: RoutingWaste
}
extension CurrentBlock {
enum CodingKeys: String, CodingKey {
case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens,
cacheHitPercent, topActivities, topModels, providers, topProjects,
modelEfficiency, topSessions, retryTax, routingWaste
}
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.decode(Double.self, forKey: .cacheHitPercent)
topActivities = try c.decode([ActivityEntry].self, forKey: .topActivities)
topModels = try c.decode([ModelEntry].self, forKey: .topModels)
providers = try c.decode([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: [])
}
}
struct ActivityEntry: Codable, Sendable {
@ -85,6 +147,54 @@ struct ModelEntry: Codable, Sendable {
let calls: Int
}
struct SessionModelEntry: Codable, Sendable {
let name: String
let cost: Double
}
struct SessionDetailEntry: Codable, Sendable {
let cost: Double
let calls: Int
let inputTokens: Int
let outputTokens: Int
let date: String
let models: [SessionModelEntry]
}
struct ProjectEntry: Codable, Sendable {
let name: String
let cost: 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)
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, 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 calls: Int
let date: String
}
struct OptimizeBlock: Codable, Sendable {
let findingCount: Int
let savingsUSD: Double
@ -115,7 +225,12 @@ extension MenubarPayload {
cacheHitPercent: 0,
topActivities: [],
topModels: [],
providers: [:]
providers: [:],
topProjects: [],
modelEfficiency: [],
topSessions: [],
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: [])
),
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: [])

View file

@ -129,9 +129,6 @@ struct AgentTabStrip: View {
private func cost(for filter: ProviderFilter) -> Double? {
let data = periodAll
if filter == .all { return data.current.cost }
if filter == store.selectedProvider, store.hasCachedData {
return store.payload.current.cost
}
let providers = Dictionary(
data.current.providers.map { ($0.key.lowercased(), $0.value) },
uniquingKeysWith: +

View file

@ -1,8 +1,5 @@
import SwiftUI
private let trendDays = 19
private let trendBarWidth: CGFloat = 13
private let trendBarGap: CGFloat = 4
private let trendChartHeight: CGFloat = 90
// Cached formatters and a calendar to avoid allocating fresh ones on every
@ -82,10 +79,11 @@ struct HeatmapSection: View {
} else {
PlanInsight(usage: store.subscription)
}
case .trend: TrendInsight(days: store.payload.history.daily)
case .trend: TrendInsight(days: store.payload.history.daily, period: store.selectedPeriod)
case .forecast: ForecastInsight(days: store.payload.history.daily)
case .pulse: PulseInsight(payload: store.payload)
case .stats: StatsInsight(payload: store.payload)
case .optimize: OptimizeInsight(payload: store.payload)
}
}
}
@ -97,22 +95,25 @@ private struct InsightPillSwitcher: View {
let visibleModes: [InsightMode]
var body: some View {
HStack(spacing: 4) {
ForEach(visibleModes) { mode in
Button {
selected = mode
} label: {
Text(mode.rawValue)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(selected == mode ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(selected == mode ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.10)))
)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(visibleModes) { mode in
Button {
selected = mode
} label: {
Text(mode.rawValue)
.font(.system(size: 11, weight: .medium))
.fixedSize()
.foregroundStyle(selected == mode ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(selected == mode ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.10)))
)
}
.buttonStyle(.plain)
}
.buttonStyle(.plain)
}
}
}
@ -122,10 +123,25 @@ private struct InsightPillSwitcher: View {
private struct TrendInsight: View {
let days: [DailyHistoryEntry]
let period: Period
private var trendDayCount: Int {
switch period {
case .today, .sevenDays: return 19
case .thirtyDays: return 30
case .month: return 31
case .all: return min(days.count, 90)
}
}
private var barGap: CGFloat {
trendDayCount > 45 ? 2 : 4
}
var body: some View {
let bars = buildTrendBars(from: days)
let stats = computeTrendStats(bars: bars, allDays: days)
let dayCount = trendDayCount
let bars = buildTrendBars(from: days, dayCount: dayCount)
let stats = computeTrendStats(bars: bars, allDays: days, dayCount: dayCount)
// Tokens are real for the .all-providers view; per-provider history doesn't carry
// token breakdown yet, so fall back to $ when no tokens are present.
let totalTokens = bars.reduce(0.0) { $0 + $1.tokens }
@ -139,7 +155,7 @@ private struct TrendInsight: View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 1) {
Text("Last \(trendDays) days")
Text("Last \(dayCount) days")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Text(formatHero(useTokens: useTokens, tokens: totalTokens, dollars: stats.totalThisWindow))
@ -152,7 +168,7 @@ private struct TrendInsight: View {
HStack(spacing: 3) {
Image(systemName: delta >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.system(size: 9, weight: .bold))
Text("\(delta >= 0 ? "+" : "")\(String(format: "%.0f", delta))% vs prior \(trendDays)d")
Text("\(delta >= 0 ? "+" : "")\(String(format: "%.0f", delta))% vs prior \(dayCount)d")
.font(.system(size: 10.5))
.monospacedDigit()
}
@ -165,7 +181,8 @@ private struct TrendInsight: View {
maxValue: maxValue,
avgValue: avgValue,
metric: metric,
formatValue: { formatValue($0, useTokens: useTokens) }
formatValue: { formatValue($0, useTokens: useTokens) },
barGap: barGap
)
.zIndex(1)
@ -209,20 +226,26 @@ private struct TrendChart: View {
let avgValue: Double
let metric: (TrendBar) -> Double
let formatValue: (Double) -> String
let barGap: CGFloat
@State private var hoveredBarID: TrendBar.ID?
private var peakBarID: TrendBar.ID? {
bars.filter { metric($0) > 0 }.max(by: { metric($0) < metric($1) })?.id
}
var body: some View {
let avgFraction = maxValue > 0 ? CGFloat(min(avgValue / maxValue, 1.0)) : 0
ZStack(alignment: .bottomLeading) {
HStack(alignment: .bottom, spacing: trendBarGap) {
HStack(alignment: .bottom, spacing: barGap) {
ForEach(bars) { bar in
BarColumn(
bar: bar,
value: metric(bar),
maxValue: maxValue,
isHovered: hoveredBarID == bar.id
isHovered: hoveredBarID == bar.id,
isPeak: bar.id == peakBarID
)
.onHover { hovering in
hoveredBarID = hovering ? bar.id : (hoveredBarID == bar.id ? nil : hoveredBarID)
@ -245,8 +268,6 @@ private struct TrendChart: View {
}
.frame(height: trendChartHeight)
.overlay(alignment: .bottomLeading) {
// Floats below the chart without taking layout space. Opaque dark card hides
// whatever sits beneath it (mini stats, activity rows).
if let hoveredBar {
BarTooltipCard(bar: hoveredBar, value: metric(hoveredBar), formatValue: formatValue)
.padding(.top, 6)
@ -270,16 +291,24 @@ private struct BarColumn: View {
let value: Double
let maxValue: Double
let isHovered: Bool
let isPeak: Bool
var body: some View {
let fraction = maxValue > 0 ? CGFloat(value / maxValue) : 0
let height = max(2, trendChartHeight * fraction)
VStack(spacing: 2) {
VStack(spacing: 0) {
Spacer(minLength: 0)
if isPeak && value > 0 {
RoundedRectangle(cornerRadius: 2)
.fill(Color.yellow.opacity(0.85))
.frame(maxWidth: .infinity)
.frame(height: max(2, trendChartHeight * 0.05))
}
RoundedRectangle(cornerRadius: 2)
.fill(barColor)
.frame(width: trendBarWidth, height: height)
.frame(maxWidth: .infinity)
.frame(height: height)
.overlay(
RoundedRectangle(cornerRadius: 2)
.stroke(Theme.brandAccent.opacity(isHovered ? 0.9 : 0), lineWidth: 1)
@ -293,7 +322,9 @@ private struct BarColumn: View {
private var barColor: Color {
if bar.isToday { return Theme.brandAccent }
if value <= 0 { return Color.secondary.opacity(0.15) }
return isHovered ? Theme.brandAccent.opacity(0.85) : Theme.brandAccent.opacity(0.55)
if isHovered { return Theme.brandAccent.opacity(0.85) }
let ratio = maxValue > 0 ? value / maxValue : 0
return Theme.brandAccent.opacity(0.42 + ratio * 0.48)
}
}
@ -342,18 +373,23 @@ private struct BarTooltipCard: View {
if !bar.topModels.isEmpty {
VStack(alignment: .leading, spacing: 3) {
ForEach(bar.topModels.prefix(4), id: \.name) { m in
ForEach(Array(bar.topModels.prefix(4).enumerated()), id: \.element.name) { idx, m in
HStack(spacing: 6) {
Circle().fill(Theme.brandAccent.opacity(0.7)).frame(width: 4, height: 4)
RoundedRectangle(cornerRadius: 1)
.fill(Theme.brandAccent.opacity(0.75 - Double(idx) * 0.12))
.frame(width: 3, height: 12)
Text(m.name)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(primaryText)
.lineLimit(1)
Spacer()
if m.cost > 0 {
Text(m.cost.asCompactCurrency())
.font(.codeMono(size: 9.5, weight: .semibold))
.foregroundStyle(secondaryText)
}
Text("\(formatTokensCompact(Double(m.totalTokens))) tok")
.font(.codeMono(size: 9.5, weight: .medium))
.foregroundStyle(secondaryText)
Text("(\(formatTokensCompact(Double(m.inputTokens)))/\(formatTokensCompact(Double(m.outputTokens))))")
.font(.codeMono(size: 9, weight: .regular))
.foregroundStyle(tertiaryText)
}
}
@ -390,7 +426,7 @@ private struct MiniStat: View {
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 1) {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.tertiary)
@ -399,7 +435,13 @@ private struct MiniStat: View {
.monospacedDigit()
.foregroundStyle(.primary)
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color(nsColor: .separatorColor).opacity(0.35))
)
}
}
@ -424,7 +466,7 @@ private struct TrendStats {
let yesterdayBar: TrendBar?
}
private func buildTrendBars(from days: [DailyHistoryEntry]) -> [TrendBar] {
private func buildTrendBars(from days: [DailyHistoryEntry], dayCount: Int) -> [TrendBar] {
let calendar = gregorianCalendar
let formatter = yyyymmdd
let entryByDate = Dictionary(days.map { ($0.date, $0) }, uniquingKeysWith: { _, new in new })
@ -432,7 +474,7 @@ private func buildTrendBars(from days: [DailyHistoryEntry]) -> [TrendBar] {
let todayKey = formatter.string(from: today)
var bars: [TrendBar] = []
for offset in (0..<trendDays).reversed() {
for offset in (0..<dayCount).reversed() {
guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { continue }
let key = formatter.string(from: d)
let entry = entryByDate[key]
@ -448,7 +490,7 @@ private func buildTrendBars(from days: [DailyHistoryEntry]) -> [TrendBar] {
return bars
}
private func computeTrendStats(bars: [TrendBar], allDays: [DailyHistoryEntry]) -> TrendStats {
private func computeTrendStats(bars: [TrendBar], allDays: [DailyHistoryEntry], dayCount: Int) -> TrendStats {
let total = bars.reduce(0.0) { $0 + $1.cost }
let active = bars.filter { $0.cost > 0 }.count
let avg = bars.isEmpty ? 0 : total / Double(bars.count)
@ -457,8 +499,8 @@ private func computeTrendStats(bars: [TrendBar], allDays: [DailyHistoryEntry]) -
let calendar = gregorianCalendar
let formatter = yyyymmdd
let today = calendar.startOfDay(for: Date())
let priorWindowStart = calendar.date(byAdding: .day, value: -(2 * trendDays - 1), to: today)
let thisWindowStart = calendar.date(byAdding: .day, value: -(trendDays - 1), to: today)
let priorWindowStart = calendar.date(byAdding: .day, value: -(2 * dayCount - 1), to: today)
let thisWindowStart = calendar.date(byAdding: .day, value: -(dayCount - 1), to: today)
var deltaPercent: Double? = nil
if let priorStart = priorWindowStart, let thisStart = thisWindowStart {
let priorStartStr = formatter.string(from: priorStart)
@ -629,16 +671,19 @@ private struct PulseInsight: View {
let payload: MenubarPayload
var body: some View {
HStack(spacing: 10) {
PulseTile(label: "Cache hit", value: cacheHitText, color: Theme.brandAccent)
PulseTile(label: "1-shot", value: oneShotText, color: oneShotColor)
PulseTile(
label: "Cost / session",
value: payload.current.sessions > 0
? (payload.current.cost / Double(payload.current.sessions)).asCompactCurrency()
: "",
color: .secondary
)
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
PulseTile(label: "Cache hit", value: cacheHitText, color: Theme.brandAccent)
PulseTile(label: "1-shot", value: oneShotText, color: oneShotColor)
PulseTile(
label: "Cost / session",
value: payload.current.sessions > 0
? (payload.current.cost / Double(payload.current.sessions)).asCompactCurrency()
: "",
color: .secondary
)
}
CostPerEditCaption(models: payload.current.modelEfficiency)
}
}
@ -682,6 +727,53 @@ private struct PulseTile: View {
}
}
private struct CostPerEditCaption: View {
let models: [ModelEfficiencyEntry]
var body: some View {
let valid = models.compactMap { m -> (String, Double)? in
guard let cpe = m.costPerEdit, cpe > 0 else { return nil }
return (m.name, cpe)
}.sorted(by: { $0.1 < $1.1 })
if let best = valid.first {
HStack(spacing: 4) {
Image(systemName: "pencil.line")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.tertiary)
Text("Cost/edit")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Text(formatCPE(best.1))
.font(.codeMono(size: 10.5, weight: .semibold))
.foregroundStyle(Theme.brandAccent)
Text(best.0)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
if valid.count > 1, let worst = valid.last, worst.0 != best.0 {
Text("")
.font(.system(size: 9))
.foregroundStyle(.quaternary)
Text(formatCPE(worst.1))
.font(.codeMono(size: 10.5, weight: .semibold))
.foregroundStyle(.primary)
Text(worst.0)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
}
}
}
private func formatCPE(_ v: Double) -> String {
if v < 0.01 { return String(format: "$%.3f", v) }
return String(format: "$%.2f", v)
}
}
/// Connects optimize findings directly to plan utilization: "address N findings to recover X
/// tokens" framed as the same currency the rest of the Plan view uses (effective tokens).
/// Scoped to whatever period the user selected (today / 7d / 30d / month / all).
@ -777,6 +869,112 @@ private struct StatsInsight: View {
.foregroundStyle(Theme.brandAccent)
}
}
if !payload.current.topProjects.isEmpty {
Divider().opacity(0.5)
TopProjectsList(projects: payload.current.topProjects)
}
if let top = payload.current.topSessions.first, top.cost > 0 {
HStack(spacing: 4) {
Image(systemName: "flame")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(Theme.brandAccent)
Text("Costliest session")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Spacer()
Text(top.cost.asCompactCurrency())
.font(.codeMono(size: 10.5, weight: .semibold))
.foregroundStyle(.secondary)
.monospacedDigit()
Text("· \(projectDisplayName(top.project))")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
.lineLimit(1)
}
}
}
}
}
private struct RetryTaxSection: View {
let retryTax: RetryTax
let totalCost: Double
@State private var expanded = false
var body: some View {
if retryTax.totalUSD > 0 {
Divider().opacity(0.5)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Image(systemName: "arrow.2.squarepath")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.orange)
Text("Retry tax")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Spacer()
Text(retryTax.totalUSD.asCompactCurrency())
.font(.codeMono(size: 11, weight: .bold))
.foregroundStyle(.orange)
.monospacedDigit()
if totalCost > 0 {
Text("(\(Int((retryTax.totalUSD / totalCost * 100).rounded()))%)")
.font(.system(size: 9.5))
.foregroundStyle(.tertiary)
}
Image(systemName: "chevron.right")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.quaternary)
.rotationEffect(.degrees(expanded ? 90 : 0))
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
expanded.toggle()
}
}
Text("\(retryTax.retries) retries across \(retryTax.editTurns) edits")
.font(.system(size: 9.5))
.foregroundStyle(.quaternary)
if expanded {
VStack(alignment: .leading, spacing: 3) {
ForEach(Array(retryTax.byModel.enumerated()), id: \.offset) { idx, model in
HStack(spacing: 0) {
Text(model.name)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
if let rpe = model.retriesPerEdit {
Text(String(format: "%.1f ret/edit", rpe))
.font(.system(size: 9))
.foregroundStyle(.quaternary)
.padding(.trailing, 8)
}
Text(model.taxUSD.asCompactCurrency())
.font(.codeMono(size: 10, weight: .semibold))
.foregroundStyle(.orange.opacity(0.85))
.monospacedDigit()
}
.padding(.vertical, 2)
.padding(.horizontal, 6)
.background(RoundedRectangle(cornerRadius: 4).fill(.orange.opacity(0.05)))
.transition(
.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top))
.animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)),
removal: .opacity.animation(.easeOut(duration: 0.12))
)
)
}
}
.padding(.top, 2)
}
}
}
}
}
@ -798,6 +996,257 @@ private struct StatRow: View {
}
}
private func projectDisplayName(_ path: String) -> String {
path.split(separator: "/").last.map(String.init) ?? path
}
private struct TopProjectsList: View {
let projects: [ProjectEntry]
@State private var expanded: String?
var body: some View {
let top = Array(projects.prefix(3))
let maxCost = top.first?.cost ?? 1
VStack(alignment: .leading, spacing: 5) {
ForEach(Array(top.enumerated()), id: \.offset) { idx, project in
let expandKey = "\(idx):\(project.name)"
let isOpen = expanded == expandKey
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.quaternary)
.rotationEffect(.degrees(isOpen ? 90 : 0))
Text(projectDisplayName(project.name))
.font(.system(size: 10.5, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(1)
Spacer()
Text("\(project.sessions) sess")
.font(.system(size: 9.5))
.foregroundStyle(.quaternary)
Text(project.cost.asCompactCurrency())
.font(.codeMono(size: 10.5, weight: .semibold))
.foregroundStyle(.secondary)
.monospacedDigit()
RoundedRectangle(cornerRadius: 2)
.fill(Theme.brandAccent.opacity(0.5))
.frame(
width: max(2, 40 * CGFloat(project.cost / max(maxCost, 0.01))),
height: 6
)
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
expanded = isOpen ? nil : expandKey
}
}
if isOpen, !project.sessionDetails.isEmpty {
SessionDetailsList(sessions: project.sessionDetails)
.padding(.top, 6)
.padding(.leading, 14)
}
}
}
}
}
}
private struct SessionDetailsList: View {
let sessions: [SessionDetailEntry]
var body: some View {
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(sessions.prefix(5).enumerated()), id: \.offset) { idx, sess in
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 0) {
Text(sess.cost.asCompactCurrency())
.font(.codeMono(size: 10, weight: .semibold))
.foregroundStyle(.primary)
.monospacedDigit()
.frame(width: 52, alignment: .trailing)
Text(" \(sess.calls) calls")
.font(.system(size: 9))
.foregroundStyle(.quaternary)
Spacer()
HStack(spacing: 3) {
Image(systemName: "arrow.down")
.font(.system(size: 7, weight: .semibold))
Text(compactTokens(sess.inputTokens))
}
.font(.system(size: 9))
.foregroundStyle(.tertiary)
HStack(spacing: 3) {
Image(systemName: "arrow.up")
.font(.system(size: 7, weight: .semibold))
Text(compactTokens(sess.outputTokens))
}
.font(.system(size: 9))
.foregroundStyle(.tertiary)
.padding(.leading, 4)
}
HStack(spacing: 4) {
ForEach(Array(sess.models.prefix(3).enumerated()), id: \.offset) { _, model in
Text(model.name)
.font(.system(size: 8.5, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 1.5)
.background(Theme.brandAccent.opacity(0.1))
.clipShape(Capsule())
}
}
.padding(.leading, 52)
}
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(.primary.opacity(0.03))
)
.transition(
.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top))
.animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)),
removal: .opacity.animation(.easeOut(duration: 0.15))
)
)
}
}
}
private func compactTokens(_ n: Int) -> String {
let d = Double(n)
if d >= 1_000_000 { return String(format: "%.1fM", d / 1_000_000) }
if d >= 1_000 { return String(format: "%.0fK", d / 1_000) }
return "\(n)"
}
}
// MARK: - Optimize
private struct OptimizeInsight: View {
let payload: MenubarPayload
var body: some View {
let totalWaste = payload.current.retryTax.totalUSD + payload.current.routingWaste.totalSavingsUSD
let cost = payload.current.cost
VStack(alignment: .leading, spacing: 12) {
if totalWaste > 0, cost > 0 {
HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 2) {
Text("Potential savings")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Text(totalWaste.asCompactCurrency())
.font(.system(size: 24, weight: .bold, design: .rounded))
.monospacedDigit()
.foregroundStyle(.orange)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(Int((totalWaste / cost * 100).rounded()))% of spend")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.orange.opacity(0.8))
Text("could be optimized")
.font(.system(size: 9.5))
.foregroundStyle(.quaternary)
}
}
.padding(.bottom, 2)
}
RetryTaxSection(retryTax: payload.current.retryTax, totalCost: cost)
RoutingWasteSection(routingWaste: payload.current.routingWaste, totalCost: cost)
}
}
}
private struct RoutingWasteSection: View {
let routingWaste: RoutingWaste
let totalCost: Double
@State private var expanded = false
var body: some View {
if routingWaste.totalSavingsUSD > 0 {
Divider().opacity(0.5)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.swap")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.purple)
Text("Routing waste")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary)
Spacer()
Text(routingWaste.totalSavingsUSD.asCompactCurrency())
.font(.codeMono(size: 11, weight: .bold))
.foregroundStyle(.purple)
.monospacedDigit()
if totalCost > 0 {
Text("(\(Int((routingWaste.totalSavingsUSD / totalCost * 100).rounded()))%)")
.font(.system(size: 9.5))
.foregroundStyle(.tertiary)
}
Image(systemName: "chevron.right")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(.quaternary)
.rotationEffect(.degrees(expanded ? 90 : 0))
}
.contentShape(Rectangle())
.onTapGesture {
withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
expanded.toggle()
}
}
if !routingWaste.baselineModel.isEmpty {
Text("vs \(routingWaste.baselineModel) @ \(routingWaste.baselineCostPerEdit.asCompactCurrency())/edit")
.font(.system(size: 9.5))
.foregroundStyle(.quaternary)
}
if expanded {
VStack(alignment: .leading, spacing: 3) {
ForEach(Array(routingWaste.byModel.enumerated()), id: \.offset) { idx, model in
HStack(spacing: 0) {
Text(model.name)
.font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
Text(String(format: "$%.2f/edit", model.costPerEdit))
.font(.system(size: 9))
.foregroundStyle(.quaternary)
.padding(.trailing, 8)
Text(model.savingsUSD.asCompactCurrency())
.font(.codeMono(size: 10, weight: .semibold))
.foregroundStyle(.purple.opacity(0.85))
.monospacedDigit()
}
.padding(.vertical, 2)
.padding(.horizontal, 6)
.background(RoundedRectangle(cornerRadius: 4).fill(.purple.opacity(0.05)))
.transition(
.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .top))
.animation(.spring(response: 0.3, dampingFraction: 0.8).delay(Double(idx) * 0.03)),
removal: .opacity.animation(.easeOut(duration: 0.12))
)
)
}
}
.padding(.top, 2)
}
}
}
}
}
private struct AllStats {
let favoriteModel: String
let activeDaysFraction: String

View file

@ -8,7 +8,7 @@ struct HeroSection: View {
SectionCaption(text: caption)
HStack(alignment: .firstTextBaseline) {
Text(store.payload.current.cost.asCurrency())
Text(heroText)
.font(.system(size: 32, weight: .semibold, design: .rounded))
.monospacedDigit()
.tracking(-1)
@ -23,22 +23,73 @@ struct HeroSection: View {
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("\(store.payload.current.calls.asThousandsSeparated()) calls")
if store.displayMetric == .tokens {
HStack(spacing: 2) {
Image(systemName: "arrow.up")
.font(.system(size: 9, weight: .semibold))
Text(formatTokens(Double(store.payload.current.outputTokens)))
}
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
Text("\(store.payload.current.sessions) sessions")
HStack(spacing: 2) {
Image(systemName: "arrow.down")
.font(.system(size: 9, weight: .semibold))
Text(formatTokens(Double(store.payload.current.inputTokens)))
}
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.tertiary)
} else {
Text("\(store.payload.current.calls.asThousandsSeparated()) calls")
.font(.system(size: 11))
.monospacedDigit()
.foregroundStyle(.secondary)
Text("\(store.payload.current.sessions) sessions")
.font(.system(size: 10.5))
.monospacedDigit()
.foregroundStyle(.tertiary)
}
}
}
if store.selectedPeriod == .today,
store.dailyBudget > 0,
let todayCost = store.todayPayload?.current.cost,
todayCost >= store.dailyBudget {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 10))
Text("Daily budget of \(store.dailyBudget.asCurrency()) exceeded")
.font(.system(size: 11, weight: .medium))
}
.foregroundStyle(.orange)
.padding(.top, 2)
}
}
.padding(.horizontal, 14)
.padding(.top, 10)
.padding(.bottom, 12)
}
private var heroText: String {
if store.displayMetric == .tokens || store.displayMetric == .totalTokens {
let total = Double(store.payload.current.inputTokens + store.payload.current.outputTokens)
if total >= 1_000_000_000 { return String(format: "%.2fB tok", total / 1_000_000_000) }
if total >= 1_000_000 { return String(format: "%.1fM tok", total / 1_000_000) }
if total >= 1_000 { return String(format: "%.0fK tok", total / 1_000) }
return String(format: "%.0f tok", total)
}
return store.payload.current.cost.asCurrency()
}
private func formatTokens(_ n: Double) -> String {
if n >= 1_000_000_000 { return String(format: "%.1fB", n / 1_000_000_000) }
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
return String(format: "%.0f", n)
}
private var caption: String {
let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label
if store.selectedPeriod == .today {

View file

@ -40,6 +40,14 @@ private struct GeneralSettingsTab: View {
Text(code).tag(code)
}
}
Picker("Metric", selection: Binding(
get: { store.displayMetric },
set: { store.displayMetric = $0 }
)) {
Text("Cost ($)").tag(DisplayMetric.cost)
Text("Tokens (↑↓)").tag(DisplayMetric.tokens)
Text("Total Tokens").tag(DisplayMetric.totalTokens)
}
Picker("Accent", selection: Binding(
get: { store.accentPreset },
set: { store.accentPreset = $0 }
@ -49,6 +57,23 @@ private struct GeneralSettingsTab: View {
}
}
}
Section("Alerts") {
Picker("Daily budget", selection: Binding(
get: { store.dailyBudget },
set: { store.dailyBudget = $0 }
)) {
Text("Off").tag(0.0)
Text("$25").tag(25.0)
Text("$50").tag(50.0)
Text("$100").tag(100.0)
Text("$200").tag(200.0)
Text("$500").tag(500.0)
}
Text("Flame icon turns yellow when you pass the daily budget.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.padding()

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "codeburn",
"version": "0.9.9",
"version": "0.9.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "codeburn",
"version": "0.9.9",
"version": "0.9.10",
"license": "MIT",
"dependencies": {
"chalk": "^5.4.1",

View file

@ -1,6 +1,6 @@
{
"name": "codeburn",
"version": "0.9.9",
"version": "0.9.10",
"description": "See where your AI coding tokens go - by task, tool, model, and project",
"type": "module",
"main": "./dist/cli.js",

View file

@ -154,13 +154,22 @@ function classifyConversation(userMessage: string): TaskCategory {
}
function countRetries(turn: ParsedTurn): number {
const steps: string[][] = []
for (const call of turn.assistantCalls) {
if (call.toolSequence && call.toolSequence.length > 0) {
steps.push(...call.toolSequence)
} else if (call.tools.length > 0) {
steps.push(call.tools)
}
}
let sawEditBeforeBash = false
let sawBashAfterEdit = false
let retries = 0
for (const call of turn.assistantCalls) {
const hasEdit = call.tools.some(t => EDIT_TOOLS.has(t))
const hasBash = call.tools.some(t => BASH_TOOLS.has(t))
for (const tools of steps) {
const hasEdit = tools.some(t => EDIT_TOOLS.has(t))
const hasBash = tools.some(t => BASH_TOOLS.has(t))
if (hasEdit) {
if (sawBashAfterEdit) retries++

View file

@ -1,3 +1,4 @@
import { homedir } from 'node:os'
import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js'
@ -7,7 +8,7 @@ import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { type PeriodData, type ProviderCost } from './menubar-json.js'
import { buildMenubarPayload } from './menubar-json.js'
import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js'
import { getDaysInRange, ensureCacheHydrated, loadDailyCache, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache } from './daily-cache.js'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
@ -444,7 +445,6 @@ program
const rangeEndStr = toDateString(periodInfo.range.end)
const isAllProviders = pf === 'all'
const cache = await hydrateCache()
let todayAllProjects: ProjectSummary[] | null = null
let todayAllDays: ReturnType<typeof aggregateProjectsIntoDays> | null = null
@ -462,44 +462,57 @@ program
return todayAllDays
}
// CURRENT PERIOD DATA
// - .all provider: assemble from cache + today (fast)
// - specific provider: parse the period range with provider filter (correct, but slower)
let currentData: PeriodData
let scanProjects: ProjectSummary[]
let scanRange: DateRange
let cache: DailyCache
let todayProviderData: PeriodData | null = null
let usedPerProviderCachePath = false
if (isAllProviders) {
// Parse today's all-provider sessions once; historical data comes from cache to avoid
// double-counting. Reusing the same parsed object is important for the menubar path:
// large active sessions can OOM if this command retains multiple near-identical scans.
cache = await hydrateCache()
const todayProjects = await getTodayAllProjects()
const todayDays = await getTodayAllDays()
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
scanProjects = todayProjects
scanRange = periodInfo.range
const isTodayOnly = opts.period === 'today'
if (isTodayOnly) {
scanProjects = todayProjects
scanRange = todayRange
} else {
scanProjects = fp(await parseAllSessions(periodInfo.range, 'all'))
scanRange = periodInfo.range
}
} else {
// Per-provider: parse only today (fast), use cache for historical days.
// The cache stores per-provider cost+calls per day, so we extract those
// and combine with today's fully-parsed provider data.
const todayProviderProjects = fp(await parseAllSessions(todayRange, pf))
const todayData = buildPeriodData(periodInfo.label, todayProviderProjects)
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
let histCost = 0, histCalls = 0
for (const d of historicalDays) {
const prov = d.providers[pf]
if (prov) { histCost += prov.cost; histCalls += prov.calls }
cache = await loadDailyCache()
const cacheIsCurrent = cache.lastComputedDate !== null
&& cache.lastComputedDate >= yesterdayStr
if (cacheIsCurrent && rangeStartStr < todayStr) {
const todayProviderProjects = fp(await parseAllSessions(todayRange, pf))
todayProviderData = buildPeriodData(periodInfo.label, todayProviderProjects)
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
let histCost = 0, histCalls = 0
for (const d of historicalDays) {
const prov = d.providers[pf]
if (prov) { histCost += prov.cost; histCalls += prov.calls }
}
currentData = {
...todayProviderData,
cost: todayProviderData.cost + histCost,
calls: todayProviderData.calls + histCalls,
}
scanProjects = todayProviderProjects
scanRange = todayRange
usedPerProviderCachePath = true
} else {
const fullProjects = fp(await parseAllSessions(periodInfo.range, pf))
todayProviderData = buildPeriodData(periodInfo.label, fullProjects)
currentData = todayProviderData
scanProjects = fullProjects
scanRange = periodInfo.range
}
currentData = {
...todayData,
cost: todayData.cost + histCost,
calls: todayData.calls + histCalls,
}
scanProjects = todayProviderProjects
scanRange = todayRange
}
// PROVIDERS
@ -538,9 +551,12 @@ program
// in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS))
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
const fullHistory = [...allCacheDays, ...(await getTodayAllDays()).filter(d => d.date === todayStr)]
const dailyHistory = fullHistory.map(d => {
if (isAllProviders) {
let dailyHistory
if (isAllProviders) {
const todayDays = (await getTodayAllDays()).filter(d => d.date === todayStr)
const fullHistory = [...allCacheDays, ...todayDays]
dailyHistory = fullHistory.map(d => {
const topModels = Object.entries(d.models)
.filter(([name]) => name !== '<synthetic>')
.sort(([, a], [, b]) => b.cost - a.cost)
@ -562,22 +578,159 @@ program
cacheWriteTokens: d.cacheWriteTokens,
topModels,
}
})
} else if (usedPerProviderCachePath) {
const historyFromCache = allCacheDays.map(d => {
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
return {
date: d.date,
cost: prov.cost,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[],
}
})
const todayCost = todayProviderData!.cost
const todayCalls = todayProviderData!.calls
if (todayCost > 0 || todayCalls > 0) {
historyFromCache.push({
date: todayStr,
cost: todayCost,
calls: todayCalls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
})
}
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
return {
date: d.date,
cost: prov.cost,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [],
}
})
dailyHistory = historyFromCache
} else {
const histFromCache = allCacheDays.map(d => {
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
return {
date: d.date,
cost: prov.cost,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[],
}
})
const fallbackDays = aggregateProjectsIntoDays(scanProjects)
const liveDays = fallbackDays.map(d => {
const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
return {
date: d.date,
cost: prov.cost,
calls: prov.calls,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
topModels: [] as { name: string; cost: number; calls: number; inputTokens: number; outputTokens: number }[],
}
})
dailyHistory = [...histFromCache, ...liveDays]
}
const home = homedir()
const friendlyProject = (p: ProjectSummary) => {
const resolved = p.projectPath || p.project
if (resolved === home || resolved === home + '/') return 'Home'
return resolved.split('/').filter(Boolean).pop() || p.project
}
currentData.projects = scanProjects.map(p => ({
name: friendlyProject(p),
cost: p.totalCostUSD,
sessions: p.sessions.length,
sessionDetails: [...p.sessions]
.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
.slice(0, 10)
.map(s => ({
cost: s.totalCostUSD,
calls: s.apiCalls,
inputTokens: s.totalInputTokens,
outputTokens: s.totalOutputTokens,
date: s.firstTimestamp?.split('T')[0] ?? '',
models: Object.entries(s.modelBreakdown)
.map(([name, m]) => ({ name, cost: m.costUSD }))
.sort((a, b) => b.cost - a.cost)
.slice(0, 3),
})),
}))
const effMap = aggregateModelEfficiency(scanProjects)
currentData.modelEfficiency = [...effMap.entries()].map(([name, eff]) => ({
name,
costPerEdit: eff.costPerEditUSD,
oneShotRate: eff.oneShotRate,
}))
const retryTaxByModel = [...effMap.values()]
.filter(m => m.retries > 0 && m.editTurns > 0)
.map(m => ({
name: m.model,
taxUSD: m.retries * (m.editCostUSD / m.editTurns),
retries: m.retries,
retriesPerEdit: m.retriesPerEdit,
}))
.sort((a, b) => b.taxUSD - a.taxUSD)
const retryTax = {
totalUSD: retryTaxByModel.reduce((s, m) => s + m.taxUSD, 0),
retries: retryTaxByModel.reduce((s, m) => s + m.retries, 0),
editTurns: [...effMap.values()].filter(m => m.retries > 0).reduce((s, m) => s + m.editTurns, 0),
byModel: retryTaxByModel.slice(0, 5),
}
currentData.topSessions = scanProjects.flatMap(p =>
p.sessions.map(s => ({
project: friendlyProject(p),
cost: s.totalCostUSD,
calls: s.apiCalls,
date: s.firstTimestamp?.split('T')[0] ?? '',
}))
).sort((a, b) => b.cost - a.cost).slice(0, 5)
// Routing waste: find cheapest reliable model (≥90% 1-shot, ≥5 edits),
// then compute how much each pricier model overpaid.
const reliableModels = [...effMap.values()]
.filter(m => m.oneShotRate !== null && m.oneShotRate >= 90 && m.editTurns >= 5
&& (m.costPerEditUSD ?? 0) >= 0.01)
.sort((a, b) => (a.costPerEditUSD ?? Infinity) - (b.costPerEditUSD ?? Infinity))
const baseline = reliableModels[0]
const routingWasteByModel = baseline
? [...effMap.values()]
.filter(m => m.model !== baseline.model && m.editTurns > 0 && (m.costPerEditUSD ?? 0) > (baseline.costPerEditUSD ?? 0))
.map(m => {
const counterfactual = m.editTurns * (baseline.costPerEditUSD ?? 0)
return {
name: m.model,
costPerEdit: m.costPerEditUSD ?? 0,
editTurns: m.editTurns,
actualUSD: m.editCostUSD,
counterfactualUSD: counterfactual,
savingsUSD: m.editCostUSD - counterfactual,
}
})
.filter(m => m.savingsUSD > 0)
.sort((a, b) => b.savingsUSD - a.savingsUSD)
: []
const routingWaste = {
totalSavingsUSD: routingWasteByModel.reduce((s, m) => s + m.savingsUSD, 0),
baselineModel: baseline?.model ?? '',
baselineCostPerEdit: baseline?.costPerEditUSD ?? 0,
byModel: routingWasteByModel.slice(0, 5),
}
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))
console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste)))
return
}

View file

@ -12,6 +12,9 @@ export type PeriodData = {
cacheWriteTokens: number
categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }>
models: Array<{ name: string; cost: number; calls: number }>
projects?: Array<{ name: string; cost: number; sessions: number; sessionDetails?: Array<{ cost: number; calls: number; inputTokens: number; outputTokens: number; date: string; models: Array<{ name: string; cost: number }> }> }>
modelEfficiency?: Array<{ name: string; costPerEdit: number | null; oneShotRate: number | null }>
topSessions?: Array<{ project: string; cost: number; calls: number; date: string }>
}
export type ProviderCost = {
@ -25,6 +28,9 @@ const TOP_MODELS_LIMIT = 20
const TOP_FINDINGS_LIMIT = 10
const HISTORY_DAYS_LIMIT = 365
const SYNTHETIC_MODEL_NAME = '<synthetic>'
const TOP_PROJECTS_LIMIT = 5
const TOP_SESSIONS_LIMIT = 3
const MODEL_EFFICIENCY_LIMIT = 5
export type DailyModelBreakdown = {
name: string
@ -68,6 +74,55 @@ export type MenubarPayload = {
calls: number
}>
providers: Record<string, number>
topProjects: Array<{
name: string
cost: number
sessions: number
avgCostPerSession: number
sessionDetails: Array<{
cost: number
calls: number
inputTokens: number
outputTokens: number
date: string
models: Array<{ name: string; cost: number }>
}>
}>
modelEfficiency: Array<{
name: string
costPerEdit: number | null
oneShotRate: number | null
}>
topSessions: Array<{
project: string
cost: number
calls: number
date: string
}>
retryTax: {
totalUSD: number
retries: number
editTurns: number
byModel: Array<{
name: string
taxUSD: number
retries: number
retriesPerEdit: number | null
}>
}
routingWaste: {
totalSavingsUSD: number
baselineModel: string
baselineCostPerEdit: number
byModel: Array<{
name: string
costPerEdit: number
editTurns: number
actualUSD: number
counterfactualUSD: number
savingsUSD: number
}>
}
}
optimize: {
findingCount: number
@ -155,11 +210,49 @@ function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['h
return { daily: trimmed }
}
function buildTopProjects(projects: PeriodData['projects']): MenubarPayload['current']['topProjects'] {
return (projects ?? [])
.filter(p => p.cost > 0)
.sort((a, b) => b.cost - a.cost)
.slice(0, TOP_PROJECTS_LIMIT)
.map(p => ({
name: p.name,
cost: p.cost,
sessions: p.sessions,
avgCostPerSession: p.sessions > 0 ? p.cost / p.sessions : 0,
sessionDetails: (p.sessionDetails ?? []).map(s => ({
cost: s.cost,
calls: s.calls,
inputTokens: s.inputTokens,
outputTokens: s.outputTokens,
date: s.date,
models: s.models,
})),
}))
}
function buildModelEfficiency(models: PeriodData['modelEfficiency']): MenubarPayload['current']['modelEfficiency'] {
return (models ?? [])
.filter(m => m.costPerEdit !== null)
.sort((a, b) => (a.costPerEdit ?? Infinity) - (b.costPerEdit ?? Infinity))
.slice(0, MODEL_EFFICIENCY_LIMIT)
.map(m => ({ name: m.name, costPerEdit: m.costPerEdit, oneShotRate: m.oneShotRate }))
}
function buildTopSessions(sessions: PeriodData['topSessions']): MenubarPayload['current']['topSessions'] {
return (sessions ?? [])
.sort((a, b) => b.cost - a.cost)
.slice(0, TOP_SESSIONS_LIMIT)
.map(s => ({ project: s.project, cost: s.cost, calls: s.calls, date: s.date }))
}
export function buildMenubarPayload(
current: PeriodData,
providers: ProviderCost[],
optimize: OptimizeResult | null,
dailyHistory?: DailyHistoryEntry[],
retryTax?: MenubarPayload['current']['retryTax'],
routingWaste?: MenubarPayload['current']['routingWaste'],
): MenubarPayload {
return {
generated: new Date().toISOString(),
@ -175,6 +268,11 @@ export function buildMenubarPayload(
topActivities: buildTopActivities(current.categories),
topModels: buildTopModels(current.models),
providers: buildProviders(providers),
topProjects: buildTopProjects(current.projects ?? []),
modelEfficiency: buildModelEfficiency(current.modelEfficiency ?? []),
topSessions: buildTopSessions(current.topSessions ?? []),
retryTax: retryTax ?? { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
routingWaste: routingWaste ?? { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
},
optimize: buildOptimize(optimize),
history: buildHistory(dailyHistory),

View file

@ -1321,18 +1321,24 @@ async function parseSessionFile(
async function collectJsonlFiles(dirPath: string): Promise<string[]> {
const files = await readdir(dirPath).catch(() => [])
const jsonlFiles = files.filter(f => f.endsWith('.jsonl')).map(f => join(dirPath, f))
const jsonlFiles = new Set(files.filter(f => f.endsWith('.jsonl')).map(f => join(dirPath, f)))
const directSubagentsPath = join(dirPath, 'subagents')
const directSubFiles = await readdir(directSubagentsPath).catch(() => [])
for (const sf of directSubFiles) {
if (sf.endsWith('.jsonl')) jsonlFiles.add(join(directSubagentsPath, sf))
}
for (const entry of files) {
if (entry.endsWith('.jsonl')) continue
const subagentsPath = join(dirPath, entry, 'subagents')
const subFiles = await readdir(subagentsPath).catch(() => [])
for (const sf of subFiles) {
if (sf.endsWith('.jsonl')) jsonlFiles.push(join(subagentsPath, sf))
if (sf.endsWith('.jsonl')) jsonlFiles.add(join(subagentsPath, sf))
}
}
return jsonlFiles
return [...jsonlFiles]
}
async function scanProjectDirs(
@ -1373,9 +1379,7 @@ async function scanProjectDirs(
}
}
// Parse changed files, update cache
for (const { filePath, info } of changedFiles) {
// Clear stale entry before parse — if parse fails, file is excluded
delete section.files[filePath]
const tracker = { lastCompleteLineOffset: 0 }
@ -1390,16 +1394,18 @@ async function scanProjectDirs(
mcpInventory: extractMcpInventory(entries),
turns: turns.map(parsedTurnToCachedTurn),
}
;(diskCache as { _dirty?: boolean })._dirty = true
}
// Remove deleted files from cache
for (const cachedPath of Object.keys(section.files)) {
if (!allDiscoveredFiles.has(cachedPath)) {
delete section.files[cachedPath]
if (dirs.length > 0) {
for (const cachedPath of Object.keys(section.files)) {
if (!allDiscoveredFiles.has(cachedPath)) {
delete section.files[cachedPath]
;(diskCache as { _dirty?: boolean })._dirty = true
}
}
}
// Query-time: derive ProjectSummary[] from all cached turns
const projectMap = new Map<string, { project: string; projectPath: string; sessions: SessionSummary[] }>()
const allFiles = [
@ -1511,6 +1517,33 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {
// ── Cache Conversion ───────────────────────────────────────────────────
function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
return {
provider: call.provider,
model: call.model,
usage: {
inputTokens: call.inputTokens,
outputTokens: call.outputTokens,
cacheCreationInputTokens: call.cacheCreationInputTokens,
cacheReadInputTokens: call.cacheReadInputTokens,
cachedInputTokens: call.cachedInputTokens,
reasoningTokens: call.reasoningTokens,
webSearchRequests: call.webSearchRequests,
cacheCreationOneHourTokens: 0,
},
costUSD: call.provider === 'mistral-vibe' ? call.costUSD : undefined,
speed: call.speed,
timestamp: call.timestamp,
tools: call.tools,
bashCommands: call.bashCommands,
skills: [],
deduplicationKey: call.deduplicationKey,
project: call.project,
projectPath: call.projectPath,
toolSequence: call.toolSequence,
}
}
function apiCallToCachedCall(call: ParsedApiCall): CachedCall {
return {
provider: call.provider,
@ -1539,31 +1572,38 @@ function providerCallToCachedTurn(call: ParsedProviderCall): CachedTurn {
timestamp: call.timestamp,
sessionId: call.sessionId,
userMessage: call.userMessage.slice(0, 2000),
calls: [{
provider: call.provider,
model: call.model,
usage: {
inputTokens: call.inputTokens,
outputTokens: call.outputTokens,
cacheCreationInputTokens: call.cacheCreationInputTokens,
cacheReadInputTokens: call.cacheReadInputTokens,
cachedInputTokens: call.cachedInputTokens,
reasoningTokens: call.reasoningTokens,
webSearchRequests: call.webSearchRequests,
cacheCreationOneHourTokens: 0,
},
speed: call.speed,
timestamp: call.timestamp,
tools: call.tools,
bashCommands: call.bashCommands,
skills: [],
deduplicationKey: call.deduplicationKey,
project: call.project,
projectPath: call.projectPath,
}],
calls: [providerCallToCachedCall(call)],
}
}
function providerCallsToCachedTurns(calls: ParsedProviderCall[]): CachedTurn[] {
const turns: CachedTurn[] = []
const grouped = new Map<string, CachedTurn>()
for (const call of calls) {
if (!call.turnId) {
turns.push(providerCallToCachedTurn(call))
continue
}
const key = `${call.sessionId}\0${call.turnId}`
let turn = grouped.get(key)
if (!turn) {
turn = {
timestamp: call.timestamp,
sessionId: call.sessionId,
userMessage: call.userMessage.slice(0, 2000),
calls: [],
}
grouped.set(key, turn)
turns.push(turn)
}
turn.calls.push(providerCallToCachedCall(call))
}
return turns
}
function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
const u = call.usage
const outputForCost = call.provider === 'claude'
@ -1586,7 +1626,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
reasoningTokens: u.reasoningTokens,
webSearchRequests: u.webSearchRequests,
},
costUSD,
costUSD: call.costUSD ?? costUSD,
tools: call.tools,
mcpTools: extractMcpTools(call.tools),
skills: call.skills,
@ -1597,6 +1637,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
bashCommands: call.bashCommands,
deduplicationKey: call.deduplicationKey,
cacheCreationOneHourTokens: u.cacheCreationOneHourTokens || undefined,
toolSequence: call.toolSequence,
}
}
@ -1639,6 +1680,14 @@ function getOrCreateProviderSection(cache: SessionCache, provider: string): Prov
return section
}
function cachedFileNeedsProviderReparse(providerName: string, cached: CachedFile): boolean {
if (providerName !== 'gemini') return false
return cached.turns.some(turn =>
turn.calls.some(call => call.deduplicationKey === `gemini:${turn.sessionId}`),
)
}
const warnedProviderReadFailures = new Set<string>()
function warnProviderReadFailureOnce(providerName: string, err: unknown): void {
@ -1674,9 +1723,10 @@ async function parseProviderSources(
const fp = await fingerprintFile(source.path)
if (!fp) continue
const action = reconcileFile(fp, section.files[source.path])
if (action.action === 'unchanged') {
unchangedSources.push({ source, cached: section.files[source.path]! })
const cached = section.files[source.path]
const action = reconcileFile(fp, cached)
if (action.action === 'unchanged' && cached && !cachedFileNeedsProviderReparse(providerName, cached)) {
unchangedSources.push({ source, cached })
} else {
changedSources.push({ source, fp })
}
@ -1710,12 +1760,14 @@ async function parseProviderSources(
)
try {
const turns: CachedTurn[] = []
const providerCalls: ParsedProviderCall[] = []
for await (const call of parser.parse()) {
turns.push(providerCallToCachedTurn(call))
providerCalls.push(call)
}
const turns = providerCallsToCachedTurns(providerCalls)
section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns }
didParse = true
;(diskCache as { _dirty?: boolean })._dirty = true
} catch (err) {
if (isSqliteBusyError(err)) {
warnProviderReadFailureOnce(providerName, err)
@ -1732,10 +1784,12 @@ async function parseProviderSources(
}
}
// Remove deleted files from cache
for (const cachedPath of Object.keys(section.files)) {
if (!allDiscoveredFiles.has(cachedPath)) {
delete section.files[cachedPath]
if (sources.length > 0) {
for (const cachedPath of Object.keys(section.files)) {
if (!allDiscoveredFiles.has(cachedPath)) {
delete section.files[cachedPath]
;(diskCache as { _dirty?: boolean })._dirty = true
}
}
}
@ -1805,7 +1859,7 @@ async function parseProviderSources(
return projects
}
const CACHE_TTL_MS = 60_000
const CACHE_TTL_MS = 180_000
const MAX_CACHE_ENTRIES = 10
const sessionCache = new Map<string, { data: ProjectSummary[]; ts: number }>()
@ -1919,7 +1973,9 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s
otherProjects.push(...projects)
}
try { await saveCache(diskCache) } catch {}
if ((diskCache as { _dirty?: boolean })._dirty) {
try { await saveCache(diskCache) } catch {}
}
const mergedMap = new Map<string, ProjectSummary>()
for (const p of [...claudeProjects, ...otherProjects]) {

View file

@ -66,84 +66,85 @@ type GeminiSession = {
function parseSession(data: GeminiSession, seenKeys: Set<string>): ParsedProviderCall[] {
const results: ParsedProviderCall[] = []
const geminiMessages = data.messages.filter(m => m.type === 'gemini' && m.tokens && m.model)
if (geminiMessages.length === 0) return results
let lastUserMessage = ''
let turnOrdinal = 0
let currentTurnId = `${data.sessionId}:prelude`
let geminiOrdinal = 0
const dedupKey = `gemini:${data.sessionId}`
if (seenKeys.has(dedupKey)) return results
seenKeys.add(dedupKey)
for (const msg of data.messages) {
if (msg.type === 'user') {
if (Array.isArray(msg.content)) {
lastUserMessage = msg.content.map(c => c.text).join(' ').slice(0, 500)
} else if (typeof msg.content === 'string') {
lastUserMessage = msg.content.slice(0, 500)
}
currentTurnId = `${data.sessionId}:turn-${turnOrdinal++}`
continue
}
let totalInput = 0
let totalOutput = 0
let totalCached = 0
let totalThoughts = 0
const allTools: string[] = []
const bashCommands: string[] = []
let model = ''
if (msg.type !== 'gemini' || !msg.tokens || !msg.model) continue
for (const msg of geminiMessages) {
const t = msg.tokens!
totalInput += t.input ?? 0
totalOutput += t.output ?? 0
totalCached += t.cached ?? 0
totalThoughts += t.thoughts ?? 0
if (msg.model && !model) model = msg.model
const t = msg.tokens
const totalInput = t.input ?? 0
const totalOutput = t.output ?? 0
const totalCached = t.cached ?? 0
const totalThoughts = t.thoughts ?? 0
if (totalInput === 0 && totalOutput === 0 && totalCached === 0 && totalThoughts === 0) continue
const messageKey = msg.id || `idx-${geminiOrdinal}`
geminiOrdinal++
const dedupKey = `gemini:${data.sessionId}:${messageKey}`
if (seenKeys.has(dedupKey)) continue
const tools: string[] = []
const bashCommands: string[] = []
if (msg.toolCalls) {
for (const tc of msg.toolCalls) {
const mapped = toolNameMap[tc.displayName ?? ''] ?? toolNameMap[tc.name] ?? tc.displayName ?? tc.name
allTools.push(mapped)
tools.push(mapped)
if (mapped === 'Bash' && tc.args && typeof tc.args.command === 'string') {
bashCommands.push(...extractBashCommands(tc.args.command))
}
}
}
// Gemini's `input` count includes `cached` tokens as a subset, so fresh
// input must subtract cached to avoid double-charging at both rates.
const freshInput = Math.max(0, totalInput - totalCached)
const tsDate = new Date(msg.timestamp || data.startTime)
if (isNaN(tsDate.getTime()) || tsDate.getTime() < 1_000_000_000_000) continue
seenKeys.add(dedupKey)
// Gemini bills thoughts at the output token rate; calculateCost does not
// accept a reasoning parameter, so fold thoughts into the output count for
// pricing while keeping outputTokens / reasoningTokens reported separately.
const costUSD = calculateCost(msg.model, freshInput, totalOutput + totalThoughts, 0, totalCached, 0)
results.push({
provider: 'gemini',
model: msg.model,
inputTokens: freshInput,
outputTokens: totalOutput,
cacheCreationInputTokens: 0,
cacheReadInputTokens: totalCached,
cachedInputTokens: totalCached,
reasoningTokens: totalThoughts,
webSearchRequests: 0,
costUSD,
tools: [...new Set(tools)],
bashCommands: [...new Set(bashCommands)],
timestamp: tsDate.toISOString(),
speed: 'standard',
deduplicationKey: dedupKey,
turnId: currentTurnId,
userMessage: lastUserMessage,
sessionId: data.sessionId,
})
}
if (totalInput === 0 && totalOutput === 0) return results
// Gemini's `input` count includes `cached` tokens as a subset, so fresh input
// must subtract cached to avoid double-charging at both rates.
const freshInput = totalInput - totalCached
let userMessage = ''
const firstUser = data.messages.find(m => m.type === 'user')
if (firstUser) {
if (Array.isArray(firstUser.content)) {
userMessage = firstUser.content.map(c => c.text).join(' ').slice(0, 500)
} else if (typeof firstUser.content === 'string') {
userMessage = firstUser.content.slice(0, 500)
}
}
const tsDate = new Date(data.startTime)
if (isNaN(tsDate.getTime()) || tsDate.getTime() < 1_000_000_000_000) return results
// Gemini bills thoughts at the output token rate; calculateCost does not
// accept a reasoning parameter, so fold thoughts into the output count for
// pricing while keeping outputTokens / reasoningTokens reported separately.
const costUSD = calculateCost(model, freshInput, totalOutput + totalThoughts, 0, totalCached, 0)
results.push({
provider: 'gemini',
model,
inputTokens: freshInput,
outputTokens: totalOutput,
cacheCreationInputTokens: 0,
cacheReadInputTokens: totalCached,
cachedInputTokens: totalCached,
reasoningTokens: totalThoughts,
webSearchRequests: 0,
costUSD,
tools: [...new Set(allTools)],
bashCommands: [...new Set(bashCommands)],
timestamp: tsDate.toISOString(),
speed: 'standard',
deduplicationKey: dedupKey,
userMessage,
sessionId: data.sessionId,
})
return results
}

View file

@ -80,14 +80,15 @@ function parseModelConfig(raw: string | null): ModelConfig {
}
}
function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tools: string[]; bashCommands: string[] } {
function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tools: string[]; bashCommands: string[]; toolSequence: string[][] } {
const tools: string[] = []
const bashCommands: string[] = []
const seen = new Set<string>()
const toolSequence: string[][] = []
try {
const rows = db.query<{ content_json: Uint8Array | string }>(
"SELECT CAST(content_json AS BLOB) AS content_json FROM messages WHERE session_id = ? AND role = 'assistant' AND content_json LIKE '%toolRequest%'",
"SELECT CAST(content_json AS BLOB) AS content_json FROM messages WHERE session_id = ? AND role = 'assistant' AND content_json LIKE '%toolRequest%' ORDER BY created_timestamp ASC",
[sessionId],
)
@ -98,6 +99,7 @@ function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tool
} catch {
continue
}
const msgTools: string[] = []
for (const item of items) {
if (item.type !== 'toolRequest') continue
const rawName = item.toolCall?.value?.name ?? ''
@ -107,6 +109,7 @@ function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tool
seen.add(mapped)
tools.push(mapped)
}
msgTools.push(mapped)
if (mapped === 'Bash') {
const cmd = item.toolCall?.value?.arguments?.command
if (typeof cmd === 'string') {
@ -116,10 +119,11 @@ function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tool
}
}
}
if (msgTools.length > 0) toolSequence.push(msgTools)
}
} catch { /* best-effort */ }
return { tools, bashCommands }
return { tools, bashCommands, toolSequence }
}
function getFirstUserMessage(db: SqliteDatabase, sessionId: string): string {
@ -179,7 +183,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
const model = config.model_name ?? 'unknown'
const costUSD = calculateCost(model, inputTokens, outputTokens, 0, 0, 0)
const { tools, bashCommands } = extractToolsFromMessages(db, sessionId)
const { tools, bashCommands, toolSequence } = extractToolsFromMessages(db, sessionId)
const userMessage = getFirstUserMessage(db, sessionId)
const raw = session.updated_at || session.created_at || ''
@ -200,6 +204,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
costUSD,
tools,
bashCommands,
toolSequence: toolSequence.length > 1 ? toolSequence : undefined,
timestamp: ts.toISOString(),
speed: 'standard',
deduplicationKey: dedupKey,

View file

@ -86,6 +86,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s
let pendingUserMessage = ''
const allTools: string[] = []
const toolSequence: string[][] = []
for (const msg of chat) {
if (msg.role === 'human') {
@ -93,7 +94,9 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s
pendingUserMessage = msg.content.slice(0, 500)
}
if (msg.role === 'bot') {
allTools.push(...extractToolNames(msg.content))
const msgTools = extractToolNames(msg.content)
allTools.push(...msgTools)
if (msgTools.length > 0) toolSequence.push(msgTools)
}
}
@ -125,6 +128,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s
costUSD,
tools: [...new Set(allTools)],
bashCommands: [],
toolSequence: toolSequence.length > 1 ? toolSequence : undefined,
timestamp,
speed: 'standard',
deduplicationKey: dedupKey,

View file

@ -38,6 +38,7 @@ const toolNameMap: Record<string, string> = {
type VibeStats = {
session_prompt_tokens?: number
session_completion_tokens?: number
session_cost?: number
input_price_per_million?: number
output_price_per_million?: number
tokens_per_second?: number
@ -75,6 +76,8 @@ type VibeToolCall = {
type VibeMessage = {
role?: string
content?: unknown
message_id?: string
timestamp?: string
tool_calls?: VibeToolCall[] | null
}
@ -179,6 +182,9 @@ function safeNumber(value: unknown): number {
function calculateSessionCost(metadata: VibeMetadata, model: string, inputTokens: number, outputTokens: number): number {
const stats = metadata.stats ?? {}
const sessionCost = safeNumber(stats.session_cost)
if (sessionCost > 0) return sessionCost
const configured = activeModelConfig(metadata)
const inputPrice = safeNumber(stats.input_price_per_million) || safeNumber(configured?.input_price)
const outputPrice = safeNumber(stats.output_price_per_million) || safeNumber(configured?.output_price)
@ -216,26 +222,41 @@ function parseToolArguments(raw: string | Record<string, unknown> | null | undef
}
}
function extractMessageTools(message: VibeMessage): { tools: string[]; bashCommands: string[] } {
const tools: string[] = []
const bashCommands: string[] = []
if (message.role !== 'assistant') return { tools, bashCommands }
for (const toolCall of message.tool_calls ?? []) {
const rawName = toolCall.function?.name
if (!rawName) continue
const mappedName = toolNameMap[rawName] ?? rawName
tools.push(mappedName)
if (mappedName !== 'Bash') continue
const args = parseToolArguments(toolCall.function?.arguments)
const command = args['command']
if (typeof command === 'string') {
bashCommands.push(...extractBashCommands(command))
}
}
return {
tools: [...new Set(tools)],
bashCommands: [...new Set(bashCommands)],
}
}
function extractTools(messages: VibeMessage[]): { tools: string[]; bashCommands: string[] } {
const tools: string[] = []
const bashCommands: string[] = []
for (const message of messages) {
if (message.role !== 'assistant') continue
for (const toolCall of message.tool_calls ?? []) {
const rawName = toolCall.function?.name
if (!rawName) continue
const mappedName = toolNameMap[rawName] ?? rawName
tools.push(mappedName)
if (mappedName !== 'Bash') continue
const args = parseToolArguments(toolCall.function?.arguments)
const command = args['command']
if (typeof command === 'string') {
bashCommands.push(...extractBashCommands(command))
}
}
const extracted = extractMessageTools(message)
tools.push(...extracted.tools)
bashCommands.push(...extracted.bashCommands)
}
return {
@ -267,6 +288,17 @@ function firstUserMessage(messages: VibeMessage[], fallback?: string | null): st
return (fallback ?? '').slice(0, 500)
}
function allocateInteger(total: number, index: number, count: number): number {
if (count <= 1) return total
const base = Math.floor(total / count)
const remainder = total % count
return base + (index < remainder ? 1 : 0)
}
function allocateCost(total: number, count: number): number {
return count <= 1 ? total : total / count
}
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
@ -281,33 +313,85 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
if (inputTokens === 0 && outputTokens === 0) return
const sessionId = metadata.session_id || basename(source.path)
const deduplicationKey = `mistral-vibe:${sessionId}`
if (seenKeys.has(deduplicationKey)) return
seenKeys.add(deduplicationKey)
const messages = await readMessages(messagesPath)
const model = resolveModel(metadata)
const { tools, bashCommands } = extractTools(messages)
const costUSD = calculateSessionCost(metadata, model, inputTokens, outputTokens)
const assistantMessages = messages.filter(m => m.role === 'assistant')
const fallbackTimestamp = metadata.end_time ?? metadata.start_time ?? ''
yield {
provider: 'mistral-vibe',
model,
inputTokens,
outputTokens,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
costUSD,
tools,
bashCommands,
timestamp: metadata.end_time ?? metadata.start_time ?? '',
speed: 'standard',
deduplicationKey,
userMessage: firstUserMessage(messages, metadata.title),
sessionId,
if (assistantMessages.length === 0) {
const deduplicationKey = `mistral-vibe:${sessionId}`
if (seenKeys.has(deduplicationKey)) return
seenKeys.add(deduplicationKey)
const { tools, bashCommands } = extractTools(messages)
yield {
provider: 'mistral-vibe',
model,
inputTokens,
outputTokens,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
costUSD,
tools,
bashCommands,
timestamp: fallbackTimestamp,
speed: 'standard',
deduplicationKey,
userMessage: firstUserMessage(messages, metadata.title),
sessionId,
}
return
}
let currentUserMessage = (metadata.title ?? '').slice(0, 500)
let turnOrdinal = 0
let currentTurnId = `${sessionId}:prelude`
let assistantOrdinal = 0
for (const message of messages) {
if (message.role === 'user') {
const text = normalizeContent(message.content).trim()
if (text) currentUserMessage = text.slice(0, 500)
currentTurnId = `${sessionId}:turn-${turnOrdinal++}`
continue
}
if (message.role !== 'assistant') continue
const messageKey = message.message_id || `idx-${assistantOrdinal}`
const deduplicationKey = `mistral-vibe:${sessionId}:${messageKey}`
const allocationIndex = assistantOrdinal
assistantOrdinal++
if (seenKeys.has(deduplicationKey)) continue
seenKeys.add(deduplicationKey)
const { tools, bashCommands } = extractMessageTools(message)
yield {
provider: 'mistral-vibe',
model,
inputTokens: allocateInteger(inputTokens, allocationIndex, assistantMessages.length),
outputTokens: allocateInteger(outputTokens, allocationIndex, assistantMessages.length),
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
costUSD: allocateCost(costUSD, assistantMessages.length),
tools,
bashCommands,
timestamp: message.timestamp ?? fallbackTimestamp,
speed: 'standard',
deduplicationKey,
turnId: currentTurnId,
userMessage: currentUserMessage,
sessionId,
}
}
},
}

View file

@ -13,6 +13,7 @@ import type {
} from './types.js'
type MessageRow = {
session_id: string
id: string
time_created: number
data: Uint8Array | string
@ -189,12 +190,34 @@ function createParser(
}
const messages = db.query<MessageRow>(
'SELECT id, time_created, CAST(data AS BLOB) AS data FROM message WHERE session_id = ? ORDER BY time_created ASC',
`WITH RECURSIVE session_tree(id) AS (
SELECT id FROM session WHERE id = ?
UNION
SELECT child.id
FROM session child
JOIN session_tree parent ON child.parent_id = parent.id
WHERE child.time_archived IS NULL
)
SELECT session_id, id, time_created, CAST(data AS BLOB) AS data
FROM message
WHERE session_id IN (SELECT id FROM session_tree)
ORDER BY time_created ASC, id ASC`,
[sessionId],
)
const parts = db.query<PartRow>(
'SELECT message_id, CAST(data AS BLOB) AS data FROM part WHERE session_id = ? ORDER BY message_id, id',
`WITH RECURSIVE session_tree(id) AS (
SELECT id FROM session WHERE id = ?
UNION
SELECT child.id
FROM session child
JOIN session_tree parent ON child.parent_id = parent.id
WHERE child.time_archived IS NULL
)
SELECT message_id, CAST(data AS BLOB) AS data
FROM part
WHERE session_id IN (SELECT id FROM session_tree)
ORDER BY message_id, id`,
[sessionId],
)
@ -210,7 +233,7 @@ function createParser(
}
}
let currentUserMessage = ''
const currentUserMessageBySession = new Map<string, string>()
for (const msg of messages) {
let data: MessageData
@ -226,7 +249,7 @@ function createParser(
.map((p) => p.text ?? '')
.filter(Boolean)
if (textParts.length > 0) {
currentUserMessage = textParts.join(' ')
currentUserMessageBySession.set(msg.session_id, textParts.join(' '))
}
continue
}
@ -241,16 +264,19 @@ function createParser(
cacheWrite: data.tokens?.cache?.write ?? 0,
}
const msgParts = partsByMsg.get(msg.id) ?? []
const toolParts = msgParts.filter((p) => p.type === 'tool' && normalizeToolName(p.tool))
const hasTextOutput = msgParts.some((p) => p.type === 'text' && typeof p.text === 'string' && p.text.trim().length > 0)
const hasActivity = hasTextOutput || toolParts.length > 0
const allZero =
tokens.input === 0 &&
tokens.output === 0 &&
tokens.reasoning === 0 &&
tokens.cacheRead === 0 &&
tokens.cacheWrite === 0
if (allZero && (data.cost ?? 0) === 0) continue
if (allZero && (data.cost ?? 0) === 0 && !hasActivity) continue
const msgParts = partsByMsg.get(msg.id) ?? []
const toolParts = msgParts.filter((p) => p.type === 'tool')
const tools = toolParts
.map((p) => normalizeToolName(p.tool))
.filter(Boolean)
@ -259,7 +285,7 @@ function createParser(
.filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string')
.flatMap((p) => extractBashCommands(p.state!.input!.command!))
const dedupKey = `opencode:${sessionId}:${msg.id}`
const dedupKey = `opencode:${msg.session_id}:${msg.id}`
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)
@ -293,7 +319,7 @@ function createParser(
timestamp: parseTimestamp(msg.time_created),
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: currentUserMessage,
userMessage: currentUserMessageBySession.get(msg.session_id) ?? '',
sessionId,
}
}

View file

@ -25,6 +25,8 @@ export type ParsedProviderCall = {
timestamp: string
speed: 'standard' | 'fast'
deduplicationKey: string
turnId?: string
toolSequence?: string[][]
userMessage: string
sessionId: string
project?: string

View file

@ -21,6 +21,7 @@ export type CachedCall = {
provider: string
model: string
usage: CachedUsage
costUSD?: number
speed: 'standard' | 'fast'
timestamp: string
tools: string[]
@ -29,6 +30,7 @@ export type CachedCall = {
deduplicationKey: string
project?: string
projectPath?: string
toolSequence?: string[][]
}
export type CachedTurn = {
@ -65,7 +67,7 @@ export type SessionCache = {
// ── Constants ──────────────────────────────────────────────────────────
export const CACHE_VERSION = 1
export const CACHE_VERSION = 2
const CACHE_FILE = 'session-cache.json'
const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000
@ -147,11 +149,13 @@ function validateCall(c: unknown): c is CachedCall {
&& typeof o['deduplicationKey'] === 'string'
&& typeof o['timestamp'] === 'string'
&& (o['speed'] === 'standard' || o['speed'] === 'fast')
&& isOptionalNum(o['costUSD'])
&& isStringArray(o['tools'])
&& isStringArray(o['bashCommands'])
&& isStringArray(o['skills'])
&& isOptionalString(o['project'])
&& isOptionalString(o['projectPath'])
&& (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isStringArray(s))))
&& validateUsage(o['usage'])
}
@ -209,6 +213,7 @@ export async function saveCache(cache: SessionCache): Promise<void> {
const finalPath = getCachePath()
const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
delete (cache as { _dirty?: boolean })._dirty
const payload = JSON.stringify(cache)
const handle = await open(tempPath, 'w', 0o600)
@ -234,6 +239,30 @@ export async function fingerprintFile(filePath: string): Promise<FileFingerprint
const s = await stat(filePath)
return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
} catch {
// Providers encode extra context into source paths using virtual suffixes:
// - Cursor: `<dbPath>#cursor-ws=<workspace>` (workspace-aware routing)
// - OpenCode: `<dbPath>:<sessionId>` (session scoping)
// These compound paths don't exist on disk; strip the suffix to stat the
// underlying file. Try `#` first (rare in real paths), then `:` (must use
// lastIndexOf to tolerate Windows drive letters like C:\...).
const hashIdx = filePath.indexOf('#')
if (hashIdx > 0) {
try {
const s = await stat(filePath.slice(0, hashIdx))
return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
} catch {
// fall through to colon check
}
}
const colonIdx = filePath.lastIndexOf(':')
if (colonIdx > 0) {
try {
const s = await stat(filePath.slice(0, colonIdx))
return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
} catch {
return null
}
}
return null
}
}

View file

@ -84,6 +84,7 @@ export type ParsedApiCall = {
bashCommands: string[]
deduplicationKey: string
cacheCreationOneHourTokens?: number
toolSequence?: string[][]
}
export type TaskCategory =

View file

@ -151,3 +151,46 @@ describe('classifyTurn — feature vs debugging precedence (#196)', () => {
expect(c.category).toBe('debugging')
})
})
describe('classifyTurn — retry detection via toolSequence', () => {
it('detects retries from multi-call turns (Claude-style)', () => {
const turn = makeTurn([
makeCall({ tools: ['Edit'] }),
makeCall({ tools: ['Bash'] }),
makeCall({ tools: ['Edit'] }),
], 'fix the build')
const c = classifyTurn(turn)
expect(c.retries).toBe(1)
})
it('detects retries from toolSequence on a single call (Kiro/Goose-style)', () => {
const call = makeCall({ tools: ['Edit', 'Bash'] })
call.toolSequence = [['Edit'], ['Bash'], ['Edit']]
const turn = makeTurn([call], 'fix the build')
const c = classifyTurn(turn)
expect(c.retries).toBe(1)
})
it('returns 0 retries for single call without toolSequence', () => {
const call = makeCall({ tools: ['Edit', 'Bash'] })
const turn = makeTurn([call], 'fix the build')
const c = classifyTurn(turn)
expect(c.retries).toBe(0)
})
it('counts multiple retries from toolSequence', () => {
const call = makeCall({ tools: ['Edit', 'Bash'] })
call.toolSequence = [['Edit'], ['Bash'], ['Edit'], ['Bash'], ['Edit']]
const turn = makeTurn([call], 'fix the build')
const c = classifyTurn(turn)
expect(c.retries).toBe(2)
})
it('ignores toolSequence with only one step', () => {
const call = makeCall({ tools: ['Edit', 'Bash'] })
call.toolSequence = [['Edit', 'Bash']]
const turn = makeTurn([call], 'fix the build')
const c = classifyTurn(turn)
expect(c.retries).toBe(0)
})
})

View file

@ -0,0 +1,134 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { CACHE_VERSION, computeEnvFingerprint } from '../src/session-cache.js'
import type { DateRange } from '../src/types.js'
let home: string
let cacheDir: string
let previousHome: string | undefined
let previousCacheDir: string | undefined
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'codeburn-gemini-home-'))
cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-gemini-cache-'))
previousHome = process.env['HOME']
previousCacheDir = process.env['CODEBURN_CACHE_DIR']
process.env['HOME'] = home
process.env['CODEBURN_CACHE_DIR'] = cacheDir
})
afterEach(async () => {
clearSessionCache()
if (previousHome === undefined) delete process.env['HOME']
else process.env['HOME'] = previousHome
if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
await rm(home, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
})
describe('Gemini session cache migration', () => {
it('reparses cached legacy aggregate Gemini entries into granular calls', async () => {
const chatsDir = join(home, '.gemini', 'tmp', 'project-a', 'chats')
await mkdir(chatsDir, { recursive: true })
const sessionPath = join(chatsDir, 'session-2026-05-16.json')
await writeFile(sessionPath, JSON.stringify({
sessionId: 'gemini-session-1',
startTime: '2026-05-16T10:00:00.000Z',
messages: [
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
id: 'g1',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'first',
model: 'gemini-3.1-pro-preview',
tokens: { input: 10, output: 5 },
},
{
id: 'g2',
timestamp: '2026-05-16T10:00:10.000Z',
type: 'gemini',
content: 'second',
model: 'gemini-3.1-pro-preview',
tokens: { input: 12, output: 6 },
},
],
}))
const fileStat = await stat(sessionPath)
await writeFile(join(cacheDir, 'session-cache.json'), JSON.stringify({
version: CACHE_VERSION,
providers: {
gemini: {
envFingerprint: computeEnvFingerprint('gemini'),
files: {
[sessionPath]: {
fingerprint: {
dev: fileStat.dev,
ino: fileStat.ino,
mtimeMs: fileStat.mtimeMs,
sizeBytes: fileStat.size,
},
mcpInventory: [],
turns: [{
timestamp: '2026-05-16T10:00:00.000Z',
sessionId: 'gemini-session-1',
userMessage: 'work',
calls: [{
provider: 'gemini',
model: 'gemini-3.1-pro-preview',
usage: {
inputTokens: 22,
outputTokens: 11,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
cacheCreationOneHourTokens: 0,
},
speed: 'standard',
timestamp: '2026-05-16T10:00:00.000Z',
tools: [],
bashCommands: [],
skills: [],
deduplicationKey: 'gemini:gemini-session-1',
}],
}],
},
},
},
},
}))
const range: DateRange = {
start: new Date('2026-05-16T00:00:00.000Z'),
end: new Date('2026-05-16T23:59:59.999Z'),
}
const projects = await parseAllSessions(range, 'gemini')
const keys = projects.flatMap(project =>
project.sessions.flatMap(session =>
session.turns.flatMap(turn => turn.assistantCalls.map(call => call.deduplicationKey)),
),
)
expect(projects[0]!.totalApiCalls).toBe(2)
expect(keys).toEqual([
'gemini:gemini-session-1:g1',
'gemini:gemini-session-1:g2',
])
const savedCache = JSON.parse(await readFile(join(cacheDir, 'session-cache.json'), 'utf-8'))
const savedKeys = savedCache.providers.gemini.files[sessionPath].turns.flatMap((turn: { calls: Array<{ deduplicationKey: string }> }) =>
turn.calls.map(call => call.deduplicationKey),
)
expect(savedKeys).toEqual(keys)
})
})

View file

@ -151,6 +151,63 @@ describe('parseAllSessions with large Claude fixture', () => {
expect(sess.apiCalls).toBeGreaterThanOrEqual(1)
})
it('discovers direct Claude subagent JSONL files under a project directory', async () => {
const projectDir = join(home, '.claude', 'projects', 'direct-subagents')
const subagentsDir = join(projectDir, 'subagents')
await mkdir(subagentsDir, { recursive: true })
const lines = [
userLine('subagent-session', '2026-04-10T10:00:00Z', 100),
assistantLine('subagent-session', '2026-04-10T10:01:00Z', 'subagent-msg', {
contentSize: 0,
toolCount: 2,
}),
]
await writeFile(join(subagentsDir, 'worker.jsonl'), lines.join('\n'))
const range: DateRange = {
start: new Date('2026-04-10T00:00:00Z'),
end: new Date('2026-04-10T23:59:59Z'),
}
const projects = await parseAllSessions(range, 'claude')
expect(projects).toHaveLength(1)
const session = projects[0]!.sessions[0]!
expect(session.sessionId).toBe('worker')
expect(session.apiCalls).toBe(1)
expect(session.toolBreakdown['Edit']?.calls).toBe(1)
expect(session.toolBreakdown['Read']?.calls).toBe(1)
})
it('discovers nested Claude subagent JSONL files under a direct subagents directory', async () => {
const projectDir = join(home, '.claude', 'projects', 'nested-subagents')
const nestedSubagentsDir = join(projectDir, 'subagents', 'subagents')
await mkdir(nestedSubagentsDir, { recursive: true })
const lines = [
userLine('nested-subagent-session', '2026-04-10T11:00:00Z', 100),
assistantLine('nested-subagent-session', '2026-04-10T11:01:00Z', 'nested-subagent-msg', {
contentSize: 0,
toolCount: 1,
}),
]
await writeFile(join(nestedSubagentsDir, 'worker.jsonl'), lines.join('\n'))
const range: DateRange = {
start: new Date('2026-04-10T00:00:00Z'),
end: new Date('2026-04-10T23:59:59Z'),
}
const projects = await parseAllSessions(range, 'claude')
expect(projects).toHaveLength(1)
const session = projects[0]!.sessions[0]!
expect(session.sessionId).toBe('worker')
expect(session.apiCalls).toBe(1)
expect(session.toolBreakdown['Edit']?.calls).toBe(1)
})
it('parses huge message-first assistant lines without full JSON.parse expansion', async () => {
const projectDir = join(home, '.claude', 'projects', 'messagefirst')
await mkdir(projectDir, { recursive: true })

View file

@ -0,0 +1,170 @@
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DateRange } from '../src/types.js'
let home: string
let cacheDir: string
let vibeHome: string
let originalHome: string | undefined
let originalCacheDir: string | undefined
let originalVibeHome: string | undefined
let clearParserCache: (() => void) | undefined
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'codeburn-turn-group-home-'))
cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-turn-group-cache-'))
vibeHome = await mkdtemp(join(tmpdir(), 'codeburn-turn-group-vibe-'))
originalHome = process.env['HOME']
originalCacheDir = process.env['CODEBURN_CACHE_DIR']
originalVibeHome = process.env['VIBE_HOME']
process.env['HOME'] = home
process.env['CODEBURN_CACHE_DIR'] = cacheDir
process.env['VIBE_HOME'] = vibeHome
})
afterEach(async () => {
clearParserCache?.()
clearParserCache = undefined
vi.resetModules()
if (originalHome === undefined) delete process.env['HOME']
else process.env['HOME'] = originalHome
if (originalCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = originalCacheDir
if (originalVibeHome === undefined) delete process.env['VIBE_HOME']
else process.env['VIBE_HOME'] = originalVibeHome
await rm(home, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
await rm(vibeHome, { recursive: true, force: true })
})
function dayRange(): DateRange {
return {
start: new Date('2026-05-16T00:00:00.000Z'),
end: new Date('2026-05-16T23:59:59.999Z'),
}
}
async function loadParser() {
vi.resetModules()
const parser = await import('../src/parser.js')
clearParserCache = parser.clearSessionCache
return parser.parseAllSessions
}
describe('provider turn grouping', () => {
it('groups Gemini assistant messages under their user turn so retries are counted', async () => {
const chatsDir = join(home, '.gemini', 'tmp', 'project-a', 'chats')
await mkdir(chatsDir, { recursive: true })
await writeFile(join(chatsDir, 'session-gemini.json'), JSON.stringify({
sessionId: 'gemini-session-1',
startTime: '2026-05-16T10:00:00.000Z',
messages: [
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'implement parser update in src/parser.ts' },
{
id: 'g1',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'editing',
model: 'gemini-3.1-pro-preview',
tokens: { input: 100, output: 30 },
toolCalls: [{ id: 't1', name: 'edit_file', args: { path: 'src/parser.ts' } }],
},
{
id: 'g2',
timestamp: '2026-05-16T10:00:10.000Z',
type: 'gemini',
content: 'testing',
model: 'gemini-3.1-pro-preview',
tokens: { input: 80, output: 20 },
toolCalls: [{ id: 't2', name: 'run_command', args: { command: 'npm test' } }],
},
{
id: 'g3',
timestamp: '2026-05-16T10:00:15.000Z',
type: 'gemini',
content: 'fixing after test',
model: 'gemini-3.1-pro-preview',
tokens: { input: 90, output: 25 },
toolCalls: [{ id: 't3', name: 'edit_file', args: { path: 'src/parser.ts' } }],
},
],
}))
const parseAllSessions = await loadParser()
const projects = await parseAllSessions(dayRange(), 'gemini')
const session = projects[0]!.sessions[0]!
const turn = session.turns[0]!
expect(session.turns).toHaveLength(1)
expect(turn.assistantCalls.map(call => call.deduplicationKey)).toEqual([
'gemini:gemini-session-1:g1',
'gemini:gemini-session-1:g2',
'gemini:gemini-session-1:g3',
])
expect(turn.hasEdits).toBe(true)
expect(turn.retries).toBe(1)
expect(session.categoryBreakdown[turn.category].editTurns).toBe(1)
expect(session.categoryBreakdown[turn.category].oneShotTurns).toBe(0)
})
it('groups Mistral Vibe assistant messages and uses Vibe session_cost when present', async () => {
const sessionDir = join(vibeHome, 'logs', 'session', 'session_20260516_100000_vibe')
await mkdir(sessionDir, { recursive: true })
await writeFile(join(sessionDir, 'meta.json'), JSON.stringify({
session_id: 'vibe-session-1',
start_time: '2026-05-16T10:00:00.000Z',
end_time: '2026-05-16T10:01:00.000Z',
environment: { working_directory: '/Users/test/project-a' },
stats: {
session_prompt_tokens: 300,
session_completion_tokens: 90,
session_cost: 0.123456,
input_price_per_million: 100,
output_price_per_million: 100,
},
config: { active_model: 'mistral-medium-3.5', models: [] },
title: 'vibe parser update',
}))
await writeFile(join(sessionDir, 'messages.jsonl'), [
{ role: 'user', content: 'implement parser update in src/providers/mistral-vibe.ts', message_id: 'u1' },
{
role: 'assistant',
content: 'editing',
message_id: 'a1',
tool_calls: [{ id: 't1', type: 'function', function: { name: 'search_replace', arguments: '{"file_path":"src/providers/mistral-vibe.ts"}' } }],
},
{
role: 'assistant',
content: 'testing',
message_id: 'a2',
tool_calls: [{ id: 't2', type: 'function', function: { name: 'bash', arguments: '{"command":"npm test"}' } }],
},
{
role: 'assistant',
content: 'fixing after test',
message_id: 'a3',
tool_calls: [{ id: 't3', type: 'function', function: { name: 'write_file', arguments: '{"path":"src/providers/mistral-vibe.ts"}' } }],
},
].map(message => JSON.stringify(message)).join('\n') + '\n')
const parseAllSessions = await loadParser()
const projects = await parseAllSessions(dayRange(), 'mistral-vibe')
const session = projects[0]!.sessions[0]!
const turn = session.turns[0]!
expect(session.turns).toHaveLength(1)
expect(turn.assistantCalls.map(call => call.deduplicationKey)).toEqual([
'mistral-vibe:vibe-session-1:a1',
'mistral-vibe:vibe-session-1:a2',
'mistral-vibe:vibe-session-1:a3',
])
expect(turn.retries).toBe(1)
expect(session.totalCostUSD).toBeCloseTo(0.123456, 8)
expect(session.totalInputTokens).toBe(300)
expect(session.totalOutputTokens).toBe(90)
expect(session.categoryBreakdown[turn.category].oneShotTurns).toBe(0)
})
})

View file

@ -278,6 +278,32 @@ describe('codex provider - JSONL parsing', () => {
expect(call.deduplicationKey).toContain('codex:')
})
it('normalizes Codex subagent tool calls to Agent', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-agent.jsonl', [
sessionMeta({ session_id: 'sess-agent', model: 'gpt-5.5' }),
userMessage('delegate the review'),
functionCall('spawn_agent'),
functionCall('wait_agent'),
functionCall('close_agent'),
tokenCount({
timestamp: '2026-04-14T10:01:00Z',
last: { input: 300, output: 100 },
total: { total: 400 },
}),
])
const provider = createCodexProvider(tmpDir)
const source = { path: filePath, project: 'test', provider: 'codex' }
const parser = provider.createSessionParser(source, new Set())
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) {
calls.push(call)
}
expect(calls).toHaveLength(1)
expect(calls[0]!.tools).toEqual(['Agent', 'Agent', 'Agent'])
})
it('skips duplicate token_count events', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-dedup.jsonl', [
sessionMeta(),

View file

@ -0,0 +1,193 @@
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createGeminiProvider } from '../../src/providers/gemini.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'gemini-provider-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
async function parseFixture(messages: unknown[]): Promise<ParsedProviderCall[]> {
const filePath = join(tmpDir, 'session-gemini.json')
await writeFile(filePath, JSON.stringify({
sessionId: 'gemini-session-1',
startTime: '2026-05-16T10:00:00.000Z',
messages,
}))
const provider = createGeminiProvider()
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser({ path: filePath, project: 'gemini-project', provider: 'gemini' }, new Set()).parse()) {
calls.push(call)
}
return calls
}
describe('gemini provider', () => {
it('emits one provider call per Gemini message with token usage', async () => {
const calls = await parseFixture([
{
id: 'u1',
timestamp: '2026-05-16T10:00:00.000Z',
type: 'user',
content: 'inspect the repo',
},
{
id: 'g1',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'reading files',
model: 'gemini-3.1-pro-preview',
tokens: { input: 120, cached: 20, output: 30, thoughts: 5 },
toolCalls: [{ id: 't1', name: 'read_file', args: { path: 'src/index.ts' } }],
},
{
id: 'u2',
timestamp: '2026-05-16T10:01:00.000Z',
type: 'user',
content: [{ text: 'run tests' }],
},
{
id: 'g2',
timestamp: '2026-05-16T10:01:10.000Z',
type: 'gemini',
content: 'running tests',
model: 'gemini-3.1-pro-preview',
tokens: { input: 80, cached: 10, output: 25 },
toolCalls: [{ id: 't2', name: 'run_command', args: { command: 'npm test' } }],
},
])
expect(calls).toHaveLength(2)
expect(calls.map(c => c.deduplicationKey)).toEqual([
'gemini:gemini-session-1:g1',
'gemini:gemini-session-1:g2',
])
expect(calls.map(c => c.timestamp)).toEqual([
'2026-05-16T10:00:05.000Z',
'2026-05-16T10:01:10.000Z',
])
expect(calls.map(c => c.userMessage)).toEqual(['inspect the repo', 'run tests'])
expect(calls[0]!.inputTokens).toBe(100)
expect(calls[0]!.cacheReadInputTokens).toBe(20)
expect(calls[0]!.reasoningTokens).toBe(5)
expect(calls[0]!.tools).toEqual(['Read'])
expect(calls[1]!.inputTokens).toBe(70)
expect(calls[1]!.cacheReadInputTokens).toBe(10)
expect(calls[1]!.tools).toEqual(['Bash'])
expect(calls[1]!.bashCommands).toEqual(['npm'])
})
it('keeps aggregate token totals when splitting a Gemini session into calls', async () => {
const calls = await parseFixture([
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
id: 'g1',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'first',
model: 'gemini-3.1-pro-preview',
tokens: { input: 120, cached: 20, output: 30, thoughts: 5 },
},
{
id: 'g2',
timestamp: '2026-05-16T10:00:10.000Z',
type: 'gemini',
content: 'second',
model: 'gemini-3.1-pro-preview',
tokens: { input: 80, cached: 10, output: 25, thoughts: 0 },
},
])
expect(calls).toHaveLength(2)
expect(calls.reduce((sum, call) => sum + call.inputTokens, 0)).toBe(170)
expect(calls.reduce((sum, call) => sum + call.cacheReadInputTokens, 0)).toBe(30)
expect(calls.reduce((sum, call) => sum + call.outputTokens, 0)).toBe(55)
expect(calls.reduce((sum, call) => sum + call.reasoningTokens, 0)).toBe(5)
})
it('skips Gemini messages without token usage', async () => {
const calls = await parseFixture([
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
id: 'info',
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'tool-only notice',
model: 'gemini-3.1-pro-preview',
},
])
expect(calls).toEqual([])
})
it('uses a deterministic ordinal key when Gemini message ids are missing', async () => {
const messages = [
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
timestamp: '2026-05-16T10:00:05.000Z',
type: 'gemini',
content: 'first',
model: 'gemini-3.1-pro-preview',
tokens: { input: 10, output: 5 },
},
{
timestamp: '2026-05-16T10:00:10.000Z',
type: 'gemini',
content: 'second',
model: 'gemini-3.1-pro-preview',
tokens: { input: 12, output: 6 },
},
]
const first = await parseFixture(messages)
const second = await parseFixture(messages)
expect(first.map(c => c.deduplicationKey)).toEqual([
'gemini:gemini-session-1:idx-0',
'gemini:gemini-session-1:idx-1',
])
expect(second.map(c => c.deduplicationKey)).toEqual(first.map(c => c.deduplicationKey))
})
it('does not poison seenKeys when a Gemini message timestamp is invalid', async () => {
const filePath = join(tmpDir, 'session-gemini.json')
await writeFile(filePath, JSON.stringify({
sessionId: 'gemini-session-1',
startTime: '2026-05-16T10:00:00.000Z',
messages: [
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
{
id: 'g1',
timestamp: 'not-a-date',
type: 'gemini',
content: 'first',
model: 'gemini-3.1-pro-preview',
tokens: { input: 10, output: 5 },
},
],
}))
const provider = createGeminiProvider()
const seenKeys = new Set<string>()
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(
{ path: filePath, project: 'gemini-project', provider: 'gemini' },
seenKeys,
).parse()) {
calls.push(call)
}
expect(calls).toEqual([])
expect(seenKeys.has('gemini:gemini-session-1:g1')).toBe(false)
})
})

View file

@ -29,6 +29,7 @@ function metadata(opts: {
cwd?: string
input?: number
output?: number
sessionCost?: number
inputPrice?: number
outputPrice?: number
activeModel?: string
@ -49,6 +50,7 @@ function metadata(opts: {
stats: {
session_prompt_tokens: opts.input ?? 2000,
session_completion_tokens: opts.output ?? 3000,
session_cost: opts.sessionCost,
input_price_per_million: opts.inputPrice ?? 1.5,
output_price_per_million: opts.outputPrice ?? 7.5,
tokens_per_second: 42,
@ -202,7 +204,22 @@ describe('mistral-vibe provider - parsing', () => {
expect(call.timestamp).toBe('2026-05-11T10:05:00+00:00')
expect(call.userMessage).toBe('track Mistral Vibe usage')
expect(call.sessionId).toBe('session-abc123')
expect(call.deduplicationKey).toBe('mistral-vibe:session-abc123')
expect(call.deduplicationKey).toBe('mistral-vibe:session-abc123:msg-assistant-1')
})
it('prefers Vibe session_cost over price-derived estimates when present', async () => {
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
input: 364147,
output: 1731,
sessionCost: 0.381681,
inputPrice: 100,
outputPrice: 100,
}))
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
expect(calls).toHaveLength(1)
expect(calls[0]!.costUSD).toBe(0.381681)
})
it('uses configured model prices when stats omit prices', async () => {

View file

@ -469,6 +469,49 @@ skipUnlessSqlite('opencode provider - session parsing', () => {
expect(calls).toHaveLength(0)
})
it('keeps zero-usage assistant messages when router responses contain text', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' })
insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'use the configured router' })
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
role: 'assistant', modelID: 'edenai/router-model', cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
insertPart(db, 'part-a1', 'msg-a1', 'sess-1', { type: 'text', text: 'router response text' })
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(1)
expect(calls[0]!.model).toBe('edenai/router-model')
expect(calls[0]!.inputTokens).toBe(0)
expect(calls[0]!.outputTokens).toBe(0)
expect(calls[0]!.costUSD).toBe(0)
expect(calls[0]!.userMessage).toBe('use the configured router')
})
it('keeps zero-usage assistant messages when router responses contain tool calls', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'sess-1')
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
role: 'assistant', modelID: 'edenai/router-model', cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
})
insertPart(db, 'part-a1', 'msg-a1', 'sess-1', {
type: 'tool', tool: 'bash',
state: { status: 'completed', input: { command: 'npm test' } },
})
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
expect(calls).toHaveLength(1)
expect(calls[0]!.tools).toEqual(['Bash'])
expect(calls[0]!.bashCommands).toEqual(['npm'])
expect(calls[0]!.costUSD).toBe(0)
})
it('deduplicates messages across parses', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
@ -643,6 +686,103 @@ skipUnlessSqlite('opencode provider - session parsing', () => {
expect(calls[1]!.userMessage).toBe('second question')
})
it('attributes child and grandchild session calls back to the root session', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'root')
insertSession(db, 'child', { parentId: 'root' })
insertSession(db, 'grandchild', { parentId: 'child' })
insertMessage(db, 'msg-root-user', 'root', 1700000000000, { role: 'user' })
insertPart(db, 'part-root-user', 'msg-root-user', 'root', { type: 'text', text: 'root prompt' })
insertMessage(db, 'msg-root-assistant', 'root', 1700000001000, {
role: 'assistant',
modelID: 'claude-opus-4-6',
cost: 0.01,
tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
})
insertPart(db, 'part-root-tool', 'msg-root-assistant', 'root', {
type: 'tool',
tool: 'read',
state: { status: 'completed', input: {} },
})
insertMessage(db, 'msg-child-user', 'child', 1700000002000, { role: 'user' })
insertPart(db, 'part-child-user', 'msg-child-user', 'child', { type: 'text', text: 'child prompt' })
insertMessage(db, 'msg-child-assistant', 'child', 1700000003000, {
role: 'assistant',
modelID: 'claude-opus-4-6',
cost: 0.02,
tokens: { input: 30, output: 40, reasoning: 5, cache: { read: 0, write: 0 } },
})
insertPart(db, 'part-child-tool', 'msg-child-assistant', 'child', {
type: 'tool',
tool: 'task',
state: { status: 'completed', input: {} },
})
insertMessage(db, 'msg-grand-user', 'grandchild', 1700000004000, { role: 'user' })
insertPart(db, 'part-grand-user', 'msg-grand-user', 'grandchild', { type: 'text', text: 'grandchild prompt' })
insertMessage(db, 'msg-grand-assistant', 'grandchild', 1700000005000, {
role: 'assistant',
modelID: 'claude-opus-4-6',
cost: 0.03,
tokens: { input: 50, output: 60, reasoning: 0, cache: { read: 0, write: 0 } },
})
insertPart(db, 'part-grand-tool', 'msg-grand-assistant', 'grandchild', {
type: 'tool',
tool: 'bash',
state: { status: 'completed', input: { command: 'npm test' } },
})
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'root')
expect(calls).toHaveLength(3)
expect(calls.map(call => call.sessionId)).toEqual(['root', 'root', 'root'])
expect(calls.map(call => call.deduplicationKey)).toEqual([
'opencode:root:msg-root-assistant',
'opencode:child:msg-child-assistant',
'opencode:grandchild:msg-grand-assistant',
])
expect(calls.map(call => call.userMessage)).toEqual([
'root prompt',
'child prompt',
'grandchild prompt',
])
expect(calls[0]!.tools).toEqual(['Read'])
expect(calls[1]!.tools).toEqual(['Agent'])
expect(calls[2]!.tools).toEqual(['Bash'])
expect(calls[2]!.bashCommands).toEqual(['npm'])
})
it('does not include archived child sessions in the root subtree', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, 'root')
insertSession(db, 'archived-child', { parentId: 'root', archived: 1700000002500 })
insertMessage(db, 'msg-root-assistant', 'root', 1700000001000, {
role: 'assistant',
modelID: 'claude-opus-4-6',
cost: 0.01,
tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
})
insertMessage(db, 'msg-child-assistant', 'archived-child', 1700000003000, {
role: 'assistant',
modelID: 'claude-opus-4-6',
cost: 0.02,
tokens: { input: 30, output: 40, reasoning: 0, cache: { read: 0, write: 0 } },
})
})
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'root')
expect(calls).toHaveLength(1)
expect(calls[0]!.deduplicationKey).toBe('opencode:root:msg-root-assistant')
})
it('joins multiple text parts in user messages', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {

View file

@ -185,6 +185,42 @@ describe('fingerprintFile', () => {
const fp = await fingerprintFile('/no/such/file')
expect(fp).toBeNull()
})
it('resolves compound path with # separator (Cursor workspace)', async () => {
await mkdir(TMP_DIR, { recursive: true })
const filePath = join(TMP_DIR, 'state.vscdb')
await writeFile(filePath, 'cursor-data')
const fp = await fingerprintFile(`${filePath}#cursor-ws=__orphan__`)
expect(fp).not.toBeNull()
expect(fp!.sizeBytes).toBe(11)
})
it('resolves compound path with : separator (OpenCode session)', async () => {
await mkdir(TMP_DIR, { recursive: true })
const filePath = join(TMP_DIR, 'opencode.db')
await writeFile(filePath, 'opencode-data')
const fp = await fingerprintFile(`${filePath}:ses_abc123`)
expect(fp).not.toBeNull()
expect(fp!.sizeBytes).toBe(13)
})
it('returns null when base file does not exist for compound path', async () => {
const fp = await fingerprintFile('/no/such/file.db#cursor-ws=workspace')
expect(fp).toBeNull()
})
it('prefers # separator over : when both present', async () => {
await mkdir(TMP_DIR, { recursive: true })
const filePath = join(TMP_DIR, 'state.vscdb')
await writeFile(filePath, 'both-seps')
// Path has both # and : — should strip at # first and find the base file
const fp = await fingerprintFile(`${filePath}#cursor-ws=ws:extra-colon`)
expect(fp).not.toBeNull()
expect(fp!.sizeBytes).toBe(9)
})
})
// ── reconcileFile ──────────────────────────────────────────────────────