mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-20 09:03:50 +00:00
Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3542407f8f | ||
|
|
c9487e7b0a | ||
|
|
06f69484f3 | ||
|
|
7cea9efb31 | ||
|
|
5a837c94e9 | ||
|
|
2013ecbfd9 | ||
|
|
303c9458cb | ||
|
|
1317af2acf | ||
|
|
58ccf84f02 |
36 changed files with 2276 additions and 403 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: [])
|
||||||
|
|
|
||||||
|
|
@ -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: +
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
4
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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++
|
||||||
|
|
|
||||||
239
src/main.ts
239
src/main.ts
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
144
src/parser.ts
144
src/parser.ts
|
|
@ -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]) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
134
tests/parser-gemini-cache.test.ts
Normal file
134
tests/parser-gemini-cache.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
170
tests/provider-turn-grouping.test.ts
Normal file
170
tests/provider-turn-grouping.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
193
tests/providers/gemini.test.ts
Normal file
193
tests/providers/gemini.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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 ──────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue