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 app experiments
desktop/ desktop/
# Mac App Store app (private)
appstore/
# WIP / not ready # WIP / not ready
src/summit.ts src/summit.ts

View file

@ -3,6 +3,15 @@
## Unreleased ## Unreleased
### Added (CLI) ### 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.** - **Multiple subscription plans can be tracked at the same time.**
`codeburn plan set` now stores plans in a provider-keyed `plans` map, so `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. 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 `Shell`, `ReadFile`, and `WriteFile`, and maps hidden managed Kimi Code
model aliases to priced Kimi K2 entries. 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 ## 0.9.9 - 2026-05-15
### Added (CLI) ### Added (CLI)
@ -36,6 +57,13 @@
workspace-based project names from session data. Closes #248. workspace-based project names from session data. Closes #248.
### Fixed (CLI) ### 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 - **Reduced Claude parser OOM risk.** Large Claude JSONL sessions retained
full entry objects (text, thinking blocks, tool results) in memory during full entry objects (text, thinking blocks, tool results) in memory during
parsing, causing V8 heap exhaustion on heavy usage months. Entries are now 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 ## 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 ## Deduplication
@ -29,8 +33,8 @@ Per `mistral-vibe:<session_id>`.
## Quirks ## 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. - **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 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. - **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. - **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`. - **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. - **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>`. - 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. - 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. - 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 - External MCP tools are stored as `<server>_<tool>` names (for example
`clickup_clickup_get_task`). The provider normalizes those to CodeBurn's `clickup_clickup_get_task`). The provider normalizes those to CodeBurn's
canonical `mcp__<server>__<tool>` names before aggregation so shared MCP canonical `mcp__<server>__<tool>` names before aggregation so shared MCP

View file

@ -26,6 +26,12 @@ final class AppStore {
} }
var showingAccentPicker: Bool = false var showingAccentPicker: Bool = false
var currency: String = "USD" 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 isLoading: Bool { loadingCountsByKey.values.contains { $0 > 0 } }
var isCurrentKeyLoading: Bool { loadingCountsByKey[currentKey, default: 0] > 0 } var isCurrentKeyLoading: Bool { loadingCountsByKey[currentKey, default: 0] > 0 }
var hasAttemptedCurrentKeyLoad: Bool { attemptedKeys.contains(currentKey) } var hasAttemptedCurrentKeyLoad: Bool { attemptedKeys.contains(currentKey) }
@ -934,12 +940,17 @@ enum SubscriptionLoadState: Sendable, Equatable {
case transientFailure(retryAt: Date?) // 429 / network blip; backing off automatically case transientFailure(retryAt: Date?) // 429 / network blip; backing off automatically
} }
enum DisplayMetric: String {
case cost, tokens, totalTokens
}
enum InsightMode: String, CaseIterable, Identifiable { enum InsightMode: String, CaseIterable, Identifiable {
case plan = "Plan" case plan = "Plan"
case trend = "Trend" case trend = "Trend"
case forecast = "Forecast" case forecast = "Forecast"
case pulse = "Pulse" case pulse = "Pulse"
case stats = "Stats" case stats = "Stats"
case optimize = "Optimize"
var id: String { rawValue } 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 // Track currency so the menubar title catches up immediately on
// currency switch instead of waiting for the next 30s payload tick. // currency switch instead of waiting for the next 30s payload tick.
_ = self.store.currency _ = self.store.currency
_ = self.store.displayMetric
_ = self.store.dailyBudget
// Track the live-quota state too so the flame icon re-tints on // Track the live-quota state too so the flame icon re-tints on
// every subscription / codex usage update, not just every 30s. // every subscription / codex usage update, not just every 30s.
_ = self.store.subscription _ = self.store.subscription
@ -709,7 +711,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
// warning/critical/danger override with a fixed palette color so the // warning/critical/danger override with a fixed palette color so the
// user gets a glanceable signal even when the menu bar is busy. // user gets a glanceable signal even when the menu bar is busy.
let aggregate = store.aggregateQuotaStatus 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 let flameConfig: NSImage.SymbolConfiguration
if let tint { if let tint {
flameConfig = baseConfig.applying(.init(paletteColors: [tint])) flameConfig = baseConfig.applying(.init(paletteColors: [tint]))
@ -728,11 +734,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
let hasPayload = store.todayPayload != nil let hasPayload = store.todayPayload != nil
let compact = isCompact let compact = isCompact
let fallback = compact ? "$-" : "$—" let valueText: String
let formatted = store.todayPayload?.current.cost if store.displayMetric == .tokens, let p = store.todayPayload?.current {
let valueText = compact let out = formatTokensMenubar(Double(p.outputTokens))
? (formatted?.asCompactCurrencyWhole() ?? fallback) let inp = formatTokensMenubar(Double(p.inputTokens))
: " " + (formatted?.asCompactCurrency() ?? fallback) 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] var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0]
if !hasPayload { if !hasPayload {
@ -745,6 +761,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
button.attributedTitle = composed 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 // MARK: - Popover
private func setupPopover() { private func setupPopover() {

View file

@ -37,10 +37,7 @@ enum ClaudeCredentialStore {
private static let maxCredentialBytes = 64 * 1024 private static let maxCredentialBytes = 64 * 1024
/// Legacy local cache file. New writes use the macOS Keychain; this path is /// 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 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 static let lock = NSLock()
private nonisolated(unsafe) static var memoryCache: CachedRecord? private nonisolated(unsafe) static var memoryCache: CachedRecord?
@ -279,13 +276,6 @@ enum ClaudeCredentialStore {
} }
private static func readOurCache() throws -> CredentialRecord? { 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() let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil } guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
@ -304,63 +294,10 @@ enum ClaudeCredentialStore {
try data.write(to: url, options: [.atomic, .completeFileProtection]) 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() { private static func deleteOurCache() {
deleteOurKeychainCache()
try? FileManager.default.removeItem(at: cacheFileURL()) 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) { private static func cacheInMemory(_ record: CredentialRecord) {
lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) } 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 maxCredentialBytes = 64 * 1024
private static let cacheFilename = "codex-credentials.v1.json" 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 static let lock = NSLock()
private nonisolated(unsafe) static var memoryCache: CachedRecord? private nonisolated(unsafe) static var memoryCache: CachedRecord?
@ -201,12 +199,6 @@ enum CodexCredentialStore {
} }
private static func readOurCache() throws -> CredentialRecord? { private static func readOurCache() throws -> CredentialRecord? {
if let keychainRecord = try? readOurKeychainCache() {
try? writeOurFileCache(record: keychainRecord)
deleteOurKeychainCache()
return keychainRecord
}
let url = cacheFileURL() let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil } guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
@ -225,63 +217,10 @@ enum CodexCredentialStore {
try data.write(to: url, options: [.atomic, .completeFileProtection]) 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() { private static func deleteOurCache() {
deleteOurKeychainCache()
try? FileManager.default.removeItem(at: cacheFileURL()) 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) { private static func cacheInMemory(_ record: CredentialRecord) {
lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) } 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 { struct CurrentBlock: Codable, Sendable {
let label: String let label: String
let cost: Double let cost: Double
@ -70,6 +100,38 @@ struct CurrentBlock: Codable, Sendable {
let topActivities: [ActivityEntry] let topActivities: [ActivityEntry]
let topModels: [ModelEntry] let topModels: [ModelEntry]
let providers: [String: Double] 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 { struct ActivityEntry: Codable, Sendable {
@ -85,6 +147,54 @@ struct ModelEntry: Codable, Sendable {
let calls: Int 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 { struct OptimizeBlock: Codable, Sendable {
let findingCount: Int let findingCount: Int
let savingsUSD: Double let savingsUSD: Double
@ -115,7 +225,12 @@ extension MenubarPayload {
cacheHitPercent: 0, cacheHitPercent: 0,
topActivities: [], topActivities: [],
topModels: [], 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: []), optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
history: HistoryBlock(daily: []) history: HistoryBlock(daily: [])

View file

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

View file

@ -1,8 +1,5 @@
import SwiftUI import SwiftUI
private let trendDays = 19
private let trendBarWidth: CGFloat = 13
private let trendBarGap: CGFloat = 4
private let trendChartHeight: CGFloat = 90 private let trendChartHeight: CGFloat = 90
// Cached formatters and a calendar to avoid allocating fresh ones on every // Cached formatters and a calendar to avoid allocating fresh ones on every
@ -82,10 +79,11 @@ struct HeatmapSection: View {
} else { } else {
PlanInsight(usage: store.subscription) 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 .forecast: ForecastInsight(days: store.payload.history.daily)
case .pulse: PulseInsight(payload: store.payload) case .pulse: PulseInsight(payload: store.payload)
case .stats: StatsInsight(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] let visibleModes: [InsightMode]
var body: some View { var body: some View {
HStack(spacing: 4) { ScrollView(.horizontal, showsIndicators: false) {
ForEach(visibleModes) { mode in HStack(spacing: 4) {
Button { ForEach(visibleModes) { mode in
selected = mode Button {
} label: { selected = mode
Text(mode.rawValue) } label: {
.font(.system(size: 11, weight: .medium)) Text(mode.rawValue)
.foregroundStyle(selected == mode ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary)) .font(.system(size: 11, weight: .medium))
.padding(.horizontal, 10) .fixedSize()
.padding(.vertical, 4) .foregroundStyle(selected == mode ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
.background( .padding(.horizontal, 10)
RoundedRectangle(cornerRadius: 6) .padding(.vertical, 4)
.fill(selected == mode ? AnyShapeStyle(Theme.brandAccent) : AnyShapeStyle(Color.secondary.opacity(0.10))) .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 { private struct TrendInsight: View {
let days: [DailyHistoryEntry] 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 { var body: some View {
let bars = buildTrendBars(from: days) let dayCount = trendDayCount
let stats = computeTrendStats(bars: bars, allDays: days) 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 // 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. // token breakdown yet, so fall back to $ when no tokens are present.
let totalTokens = bars.reduce(0.0) { $0 + $1.tokens } let totalTokens = bars.reduce(0.0) { $0 + $1.tokens }
@ -139,7 +155,7 @@ private struct TrendInsight: View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text("Last \(trendDays) days") Text("Last \(dayCount) days")
.font(.system(size: 10, weight: .medium)) .font(.system(size: 10, weight: .medium))
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
Text(formatHero(useTokens: useTokens, tokens: totalTokens, dollars: stats.totalThisWindow)) Text(formatHero(useTokens: useTokens, tokens: totalTokens, dollars: stats.totalThisWindow))
@ -152,7 +168,7 @@ private struct TrendInsight: View {
HStack(spacing: 3) { HStack(spacing: 3) {
Image(systemName: delta >= 0 ? "arrow.up.right" : "arrow.down.right") Image(systemName: delta >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.system(size: 9, weight: .bold)) .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)) .font(.system(size: 10.5))
.monospacedDigit() .monospacedDigit()
} }
@ -165,7 +181,8 @@ private struct TrendInsight: View {
maxValue: maxValue, maxValue: maxValue,
avgValue: avgValue, avgValue: avgValue,
metric: metric, metric: metric,
formatValue: { formatValue($0, useTokens: useTokens) } formatValue: { formatValue($0, useTokens: useTokens) },
barGap: barGap
) )
.zIndex(1) .zIndex(1)
@ -209,20 +226,26 @@ private struct TrendChart: View {
let avgValue: Double let avgValue: Double
let metric: (TrendBar) -> Double let metric: (TrendBar) -> Double
let formatValue: (Double) -> String let formatValue: (Double) -> String
let barGap: CGFloat
@State private var hoveredBarID: TrendBar.ID? @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 { var body: some View {
let avgFraction = maxValue > 0 ? CGFloat(min(avgValue / maxValue, 1.0)) : 0 let avgFraction = maxValue > 0 ? CGFloat(min(avgValue / maxValue, 1.0)) : 0
ZStack(alignment: .bottomLeading) { ZStack(alignment: .bottomLeading) {
HStack(alignment: .bottom, spacing: trendBarGap) { HStack(alignment: .bottom, spacing: barGap) {
ForEach(bars) { bar in ForEach(bars) { bar in
BarColumn( BarColumn(
bar: bar, bar: bar,
value: metric(bar), value: metric(bar),
maxValue: maxValue, maxValue: maxValue,
isHovered: hoveredBarID == bar.id isHovered: hoveredBarID == bar.id,
isPeak: bar.id == peakBarID
) )
.onHover { hovering in .onHover { hovering in
hoveredBarID = hovering ? bar.id : (hoveredBarID == bar.id ? nil : hoveredBarID) hoveredBarID = hovering ? bar.id : (hoveredBarID == bar.id ? nil : hoveredBarID)
@ -245,8 +268,6 @@ private struct TrendChart: View {
} }
.frame(height: trendChartHeight) .frame(height: trendChartHeight)
.overlay(alignment: .bottomLeading) { .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 { if let hoveredBar {
BarTooltipCard(bar: hoveredBar, value: metric(hoveredBar), formatValue: formatValue) BarTooltipCard(bar: hoveredBar, value: metric(hoveredBar), formatValue: formatValue)
.padding(.top, 6) .padding(.top, 6)
@ -270,16 +291,24 @@ private struct BarColumn: View {
let value: Double let value: Double
let maxValue: Double let maxValue: Double
let isHovered: Bool let isHovered: Bool
let isPeak: Bool
var body: some View { var body: some View {
let fraction = maxValue > 0 ? CGFloat(value / maxValue) : 0 let fraction = maxValue > 0 ? CGFloat(value / maxValue) : 0
let height = max(2, trendChartHeight * fraction) let height = max(2, trendChartHeight * fraction)
VStack(spacing: 2) { VStack(spacing: 0) {
Spacer(minLength: 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) RoundedRectangle(cornerRadius: 2)
.fill(barColor) .fill(barColor)
.frame(width: trendBarWidth, height: height) .frame(maxWidth: .infinity)
.frame(height: height)
.overlay( .overlay(
RoundedRectangle(cornerRadius: 2) RoundedRectangle(cornerRadius: 2)
.stroke(Theme.brandAccent.opacity(isHovered ? 0.9 : 0), lineWidth: 1) .stroke(Theme.brandAccent.opacity(isHovered ? 0.9 : 0), lineWidth: 1)
@ -293,7 +322,9 @@ private struct BarColumn: View {
private var barColor: Color { private var barColor: Color {
if bar.isToday { return Theme.brandAccent } if bar.isToday { return Theme.brandAccent }
if value <= 0 { return Color.secondary.opacity(0.15) } 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 { if !bar.topModels.isEmpty {
VStack(alignment: .leading, spacing: 3) { 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) { 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) Text(m.name)
.font(.system(size: 10, weight: .medium)) .font(.system(size: 10, weight: .medium))
.foregroundStyle(primaryText) .foregroundStyle(primaryText)
.lineLimit(1)
Spacer() Spacer()
if m.cost > 0 {
Text(m.cost.asCompactCurrency())
.font(.codeMono(size: 9.5, weight: .semibold))
.foregroundStyle(secondaryText)
}
Text("\(formatTokensCompact(Double(m.totalTokens))) tok") Text("\(formatTokensCompact(Double(m.totalTokens))) tok")
.font(.codeMono(size: 9.5, weight: .medium)) .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) .foregroundStyle(tertiaryText)
} }
} }
@ -390,7 +426,7 @@ private struct MiniStat: View {
let value: String let value: String
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 2) {
Text(label) Text(label)
.font(.system(size: 9.5, weight: .medium)) .font(.system(size: 9.5, weight: .medium))
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
@ -399,7 +435,13 @@ private struct MiniStat: View {
.monospacedDigit() .monospacedDigit()
.foregroundStyle(.primary) .foregroundStyle(.primary)
} }
.padding(.horizontal, 8)
.padding(.vertical, 5)
.frame(maxWidth: .infinity, alignment: .leading) .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? let yesterdayBar: TrendBar?
} }
private func buildTrendBars(from days: [DailyHistoryEntry]) -> [TrendBar] { private func buildTrendBars(from days: [DailyHistoryEntry], dayCount: Int) -> [TrendBar] {
let calendar = gregorianCalendar let calendar = gregorianCalendar
let formatter = yyyymmdd let formatter = yyyymmdd
let entryByDate = Dictionary(days.map { ($0.date, $0) }, uniquingKeysWith: { _, new in new }) 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) let todayKey = formatter.string(from: today)
var bars: [TrendBar] = [] 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 } guard let d = calendar.date(byAdding: .day, value: -offset, to: today) else { continue }
let key = formatter.string(from: d) let key = formatter.string(from: d)
let entry = entryByDate[key] let entry = entryByDate[key]
@ -448,7 +490,7 @@ private func buildTrendBars(from days: [DailyHistoryEntry]) -> [TrendBar] {
return bars 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 total = bars.reduce(0.0) { $0 + $1.cost }
let active = bars.filter { $0.cost > 0 }.count let active = bars.filter { $0.cost > 0 }.count
let avg = bars.isEmpty ? 0 : total / Double(bars.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 calendar = gregorianCalendar
let formatter = yyyymmdd let formatter = yyyymmdd
let today = calendar.startOfDay(for: Date()) let today = calendar.startOfDay(for: Date())
let priorWindowStart = calendar.date(byAdding: .day, value: -(2 * trendDays - 1), to: today) let priorWindowStart = calendar.date(byAdding: .day, value: -(2 * dayCount - 1), to: today)
let thisWindowStart = calendar.date(byAdding: .day, value: -(trendDays - 1), to: today) let thisWindowStart = calendar.date(byAdding: .day, value: -(dayCount - 1), to: today)
var deltaPercent: Double? = nil var deltaPercent: Double? = nil
if let priorStart = priorWindowStart, let thisStart = thisWindowStart { if let priorStart = priorWindowStart, let thisStart = thisWindowStart {
let priorStartStr = formatter.string(from: priorStart) let priorStartStr = formatter.string(from: priorStart)
@ -629,16 +671,19 @@ private struct PulseInsight: View {
let payload: MenubarPayload let payload: MenubarPayload
var body: some View { var body: some View {
HStack(spacing: 10) { VStack(alignment: .leading, spacing: 8) {
PulseTile(label: "Cache hit", value: cacheHitText, color: Theme.brandAccent) HStack(spacing: 10) {
PulseTile(label: "1-shot", value: oneShotText, color: oneShotColor) PulseTile(label: "Cache hit", value: cacheHitText, color: Theme.brandAccent)
PulseTile( PulseTile(label: "1-shot", value: oneShotText, color: oneShotColor)
label: "Cost / session", PulseTile(
value: payload.current.sessions > 0 label: "Cost / session",
? (payload.current.cost / Double(payload.current.sessions)).asCompactCurrency() value: payload.current.sessions > 0
: "", ? (payload.current.cost / Double(payload.current.sessions)).asCompactCurrency()
color: .secondary : "",
) 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 /// 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). /// 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). /// Scoped to whatever period the user selected (today / 7d / 30d / month / all).
@ -777,6 +869,112 @@ private struct StatsInsight: View {
.foregroundStyle(Theme.brandAccent) .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 { private struct AllStats {
let favoriteModel: String let favoriteModel: String
let activeDaysFraction: String let activeDaysFraction: String

View file

@ -8,7 +8,7 @@ struct HeroSection: View {
SectionCaption(text: caption) SectionCaption(text: caption)
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline) {
Text(store.payload.current.cost.asCurrency()) Text(heroText)
.font(.system(size: 32, weight: .semibold, design: .rounded)) .font(.system(size: 32, weight: .semibold, design: .rounded))
.monospacedDigit() .monospacedDigit()
.tracking(-1) .tracking(-1)
@ -23,22 +23,73 @@ struct HeroSection: View {
Spacer() Spacer()
VStack(alignment: .trailing, spacing: 2) { 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)) .font(.system(size: 11))
.monospacedDigit() .monospacedDigit()
.foregroundStyle(.secondary) .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)) .font(.system(size: 10.5))
.monospacedDigit() .monospacedDigit()
.foregroundStyle(.tertiary) .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(.horizontal, 14)
.padding(.top, 10) .padding(.top, 10)
.padding(.bottom, 12) .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 { private var caption: String {
let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label let label = store.payload.current.label.isEmpty ? store.selectedPeriod.rawValue : store.payload.current.label
if store.selectedPeriod == .today { if store.selectedPeriod == .today {

View file

@ -40,6 +40,14 @@ private struct GeneralSettingsTab: View {
Text(code).tag(code) 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( Picker("Accent", selection: Binding(
get: { store.accentPreset }, get: { store.accentPreset },
set: { store.accentPreset = $0 } 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) .formStyle(.grouped)
.padding() .padding()

4
package-lock.json generated
View file

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

View file

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

View file

@ -154,13 +154,22 @@ function classifyConversation(userMessage: string): TaskCategory {
} }
function countRetries(turn: ParsedTurn): number { 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 sawEditBeforeBash = false
let sawBashAfterEdit = false let sawBashAfterEdit = false
let retries = 0 let retries = 0
for (const call of turn.assistantCalls) { for (const tools of steps) {
const hasEdit = call.tools.some(t => EDIT_TOOLS.has(t)) const hasEdit = tools.some(t => EDIT_TOOLS.has(t))
const hasBash = call.tools.some(t => BASH_TOOLS.has(t)) const hasBash = tools.some(t => BASH_TOOLS.has(t))
if (hasEdit) { if (hasEdit) {
if (sawBashAfterEdit) retries++ if (sawBashAfterEdit) retries++

View file

@ -1,3 +1,4 @@
import { homedir } from 'node:os'
import { Command } from 'commander' import { Command } from 'commander'
import { installMenubarApp } from './menubar-installer.js' import { installMenubarApp } from './menubar-installer.js'
import { exportCsv, exportJson, type PeriodExport } from './export.js' import { exportCsv, exportJson, type PeriodExport } from './export.js'
@ -7,7 +8,7 @@ import { convertCost } from './currency.js'
import { renderStatusBar } from './format.js' import { renderStatusBar } from './format.js'
import { type PeriodData, type ProviderCost } from './menubar-json.js' import { type PeriodData, type ProviderCost } from './menubar-json.js'
import { buildMenubarPayload } 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 { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
import { aggregateModelEfficiency } from './model-efficiency.js' import { aggregateModelEfficiency } from './model-efficiency.js'
@ -444,7 +445,6 @@ program
const rangeEndStr = toDateString(periodInfo.range.end) const rangeEndStr = toDateString(periodInfo.range.end)
const isAllProviders = pf === 'all' const isAllProviders = pf === 'all'
const cache = await hydrateCache()
let todayAllProjects: ProjectSummary[] | null = null let todayAllProjects: ProjectSummary[] | null = null
let todayAllDays: ReturnType<typeof aggregateProjectsIntoDays> | null = null let todayAllDays: ReturnType<typeof aggregateProjectsIntoDays> | null = null
@ -462,44 +462,57 @@ program
return todayAllDays 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 currentData: PeriodData
let scanProjects: ProjectSummary[] let scanProjects: ProjectSummary[]
let scanRange: DateRange let scanRange: DateRange
let cache: DailyCache
let todayProviderData: PeriodData | null = null
let usedPerProviderCachePath = false
if (isAllProviders) { if (isAllProviders) {
// Parse today's all-provider sessions once; historical data comes from cache to avoid cache = await hydrateCache()
// 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.
const todayProjects = await getTodayAllProjects() const todayProjects = await getTodayAllProjects()
const todayDays = await getTodayAllDays() const todayDays = await getTodayAllDays()
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr) const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr) const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date)) const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
currentData = buildPeriodDataFromDays(allDays, periodInfo.label) currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
scanProjects = todayProjects const isTodayOnly = opts.period === 'today'
scanRange = periodInfo.range if (isTodayOnly) {
scanProjects = todayProjects
scanRange = todayRange
} else {
scanProjects = fp(await parseAllSessions(periodInfo.range, 'all'))
scanRange = periodInfo.range
}
} else { } else {
// Per-provider: parse only today (fast), use cache for historical days. cache = await loadDailyCache()
// The cache stores per-provider cost+calls per day, so we extract those const cacheIsCurrent = cache.lastComputedDate !== null
// and combine with today's fully-parsed provider data. && cache.lastComputedDate >= yesterdayStr
const todayProviderProjects = fp(await parseAllSessions(todayRange, pf)) if (cacheIsCurrent && rangeStartStr < todayStr) {
const todayData = buildPeriodData(periodInfo.label, todayProviderProjects) const todayProviderProjects = fp(await parseAllSessions(todayRange, pf))
const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr) todayProviderData = buildPeriodData(periodInfo.label, todayProviderProjects)
let histCost = 0, histCalls = 0 const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
for (const d of historicalDays) { let histCost = 0, histCalls = 0
const prov = d.providers[pf] for (const d of historicalDays) {
if (prov) { histCost += prov.cost; histCalls += prov.calls } 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 // PROVIDERS
@ -538,9 +551,12 @@ program
// in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost). // 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 historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS))
const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr) const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
const fullHistory = [...allCacheDays, ...(await getTodayAllDays()).filter(d => d.date === todayStr)]
const dailyHistory = fullHistory.map(d => { let dailyHistory
if (isAllProviders) { 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) const topModels = Object.entries(d.models)
.filter(([name]) => name !== '<synthetic>') .filter(([name]) => name !== '<synthetic>')
.sort(([, a], [, b]) => b.cost - a.cost) .sort(([, a], [, b]) => b.cost - a.cost)
@ -562,22 +578,159 @@ program
cacheWriteTokens: d.cacheWriteTokens, cacheWriteTokens: d.cacheWriteTokens,
topModels, 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 } dailyHistory = historyFromCache
return { } else {
date: d.date, const histFromCache = allCacheDays.map(d => {
cost: prov.cost, const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
calls: prov.calls, return {
inputTokens: 0, date: d.date,
outputTokens: 0, cost: prov.cost,
cacheReadTokens: 0, calls: prov.calls,
cacheWriteTokens: 0, inputTokens: 0,
topModels: [], 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) 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 return
} }

View file

@ -12,6 +12,9 @@ export type PeriodData = {
cacheWriteTokens: number cacheWriteTokens: number
categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }> categories: Array<{ name: string; cost: number; turns: number; editTurns: number; oneShotTurns: number }>
models: Array<{ name: string; cost: number; calls: 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 = { export type ProviderCost = {
@ -25,6 +28,9 @@ const TOP_MODELS_LIMIT = 20
const TOP_FINDINGS_LIMIT = 10 const TOP_FINDINGS_LIMIT = 10
const HISTORY_DAYS_LIMIT = 365 const HISTORY_DAYS_LIMIT = 365
const SYNTHETIC_MODEL_NAME = '<synthetic>' const SYNTHETIC_MODEL_NAME = '<synthetic>'
const TOP_PROJECTS_LIMIT = 5
const TOP_SESSIONS_LIMIT = 3
const MODEL_EFFICIENCY_LIMIT = 5
export type DailyModelBreakdown = { export type DailyModelBreakdown = {
name: string name: string
@ -68,6 +74,55 @@ export type MenubarPayload = {
calls: number calls: number
}> }>
providers: Record<string, 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: { optimize: {
findingCount: number findingCount: number
@ -155,11 +210,49 @@ function buildHistory(daily: DailyHistoryEntry[] | undefined): MenubarPayload['h
return { daily: trimmed } 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( export function buildMenubarPayload(
current: PeriodData, current: PeriodData,
providers: ProviderCost[], providers: ProviderCost[],
optimize: OptimizeResult | null, optimize: OptimizeResult | null,
dailyHistory?: DailyHistoryEntry[], dailyHistory?: DailyHistoryEntry[],
retryTax?: MenubarPayload['current']['retryTax'],
routingWaste?: MenubarPayload['current']['routingWaste'],
): MenubarPayload { ): MenubarPayload {
return { return {
generated: new Date().toISOString(), generated: new Date().toISOString(),
@ -175,6 +268,11 @@ export function buildMenubarPayload(
topActivities: buildTopActivities(current.categories), topActivities: buildTopActivities(current.categories),
topModels: buildTopModels(current.models), topModels: buildTopModels(current.models),
providers: buildProviders(providers), 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), optimize: buildOptimize(optimize),
history: buildHistory(dailyHistory), history: buildHistory(dailyHistory),

View file

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

View file

@ -66,84 +66,85 @@ type GeminiSession = {
function parseSession(data: GeminiSession, seenKeys: Set<string>): ParsedProviderCall[] { function parseSession(data: GeminiSession, seenKeys: Set<string>): ParsedProviderCall[] {
const results: ParsedProviderCall[] = [] const results: ParsedProviderCall[] = []
const geminiMessages = data.messages.filter(m => m.type === 'gemini' && m.tokens && m.model) let lastUserMessage = ''
if (geminiMessages.length === 0) return results let turnOrdinal = 0
let currentTurnId = `${data.sessionId}:prelude`
let geminiOrdinal = 0
const dedupKey = `gemini:${data.sessionId}` for (const msg of data.messages) {
if (seenKeys.has(dedupKey)) return results if (msg.type === 'user') {
seenKeys.add(dedupKey) 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 if (msg.type !== 'gemini' || !msg.tokens || !msg.model) continue
let totalOutput = 0
let totalCached = 0
let totalThoughts = 0
const allTools: string[] = []
const bashCommands: string[] = []
let model = ''
for (const msg of geminiMessages) { const t = msg.tokens
const t = msg.tokens! const totalInput = t.input ?? 0
totalInput += t.input ?? 0 const totalOutput = t.output ?? 0
totalOutput += t.output ?? 0 const totalCached = t.cached ?? 0
totalCached += t.cached ?? 0 const totalThoughts = t.thoughts ?? 0
totalThoughts += t.thoughts ?? 0 if (totalInput === 0 && totalOutput === 0 && totalCached === 0 && totalThoughts === 0) continue
if (msg.model && !model) model = msg.model
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) { if (msg.toolCalls) {
for (const tc of msg.toolCalls) { for (const tc of msg.toolCalls) {
const mapped = toolNameMap[tc.displayName ?? ''] ?? toolNameMap[tc.name] ?? tc.displayName ?? tc.name 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') { if (mapped === 'Bash' && tc.args && typeof tc.args.command === 'string') {
bashCommands.push(...extractBashCommands(tc.args.command)) 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 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 tools: string[] = []
const bashCommands: string[] = [] const bashCommands: string[] = []
const seen = new Set<string>() const seen = new Set<string>()
const toolSequence: string[][] = []
try { try {
const rows = db.query<{ content_json: Uint8Array | string }>( 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], [sessionId],
) )
@ -98,6 +99,7 @@ function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tool
} catch { } catch {
continue continue
} }
const msgTools: string[] = []
for (const item of items) { for (const item of items) {
if (item.type !== 'toolRequest') continue if (item.type !== 'toolRequest') continue
const rawName = item.toolCall?.value?.name ?? '' const rawName = item.toolCall?.value?.name ?? ''
@ -107,6 +109,7 @@ function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tool
seen.add(mapped) seen.add(mapped)
tools.push(mapped) tools.push(mapped)
} }
msgTools.push(mapped)
if (mapped === 'Bash') { if (mapped === 'Bash') {
const cmd = item.toolCall?.value?.arguments?.command const cmd = item.toolCall?.value?.arguments?.command
if (typeof cmd === 'string') { 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 */ } } catch { /* best-effort */ }
return { tools, bashCommands } return { tools, bashCommands, toolSequence }
} }
function getFirstUserMessage(db: SqliteDatabase, sessionId: string): string { 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 model = config.model_name ?? 'unknown'
const costUSD = calculateCost(model, inputTokens, outputTokens, 0, 0, 0) 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 userMessage = getFirstUserMessage(db, sessionId)
const raw = session.updated_at || session.created_at || '' const raw = session.updated_at || session.created_at || ''
@ -200,6 +204,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
costUSD, costUSD,
tools, tools,
bashCommands, bashCommands,
toolSequence: toolSequence.length > 1 ? toolSequence : undefined,
timestamp: ts.toISOString(), timestamp: ts.toISOString(),
speed: 'standard', speed: 'standard',
deduplicationKey: dedupKey, deduplicationKey: dedupKey,

View file

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

View file

@ -38,6 +38,7 @@ const toolNameMap: Record<string, string> = {
type VibeStats = { type VibeStats = {
session_prompt_tokens?: number session_prompt_tokens?: number
session_completion_tokens?: number session_completion_tokens?: number
session_cost?: number
input_price_per_million?: number input_price_per_million?: number
output_price_per_million?: number output_price_per_million?: number
tokens_per_second?: number tokens_per_second?: number
@ -75,6 +76,8 @@ type VibeToolCall = {
type VibeMessage = { type VibeMessage = {
role?: string role?: string
content?: unknown content?: unknown
message_id?: string
timestamp?: string
tool_calls?: VibeToolCall[] | null tool_calls?: VibeToolCall[] | null
} }
@ -179,6 +182,9 @@ function safeNumber(value: unknown): number {
function calculateSessionCost(metadata: VibeMetadata, model: string, inputTokens: number, outputTokens: number): number { function calculateSessionCost(metadata: VibeMetadata, model: string, inputTokens: number, outputTokens: number): number {
const stats = metadata.stats ?? {} const stats = metadata.stats ?? {}
const sessionCost = safeNumber(stats.session_cost)
if (sessionCost > 0) return sessionCost
const configured = activeModelConfig(metadata) const configured = activeModelConfig(metadata)
const inputPrice = safeNumber(stats.input_price_per_million) || safeNumber(configured?.input_price) const inputPrice = safeNumber(stats.input_price_per_million) || safeNumber(configured?.input_price)
const outputPrice = safeNumber(stats.output_price_per_million) || safeNumber(configured?.output_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[] } { function extractTools(messages: VibeMessage[]): { tools: string[]; bashCommands: string[] } {
const tools: string[] = [] const tools: string[] = []
const bashCommands: string[] = [] const bashCommands: string[] = []
for (const message of messages) { for (const message of messages) {
if (message.role !== 'assistant') continue const extracted = extractMessageTools(message)
for (const toolCall of message.tool_calls ?? []) { tools.push(...extracted.tools)
const rawName = toolCall.function?.name bashCommands.push(...extracted.bashCommands)
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 { return {
@ -267,6 +288,17 @@ function firstUserMessage(messages: VibeMessage[], fallback?: string | null): st
return (fallback ?? '').slice(0, 500) 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 { function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return { return {
async *parse(): AsyncGenerator<ParsedProviderCall> { async *parse(): AsyncGenerator<ParsedProviderCall> {
@ -281,33 +313,85 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
if (inputTokens === 0 && outputTokens === 0) return if (inputTokens === 0 && outputTokens === 0) return
const sessionId = metadata.session_id || basename(source.path) 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 messages = await readMessages(messagesPath)
const model = resolveModel(metadata) const model = resolveModel(metadata)
const { tools, bashCommands } = extractTools(messages)
const costUSD = calculateSessionCost(metadata, model, inputTokens, outputTokens) const costUSD = calculateSessionCost(metadata, model, inputTokens, outputTokens)
const assistantMessages = messages.filter(m => m.role === 'assistant')
const fallbackTimestamp = metadata.end_time ?? metadata.start_time ?? ''
yield { if (assistantMessages.length === 0) {
provider: 'mistral-vibe', const deduplicationKey = `mistral-vibe:${sessionId}`
model, if (seenKeys.has(deduplicationKey)) return
inputTokens, seenKeys.add(deduplicationKey)
outputTokens, const { tools, bashCommands } = extractTools(messages)
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0, yield {
cachedInputTokens: 0, provider: 'mistral-vibe',
reasoningTokens: 0, model,
webSearchRequests: 0, inputTokens,
costUSD, outputTokens,
tools, cacheCreationInputTokens: 0,
bashCommands, cacheReadInputTokens: 0,
timestamp: metadata.end_time ?? metadata.start_time ?? '', cachedInputTokens: 0,
speed: 'standard', reasoningTokens: 0,
deduplicationKey, webSearchRequests: 0,
userMessage: firstUserMessage(messages, metadata.title), costUSD,
sessionId, 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' } from './types.js'
type MessageRow = { type MessageRow = {
session_id: string
id: string id: string
time_created: number time_created: number
data: Uint8Array | string data: Uint8Array | string
@ -189,12 +190,34 @@ function createParser(
} }
const messages = db.query<MessageRow>( 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], [sessionId],
) )
const parts = db.query<PartRow>( 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], [sessionId],
) )
@ -210,7 +233,7 @@ function createParser(
} }
} }
let currentUserMessage = '' const currentUserMessageBySession = new Map<string, string>()
for (const msg of messages) { for (const msg of messages) {
let data: MessageData let data: MessageData
@ -226,7 +249,7 @@ function createParser(
.map((p) => p.text ?? '') .map((p) => p.text ?? '')
.filter(Boolean) .filter(Boolean)
if (textParts.length > 0) { if (textParts.length > 0) {
currentUserMessage = textParts.join(' ') currentUserMessageBySession.set(msg.session_id, textParts.join(' '))
} }
continue continue
} }
@ -241,16 +264,19 @@ function createParser(
cacheWrite: data.tokens?.cache?.write ?? 0, 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 = const allZero =
tokens.input === 0 && tokens.input === 0 &&
tokens.output === 0 && tokens.output === 0 &&
tokens.reasoning === 0 && tokens.reasoning === 0 &&
tokens.cacheRead === 0 && tokens.cacheRead === 0 &&
tokens.cacheWrite === 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 const tools = toolParts
.map((p) => normalizeToolName(p.tool)) .map((p) => normalizeToolName(p.tool))
.filter(Boolean) .filter(Boolean)
@ -259,7 +285,7 @@ function createParser(
.filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string') .filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string')
.flatMap((p) => extractBashCommands(p.state!.input!.command!)) .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 if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey) seenKeys.add(dedupKey)
@ -293,7 +319,7 @@ function createParser(
timestamp: parseTimestamp(msg.time_created), timestamp: parseTimestamp(msg.time_created),
speed: 'standard', speed: 'standard',
deduplicationKey: dedupKey, deduplicationKey: dedupKey,
userMessage: currentUserMessage, userMessage: currentUserMessageBySession.get(msg.session_id) ?? '',
sessionId, sessionId,
} }
} }

View file

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

View file

@ -21,6 +21,7 @@ export type CachedCall = {
provider: string provider: string
model: string model: string
usage: CachedUsage usage: CachedUsage
costUSD?: number
speed: 'standard' | 'fast' speed: 'standard' | 'fast'
timestamp: string timestamp: string
tools: string[] tools: string[]
@ -29,6 +30,7 @@ export type CachedCall = {
deduplicationKey: string deduplicationKey: string
project?: string project?: string
projectPath?: string projectPath?: string
toolSequence?: string[][]
} }
export type CachedTurn = { export type CachedTurn = {
@ -65,7 +67,7 @@ export type SessionCache = {
// ── Constants ────────────────────────────────────────────────────────── // ── Constants ──────────────────────────────────────────────────────────
export const CACHE_VERSION = 1 export const CACHE_VERSION = 2
const CACHE_FILE = 'session-cache.json' const CACHE_FILE = 'session-cache.json'
const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000 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['deduplicationKey'] === 'string'
&& typeof o['timestamp'] === 'string' && typeof o['timestamp'] === 'string'
&& (o['speed'] === 'standard' || o['speed'] === 'fast') && (o['speed'] === 'standard' || o['speed'] === 'fast')
&& isOptionalNum(o['costUSD'])
&& isStringArray(o['tools']) && isStringArray(o['tools'])
&& isStringArray(o['bashCommands']) && isStringArray(o['bashCommands'])
&& isStringArray(o['skills']) && isStringArray(o['skills'])
&& isOptionalString(o['project']) && isOptionalString(o['project'])
&& isOptionalString(o['projectPath']) && isOptionalString(o['projectPath'])
&& (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isStringArray(s))))
&& validateUsage(o['usage']) && validateUsage(o['usage'])
} }
@ -209,6 +213,7 @@ export async function saveCache(cache: SessionCache): Promise<void> {
const finalPath = getCachePath() const finalPath = getCachePath()
const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp` const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
delete (cache as { _dirty?: boolean })._dirty
const payload = JSON.stringify(cache) const payload = JSON.stringify(cache)
const handle = await open(tempPath, 'w', 0o600) const handle = await open(tempPath, 'w', 0o600)
@ -234,6 +239,30 @@ export async function fingerprintFile(filePath: string): Promise<FileFingerprint
const s = await stat(filePath) const s = await stat(filePath)
return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size } return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
} catch { } 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 return null
} }
} }

View file

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

View file

@ -151,3 +151,46 @@ describe('classifyTurn — feature vs debugging precedence (#196)', () => {
expect(c.category).toBe('debugging') 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) 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 () => { it('parses huge message-first assistant lines without full JSON.parse expansion', async () => {
const projectDir = join(home, '.claude', 'projects', 'messagefirst') const projectDir = join(home, '.claude', 'projects', 'messagefirst')
await mkdir(projectDir, { recursive: true }) 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:') 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 () => { it('skips duplicate token_count events', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-dedup.jsonl', [ const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-dedup.jsonl', [
sessionMeta(), 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 cwd?: string
input?: number input?: number
output?: number output?: number
sessionCost?: number
inputPrice?: number inputPrice?: number
outputPrice?: number outputPrice?: number
activeModel?: string activeModel?: string
@ -49,6 +50,7 @@ function metadata(opts: {
stats: { stats: {
session_prompt_tokens: opts.input ?? 2000, session_prompt_tokens: opts.input ?? 2000,
session_completion_tokens: opts.output ?? 3000, session_completion_tokens: opts.output ?? 3000,
session_cost: opts.sessionCost,
input_price_per_million: opts.inputPrice ?? 1.5, input_price_per_million: opts.inputPrice ?? 1.5,
output_price_per_million: opts.outputPrice ?? 7.5, output_price_per_million: opts.outputPrice ?? 7.5,
tokens_per_second: 42, 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.timestamp).toBe('2026-05-11T10:05:00+00:00')
expect(call.userMessage).toBe('track Mistral Vibe usage') expect(call.userMessage).toBe('track Mistral Vibe usage')
expect(call.sessionId).toBe('session-abc123') 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 () => { 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) 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 () => { it('deduplicates messages across parses', async () => {
const dbPath = createTestDb(tmpDir) const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => { withTestDb(dbPath, (db) => {
@ -643,6 +686,103 @@ skipUnlessSqlite('opencode provider - session parsing', () => {
expect(calls[1]!.userMessage).toBe('second question') 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 () => { it('joins multiple text parts in user messages', async () => {
const dbPath = createTestDb(tmpDir) const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => { withTestDb(dbPath, (db) => {

View file

@ -185,6 +185,42 @@ describe('fingerprintFile', () => {
const fp = await fingerprintFile('/no/such/file') const fp = await fingerprintFile('/no/such/file')
expect(fp).toBeNull() 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 ────────────────────────────────────────────────────── // ── reconcileFile ──────────────────────────────────────────────────────