diff --git a/README.md b/README.md index 5651170..c718a50 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ providers' own usage endpoints. calendar using logs from every supported provider, regardless of which providers are selected for the usage pills. Click a provider in the calendar legend to filter its history; click it again to show all providers. +- **Usage history that stays yours.** CodexIsland saves captured token counts + in its own local database, including older records still available when it + scans. Provider log cleanup no longer erases captured history. Repeated + scans update the same calls instead of counting them again. See + [usage-history storage](docs/USAGE-HISTORY.md) for coverage details. Open + **Settings → General → Recover Claude usage…** to preview verified counts + from surviving logs, backups, and old daily snapshots before importing them. + A [terminal script](docs/USAGE-HISTORY.md#recover-your-claude-usage) is also included. - **Used or remaining quota.** Display provider windows as usage consumed or quota remaining. - **Approaching-limit alerts.** Optional warning and critical thresholds tint diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index f01ab2f..1f6b48c 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -226,3 +226,54 @@ "%@ account options" = "%@ account options"; "Sign in with Claude" = "Sign in with Claude"; "Open Claude Code and run /login." = "Open Claude Code and run /login."; + +"Usage history could not be saved. Check available disk space and try refreshing." = "Usage history could not be saved. Check available disk space and try refreshing."; + +"Recover Claude usage" = "Recover Claude usage"; +"Recover Claude usage…" = "Recover Claude usage…"; +"Usage history" = "Usage history"; +"Find older Claude counts." = "Find older Claude counts."; +"Find saved counts from Claude Code, Cowork, and older local records." = "Find saved counts from Claude Code, Cowork, and older local records."; +"Scan details" = "Scan details"; +"A backup is saved before importing. Original Claude files stay untouched." = "A backup is saved before importing. Original Claude files stay untouched."; +"Cancel" = "Cancel"; +"Done" = "Done"; +"Scan" = "Scan"; +"Scan again" = "Scan again"; +"Import recovery" = "Import recovery"; +"Saving recovered usage…" = "Saving recovered usage…"; +"Scanning saved records…" = "Scanning saved records…"; +"Recovery could not finish" = "Recovery could not finish"; +"Recovered usage saved" = "Recovered usage saved"; +"This usage is already saved" = "This usage is already saved"; +"%@ additional tokens saved." = "%@ additional tokens saved."; +"Your usage history now includes the recovered records." = "Your usage history now includes the recovered records."; +"Show backup in Finder" = "Show backup in Finder"; +"Review your recovered usage" = "Review your recovered usage"; +"No additional usage found" = "No additional usage found"; +"Already saved" = "Already saved"; +"After recovery" = "After recovery"; +"Additional tokens" = "Additional tokens"; +"Messages checked: %@ · Daily records: %@" = "Messages checked: %@ · Daily records: %@"; +"Totals include cache tokens. Daily records without model details remain unpriced." = "Totals include cache tokens. Daily records without model details remain unpriced."; +"Scan your selection to preview what can be recovered." = "Scan your selection to preview what can be recovered."; +"Original timezone" = "Original timezone"; +"Why recover usage?" = "Why recover usage?"; +"Older usage can be missing when Claude logs have been deleted or moved. Recovery checks the records still on your Mac and any backups you add. It saves only missing counts, so scanning again won't duplicate your usage." = "Older usage can be missing when Claude logs have been deleted or moved. Recovery checks the records still on your Mac and any backups you add. It saves only missing counts, so scanning again won't duplicate your usage."; +"Why is the original timezone needed?" = "Why is the original timezone needed?"; +"Older daily totals used your Mac's timezone without saving its name. Choose the timezone used when those totals were recorded so recovery keeps the correct dates and avoids counting usage twice. Individual message records don't need this." = "Older daily totals used your Mac's timezone without saving its name. Choose the timezone used when those totals were recorded so recovery keeps the correct dates and avoids counting usage twice. Individual message records don't need this."; +"Not specified" = "Not specified"; +"Some old daily totals need their original timezone. Choose where they were recorded, then scan again." = "Some old daily totals need their original timezone. Choose where they were recorded, then scan again."; +"Only needed for old daily totals. Leave unspecified if you don't know where they were recorded." = "Only needed for old daily totals. Leave unspecified if you don't know where they were recorded."; +"Older backups (optional)" = "Older backups (optional)"; +"Add an extracted Claude projects folder or an old CodexIsland preferences file." = "Add an extracted Claude projects folder or an old CodexIsland preferences file."; +"Add projects folder…" = "Add projects folder…"; +"Add preferences file…" = "Add preferences file…"; +"Remove %@" = "Remove %@"; +"Choose old CodexIsland preferences" = "Choose old CodexIsland preferences"; +"Choose a Claude projects folder" = "Choose a Claude projects folder"; +"Choose a valid timezone, then scan again." = "Choose a valid timezone, then scan again."; +"Could not read or save usage history. Check file access and available disk space, then scan again." = "Could not read or save usage history. Check file access and available disk space, then scan again."; +"The usage archive changed. Scan again before saving." = "The usage archive changed. Scan again before saving."; +"A selected recovery source is no longer available. Choose it again, then scan." = "A selected recovery source is no longer available. Choose it again, then scan."; +"Saved token counts exceed the supported range. Check the recovery files before trying again." = "Saved token counts exceed the supported range. Check the recovery files before trying again."; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 27dfdb0..5a3f94d 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -226,3 +226,54 @@ "%@ account options" = "%@ 账户选项"; "Sign in with Claude" = "登录 Claude"; "Open Claude Code and run /login." = "打开 Claude Code 并运行 /login。"; + +"Usage history could not be saved. Check available disk space and try refreshing." = "无法保存用量历史。请检查可用磁盘空间,然后尝试刷新。"; + +"Recover Claude usage" = "恢复 Claude 用量"; +"Recover Claude usage…" = "恢复 Claude 用量…"; +"Usage history" = "用量历史"; +"Find older Claude counts." = "查找较早的 Claude 用量记录。"; +"Find saved counts from Claude Code, Cowork, and older local records." = "从 Claude Code、Cowork 和较早的本地记录中找回用量。"; +"Scan details" = "扫描详情"; +"A backup is saved before importing. Original Claude files stay untouched." = "导入前会保存备份,原始 Claude 文件不会被修改。"; +"Cancel" = "取消"; +"Done" = "完成"; +"Scan" = "扫描"; +"Scan again" = "重新扫描"; +"Import recovery" = "导入恢复记录"; +"Saving recovered usage…" = "正在保存恢复的用量…"; +"Scanning saved records…" = "正在扫描已有记录…"; +"Recovery could not finish" = "无法完成恢复"; +"Recovered usage saved" = "已保存恢复的用量"; +"This usage is already saved" = "这些用量已经保存"; +"%@ additional tokens saved." = "额外保存了 %@ 个 token。"; +"Your usage history now includes the recovered records." = "用量历史现已包含恢复的记录。"; +"Show backup in Finder" = "在访达中显示备份"; +"Review your recovered usage" = "核对恢复的用量"; +"No additional usage found" = "未发现额外用量"; +"Already saved" = "已保存"; +"After recovery" = "恢复后"; +"Additional tokens" = "新增 token"; +"Messages checked: %@ · Daily records: %@" = "已检查消息:%@ · 每日记录:%@"; +"Totals include cache tokens. Daily records without model details remain unpriced." = "总量包含缓存 token。缺少模型信息的每日记录不会估算价格。"; +"Scan your selection to preview what can be recovered." = "扫描所选来源,预览可恢复的用量。"; +"Original timezone" = "原始时区"; +"Why recover usage?" = "为什么需要恢复用量?"; +"Older usage can be missing when Claude logs have been deleted or moved. Recovery checks the records still on your Mac and any backups you add. It saves only missing counts, so scanning again won't duplicate your usage." = "Claude 日志被删除或移动后,较早的用量可能会缺失。恢复功能会检查 Mac 上保留的记录以及你添加的备份,只保存缺失的用量,因此重复扫描不会重复计数。"; +"Why is the original timezone needed?" = "为什么需要原始时区?"; +"Older daily totals used your Mac's timezone without saving its name. Choose the timezone used when those totals were recorded so recovery keeps the correct dates and avoids counting usage twice. Individual message records don't need this." = "较早的每日汇总按当时 Mac 的时区保存,但未记录时区名称。请选择记录这些汇总时所用的时区,以保留正确的日期并避免重复计数。单条消息记录不需要填写时区。"; +"Not specified" = "未指定"; +"Some old daily totals need their original timezone. Choose where they were recorded, then scan again." = "部分较早的每日记录需要原始时区。请选择记录时所在的时区,然后重新扫描。"; +"Only needed for old daily totals. Leave unspecified if you don't know where they were recorded." = "仅较早的每日记录需要填写。如果不清楚记录时所在的时区,请保持未指定。"; +"Older backups (optional)" = "较早的备份(可选)"; +"Add an extracted Claude projects folder or an old CodexIsland preferences file." = "添加已解压的 Claude projects 文件夹,或较早的 CodexIsland 偏好设置文件。"; +"Add projects folder…" = "添加 projects 文件夹…"; +"Add preferences file…" = "添加偏好设置文件…"; +"Remove %@" = "移除 %@"; +"Choose old CodexIsland preferences" = "选择较早的 CodexIsland 偏好设置"; +"Choose a Claude projects folder" = "选择 Claude projects 文件夹"; +"Choose a valid timezone, then scan again." = "请选择有效的时区,然后重新扫描。"; +"Could not read or save usage history. Check file access and available disk space, then scan again." = "无法读取或保存用量历史。请检查文件访问权限和可用磁盘空间,然后重新扫描。"; +"The usage archive changed. Scan again before saving." = "用量存档已更改。请重新扫描后再保存。"; +"A selected recovery source is no longer available. Choose it again, then scan." = "所选恢复来源已不可用。请重新选择后再扫描。"; +"Saved token counts exceed the supported range. Check the recovery files before trying again." = "保存的 token 数量超出了支持范围。请检查恢复文件后重试。"; diff --git a/Sources/App.swift b/Sources/App.swift index 2b7c9a5..f26d449 100644 --- a/Sources/App.swift +++ b/Sources/App.swift @@ -2,6 +2,16 @@ import SwiftUI import AppKit @main +enum CodexIslandEntryPoint { + @MainActor + static func main() { + if CommandLine.arguments.dropFirst().first == "--recover-claude" { + exit(ClaudeUsageRecovery.run(arguments: Array(CommandLine.arguments.dropFirst(2)))) + } + CodexIslandApp.main() + } +} + struct CodexIslandApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { diff --git a/Sources/Cost/AntigravityLogReader.swift b/Sources/Cost/AntigravityLogReader.swift index 148515a..97a8145 100644 --- a/Sources/Cost/AntigravityLogReader.swift +++ b/Sources/Cost/AntigravityLogReader.swift @@ -7,7 +7,7 @@ enum AntigravityLogReader { let event: TokenEvent } - static func scan(lookbackDays: Int = 30, root: URL? = nil, now: Date = Date()) -> LocalCostScan { + static func scan(lookbackDays: Int? = 30, root: URL? = nil, now: Date = Date()) -> LocalCostScan { let root = root ?? FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".gemini/antigravity-cli/conversations", isDirectory: true) var result = LocalCostScan() @@ -16,7 +16,7 @@ enum AntigravityLogReader { result.unreadableFiles = 1 return result } - let cutoff = now.addingTimeInterval(-Double(lookbackDays) * 86400) + let cutoff = lookbackDays.map { now.addingTimeInterval(-Double($0) * 86400) } ?? .distantPast var seen = Set() for file in files.filter({ $0.pathExtension == "db" }).sorted(by: { $0.path < $1.path }) { do { @@ -49,7 +49,7 @@ enum AntigravityLogReader { let id = messageID.flatMap { $0.isEmpty ? nil : $0 } ?? fallbackID return Record(id: id, event: TokenEvent(provider: .antigravity, timestamp: date, model: model, inputTokens: input, outputTokens: output, - cacheCreationTokens: cacheWrite, cacheReadTokens: cacheRead)) + cacheCreationTokens: cacheWrite, cacheReadTokens: cacheRead, recordID: id)) } private enum ReadError: Error { case database } diff --git a/Sources/Cost/ClaudeLogReader.swift b/Sources/Cost/ClaudeLogReader.swift index 0052bcc..b00318b 100644 --- a/Sources/Cost/ClaudeLogReader.swift +++ b/Sources/Cost/ClaudeLogReader.swift @@ -1,9 +1,10 @@ import Foundation /// Walks the local Claude Code session JSONL files and emits TokenEvents for -/// every assistant message that recorded usage. Mirrors ccusage's data path: +/// every assistant message that recorded usage: /// - reads from ~/.claude/projects/**/*.jsonl AND ~/.config/claude/projects/**/*.jsonl /// - honors CLAUDE_CONFIG_DIR (comma-separated) when set +/// - includes locally mirrored Cowork project logs in the default search /// - dedupes by `messageId:requestId` /// - skips synthetic placeholder models /// @@ -15,38 +16,51 @@ enum ClaudeLogReader { /// Walk the configured project roots and return every usage-bearing /// assistant turn from the last `lookbackDays` days. Pure file IO; no /// network. Safe to call from a background thread. - static func scan(lookbackDays: Int = 30) -> [TokenEvent] { - let cutoff = Date().addingTimeInterval(-Double(lookbackDays) * 86400) - var seen = Set() + static func scan(lookbackDays: Int? = 30, roots: [URL]? = nil, requireStableIDs: Bool = false) -> [TokenEvent] { + let cutoff = lookbackDays.map { Date().addingTimeInterval(-Double($0) * 86400) } ?? .distantPast + var positions: [String: Int] = [:] + var occurrences: [String: Int] = [:] var out: [TokenEvent] = [] LogParseCache.walk( - roots: projectRoots(), + roots: roots ?? projectRoots(), cutoff: cutoff, cacheFilename: "claude-parse-cache.v1.json", cacheVersion: cacheVersion, + useCache: roots == nil, parse: parseFile(at:), - emit: { (ev: CachedEvent) in - guard ev.timestamp >= cutoff else { return } - if !ev.dedupKey.isEmpty { - if seen.contains(ev.dedupKey) { return } - seen.insert(ev.dedupKey) - } - out.append(TokenEvent( + emit: { (ev: CachedEvent, file: URL) in + guard ev.timestamp >= cutoff, !requireStableIDs || !ev.dedupKey.isEmpty else { return } + let event = TokenEvent( provider: .claude, timestamp: ev.timestamp, model: ev.model, inputTokens: ev.inputTokens, outputTokens: ev.outputTokens, cacheCreationTokens: ev.cacheCreationTokens, - cacheReadTokens: ev.cacheReadTokens - )) + cacheReadTokens: ev.cacheReadTokens, + recordID: ev.dedupKey.isEmpty + ? LogParseCache.recordID(file: file, timestamp: ev.timestamp, occurrences: &occurrences) + : ev.dedupKey + ) + if !ev.dedupKey.isEmpty, let position = positions[ev.dedupKey] { + let previous = out[position] + // Streaming rows can repeat a call with more complete usage. Keep an actual row, never a synthetic sum. + if event.model == previous.model, + event.inputTokens >= previous.inputTokens, event.outputTokens >= previous.outputTokens, + event.cacheCreationTokens >= previous.cacheCreationTokens, event.cacheReadTokens >= previous.cacheReadTokens { + out[position] = event + } + } else { + if !ev.dedupKey.isEmpty { positions[ev.dedupKey] = out.count } + out.append(event) + } } ) return out } - private static func projectRoots() -> [URL] { + static func projectRoots() -> [URL] { if let env = ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"], !env.isEmpty { return env.split(separator: ",").map { URL(fileURLWithPath: String($0).trimmingCharacters(in: .whitespaces)) @@ -57,7 +71,22 @@ enum ClaudeLogReader { return [ home.appendingPathComponent(".claude/projects", isDirectory: true), home.appendingPathComponent(".config/claude/projects", isDirectory: true), - ].filter { FileManager.default.fileExists(atPath: $0.path) } + ].filter { FileManager.default.fileExists(atPath: $0.path) } + desktopProjectRoots(home: home) + } + + private static func desktopProjectRoots(home: URL) -> [URL] { + let root = home.appendingPathComponent("Library/Application Support/Claude/local-agent-mode-sessions", isDirectory: true) + guard let enumerator = FileManager.default.enumerator(at: root, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsPackageDescendants]) else { return [] } + var roots: [URL] = [] + for case let url as URL in enumerator where url.lastPathComponent == "projects" + && url.deletingLastPathComponent().lastPathComponent == ".claude" { + guard (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue } + roots.append(url) + enumerator.skipDescendants() + } + return roots } /// Parse a single file end-to-end. Caller is responsible for cutoff diff --git a/Sources/Cost/CodexLogReader.swift b/Sources/Cost/CodexLogReader.swift index 7c6a8b4..a055d38 100644 --- a/Sources/Cost/CodexLogReader.swift +++ b/Sources/Cost/CodexLogReader.swift @@ -13,18 +13,20 @@ import Foundation /// keyed by (path, mtime, size). Between two 5/15/30-minute polls almost no /// rollout file has changed, so the steady-state refresh skips re-parsing. enum CodexLogReader { - static func scan(lookbackDays: Int = 30) -> [TokenEvent] { - let cutoff = Date().addingTimeInterval(-Double(lookbackDays) * 86400) + static func scan(lookbackDays: Int? = 30, root: URL? = nil) -> [TokenEvent] { + let cutoff = lookbackDays.map { Date().addingTimeInterval(-Double($0) * 86400) } ?? .distantPast var out: [TokenEvent] = [] + var occurrences: [String: Int] = [:] LogParseCache.walk( - roots: [sessionsRoot()], + roots: [root ?? sessionsRoot()], cutoff: cutoff, cacheFilename: "codex-parse-cache.v1.json", cacheVersion: cacheVersion, + useCache: root == nil, fileFilter: { $0.lastPathComponent.hasPrefix("rollout-") }, parse: parseFile(at:), - emit: { (ev: CachedEvent) in + emit: { (ev: CachedEvent, file: URL) in guard ev.timestamp >= cutoff else { return } out.append(TokenEvent( provider: .codex, @@ -33,7 +35,8 @@ enum CodexLogReader { inputTokens: ev.inputTokens, outputTokens: ev.outputTokens, cacheCreationTokens: 0, - cacheReadTokens: ev.cacheReadTokens + cacheReadTokens: ev.cacheReadTokens, + recordID: LogParseCache.recordID(file: file, timestamp: ev.timestamp, occurrences: &occurrences) )) } ) diff --git a/Sources/Cost/CostStore.swift b/Sources/Cost/CostStore.swift index 68d6c1e..8e6d23d 100644 --- a/Sources/Cost/CostStore.swift +++ b/Sources/Cost/CostStore.swift @@ -24,6 +24,7 @@ final class CostStore: ObservableObject { @Published private(set) var connectedLoading: Set = [] @Published private(set) var connectedUpdated: [IslandProvider: Date] = [:] @Published private(set) var localNotices: [IslandProvider: String] = [:] + @Published private(set) var historySaveErrors: [IslandProvider: String] = [:] func cost(for provider: IslandProvider) -> ProviderCost { switch provider { @@ -76,23 +77,29 @@ final class CostStore: ObservableObject { loadDemoData() return } - let days = CostSummary.yearHistoryDays() for provider in [IslandProvider.antigravity, .grok] where !connectedLoading.contains(provider) { connectedLoading.insert(provider) Task.detached(priority: .utility) { [weak self] in - let scan = provider == .antigravity - ? AntigravityLogReader.scan(lookbackDays: days) - : GrokLogReader.scan(lookbackDays: days) - let cost = CostSummary.summarize(events: scan.events) - await self?.commitLocal(cost, scan: scan, provider: provider) + let observedAt = Date() + var scan = provider == .antigravity + ? AntigravityLogReader.scan(lookbackDays: nil) + : GrokLogReader.scan(lookbackDays: nil) + let saved = UsageLedger.shared.retain(scan.events, + source: provider == .antigravity ? .antigravity : .grok, + observedAt: observedAt) + scan.events = saved.events + let cost = CostSummary.summarize(events: scan.events, historicalDays: saved.historicalDays) + await self?.commitLocal(cost, scan: scan, provider: provider, saveError: saved.saveError) } } // Only scan OpenCode when at least one provider will consume // the result; avoids wasted I/O when both are already loading. - let openCodeTask: Task<[TokenEvent], Never>? + let openCodeTask: Task? if !claudeLoading || !codexLoading { openCodeTask = Task.detached(priority: .userInitiated) { - OpenCodeLogReader.scan(lookbackDays: days) + let observedAt = Date() + return UsageLedger.shared.retain(OpenCodeLogReader.scan(lookbackDays: nil), + source: .openCode, observedAt: observedAt) } } else { openCodeTask = nil @@ -102,28 +109,33 @@ final class CostStore: ObservableObject { if !claudeLoading { claudeLoading = true Task.detached(priority: .userInitiated) { [weak self] in - let openCodeEvents = await openCodeTask?.value ?? [] - let events = ClaudeLogReader.scan(lookbackDays: days) - + openCodeEvents.filter { $0.provider == .claude } - let cost = CostSummary.summarize(events: events) - await self?.commitClaude(cost) + let openCode = await openCodeTask?.value + let observedAt = Date() + let saved = UsageLedger.shared.retain(ClaudeLogReader.scan(lookbackDays: nil), + source: .claude, observedAt: observedAt) + let events = saved.events + (openCode?.events.filter { $0.provider == .claude } ?? []) + let cost = CostSummary.summarize(events: events, historicalDays: saved.historicalDays) + await self?.commitClaude(cost, saveError: saved.saveError ?? openCode?.saveError) } } if !codexLoading { codexLoading = true Task.detached(priority: .userInitiated) { [weak self] in - let openCodeEvents = await openCodeTask?.value ?? [] - let events = CodexLogReader.scan(lookbackDays: days) - + openCodeEvents.filter { $0.provider == .codex } - let cost = CostSummary.summarize(events: events) - await self?.commitCodex(cost) + let openCode = await openCodeTask?.value + let observedAt = Date() + let saved = UsageLedger.shared.retain(CodexLogReader.scan(lookbackDays: nil), + source: .codex, observedAt: observedAt) + let events = saved.events + (openCode?.events.filter { $0.provider == .codex } ?? []) + let cost = CostSummary.summarize(events: events, historicalDays: saved.historicalDays) + await self?.commitCodex(cost, saveError: saved.saveError ?? openCode?.saveError) } } } - private func commitLocal(_ cost: ProviderCost, scan: LocalCostScan, provider: IslandProvider) { + private func commitLocal(_ cost: ProviderCost, scan: LocalCostScan, provider: IslandProvider, saveError: String?) { connectedLoading.remove(provider) - localNotices[provider] = scan.notice + historySaveErrors[provider] = saveError + localNotices[provider] = saveError ?? scan.notice if scan.unreadableFiles > 0 && scan.events.isEmpty { return } var displayed = cost if scan.events.isEmpty { @@ -134,15 +146,19 @@ final class CostStore: ObservableObject { connectedUpdated[provider] = Date() } - private func commitClaude(_ cost: ProviderCost) { + private func commitClaude(_ cost: ProviderCost, saveError: String?) { self.claude = cost + historySaveErrors[.claude] = saveError + localNotices[.claude] = saveError self.claudeLoading = false self.lastUpdated = Date() persist() } - private func commitCodex(_ cost: ProviderCost) { + private func commitCodex(_ cost: ProviderCost, saveError: String?) { self.codex = cost + historySaveErrors[.codex] = saveError + localNotices[.codex] = saveError self.codexLoading = false self.lastUpdated = Date() persist() diff --git a/Sources/Cost/CostSummary.swift b/Sources/Cost/CostSummary.swift index f8302ac..ccc4b3d 100644 --- a/Sources/Cost/CostSummary.swift +++ b/Sources/Cost/CostSummary.swift @@ -8,14 +8,24 @@ import Foundation /// it off the main actor without touching `@MainActor` state. `now` is /// injectable so the window boundaries are testable. enum CostSummary { - static func summarize(events: [TokenEvent], now: Date = Date()) -> ProviderCost { + static func summarize(events: [TokenEvent], now: Date = Date(), includeAllHistory: Bool = false, + historicalDays: [HistoricalUsageDay] = []) -> ProviderCost { var cal = Calendar(identifier: .gregorian) cal.timeZone = .current let startOfDay = cal.startOfDay(for: now) let monthStart = cal.date(from: cal.dateComponents([.year, .month], from: now)) ?? startOfDay let currentHour = cal.dateComponents([.hour], from: now).hour ?? 0 let currentDay = (cal.dateComponents([.day], from: now).day ?? 1) - 1 - let historyDays = yearHistoryDays(now: now) + let recovered = HistoricalUsageDay.supplements(historicalDays, events: events.filter { $0.timestamp <= now }, calendar: cal) + var historyDays = localHistoryDays(now: now) + let earliestEvent = events.lazy.filter({ + $0.timestamp <= now && $0.inputTokens + $0.outputTokens + $0.cacheCreationTokens + $0.cacheReadTokens > 0 + }).map(\.timestamp).min() + let earliestRecovered = recovered.filter { $0.dayStart <= now }.map(\.dayStart).min() + if includeAllHistory, let earliest = [earliestEvent, earliestRecovered].compactMap({ $0 }).min() { + let recordedDays = (cal.dateComponents([.day], from: cal.startOfDay(for: earliest), to: startOfDay).day ?? 0) + 1 + historyDays = max(historyDays, recordedDays) + } let historyStart = cal.date( byAdding: .day, value: -(historyDays - 1), @@ -35,6 +45,9 @@ enum CostSummary { var dailyBuckets = Array(repeating: 0.0, count: currentDay + 1) var historyTokenBuckets = Array(repeating: 0, count: historyDays) var historyBillableBuckets = Array(repeating: 0, count: historyDays) + var historyDollarBuckets = Array(repeating: 0.0, count: historyDays) + var historyUnpricedBuckets = Array(repeating: 0, count: historyDays) + var historyRecoveredBuckets = Array(repeating: 0, count: historyDays) // Filtered to non-zero token events so handshake/stub rows don't // show up as "unpriced" warnings — the user only cares about // models that actually moved tokens. @@ -61,7 +74,7 @@ enum CostSummary { // assumption — keep the broader guard. let earliestStart = min(monthStart, weekStart, historyStart) for event in events { - guard event.timestamp >= earliestStart else { continue } + guard event.timestamp >= earliestStart, event.timestamp <= now else { continue } let cost = Pricing.cost(for: event) // Two parallel running totals: `tokens` is the wire-level sum // (ccusage parity); `billable` is input + output only, matching @@ -78,6 +91,8 @@ enum CostSummary { if historyTokenBuckets.indices.contains(dayOffset) { historyTokenBuckets[dayOffset] += tokens historyBillableBuckets[dayOffset] += billable + historyDollarBuckets[dayOffset] += cost + if isUnpriced { historyUnpricedBuckets[dayOffset] += tokens } } } @@ -127,6 +142,25 @@ enum CostSummary { } } + for bucket in recovered where bucket.dayStart >= historyStart && bucket.dayStart <= now { + let offset = cal.dateComponents([.day], from: historyStart, to: bucket.dayStart).day ?? -1 + guard historyTokenBuckets.indices.contains(offset) else { continue } + historyTokenBuckets[offset] += bucket.tokens + historyBillableBuckets[offset] += bucket.billableTokens + historyUnpricedBuckets[offset] += bucket.tokens + historyRecoveredBuckets[offset] += bucket.tokens + if bucket.dayStart >= monthStart { + monthTokens += bucket.tokens + monthBillable += bucket.billableTokens + monthUnknown.insert("Recovered daily totals") + } + if bucket.dayStart == startOfDay { + todayTokens += bucket.tokens + todayBillable += bucket.billableTokens + todayUnknown.insert("Recovered daily totals") + } + } + let recentRows = modelRows( tokensByModel: recentTokensByModel, dollarsByModel: recentDollarsByModel @@ -161,11 +195,19 @@ enum CostSummary { start: historyStart, tokens: historyTokenBuckets, billableTokens: historyBillableBuckets, + dollars: historyDollarBuckets, + unpricedTokens: historyUnpricedBuckets, + recoveredTokens: historyRecoveredBuckets, calendar: cal ) ) } + static func localHistoryDays(now: Date = Date()) -> Int { + // A complete previous week can cross New Year's Day. + max(14, yearHistoryDays(now: now)) + } + static func yearHistoryDays(now: Date = Date()) -> Int { var cal = Calendar(identifier: .gregorian) cal.timeZone = .current @@ -179,6 +221,9 @@ enum CostSummary { start: Date, tokens: [Int], billableTokens: [Int], + dollars: [Double], + unpricedTokens: [Int], + recoveredTokens: [Int], calendar: Calendar ) -> [DailyTokenBucket] { tokens.indices.map { index in @@ -186,7 +231,10 @@ enum CostSummary { return DailyTokenBucket( dayStart: day, tokens: tokens[index], - billableTokens: billableTokens[index] + billableTokens: billableTokens[index], + dollars: dollars[index], + unpricedTokens: unpricedTokens[index], + recoveredTokens: recoveredTokens[index] ) } } diff --git a/Sources/Cost/CostUsage.swift b/Sources/Cost/CostUsage.swift index 4979e7f..d834d47 100644 --- a/Sources/Cost/CostUsage.swift +++ b/Sources/Cost/CostUsage.swift @@ -74,6 +74,10 @@ struct DailyTokenBucket: Codable { let dayStart: Date let tokens: Int let billableTokens: Int + // Optional so older caches stay readable without inventing a $0 price. + var dollars: Double? = nil + var unpricedTokens: Int? = nil + var recoveredTokens: Int? = nil } /// Per-provider cost summary: today + month-to-date in calendar-local time. diff --git a/Sources/Cost/GrokLogReader.swift b/Sources/Cost/GrokLogReader.swift index b584c9a..76768b1 100644 --- a/Sources/Cost/GrokLogReader.swift +++ b/Sources/Cost/GrokLogReader.swift @@ -6,14 +6,14 @@ enum GrokLogReader { let event: TokenEvent } - static func scan(lookbackDays: Int = 30, root: URL? = nil, now: Date = Date()) -> LocalCostScan { + static func scan(lookbackDays: Int? = 30, root: URL? = nil, now: Date = Date()) -> LocalCostScan { let home = ProcessInfo.processInfo.environment["GROK_HOME"].flatMap { $0.isEmpty ? nil : $0 } .map { URL(fileURLWithPath: $0) } ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".grok") let root = root ?? home.appendingPathComponent("sessions", isDirectory: true) var result = LocalCostScan() guard FileManager.default.fileExists(atPath: root.path) else { return result } - let cutoff = now.addingTimeInterval(-Double(lookbackDays) * 86400) + let cutoff = lookbackDays.map { now.addingTimeInterval(-Double($0) * 86400) } ?? .distantPast var records: [String: TokenEvent] = [:] let files = LogParseCache.jsonlFiles(under: root, modifiedAfter: cutoff) { $0.lastPathComponent == "updates.jsonl" } for file in files { @@ -52,7 +52,8 @@ enum GrokLogReader { // ACP PromptUsage input includes cache; TokenEvent buckets are disjoint. return Record(id: "\(prompt):\(model)", event: TokenEvent(provider: .grok, timestamp: timestamp, model: model, inputTokens: fullInput - cacheRead - cacheWrite, - outputTokens: output, cacheCreationTokens: cacheWrite, cacheReadTokens: cacheRead)) + outputTokens: output, cacheCreationTokens: cacheWrite, cacheReadTokens: cacheRead, + recordID: "\(prompt):\(model)")) } } diff --git a/Sources/Cost/HistoricalUsageDay.swift b/Sources/Cost/HistoricalUsageDay.swift new file mode 100644 index 0000000..d7c43cd --- /dev/null +++ b/Sources/Cost/HistoricalUsageDay.swift @@ -0,0 +1,63 @@ +import Foundation + +struct HistoricalUsageDay: Codable { + let provider: TokenEvent.Provider + let sourceIdentity: String + let intervalStart: Date + let intervalEnd: Date + let sourceLocalDate: String + let sourceUTCOffsetSeconds: Int + let tokensIncludingCache: Int + let inputPlusOutputTokens: Int + + var isValid: Bool { + guard intervalStart.timeIntervalSince1970.isFinite, intervalEnd.timeIntervalSince1970.isFinite, + intervalStart.timeIntervalSince1970 > 0, + (23 * 3600 ... 25 * 3600).contains(intervalEnd.timeIntervalSince(intervalStart)), + tokensIncludingCache > 0, tokensIncludingCache <= Int.max / 4, + inputPlusOutputTokens >= 0, inputPlusOutputTokens <= tokensIncludingCache, + let zone = TimeZone(secondsFromGMT: sourceUTCOffsetSeconds) else { return false } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = zone + return displayDay(calendar: calendar) == intervalStart + } + + func displayDay(calendar: Calendar) -> Date? { + let values = sourceLocalDate.split(separator: "-").compactMap { Int($0) } + guard values.count == 3 else { return nil } + let parts = DateComponents(year: values[0], month: values[1], day: values[2]) + guard let date = calendar.date(from: parts) else { return nil } + let actual = calendar.dateComponents([.year, .month, .day], from: date) + guard actual.year == values[0], actual.month == values[1], actual.day == values[2] else { return nil } + return date + } + + static func supplements(_ days: [HistoricalUsageDay], events: [TokenEvent], calendar: Calendar) -> [DailyTokenBucket] { + var output: [DailyTokenBucket] = [] + for provider in Set(days.map(\.provider)) { + let saved = days.filter { $0.provider == provider && $0.isValid }.sorted { $0.intervalStart < $1.intervalStart } + var observed = Array(repeating: 0, count: saved.count) + var observedBillable = observed + for event in events where event.provider == provider { + var lower = 0, upper = saved.count + while lower < upper { + let middle = (lower + upper) / 2 + if saved[middle].intervalStart <= event.timestamp { lower = middle + 1 } else { upper = middle } + } + let index = lower - 1 + guard saved.indices.contains(index), event.timestamp < saved[index].intervalEnd else { continue } + observed[index] += event.inputTokens + event.outputTokens + event.cacheCreationTokens + event.cacheReadTokens + observedBillable[index] += event.inputTokens + event.outputTokens + } + for (index, day) in saved.enumerated() { + let remainder = max(0, day.tokensIncludingCache - observed[index]) + guard remainder > 0, let displayDay = day.displayDay(calendar: calendar) else { continue } + let billable = min(remainder, max(0, day.inputPlusOutputTokens - observedBillable[index])) + // A daily snapshot has no model or per-call timing. Preserve its original date and leave its API value unpriced. + output.append(DailyTokenBucket(dayStart: displayDay, tokens: remainder, billableTokens: billable, + dollars: 0, unpricedTokens: remainder, recoveredTokens: remainder)) + } + } + return output + } +} diff --git a/Sources/Cost/LogParseCache.swift b/Sources/Cost/LogParseCache.swift index 5938070..e95151b 100644 --- a/Sources/Cost/LogParseCache.swift +++ b/Sources/Cost/LogParseCache.swift @@ -6,6 +6,13 @@ import Foundation /// files. Each provider supplies only its `Event` Codable shape and a /// `parseFile` closure that consumes lines one at a time. enum LogParseCache { + static func recordID(file: URL, timestamp: Date, occurrences: inout [String: Int]) -> String { + let base = "\(file.lastPathComponent):\(Int64((timestamp.timeIntervalSince1970 * 1000).rounded()))" + let occurrence = occurrences[base, default: 0] + occurrences[base] = occurrence + 1 + return "\(base):\(occurrence)" + } + struct FileEntry { let url: URL let mtime: Date @@ -173,11 +180,13 @@ enum LogParseCache { cutoff: Date, cacheFilename: String, cacheVersion: Int, + useCache: Bool = true, fileFilter: (URL) -> Bool = { _ in true }, parse: (URL) -> [Event], - emit: (Event) -> Void + emit: (Event, URL) -> Void ) { - var cache = loadCache(filename: cacheFilename, version: cacheVersion, eventType: Event.self) + var cache = useCache ? loadCache(filename: cacheFilename, version: cacheVersion, eventType: Event.self) + : ParseCache(version: cacheVersion, files: [:]) var visited = Set() var cacheChanged = false @@ -194,7 +203,7 @@ enum LogParseCache { cache.files[path] = CachedFile(mtime: entry.mtime, size: entry.size, events: events) cacheChanged = true } - for ev in events { emit(ev) } + for ev in events { emit(ev, entry.url) } } } @@ -204,6 +213,6 @@ enum LogParseCache { cache.files = cache.files.filter { visited.contains($0.key) } if cache.files.count != preCount { cacheChanged = true } - if cacheChanged { saveCache(cache, filename: cacheFilename) } + if useCache && cacheChanged { saveCache(cache, filename: cacheFilename) } } } diff --git a/Sources/Cost/OpenCodeLogReader.swift b/Sources/Cost/OpenCodeLogReader.swift index 9e72b87..f13712c 100644 --- a/Sources/Cost/OpenCodeLogReader.swift +++ b/Sources/Cost/OpenCodeLogReader.swift @@ -16,8 +16,8 @@ enum OpenCodeLogReader { // MARK: - Public - static func scan(lookbackDays: Int = 30) -> [TokenEvent] { - let cutoff = Date().addingTimeInterval(-Double(lookbackDays) * 86400) + static func scan(lookbackDays: Int? = 30) -> [TokenEvent] { + let cutoff = lookbackDays.map { Date().addingTimeInterval(-Double($0) * 86400) } ?? .distantPast var seenIds = Set() var seenFingerprints = Set() var out: [TokenEvent] = [] @@ -32,7 +32,9 @@ enum OpenCodeLogReader { // so cost totals match tokscale's hash-based dedup. let fp = "\(Int64(ev.timestamp.timeIntervalSince1970 * 1000)):\(ev.model):\(ev.inputTokens):\(ev.outputTokens):\(ev.cacheReadTokens):\(ev.cacheCreationTokens)" guard seenFingerprints.insert(fp).inserted else { return } - guard let token = ev.tokenEvent() else { return } + guard var token = ev.tokenEvent() else { return } + token.recordID = ev.messageId.isEmpty ? "fingerprint:\(fp)" : ev.messageId + token.recordAliases = ["fingerprint:\(fp)"] out.append(token) } diff --git a/Sources/Cost/TokenEvent.swift b/Sources/Cost/TokenEvent.swift index 9127467..c461382 100644 --- a/Sources/Cost/TokenEvent.swift +++ b/Sources/Cost/TokenEvent.swift @@ -4,7 +4,7 @@ import Foundation /// Both `ClaudeLogReader` and `CodexLogReader` emit these so the cost pipeline /// downstream is provider-agnostic. struct TokenEvent { - enum Provider { + enum Provider: String, Codable { case claude case codex case grok @@ -22,4 +22,6 @@ struct TokenEvent { /// (Codex calls these "cached_input_tokens" — they are billed at a /// discount but still draw from the input bucket). let cacheReadTokens: Int + var recordID: String? = nil + var recordAliases: [String] = [] } diff --git a/Sources/Cost/UsageLedger.swift b/Sources/Cost/UsageLedger.swift new file mode 100644 index 0000000..a240987 --- /dev/null +++ b/Sources/Cost/UsageLedger.swift @@ -0,0 +1,504 @@ +import CryptoKit +import Foundation +import SQLite3 + +final class UsageLedger { + enum Source: String, CaseIterable { + case claude, codex, openCode, grok, antigravity + } + + struct Snapshot { + let events: [TokenEvent] + let saveError: String? + var historicalDays: [HistoricalUsageDay] = [] + } + + struct HistoricalBatch { + let days: [HistoricalUsageDay] + let capturedAt: Date + } + + struct RecoveryCommit { + let before: Snapshot + let after: Snapshot + } + + static let shared = UsageLedger() + static let saveErrorMessage = "Usage history could not be saved. Check available disk space and try refreshing." + static var defaultURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/dev.codexisland.CodexIsland", isDirectory: true) + .appendingPathComponent("usage-history.sqlite3") + } + + let url: URL + private let lock = NSLock() + private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + + init(url: URL = UsageLedger.defaultURL) { + self.url = url + } + + func retain(_ events: [TokenEvent], source: Source, now: Date = Date(), observedAt: Date? = nil, + insertOnly: Bool = false) -> Snapshot { + lock.lock() + defer { lock.unlock() } + let incoming = Self.records(events, now: now) + let observedAt = observedAt ?? now + var database: OpaquePointer? + defer { if let database { sqlite3_close(database) } } + + do { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK, + let database else { throw StorageError.database } + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + sqlite3_busy_timeout(database, 5000) + try Self.prepareDatabase(database) + try Self.execute(database, "BEGIN IMMEDIATE") + do { + let batch = Self.canonicalized(incoming, aliases: try Self.readAliases(source: source, database: database)) + let isCurrent = try Self.isCurrent(observedAt, source: source, database: database) + try Self.upsert(batch.records, source: source, now: now, updateExisting: isCurrent && !insertOnly, database: database) + try Self.saveAliases(batch.aliases, source: source, database: database) + if isCurrent && !insertOnly { try Self.recordScan(observedAt, source: source, database: database) } + try Self.execute(database, "COMMIT") + } catch { + try? Self.execute(database, "ROLLBACK") + throw error + } + return Snapshot(events: try Self.read(source: source, database: database), saveError: nil, + historicalDays: try Self.readHistoricalDays(source: source, database: database)) + } catch { + // A failed write must not hide either the previously saved history or this scan. + let saved = database.flatMap { try? Self.read(source: source, database: $0) } ?? [] + var combined = Self.records(saved, now: now) + let aliases = database.flatMap { try? Self.readAliases(source: source, database: $0) } ?? [:] + let isCurrent = database.flatMap { try? Self.isCurrent(observedAt, source: source, database: $0) } ?? true + combined.merge(Self.canonicalized(incoming, aliases: aliases).records) { saved, latest in isCurrent && !insertOnly ? latest : saved } + let historical = database.flatMap { try? Self.readHistoricalDays(source: source, database: $0) } ?? [] + return Snapshot(events: Array(combined.values), saveError: Self.saveErrorMessage, historicalDays: historical) + } + } + + @discardableResult + func copyDatabase(to destination: URL) throws -> Bool { + lock.lock() + defer { lock.unlock() } + guard FileManager.default.fileExists(atPath: url.path) else { return false } + guard !FileManager.default.fileExists(atPath: destination.path) else { throw StorageError.database } + try FileManager.default.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + var sourcePointer: OpaquePointer?, targetPointer: OpaquePointer? + var isPortable = false + defer { + if let sourcePointer { sqlite3_close(sourcePointer) } + if let targetPointer { sqlite3_close(targetPointer) } + if isPortable { + for suffix in ["-shm", "-wal"] { + try? FileManager.default.removeItem(atPath: destination.path + suffix) + } + } + } + guard sqlite3_open_v2(url.path, &sourcePointer, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK, + let source = sourcePointer, + sqlite3_open_v2(destination.path, &targetPointer, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nil) == SQLITE_OK, + let target = targetPointer else { throw StorageError.database } + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path) + sqlite3_busy_timeout(source, 5000) + sqlite3_busy_timeout(target, 5000) + guard let backup = sqlite3_backup_init(target, "main", source, "main") else { throw StorageError.database } + let copied = sqlite3_backup_step(backup, -1) + let finished = sqlite3_backup_finish(backup) + guard copied == SQLITE_DONE, finished == SQLITE_OK else { throw StorageError.database } + // A portable backup must not need WAL sidecars during its first read-only open. + let mode = try Self.statement(target, "PRAGMA journal_mode=DELETE") + let modeStatus = sqlite3_step(mode) + let modeName = sqlite3_column_text(mode, 0).map { String(cString: $0) } + sqlite3_finalize(mode) + guard modeStatus == SQLITE_ROW, modeName == "delete" else { throw StorageError.database } + isPortable = true + return true + } + + func importHistoricalDays(_ days: [HistoricalUsageDay], capturedAt: Date, preserveLargerTotals: Bool = false) throws { + try Self.validateHistoricalDays(days, capturedAt: capturedAt) + try writeTransaction { database in + try Self.upsertHistoricalDays(days, capturedAt: capturedAt, preserveLargerTotals: preserveLargerTotals, database: database) + try Self.validateHistoricalIntervals(providers: Set(days.map(\.provider)), database: database) + } + } + + func recoverClaude(_ events: [TokenEvent], historicalBatches: [HistoricalBatch], now: Date) throws -> RecoveryCommit { + guard events.allSatisfy({ $0.provider == .claude }), + historicalBatches.flatMap(\.days).allSatisfy({ $0.provider == .claude }) else { throw StorageError.record } + try Self.validateHistoricalDays(historicalBatches.flatMap(\.days), capturedAt: now) + for batch in historicalBatches { try Self.validateHistoricalDays(batch.days, capturedAt: batch.capturedAt) } + let incoming = Self.records(events, now: now) + return try writeTransaction { database in + let before = try Self.readClaudeHistory(database: database) + let batch = Self.canonicalized(incoming, aliases: try Self.readAliases(source: .claude, database: database)) + try Self.upsert(batch.records, source: .claude, now: now, updateExisting: false, database: database) + try Self.saveAliases(batch.aliases, source: .claude, database: database) + for historical in historicalBatches.sorted(by: { $0.capturedAt < $1.capturedAt }) { + try Self.upsertHistoricalDays(historical.days, capturedAt: historical.capturedAt, + preserveLargerTotals: true, database: database) + } + try Self.validateHistoricalIntervals(providers: [.claude], database: database) + let after = try Self.readClaudeHistory(database: database) + return RecoveryCommit(before: before, after: after) + } + } + + private static func readClaudeHistory(database: OpaquePointer) throws -> Snapshot { + let events = try read(source: .claude, database: database) + + read(source: .openCode, database: database).filter { $0.provider == .claude } + let days = try readHistoricalDays(source: .claude, database: database) + var total = 0 + for count in events.flatMap({ [$0.inputTokens, $0.outputTokens, $0.cacheCreationTokens, $0.cacheReadTokens] }) + + days.map(\.tokensIncludingCache) { + let result = total.addingReportingOverflow(count) + guard count >= 0, !result.overflow, result.partialValue <= Int.max / 4 else { throw StorageError.record } + total = result.partialValue + } + return Snapshot(events: events, saveError: nil, historicalDays: days) + } + + func savedSnapshot(source: Source) throws -> Snapshot { + lock.lock() + defer { lock.unlock() } + guard FileManager.default.fileExists(atPath: url.path) else { return Snapshot(events: [], saveError: nil) } + var pointer: OpaquePointer? + defer { if let pointer { sqlite3_close(pointer) } } + guard sqlite3_open_v2(url.path, &pointer, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK, + let database = pointer else { throw StorageError.database } + sqlite3_busy_timeout(database, 5000) + let version = try Self.statement(database, "PRAGMA user_version") + defer { sqlite3_finalize(version) } + let status = sqlite3_step(version) + guard status == SQLITE_ROW else { throw StorageError.database } + guard sqlite3_column_int(version, 0) == 1 else { throw StorageError.schema } + return Snapshot(events: try Self.read(source: source, database: database), saveError: nil, + historicalDays: try Self.readHistoricalDays(source: source, database: database)) + } + + private static func validateHistoricalDays(_ days: [HistoricalUsageDay], capturedAt: Date) throws { + let keys = days.map { "\($0.provider.rawValue):\($0.intervalStart.timeIntervalSince1970.bitPattern)" } + guard days.allSatisfy(\.isValid), Set(keys).count == keys.count, + capturedAt.timeIntervalSince1970.isFinite, capturedAt.timeIntervalSince1970 > 0 else { throw StorageError.record } + } + + private func writeTransaction(_ operation: (OpaquePointer) throws -> Result) throws -> Result { + lock.lock() + defer { lock.unlock() } + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + var pointer: OpaquePointer? + guard sqlite3_open_v2(url.path, &pointer, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK, + let database = pointer else { + if let pointer { sqlite3_close(pointer) } + throw StorageError.database + } + defer { sqlite3_close(database) } + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + sqlite3_busy_timeout(database, 5000) + try Self.prepareDatabase(database) + try Self.execute(database, "BEGIN IMMEDIATE") + do { + let result = try operation(database) + try Self.execute(database, "COMMIT") + return result + } catch { + try? Self.execute(database, "ROLLBACK") + throw error + } + } + + private static func upsertHistoricalDays(_ days: [HistoricalUsageDay], capturedAt: Date, + preserveLargerTotals: Bool, database: OpaquePointer) throws { + let recoveryCondition = preserveLargerTotals + ? " AND excluded.tokens >= tokens AND excluded.billable_tokens >= billable_tokens" : "" + let statement = try statement(database, """ + INSERT INTO historical_daily_usage VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, interval_start) DO UPDATE SET + interval_end=excluded.interval_end, source_date=excluded.source_date, + utc_offset=excluded.utc_offset, tokens=excluded.tokens, billable_tokens=excluded.billable_tokens, + evidence_id=excluded.evidence_id, captured_at=excluded.captured_at + WHERE excluded.captured_at >= captured_at\(recoveryCondition) + """) + defer { sqlite3_finalize(statement) } + for day in days { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + try bind(day.provider.rawValue, at: 1, to: statement) + guard sqlite3_bind_double(statement, 2, day.intervalStart.timeIntervalSince1970) == SQLITE_OK, + sqlite3_bind_double(statement, 3, day.intervalEnd.timeIntervalSince1970) == SQLITE_OK else { throw StorageError.database } + try bind(day.sourceLocalDate, at: 4, to: statement) + guard sqlite3_bind_int(statement, 5, Int32(day.sourceUTCOffsetSeconds)) == SQLITE_OK, + sqlite3_bind_int64(statement, 6, Int64(day.tokensIncludingCache)) == SQLITE_OK, + sqlite3_bind_int64(statement, 7, Int64(day.inputPlusOutputTokens)) == SQLITE_OK else { throw StorageError.database } + try bind(digest(day.sourceIdentity, provider: day.provider), at: 8, to: statement) + guard sqlite3_bind_double(statement, 9, capturedAt.timeIntervalSince1970) == SQLITE_OK, + sqlite3_step(statement) == SQLITE_DONE else { throw StorageError.database } + } + } + + private static func validateHistoricalIntervals(providers: Set, database: OpaquePointer) throws { + for provider in providers { + guard let source = Source(rawValue: provider.rawValue) else { throw StorageError.record } + let saved = try readHistoricalDays(source: source, database: database) + for pair in zip(saved, saved.dropFirst()) where pair.0.intervalEnd > pair.1.intervalStart { throw StorageError.record } + } + } + + private static func records(_ events: [TokenEvent], now: Date) -> [String: TokenEvent] { + var result: [String: TokenEvent] = [:] + var occurrences: [String: Int] = [:] + for event in events { + let counts = [event.inputTokens, event.outputTokens, event.cacheCreationTokens, event.cacheReadTokens] + guard event.timestamp.timeIntervalSince1970.isFinite, + event.timestamp.timeIntervalSince1970 > 0, event.timestamp <= now, + counts.allSatisfy({ $0 >= 0 && $0 <= Int.max / 4 }), counts.contains(where: { $0 > 0 }) else { continue } + let key: String + if let id = event.recordID, id.hasPrefix("ledger:") { + key = String(id.dropFirst(7)) + } else { + let identity: String + if let id = event.recordID, !id.isEmpty { + identity = id + } else { + let base = "\(event.provider.rawValue):\(event.timestamp.timeIntervalSince1970.bitPattern):\(event.model)" + let occurrence = occurrences[base, default: 0] + occurrences[base] = occurrence + 1 + identity = "fallback:\(base):\(occurrence)" + } + key = digest(identity, provider: event.provider) + } + result[key] = event + } + return result + } + + private static func digest(_ identity: String, provider: TokenEvent.Provider) -> String { + let input = [provider.rawValue, identity].map { "\($0.utf8.count):\($0)" }.joined() + return SHA256.hash(data: Data(input.utf8)).map { String(format: "%02x", $0) }.joined() + } + + private static func canonicalized(_ records: [String: TokenEvent], aliases: [String: String]) + -> (records: [String: TokenEvent], aliases: [String: String]) { + var known = aliases + var added: [String: String] = [:] + var result: [String: TokenEvent] = [:] + for (key, event) in records { + let keys = [key] + event.recordAliases.map { digest($0, provider: event.provider) } + let canonical = keys.compactMap { known[$0] }.first ?? key + result[canonical] = event + for alias in keys where known[alias] == nil { + known[alias] = canonical + added[alias] = canonical + } + } + return (result, added) + } + + private enum StorageError: Error { case database, schema, record } + + private static func execute(_ database: OpaquePointer, _ sql: String) throws { + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { throw StorageError.database } + } + + private static func statement(_ database: OpaquePointer, _ sql: String) throws -> OpaquePointer { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw StorageError.database + } + return statement + } + + private static func prepareDatabase(_ database: OpaquePointer) throws { + let versionStatement = try statement(database, "PRAGMA user_version") + let status = sqlite3_step(versionStatement) + let version = sqlite3_column_int(versionStatement, 0) + sqlite3_finalize(versionStatement) + guard status == SQLITE_ROW else { throw StorageError.database } + guard version == 0 || version == 1 else { throw StorageError.schema } + try execute(database, "PRAGMA journal_mode=WAL") + try execute(database, "PRAGMA synchronous=FULL") + guard version == 0 else { return } + try execute(database, """ + CREATE TABLE IF NOT EXISTS usage_events ( + source TEXT NOT NULL, + record_id TEXT NOT NULL, + provider TEXT NOT NULL, + timestamp REAL NOT NULL, + model TEXT NOT NULL, + input_tokens INTEGER NOT NULL CHECK (input_tokens >= 0), + output_tokens INTEGER NOT NULL CHECK (output_tokens >= 0), + cache_creation_tokens INTEGER NOT NULL CHECK (cache_creation_tokens >= 0), + cache_read_tokens INTEGER NOT NULL CHECK (cache_read_tokens >= 0), + captured_at REAL NOT NULL, + PRIMARY KEY (source, record_id) + ) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS event_aliases ( + source TEXT NOT NULL, + alias_id TEXT NOT NULL, + record_id TEXT NOT NULL, + PRIMARY KEY (source, alias_id) + ) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS source_scans ( + source TEXT PRIMARY KEY, + observed_at REAL NOT NULL + ) WITHOUT ROWID; + CREATE TABLE IF NOT EXISTS historical_daily_usage ( + provider TEXT NOT NULL, + interval_start REAL NOT NULL, + interval_end REAL NOT NULL, + source_date TEXT NOT NULL, + utc_offset INTEGER NOT NULL, + tokens INTEGER NOT NULL CHECK (tokens > 0), + billable_tokens INTEGER NOT NULL CHECK (billable_tokens >= 0 AND billable_tokens <= tokens), + evidence_id TEXT NOT NULL, + captured_at REAL NOT NULL, + PRIMARY KEY (provider, interval_start) + ) WITHOUT ROWID; + PRAGMA user_version=1; + """) + } + + private static func readHistoricalDays(source: Source, database: OpaquePointer) throws -> [HistoricalUsageDay] { + guard let provider = TokenEvent.Provider(rawValue: source.rawValue) else { return [] } + let statement = try statement(database, """ + SELECT interval_start, interval_end, source_date, utc_offset, tokens, billable_tokens, evidence_id + FROM historical_daily_usage WHERE provider=? ORDER BY interval_start + """) + defer { sqlite3_finalize(statement) } + try bind(provider.rawValue, at: 1, to: statement) + var days: [HistoricalUsageDay] = [] + while true { + let status = sqlite3_step(statement) + if status == SQLITE_DONE { return days } + guard status == SQLITE_ROW, let date = sqlite3_column_text(statement, 2), + let identity = sqlite3_column_text(statement, 6) else { throw StorageError.database } + let day = HistoricalUsageDay(provider: provider, sourceIdentity: String(cString: identity), + intervalStart: Date(timeIntervalSince1970: sqlite3_column_double(statement, 0)), + intervalEnd: Date(timeIntervalSince1970: sqlite3_column_double(statement, 1)), + sourceLocalDate: String(cString: date), sourceUTCOffsetSeconds: Int(sqlite3_column_int(statement, 3)), + tokensIncludingCache: Int(sqlite3_column_int64(statement, 4)), + inputPlusOutputTokens: Int(sqlite3_column_int64(statement, 5))) + guard day.isValid else { throw StorageError.record } + days.append(day) + } + } + + private static func isCurrent(_ date: Date, source: Source, database: OpaquePointer) throws -> Bool { + let statement = try statement(database, "SELECT observed_at FROM source_scans WHERE source=?") + defer { sqlite3_finalize(statement) } + try bind(source.rawValue, at: 1, to: statement) + let status = sqlite3_step(statement) + if status == SQLITE_DONE { return true } + guard status == SQLITE_ROW else { throw StorageError.database } + return date.timeIntervalSince1970 >= sqlite3_column_double(statement, 0) + } + + private static func recordScan(_ date: Date, source: Source, database: OpaquePointer) throws { + let statement = try statement(database, """ + INSERT INTO source_scans VALUES (?, ?) + ON CONFLICT(source) DO UPDATE SET observed_at=excluded.observed_at + """) + defer { sqlite3_finalize(statement) } + try bind(source.rawValue, at: 1, to: statement) + guard sqlite3_bind_double(statement, 2, date.timeIntervalSince1970) == SQLITE_OK, + sqlite3_step(statement) == SQLITE_DONE else { throw StorageError.database } + } + + private static func readAliases(source: Source, database: OpaquePointer) throws -> [String: String] { + let statement = try statement(database, "SELECT alias_id, record_id FROM event_aliases WHERE source=?") + defer { sqlite3_finalize(statement) } + try bind(source.rawValue, at: 1, to: statement) + var aliases: [String: String] = [:] + while true { + let status = sqlite3_step(statement) + if status == SQLITE_DONE { return aliases } + guard status == SQLITE_ROW, let alias = sqlite3_column_text(statement, 0), + let key = sqlite3_column_text(statement, 1) else { throw StorageError.database } + aliases[String(cString: alias)] = String(cString: key) + } + } + + private static func saveAliases(_ aliases: [String: String], source: Source, database: OpaquePointer) throws { + let statement = try statement(database, "INSERT OR IGNORE INTO event_aliases VALUES (?, ?, ?)") + defer { sqlite3_finalize(statement) } + for (alias, key) in aliases { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + try bind(source.rawValue, at: 1, to: statement) + try bind(alias, at: 2, to: statement) + try bind(key, at: 3, to: statement) + guard sqlite3_step(statement) == SQLITE_DONE else { throw StorageError.database } + } + } + + private static func bind(_ value: String, at index: Int32, to statement: OpaquePointer) throws { + guard sqlite3_bind_text(statement, index, value, -1, transient) == SQLITE_OK else { throw StorageError.database } + } + + private static func upsert(_ records: [String: TokenEvent], source: Source, now: Date, + updateExisting: Bool, database: OpaquePointer) throws { + let onConflict = updateExisting ? """ + DO UPDATE SET + provider=excluded.provider, timestamp=excluded.timestamp, model=excluded.model, + input_tokens=excluded.input_tokens, output_tokens=excluded.output_tokens, + cache_creation_tokens=excluded.cache_creation_tokens, cache_read_tokens=excluded.cache_read_tokens + WHERE provider != excluded.provider OR timestamp != excluded.timestamp OR model != excluded.model + OR input_tokens != excluded.input_tokens OR output_tokens != excluded.output_tokens + OR cache_creation_tokens != excluded.cache_creation_tokens OR cache_read_tokens != excluded.cache_read_tokens + """ : "DO NOTHING" + let statement = try statement(database, + "INSERT INTO usage_events VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(source, record_id) \(onConflict)") + defer { sqlite3_finalize(statement) } + for (key, event) in records { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + try bind(source.rawValue, at: 1, to: statement) + try bind(key, at: 2, to: statement) + try bind(event.provider.rawValue, at: 3, to: statement) + guard sqlite3_bind_double(statement, 4, event.timestamp.timeIntervalSince1970) == SQLITE_OK else { throw StorageError.database } + try bind(event.model, at: 5, to: statement) + for (offset, count) in [event.inputTokens, event.outputTokens, event.cacheCreationTokens, event.cacheReadTokens].enumerated() { + guard sqlite3_bind_int64(statement, Int32(offset + 6), Int64(count)) == SQLITE_OK else { throw StorageError.database } + } + guard sqlite3_bind_double(statement, 10, now.timeIntervalSince1970) == SQLITE_OK, + sqlite3_step(statement) == SQLITE_DONE else { throw StorageError.database } + } + } + + private static func read(source: Source, database: OpaquePointer) throws -> [TokenEvent] { + let statement = try statement(database, """ + SELECT record_id, provider, timestamp, model, input_tokens, output_tokens, + cache_creation_tokens, cache_read_tokens + FROM usage_events WHERE source=? ORDER BY timestamp, record_id + """) + defer { sqlite3_finalize(statement) } + try bind(source.rawValue, at: 1, to: statement) + var events: [TokenEvent] = [] + while true { + let status = sqlite3_step(statement) + if status == SQLITE_DONE { return events } + guard status == SQLITE_ROW, + let key = sqlite3_column_text(statement, 0), + let providerText = sqlite3_column_text(statement, 1), + let provider = TokenEvent.Provider(rawValue: String(cString: providerText)), + let model = sqlite3_column_text(statement, 3) else { throw StorageError.record } + events.append(TokenEvent(provider: provider, + timestamp: Date(timeIntervalSince1970: sqlite3_column_double(statement, 2)), + model: String(cString: model), + inputTokens: Int(sqlite3_column_int64(statement, 4)), + outputTokens: Int(sqlite3_column_int64(statement, 5)), + cacheCreationTokens: Int(sqlite3_column_int64(statement, 6)), + cacheReadTokens: Int(sqlite3_column_int64(statement, 7)), + recordID: "ledger:\(String(cString: key))")) + } + } +} diff --git a/Sources/Recovery/ClaudeRecoveryModel.swift b/Sources/Recovery/ClaudeRecoveryModel.swift new file mode 100644 index 0000000..31dee04 --- /dev/null +++ b/Sources/Recovery/ClaudeRecoveryModel.swift @@ -0,0 +1,115 @@ +import Combine +import Foundation + +@MainActor +final class ClaudeRecoveryModel: ObservableObject { + @Published var timeZoneID = "" { + didSet { if timeZoneID != oldValue { invalidatePreview() } } + } + @Published private(set) var projects: [URL] = [] + @Published private(set) var preferences: [URL] = [] + @Published private(set) var preview: ClaudeUsageRecovery.Preview? + @Published private(set) var outcome: ClaudeUsageRecovery.Outcome? + @Published private(set) var errorMessage: String? + @Published private(set) var isScanning = false + @Published private(set) var isSaving = false + + private let ledger: UsageLedger + private let defaultRoots: [URL]? + private let defaultPreferences: [URL]? + private let onSaved: () -> Void + private var scanTask: Task? + + init(ledger: UsageLedger = .shared, defaultRoots: [URL]? = nil, defaultPreferences: [URL]? = nil, + onSaved: @escaping () -> Void = {}) { + self.ledger = ledger + self.defaultRoots = defaultRoots + self.defaultPreferences = defaultPreferences + self.onSaved = onSaved + } + + var isWorking: Bool { isScanning || isSaving } + var canSave: Bool { !isWorking && outcome == nil && preview?.hasChanges == true } + + func addSources(_ urls: [URL], arePreferences: Bool) { + guard !isWorking else { return } + if arePreferences { preferences = Array(Set(preferences + urls)).sorted { $0.path < $1.path } } + else { projects = Array(Set(projects + urls)).sorted { $0.path < $1.path } } + invalidatePreview() + } + + func removeSource(_ url: URL, isPreference: Bool) { + guard !isWorking else { return } + if isPreference { preferences.removeAll { $0 == url } } else { projects.removeAll { $0 == url } } + invalidatePreview() + } + + func scan() { + guard !isSaving else { return } + invalidatePreview() + var options = ClaudeUsageRecovery.Options() + options.projects = projects + options.preferences = preferences + if !timeZoneID.isEmpty { + guard let zone = TimeZone(identifier: timeZoneID) else { + errorMessage = "Choose a valid timezone, then scan again." + return + } + options.timeZone = zone + } + let selected = options + let ledger = ledger, roots = defaultRoots, preferences = defaultPreferences + isScanning = true + scanTask = Task { [weak self] in + let result = await Task.detached(priority: .userInitiated) { + Result { try ClaudeUsageRecovery.prepare(options: selected, ledger: ledger, + defaultRoots: roots, defaultPreferences: preferences) } + }.value + guard !Task.isCancelled, let self else { return } + isScanning = false + switch result { + case .success(let value): preview = value + case .failure(let error): errorMessage = ClaudeUsageRecovery.userMessage(for: error) + } + scanTask = nil + } + } + + func save() { + guard canSave, let preview else { return } + let ledger = ledger + isSaving = true + errorMessage = nil + Task { + let result = await Task.detached(priority: .userInitiated) { + Result { try ClaudeUsageRecovery.apply(preview, ledger: ledger) } + }.value + isSaving = false + switch result { + case .success(let value): + outcome = value + if value.didSave { onSaved() } + case .failure(let error): + self.preview = nil + errorMessage = ClaudeUsageRecovery.userMessage(for: error) + } + } + } + + func cancelPreview() { + scanTask?.cancel() + scanTask = nil + isScanning = false + } + + private func invalidatePreview() { + cancelPreview() + preview = nil + outcome = nil + errorMessage = nil + } +} + +extension Notification.Name { + static let codexIslandUsageHistoryRecovered = Notification.Name("CodexIsland.usageHistoryRecovered") +} diff --git a/Sources/Recovery/ClaudeUsageRecovery.swift b/Sources/Recovery/ClaudeUsageRecovery.swift new file mode 100644 index 0000000..787d30e --- /dev/null +++ b/Sources/Recovery/ClaudeUsageRecovery.swift @@ -0,0 +1,340 @@ +import CryptoKit +import Foundation + +enum ClaudeUsageRecovery { + struct Options { + var apply = false + var help = false + var projects: [URL] = [] + var preferences: [URL] = [] + var timeZone: TimeZone? + + init() {} + + init(arguments: [String]) throws { + var index = 0 + while index < arguments.count { + let argument = arguments[index] + switch argument { + case "--apply": apply = true + case "--help", "-h": help = true + case "--projects", "--preferences", "--time-zone": + index += 1 + guard index < arguments.count, !arguments[index].hasPrefix("--") else { + throw RecoveryError.message("Missing value for \(argument).") + } + let value = arguments[index] + if argument == "--time-zone" { + guard let zone = TimeZone(identifier: value) else { + throw RecoveryError.message("Unknown timezone: \(value). Use a name such as America/Chicago or Asia/Seoul.") + } + timeZone = zone + } else { + let url = URL(fileURLWithPath: NSString(string: value).expandingTildeInPath).standardizedFileURL + var directory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &directory), + directory.boolValue == (argument == "--projects") else { + throw RecoveryError.message("\(argument) requires an existing \(argument == "--projects" ? "folder" : "plist file"): \(url.path)") + } + if argument == "--projects" { projects.append(url) } else { preferences.append(url) } + } + default: throw RecoveryError.message("Unknown option: \(argument). Use --help for usage.") + } + index += 1 + } + } + } + + struct Candidate { + let day: HistoricalUsageDay + let capturedAt: Date + } + + struct Totals { + let tokens: Int + let messages: Int + let days: Int + } + + struct Preview { + let before: Totals + let after: Totals + let messageCount: Int + let dailyCount: Int + let notices: [String] + let needsTimeZone: Bool + fileprivate let events: [TokenEvent] + fileprivate let candidates: [Candidate] + fileprivate let ledgerURL: URL + + var additionalTokens: Int { max(0, after.tokens - before.tokens) } + var hasChanges: Bool { + after.tokens > before.tokens || after.messages > before.messages || after.days > before.days + } + } + + struct Outcome { + let tokens: Int + let additionalTokens: Int + let didSave: Bool + let backupURL: URL? + } + + private struct LegacyCache: Decodable { + let claudeDailyTokens: [DailyTokenBucket] + let lastUpdated: Date + } + + enum RecoveryError: Error, LocalizedError { + case message(String) + var errorDescription: String? { + switch self { case .message(let text): return text } + } + } + + static let help = """ + Recover saved Claude usage into CodexIsland. + + Usage: recover-claude-usage.sh [--apply] [--projects PATH] [--preferences PATH] [--time-zone NAME] + + With no options, preview recovery from configured Claude Code and Cowork logs. + --apply Save verified counts after backing up the existing usage archive. + --projects PATH Also scan an extracted backup's projects folder; repeat for multiple backups. + --preferences PATH Also read an old CodexIsland preferences plist; repeat as needed. + --time-zone NAME Original timezone of saved daily snapshots, e.g. Asia/Seoul. + --help Show this help. + + Existing CodexIsland daily snapshots are checked automatically. Daily recovery + requires their original timezone. Only saved counters are used; missing usage + is never estimated. All counts include cache tokens. No login or network access. + """ + + static func run(arguments: [String], ledger: UsageLedger = .shared, + defaultRoots: [URL]? = nil, defaultPreferences: [URL]? = nil, + output: (String) -> Void = { print($0) }) -> Int32 { + do { + let options = try Options(arguments: arguments) + if options.help { output(help); return 0 } + output("Scanning saved Claude records locally…") + let preview = try prepare(options: options, ledger: ledger, defaultRoots: defaultRoots, defaultPreferences: defaultPreferences) + preview.notices.forEach(output) + output("Found \(number(preview.messageCount)) distinct messages with usage IDs and \(number(preview.dailyCount)) saved daily totals.") + output("Claude tokens already preserved: \(number(preview.before.tokens))") + output("Claude tokens after recovery: \(number(preview.after.tokens))") + output("Additional tokens: \(number(preview.additionalTokens))") + output("Counts include cache tokens. Recovered daily totals without model details are left unpriced.") + + guard options.apply else { + output("Preview only; your saved counts are unchanged. Run again with --apply to save the recovery.") + return 0 + } + let outcome = try apply(preview, ledger: ledger, backupCreated: { output("Archive backup: \($0.path)") }) + guard outcome.didSave else { + output("No additional verified tokens to save. Your usage archive is unchanged.") + return 0 + } + output("Saved. Claude tokens now preserved: \(number(outcome.tokens))") + output("Refresh CodexIsland to include the recovered counts.") + return 0 + } catch { + output("Recovery stopped: \(userMessage(for: error))") + output("Original Claude files were not changed. Any counts already saved remain in the archive.") + return 1 + } + } + + static func prepare(options: Options, ledger: UsageLedger = .shared, defaultRoots: [URL]? = nil, + defaultPreferences: [URL]? = nil) throws -> Preview { + for (urls, expectsDirectory) in [(options.projects, true), (options.preferences, false)] { + for url in urls { + var directory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &directory), + directory.boolValue == expectsDirectory else { + throw RecoveryError.message("A selected recovery source is no longer available. Choose it again, then scan.") + } + } + } + let roots = Array(Set((defaultRoots ?? ClaudeLogReader.projectRoots()) + options.projects)).sorted { $0.path < $1.path } + let ownPreferences = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Preferences/dev.codexisland.CodexIsland.plist") + let preferences = Array(Set((defaultPreferences ?? [ownPreferences]) + options.preferences)) + .filter { FileManager.default.fileExists(atPath: $0.path) }.sorted { $0.path < $1.path } + let now = Date() + let scanned = ClaudeLogReader.scan(lookbackDays: nil, roots: roots, requireStableIDs: true) + let events = scanned.filter { event in + let counts = [event.inputTokens, event.outputTokens, event.cacheCreationTokens, event.cacheReadTokens] + return event.timestamp.timeIntervalSince1970.isFinite && event.timestamp.timeIntervalSince1970 > 0 + && event.timestamp <= now && counts.allSatisfy { $0 >= 0 && $0 <= Int.max / 4 } + && counts.contains { $0 > 0 } + } + var notices: [String] = [] + if events.count < scanned.count { notices.append("Skipped \(scanned.count - events.count) records with invalid counts or future dates.") } + var needsTimeZone = false + let candidates = try dailyCandidates(preferences: preferences, timeZone: options.timeZone, now: now, + output: { notices.append($0) }, timezoneNeeded: { needsTimeZone = true }) + try validateTotals(events: events, days: candidates.map(\.day)) + let result = try simulate(events: events, candidates: candidates, ledger: ledger, now: now) + return Preview(before: result.before, after: result.after, messageCount: events.count, dailyCount: candidates.count, + notices: notices, needsTimeZone: needsTimeZone, events: events, candidates: candidates, ledgerURL: ledger.url) + } + + static func apply(_ preview: Preview, ledger: UsageLedger = .shared, backupCreated: (URL) -> Void = { _ in }) throws -> Outcome { + guard preview.ledgerURL.standardizedFileURL == ledger.url.standardizedFileURL else { + throw RecoveryError.message("The usage archive changed. Scan again before saving.") + } + let now = Date() + // Recheck the current archive against exactly the reviewed records, without re-reading their source files. + let current = try simulate(events: preview.events, candidates: preview.candidates, ledger: ledger, now: now) + guard current.after.tokens > current.before.tokens || current.after.messages > current.before.messages + || current.after.days > current.before.days else { + return Outcome(tokens: current.before.tokens, additionalTokens: 0, didSave: false, backupURL: nil) + } + let backup = ledger.url.deletingLastPathComponent().appendingPathComponent("Recovery Backups", isDirectory: true) + .appendingPathComponent("usage-history-\(UUID().uuidString).sqlite3") + let backedUp = try ledger.copyDatabase(to: backup) + if backedUp { backupCreated(backup) } + let saved = try recover(events: preview.events, candidates: preview.candidates, ledger: ledger, now: now) + let changed = saved.after.tokens > saved.before.tokens || saved.after.messages > saved.before.messages + || saved.after.days > saved.before.days + return Outcome(tokens: saved.after.tokens, additionalTokens: max(0, saved.after.tokens - saved.before.tokens), + didSave: changed, backupURL: backedUp ? backup : nil) + } + + static func userMessage(for error: Error) -> String { + if let error = error as? RecoveryError { return error.localizedDescription } + return "Could not read or save usage history. Check file access and available disk space, then scan again." + } + + private static func simulate(events: [TokenEvent], candidates: [Candidate], ledger: UsageLedger, now: Date) throws -> (before: Totals, after: Totals) { + let temporary = FileManager.default.temporaryDirectory.appendingPathComponent("codexisland-recovery-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: temporary, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + defer { try? FileManager.default.removeItem(at: temporary) } + let scratch = UsageLedger(url: temporary.appendingPathComponent("preview.sqlite3")) + try ledger.copyDatabase(to: scratch.url) + let before = try snapshot(ledger: scratch, now: now) + let result = try recover(events: events, candidates: candidates, ledger: scratch, now: now) + return (before, result.after) + } + + static func dailyCandidates(preferences: [URL], timeZone: TimeZone?, now: Date, + output: (String) -> Void, timezoneNeeded: () -> Void = {}) throws -> [Candidate] { + var candidates: [Date: Candidate] = [:] + var conflicts: Set = [] + for url in preferences { + let data = try Data(contentsOf: url) + guard let values = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { + throw RecoveryError.message("Could not read preferences: \(url.path)") + } + cacheLoop: for version in 4...7 { + let key = "MacIsland.costCache.v\(version)" + guard let cache = values[key] as? Data else { continue } + guard let saved = try? JSONDecoder().decode(LegacyCache.self, from: cache), + saved.lastUpdated.timeIntervalSince1970.isFinite, + saved.lastUpdated.timeIntervalSince1970 > 0, saved.lastUpdated <= now else { + output("Skipped \(key): no supported, dated daily snapshot.") + continue + } + guard saved.claudeDailyTokens.contains(where: { $0.tokens > 0 }) else { continue } + guard !saved.claudeDailyTokens.contains(where: { ($0.recoveredTokens ?? 0) > 0 }) else { + output("Skipped \(key): already contains recovered aggregates.") + continue + } + guard let timeZone else { + timezoneNeeded() + output("Found \(key) daily totals. Add --time-zone with their original timezone to include them.") + continue + } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let digest = SHA256.hash(data: cache).map { String(format: "%02x", $0) }.joined() + var incoming: [Candidate] = [] + var seen: Set = [] + for bucket in saved.claudeDailyTokens where bucket.tokens != 0 { + let start = bucket.dayStart + guard calendar.startOfDay(for: start) == start else { + output("Skipped \(key): daily boundaries do not match \(timeZone.identifier). Use the snapshot's original timezone.") + continue cacheLoop + } + guard start <= saved.lastUpdated, + let end = calendar.date(byAdding: .day, value: 1, to: start), + seen.insert(start).inserted else { + throw RecoveryError.message("\(key) has invalid or repeated daily dates; nothing was imported.") + } + let parts = calendar.dateComponents([.year, .month, .day], from: start) + guard let year = parts.year, let month = parts.month, let day = parts.day else { continue } + let record = HistoricalUsageDay(provider: .claude, sourceIdentity: "\(key):\(digest):\(start.timeIntervalSince1970)", + intervalStart: start, intervalEnd: end, + sourceLocalDate: String(format: "%04d-%02d-%02d", year, month, day), + sourceUTCOffsetSeconds: timeZone.secondsFromGMT(for: start), + tokensIncludingCache: bucket.tokens, inputPlusOutputTokens: bucket.billableTokens) + guard record.isValid else { throw RecoveryError.message("\(key) contains invalid token counts or dates; nothing was imported.") } + incoming.append(Candidate(day: record, capturedAt: saved.lastUpdated)) + } + for candidate in incoming { + let start = candidate.day.intervalStart + guard !conflicts.contains(start) else { continue } + if let previous = candidates[start] { + let old = previous.day, new = candidate.day + if old.intervalEnd != new.intervalEnd { + candidates.removeValue(forKey: start) + conflicts.insert(start) + } else if new.tokensIncludingCache >= old.tokensIncludingCache, + new.inputPlusOutputTokens >= old.inputPlusOutputTokens { + candidates[start] = candidate + } else if old.tokensIncludingCache < new.tokensIncludingCache + || old.inputPlusOutputTokens < new.inputPlusOutputTokens { + candidates.removeValue(forKey: start) + conflicts.insert(start) + } + } else { candidates[start] = candidate } + } + } + } + if !conflicts.isEmpty { output("Skipped \(conflicts.count) days with conflicting saved counters.") } + return candidates.values.sorted { $0.day.intervalStart < $1.day.intervalStart } + } + + private static func recover(events: [TokenEvent], candidates: [Candidate], ledger: UsageLedger, now: Date) throws -> (before: Totals, after: Totals) { + let batches = Dictionary(grouping: candidates, by: \.capturedAt).map { + UsageLedger.HistoricalBatch(days: $0.value.map(\.day), capturedAt: $0.key) + } + let saved = try ledger.recoverClaude(events, historicalBatches: batches, now: now) + return try (summarize(saved.before, now: now), summarize(saved.after, now: now)) + } + + private static func snapshot(ledger: UsageLedger, now: Date) throws -> Totals { + let claude = try ledger.savedSnapshot(source: .claude) + let openCode = try ledger.savedSnapshot(source: .openCode) + return try summarize(UsageLedger.Snapshot(events: claude.events + openCode.events.filter { $0.provider == .claude }, + saveError: nil, historicalDays: claude.historicalDays), now: now) + } + + private static func summarize(_ saved: UsageLedger.Snapshot, now: Date) throws -> Totals { + let events = saved.events.filter { $0.timestamp <= now } + let days = saved.historicalDays.filter { $0.intervalStart <= now } + try validateTotals(events: events, days: days) + let detailed = events.reduce(0) { $0 + $1.inputTokens + $1.outputTokens + $1.cacheCreationTokens + $1.cacheReadTokens } + let residual = HistoricalUsageDay.supplements(days, events: events, calendar: .current).reduce(0) { $0 + $1.tokens } + return Totals(tokens: detailed + residual, messages: events.count, days: days.count) + } + + private static func validateTotals(events: [TokenEvent], days: [HistoricalUsageDay]) throws { + var total = 0 + let counts = events.flatMap { [$0.inputTokens, $0.outputTokens, $0.cacheCreationTokens, $0.cacheReadTokens] } + + days.map(\.tokensIncludingCache) + for count in counts { + let result = total.addingReportingOverflow(count) + guard count >= 0, !result.overflow, result.partialValue <= Int.max / 4 else { + throw RecoveryError.message("Saved token counts exceed the supported range. Check the recovery files before trying again.") + } + total = result.partialValue + } + } + + private static func number(_ value: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = Locale(identifier: "en_US") + return formatter.string(from: NSNumber(value: value)) ?? String(value) + } +} diff --git a/Sources/Views/Settings/ClaudeRecoveryView.swift b/Sources/Views/Settings/ClaudeRecoveryView.swift new file mode 100644 index 0000000..a92a663 --- /dev/null +++ b/Sources/Views/Settings/ClaudeRecoveryView.swift @@ -0,0 +1,239 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +@MainActor +struct ClaudeRecoveryView: View { + @StateObject var model: ClaudeRecoveryModel + @Environment(\.dismiss) private var dismiss + @State private var backupsExpanded = false + @State private var detailsExpanded = false + + private static let timeZones = Array(Set(TimeZone.knownTimeZoneIdentifiers + ["UTC"])).sorted() + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 8) { + Text(L10n.tr("Recover Claude usage")) + .font(.system(size: 21, weight: .semibold)) + Text(L10n.tr("Find saved counts from Claude Code, Cowork, and older local records.")) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(24) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + result + sourceOptions + if let notices = model.preview?.notices, !notices.isEmpty { + DisclosureGroup(L10n.tr("Scan details"), isExpanded: $detailsExpanded) { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(notices.enumerated()), id: \.offset) { _, notice in + Text(notice).font(.system(size: 12)).fixedSize(horizontal: false, vertical: true) + } + } + .foregroundStyle(.secondary) + .padding(.top, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Divider() + + VStack(alignment: .leading, spacing: 14) { + Text(L10n.tr("A backup is saved before importing. Original Claude files stay untouched.")) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 10) { + Button(L10n.tr(model.outcome == nil ? "Cancel" : "Done")) { dismiss() } + .keyboardShortcut(.cancelAction) + .disabled(model.isSaving) + Spacer() + Button(L10n.tr(model.preview == nil && model.outcome == nil ? "Scan" : "Scan again")) { model.scan() } + .disabled(model.isWorking) + Button(L10n.tr("Import recovery")) { model.save() } + .keyboardShortcut(.defaultAction) + .disabled(!model.canSave) + .opacity(model.canSave ? 1 : 0.45) + } + } + .padding(20) + } + .frame(width: 560, height: 610) + .background(Color(nsColor: .windowBackgroundColor)) + .preferredColorScheme(.dark) + .interactiveDismissDisabled(model.isSaving) + .task { model.scan() } + .onDisappear { model.cancelPreview() } + } + + @ViewBuilder + private var result: some View { + if model.isWorking { + HStack(spacing: 12) { + ProgressView().controlSize(.small) + Text(L10n.tr(model.isSaving ? "Saving recovered usage…" : "Scanning saved records…")) + .font(.system(size: 14, weight: .medium)) + } + .frame(maxWidth: .infinity, minHeight: 110, alignment: .leading) + } else if let error = model.errorMessage { + VStack(alignment: .leading, spacing: 8) { + Label(L10n.tr("Recovery could not finish"), systemImage: "exclamationmark.triangle") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.orange) + Text(L10n.tr(error)).font(.system(size: 13)).fixedSize(horizontal: false, vertical: true) + } + } else if let outcome = model.outcome { + VStack(alignment: .leading, spacing: 12) { + Label(L10n.tr(outcome.didSave ? "Recovered usage saved" : "This usage is already saved"), systemImage: "checkmark.circle") + .font(.system(size: 16, weight: .semibold)) + Text(L10n.tr("%@ additional tokens saved.", formatted(outcome.additionalTokens))) + .font(.system(size: 13)) + Text(L10n.tr("Your usage history now includes the recovered records.")) + .font(.system(size: 13)).foregroundStyle(.secondary) + if let backup = outcome.backupURL { + Button(L10n.tr("Show backup in Finder")) { NSWorkspace.shared.activateFileViewerSelecting([backup]) } + .controlSize(.small) + } + } + } else if let preview = model.preview { + VStack(alignment: .leading, spacing: 16) { + Text(L10n.tr(preview.hasChanges ? "Review your recovered usage" : "No additional usage found")) + .font(.system(size: 16, weight: .semibold)) + VStack(spacing: 9) { + totalRow("Already saved", tokens: preview.before.tokens) + totalRow("After recovery", tokens: preview.after.tokens) + totalRow("Additional tokens", tokens: preview.additionalTokens, emphasized: true) + } + Text(L10n.tr("Messages checked: %@ · Daily records: %@", formatted(preview.messageCount), formatted(preview.dailyCount))) + .font(.system(size: 12)).foregroundStyle(.secondary) + Text(L10n.tr("Totals include cache tokens. Daily records without model details remain unpriced.")) + .font(.system(size: 12)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } else { + Text(L10n.tr("Scan your selection to preview what can be recovered.")) + .font(.system(size: 14)).foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 80, alignment: .leading) + } + } + + private var sourceOptions: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 8) { + HStack { + HStack(spacing: 4) { + Text(L10n.tr("Original timezone")) + .font(.system(size: 13, weight: .medium)) + RecoveryHelp( + title: "Why is the original timezone needed?", + explanation: "Older daily totals used your Mac's timezone without saving its name. Choose the timezone used when those totals were recorded so recovery keeps the correct dates and avoids counting usage twice. Individual message records don't need this." + ) + } + Spacer(minLength: 16) + Picker(L10n.tr("Original timezone"), selection: $model.timeZoneID) { + Text(L10n.tr("Not specified")).tag("") + ForEach(Self.timeZones, id: \.self) { zone in + Text(zone.replacingOccurrences(of: "_", with: " ")).tag(zone) + } + } + .labelsHidden() + .frame(width: 265) + } + Text(L10n.tr(model.preview?.needsTimeZone == true + ? "Some old daily totals need their original timezone. Choose where they were recorded, then scan again." + : "Only needed for old daily totals. Leave unspecified if you don't know where they were recorded.")) + .font(.system(size: 12)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + DisclosureGroup(L10n.tr("Older backups (optional)"), isExpanded: $backupsExpanded) { + VStack(alignment: .leading, spacing: 12) { + Text(L10n.tr("Add an extracted Claude projects folder or an old CodexIsland preferences file.")) + .font(.system(size: 12)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack(spacing: 10) { + Button(L10n.tr("Add projects folder…")) { chooseSources(arePreferences: false) } + Button(L10n.tr("Add preferences file…")) { chooseSources(arePreferences: true) } + } + .controlSize(.small) + sourceList(model.projects, isPreference: false) + sourceList(model.preferences, isPreference: true) + } + .padding(.top, 12) + } + } + .disabled(model.isWorking) + } + + private func totalRow(_ label: String, tokens: Int, emphasized: Bool = false) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(L10n.tr(label)).foregroundStyle(emphasized ? .primary : .secondary) + Spacer() + Text(formatted(tokens)).monospacedDigit() + } + .font(.system(size: 13, weight: emphasized ? .semibold : .regular)) + .accessibilityElement(children: .combine) + } + + private func sourceList(_ urls: [URL], isPreference: Bool) -> some View { + ForEach(urls, id: \.self) { url in + HStack { + Label(url.lastPathComponent, systemImage: isPreference ? "doc" : "folder") + .lineLimit(1).truncationMode(.middle).help(url.path) + Spacer() + Button { model.removeSource(url, isPreference: isPreference) } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .accessibilityLabel(L10n.tr("Remove %@", url.lastPathComponent)) + } + .font(.system(size: 12)) + } + } + + private func chooseSources(arePreferences: Bool) { + let panel = NSOpenPanel() + panel.canChooseDirectories = !arePreferences + panel.canChooseFiles = arePreferences + panel.allowsMultipleSelection = true + panel.showsHiddenFiles = true + panel.title = L10n.tr(arePreferences ? "Choose old CodexIsland preferences" : "Choose a Claude projects folder") + if arePreferences { panel.allowedContentTypes = [.propertyList] } + panel.begin { response in + if response == .OK { model.addSources(panel.urls, arePreferences: arePreferences) } + } + } + + private func formatted(_ value: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.locale = L10n.locale + return formatter.string(from: NSNumber(value: value)) ?? String(value) + } +} + +struct RecoveryHelp: View { + let title: String + let explanation: String + + var body: some View { + Image(systemName: "info.circle") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + .help(L10n.tr(explanation)) + .accessibilityLabel(L10n.tr(title)) + .accessibilityHint(L10n.tr(explanation)) + } +} diff --git a/Sources/Views/SettingsView.swift b/Sources/Views/SettingsView.swift index ae2f3b2..4fd5db2 100644 --- a/Sources/Views/SettingsView.swift +++ b/Sources/Views/SettingsView.swift @@ -26,6 +26,7 @@ struct SettingsView: View { @ObservedObject private var updater = UpdaterController.shared @AppStorage("Settings.activeTab") private var activeTabRaw: String = SettingsTab.general.rawValue + @State private var recoveryPresented = false private var activeTab: SettingsTab { get { SettingsTab(rawValue: activeTabRaw) ?? .general } @@ -70,6 +71,12 @@ struct SettingsView: View { .frame(minWidth: 440, minHeight: 560) .background(Color(red: 0.020, green: 0.020, blue: 0.027)) .preferredColorScheme(.dark) + .sheet(isPresented: $recoveryPresented) { + ClaudeRecoveryView(model: ClaudeRecoveryModel(onSaved: { + CostStore.shared.refresh() + NotificationCenter.default.post(name: .codexIslandUsageHistoryRecovered, object: nil) + })) + } } // MARK: - Tabs @@ -130,6 +137,18 @@ struct SettingsView: View { private var generalTab: some View { VStack(alignment: .leading, spacing: 0) { generalSection + SettingsRow(title: "Usage history", subtitle: "Find older Claude counts.") { + HStack(spacing: 8) { + RecoveryHelp( + title: "Why recover usage?", + explanation: "Older usage can be missing when Claude logs have been deleted or moved. Recovery checks the records still on your Mac and any backups you add. It saves only missing counts, so scanning again won't duplicate your usage." + ) + Button(L10n.tr("Recover Claude usage…")) { recoveryPresented = true } + .controlSize(.small) + .disabled(AppEnvironment.isDemo) + } + } + .padding(.horizontal, 14) alertsSection updatesSection } diff --git a/Tests/ClaudeRecoveryModelTests.swift b/Tests/ClaudeRecoveryModelTests.swift new file mode 100644 index 0000000..33fcb83 --- /dev/null +++ b/Tests/ClaudeRecoveryModelTests.swift @@ -0,0 +1,77 @@ +import Foundation + +enum L10n { + static let locale = Locale(identifier: "en_US") + static func tr(_ text: String, _ args: CVarArg...) -> String { String(format: text, arguments: args) } +} + +@main +@MainActor +struct ClaudeRecoveryModelTests { + static var checks = 0 + + static func expect(_ condition: Bool, _ label: String) { + guard condition else { print("FAIL \(label)"); exit(1) } + checks += 1 + print("PASS \(label)") + } + + static func wait(_ model: ClaudeRecoveryModel) async throws { + let deadline = Date().addingTimeInterval(10) + while model.isWorking && Date() < deadline { try await Task.sleep(nanoseconds: 10_000_000) } + expect(!model.isWorking, "background recovery work finishes") + } + + static func main() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("recovery-model-tests-\(UUID().uuidString)") + let projects = root.appendingPathComponent("projects") + try FileManager.default.createDirectory(at: projects, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let line = """ + {"type":"assistant","timestamp":"2026-01-09T03:00:00Z","requestId":"request","message":{"id":"message","model":"claude-sonnet-4-6","usage":{"input_tokens":100}}} + """ + try Data(line.utf8).write(to: projects.appendingPathComponent("session.jsonl")) + let ledger = UsageLedger(url: root.appendingPathComponent("archive/usage.sqlite3")) + var refreshed = 0 + let model = ClaudeRecoveryModel(ledger: ledger, defaultRoots: [projects], defaultPreferences: [], onSaved: { refreshed += 1 }) + expect(!model.canSave, "Import is disabled before preview") + model.save() + expect(!model.isSaving && !FileManager.default.fileExists(atPath: ledger.url.path), "saving without a preview has no side effects") + model.scan() + expect(model.isScanning && !model.canSave, "Import stays disabled during scanning") + try await wait(model) + expect(model.preview?.additionalTokens == 100 && model.canSave, "a successful preview enables Import for its exact counts") + expect(!FileManager.default.fileExists(atPath: ledger.url.path), "the Settings preview leaves the archive untouched") + + model.timeZoneID = "Asia/Seoul" + expect(model.preview == nil && !model.canSave, "changing timezone invalidates the old preview") + model.scan() + model.timeZoneID = "America/Chicago" + try await Task.sleep(nanoseconds: 200_000_000) + expect(model.preview == nil && !model.isScanning && !model.canSave, "a cancelled scan cannot restore a stale preview") + model.scan() + try await wait(model) + model.addSources([projects], arePreferences: false) + expect(model.preview == nil && !model.canSave, "adding a source requires a new preview") + model.scan() + try await wait(model) + model.removeSource(projects, isPreference: false) + expect(model.preview == nil && !model.canSave, "removing a source requires a new preview") + model.scan() + try await wait(model) + model.save() + model.save() + expect(model.isSaving && !model.canSave, "Import cannot start twice while saving") + try await wait(model) + expect(model.outcome?.tokens == 100 && model.outcome?.didSave == true && refreshed == 1, "successful import refreshes usage exactly once") + expect(!model.canSave, "a completed import cannot be replayed from the same screen") + model.scan() + try await wait(model) + expect(model.preview?.additionalTokens == 0 && !model.canSave, "rescanning saved usage shows no additions and disables Import") + + model.timeZoneID = "No/SuchZone" + model.scan() + expect(model.errorMessage != nil && !model.isWorking && !model.canSave, "invalid settings show an actionable error instead of an import") + print("PASS all \(checks) recovery Settings state checks") + } +} diff --git a/Tests/ClaudeUsageRecoveryTests.swift b/Tests/ClaudeUsageRecoveryTests.swift new file mode 100644 index 0000000..fb57879 --- /dev/null +++ b/Tests/ClaudeUsageRecoveryTests.swift @@ -0,0 +1,259 @@ +import Foundation +import SQLite3 + +enum L10n { + static let locale = Locale(identifier: "en_US") + static func tr(_ text: String, _ args: CVarArg...) -> String { String(format: text, arguments: args) } +} + +@main +struct ClaudeUsageRecoveryTests { + static var checks = 0 + static let now = Date(timeIntervalSince1970: 1_789_070_400) + + struct Cache: Encodable { + let claudeDailyTokens: [DailyTokenBucket] + var lastUpdated = now + } + + static func expect(_ value: Bool, _ label: String) { + guard value else { print("FAIL \(label)"); exit(1) } + checks += 1 + print("PASS \(label)") + } + + static func date(_ value: String) throws -> Date { + guard let date = ISO8601DateFormatter().date(from: value) else { + throw ClaudeUsageRecovery.RecoveryError.message("Invalid fixture date") + } + return date + } + + static func log(id: String?, timestamp: String = "2026-01-09T03:00:00Z", tokens: Int = 100) throws -> Data { + var message: [String: Any] = ["model": "claude-sonnet-4-6", "usage": ["input_tokens": tokens]] + if let id { message["id"] = id } + var value: [String: Any] = ["type": "assistant", "timestamp": timestamp, "message": message] + if id != nil { value["requestId"] = "request" } + var data = try JSONSerialization.data(withJSONObject: value) + data.append(10) + return data + } + + static func preferences(_ url: URL, snapshots: [String: [DailyTokenBucket]]) throws { + let data = try snapshots.mapValues { try JSONEncoder().encode(Cache(claudeDailyTokens: $0)) } + try PropertyListSerialization.data(fromPropertyList: data, format: .binary, options: 0).write(to: url) + } + + static func run(_ arguments: [String], ledger: UsageLedger, roots: [URL] = [], preferences: [URL] = []) -> (Int32, String) { + var lines: [String] = [] + let status = ClaudeUsageRecovery.run(arguments: arguments, ledger: ledger, defaultRoots: roots, + defaultPreferences: preferences, output: { lines.append($0) }) + return (status, lines.joined(separator: "\n")) + } + + static func tokens(_ snapshot: UsageLedger.Snapshot) -> Int { + let detailed = snapshot.events.reduce(0) { $0 + $1.inputTokens + $1.outputTokens + $1.cacheCreationTokens + $1.cacheReadTokens } + return detailed + HistoricalUsageDay.supplements(snapshot.historicalDays, events: snapshot.events, calendar: .current) + .reduce(0) { $0 + $1.tokens } + } + + static func execute(_ url: URL, sql: String) throws { + var pointer: OpaquePointer? + guard sqlite3_open(url.path, &pointer) == SQLITE_OK, let database = pointer else { + throw ClaudeUsageRecovery.RecoveryError.message("Could not open fixture archive") + } + defer { sqlite3_close(database) } + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + throw ClaudeUsageRecovery.RecoveryError.message("Could not prepare fixture archive") + } + } + + static func main() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("claude-recovery-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let projects = root.appendingPathComponent("projects") + let backup = root.appendingPathComponent("backup with spaces") + try FileManager.default.createDirectory(at: projects, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: backup, withIntermediateDirectories: true) + let normalFile = projects.appendingPathComponent("session.jsonl") + let backupFile = backup.appendingPathComponent("session.jsonl") + var original = try log(id: "one") + original.append(try log(id: nil, tokens: 999_999)) + try original.write(to: normalFile) + var copied = try log(id: "one") + copied.append(try log(id: "older", timestamp: "2026-01-10T03:00:00Z", tokens: 300)) + try copied.write(to: backupFile) + let plist = root.appendingPathComponent("old preferences.plist") + let day = DailyTokenBucket(dayStart: try date("2026-01-08T15:00:00Z"), tokens: 1000, billableTokens: 1000) + try preferences(plist, snapshots: ["MacIsland.costCache.v5": [day], "MacIsland.costCache.v6": [day]]) + let plistBefore = try Data(contentsOf: plist) + let archive = root.appendingPathComponent("archive/history.sqlite3") + let ledger = UsageLedger(url: archive) + let arguments = ["--projects", backup.path, "--time-zone", "Asia/Seoul"] + + expect(run(["--help"], ledger: ledger).0 == 0, "help exits without starting the app") + expect(!FileManager.default.fileExists(atPath: archive.path), "help does not create an archive") + expect(run(["--aply"], ledger: ledger).0 == 1, "unknown flag fails") + expect(run(["--projects"], ledger: ledger).0 == 1, "missing folder value fails") + expect(run(["--projects", root.appendingPathComponent("missing").path], ledger: ledger).0 == 1, "missing backup fails") + expect(run(["--time-zone", "Not/AZone"], ledger: ledger).0 == 1, "unknown timezone fails") + let preview = run(arguments, ledger: ledger, roots: [projects], preferences: [plist]) + expect(preview.0 == 0 && preview.1.contains("Additional tokens: 1,300"), "preview combines detailed records and only aggregate remainder") + expect(preview.1.contains("Found 2 distinct messages") && preview.1.contains("1 saved daily totals"), "copied logs and repeated cache versions are deduplicated") + expect(!FileManager.default.fileExists(atPath: archive.path), "preview leaves the real archive absent") + let noZone = run([], ledger: ledger, roots: [projects], preferences: [plist]) + expect(noZone.1.contains("--time-zone") && noZone.1.contains("Additional tokens: 100"), "unknown original timezone is never guessed") + let wrongZone = run(["--time-zone", "America/Chicago"], ledger: ledger, preferences: [plist]) + expect(wrongZone.0 == 0 && wrongZone.1.contains("daily boundaries do not match") && wrongZone.1.contains("0 saved daily totals"), "wrong timezone cannot shift old daily totals") + + let applied = run(arguments + ["--apply"], ledger: ledger, roots: [projects], preferences: [plist]) + expect(applied.0 == 0 && applied.1.contains("Saved. Claude tokens now preserved: 1,300"), "apply saves verified records") + let first = ledger.retain([], source: .claude, now: now, insertOnly: true) + expect(first.events.count == 2 && first.historicalDays.count == 1 && tokens(first) == 1300, "imported archive matches preview") + expect(first.historicalDays.first?.sourceLocalDate == "2026-01-09", "Korean source date is preserved") + expect(first.historicalDays.first?.sourceUTCOffsetSeconds == 9 * 3600, "original UTC interval is preserved") + let permission = try FileManager.default.attributesOfItem(atPath: archive.path)[.posixPermissions] as? NSNumber + expect(permission?.intValue == 0o600, "usage archive stays private") + let repeated = run(arguments + ["--apply"], ledger: ledger, roots: [projects], preferences: [plist]) + expect(repeated.0 == 0 && repeated.1.contains("No additional verified tokens"), "repeating recovery is a no-op") + expect(!FileManager.default.fileExists(atPath: archive.deletingLastPathComponent().appendingPathComponent("Recovery Backups").path), "no-op recovery does not create unnecessary backups") + expect(try Data(contentsOf: normalFile) == original && Data(contentsOf: backupFile) == copied && Data(contentsOf: plist) == plistBefore, "source logs and preferences are unchanged") + + let extra = projects.appendingPathComponent("new.jsonl") + try log(id: "extra", timestamp: "2026-02-01T03:00:00Z", tokens: 50).write(to: extra) + let more = run(arguments + ["--apply"], ledger: ledger, roots: [projects], preferences: [plist]) + expect(more.0 == 0 && more.1.contains("Archive backup:") && more.1.contains("preserved: 1,350"), "new recovery backs up an existing archive before adding records") + let backups = try FileManager.default.contentsOfDirectory(at: archive.deletingLastPathComponent().appendingPathComponent("Recovery Backups"), includingPropertiesForKeys: nil) + expect(backups.count == 1, "one consistent archive backup is retained") + let prior = try UsageLedger(url: backups[0]).savedSnapshot(source: .claude) + expect(tokens(prior) == 1300 && prior.events.count == 2, "portable backup opens read-only with pre-import WAL records and daily aggregates") + try FileManager.default.removeItem(at: projects) + try FileManager.default.removeItem(at: backup) + expect(tokens(ledger.retain([], source: .claude, now: now, insertOnly: true)) == 1350, "recovered counts survive deleting all provider fixtures") + + let conflictPlist = root.appendingPathComponent("conflict.plist") + let conflicting = DailyTokenBucket(dayStart: day.dayStart, tokens: 900, billableTokens: 900) + let differentBreakdown = DailyTokenBucket(dayStart: day.dayStart, tokens: 1100, billableTokens: 800) + try preferences(conflictPlist, snapshots: ["MacIsland.costCache.v5": [conflicting], "MacIsland.costCache.v6": [differentBreakdown]]) + let conflict = run(["--time-zone", "Asia/Seoul"], ledger: ledger, preferences: [conflictPlist]) + expect(conflict.0 == 0 && conflict.1.contains("conflicting saved counters") && conflict.1.contains("0 saved daily totals"), "noncomparable daily counters are not combined") + + let daylight = DailyTokenBucket(dayStart: try date("2026-03-08T06:00:00Z"), tokens: 20, billableTokens: 20) + try preferences(conflictPlist, snapshots: ["MacIsland.costCache.v6": [daylight]]) + let daylightCandidates = try ClaudeUsageRecovery.dailyCandidates(preferences: [conflictPlist], timeZone: TimeZone(identifier: "America/Chicago"), now: now, output: { _ in }) + expect(daylightCandidates.count == 1 && daylightCandidates[0].day.intervalEnd.timeIntervalSince(daylightCandidates[0].day.intervalStart) == 23 * 3600, "daylight saving recovery uses actual 23-hour interval") + + var alreadyRecovered = day + alreadyRecovered.recoveredTokens = 1000 + try preferences(conflictPlist, snapshots: ["MacIsland.costCache.v7": [alreadyRecovered]]) + expect(run(["--time-zone", "Asia/Seoul"], ledger: ledger, preferences: [conflictPlist]).1.contains("already contains recovered aggregates"), "recovered display buckets are not reinterpreted as raw history") + + try preferences(conflictPlist, snapshots: ["MacIsland.costCache.v6": [day, day]]) + expect(run(["--apply", "--time-zone", "Asia/Seoul"], ledger: ledger, preferences: [conflictPlist]).0 == 1, "malformed duplicate daily records stop before import") + expect(tokens(ledger.retain([], source: .claude, now: now, insertOnly: true)) == 1350, "invalid recovery preserves existing history") + + let broken = root.appendingPathComponent("broken.sqlite3") + let brokenData = Data("not a database".utf8) + try brokenData.write(to: broken) + expect(run(["--apply"], ledger: UsageLedger(url: broken)).0 == 1, "unreadable archive stops recovery") + expect(try Data(contentsOf: broken) == brokenData, "unreadable archive is never replaced") + + let richer = TokenEvent(provider: .claude, timestamp: now, model: "claude-sonnet-4-6", inputTokens: 100, + outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, recordID: "existing") + _ = ledger.retain([richer], source: .claude, now: now) + let poorer = TokenEvent(provider: .claude, timestamp: now, model: richer.model, inputTokens: 1, + outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, recordID: "existing") + let preserved = ledger.retain([poorer], source: .claude, now: now.addingTimeInterval(1), insertOnly: true) + expect(tokens(preserved) == 1450, "an older backup cannot downgrade an existing message") + let lower = HistoricalUsageDay(provider: .claude, sourceIdentity: "lower", intervalStart: day.dayStart, + intervalEnd: day.dayStart.addingTimeInterval(86400), sourceLocalDate: "2026-01-09", sourceUTCOffsetSeconds: 9 * 3600, + tokensIncludingCache: 800, inputPlusOutputTokens: 800) + try ledger.importHistoricalDays([lower], capturedAt: now.addingTimeInterval(1), preserveLargerTotals: true) + expect(tokens(ledger.retain([], source: .claude, now: now, insertOnly: true)) == 1450, "recovery cannot reduce already preserved daily totals") + + let interrupted = UsageLedger(url: root.appendingPathComponent("interrupted/archive.sqlite3")) + _ = interrupted.retain([richer], source: .claude, now: now) + let recoveryRoot = root.appendingPathComponent("failure-projects") + try FileManager.default.createDirectory(at: recoveryRoot, withIntermediateDirectories: true) + try log(id: "not-partially-saved", tokens: 100).write(to: recoveryRoot.appendingPathComponent("new.jsonl")) + try preferences(conflictPlist, snapshots: ["MacIsland.costCache.v6": [day]]) + var failureInstalled = false + let stopped = ClaudeUsageRecovery.run(arguments: ["--apply", "--time-zone", "Asia/Seoul"], ledger: interrupted, + defaultRoots: [recoveryRoot], defaultPreferences: [conflictPlist], output: { line in + if line.hasPrefix("Archive backup:") { + do { + try execute(interrupted.url, sql: "CREATE TRIGGER reject_recovery BEFORE INSERT ON historical_daily_usage BEGIN SELECT RAISE(ABORT, 'simulated storage failure'); END;") + failureInstalled = true + } catch { exit(1) } + } + }) + expect(failureInstalled && stopped == 1, "write failure after preview stops the import") + let untouched = interrupted.retain([], source: .claude, now: now, insertOnly: true) + expect(untouched.events.count == 1 && untouched.historicalDays.isEmpty && tokens(untouched) == 100, + "a failure while saving daily totals rolls back the entire recovery") + + let frozenLedger = UsageLedger(url: root.appendingPathComponent("frozen/archive.sqlite3")) + let frozenFile = recoveryRoot.appendingPathComponent("new.jsonl") + try log(id: "reviewed", tokens: 100).write(to: frozenFile) + let frozen = try ClaudeUsageRecovery.prepare(options: .init(), ledger: frozenLedger, + defaultRoots: [recoveryRoot], defaultPreferences: []) + var changedSource = try log(id: "reviewed", tokens: 999_999) + changedSource.append(try log(id: "not-reviewed", tokens: 500)) + try changedSource.write(to: frozenFile) + let frozenResult = try ClaudeUsageRecovery.apply(frozen, ledger: frozenLedger) + expect(frozenResult.didSave && frozenResult.tokens == 100, "import saves the reviewed counters even if source files change") + expect(try frozenLedger.savedSnapshot(source: .claude).events.count == 1, "records appearing after preview are not silently imported") + try FileManager.default.removeItem(at: frozenFile) + expect(try !ClaudeUsageRecovery.apply(frozen, ledger: frozenLedger).didSave, "replaying a reviewed plan is a no-op even after its source disappears") + + try log(id: "already-captured", tokens: 100).write(to: frozenFile) + let concurrent = UsageLedger(url: root.appendingPathComponent("concurrent/archive.sqlite3")) + let concurrentPlan = try ClaudeUsageRecovery.prepare(options: .init(), ledger: concurrent, + defaultRoots: [recoveryRoot], defaultPreferences: []) + _ = concurrent.retain(ClaudeLogReader.scan(lookbackDays: nil, roots: [recoveryRoot]), source: .claude, now: now) + let concurrentResult = try ClaudeUsageRecovery.apply(concurrentPlan, ledger: concurrent) + expect(!concurrentResult.didSave && concurrentResult.tokens == 100 && concurrentResult.backupURL == nil, + "usage captured by the app after preview is rechecked without duplicates or needless backups") + + let arriving = UsageLedger(url: root.appendingPathComponent("arriving/archive.sqlite3")) + _ = arriving.retain([richer], source: .claude, now: now) + try log(id: "arriving-recovery", tokens: 200).write(to: frozenFile) + let arrivingPlan = try ClaudeUsageRecovery.prepare(options: .init(), ledger: arriving, + defaultRoots: [recoveryRoot], defaultPreferences: []) + let normalCapture = TokenEvent(provider: .claude, timestamp: now, model: richer.model, inputTokens: 900, + outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, recordID: "normal-app") + let arrivingResult = try ClaudeUsageRecovery.apply(arrivingPlan, ledger: arriving, backupCreated: { _ in + _ = arriving.retain([normalCapture], source: .claude, now: now) + }) + expect(arrivingResult.tokens == 1200 && arrivingResult.additionalTokens == 200, + "normal app captures during import are not reported as recovered tokens") + + let invalid = UsageLedger(url: root.appendingPathComponent("invalid/archive.sqlite3")) + var excessive = try log(id: "large-one", tokens: Int.max / 4) + excessive.append(try log(id: "large-two", tokens: Int.max / 4)) + try excessive.write(to: frozenFile) + let overflow = run(["--apply"], ledger: invalid, roots: [recoveryRoot]) + expect(overflow.0 == 1 && overflow.1.contains("supported range"), "excessive counters fail safely instead of overflowing") + expect(!FileManager.default.fileExists(atPath: invalid.url.path), "out-of-range recovery cannot create a real archive") + var invalidCounts = try log(id: "negative", tokens: -100) + invalidCounts.append(try log(id: "future", timestamp: "2099-01-01T00:00:00Z", tokens: 500)) + try invalidCounts.write(to: frozenFile) + let invalidPreview = run([], ledger: invalid, roots: [recoveryRoot]) + expect(invalidPreview.0 == 0 && invalidPreview.1.contains("Skipped 2 records") && invalidPreview.1.contains("Found 0 distinct messages"), + "invalid and future records are disclosed and excluded from the preview") + + let futureArchive = UsageLedger(url: root.appendingPathComponent("future-schema/archive.sqlite3")) + _ = futureArchive.retain([richer], source: .claude, now: now) + try execute(futureArchive.url, sql: "PRAGMA user_version=99;") + let originalFutureArchive = try Data(contentsOf: futureArchive.url) + expect(run(["--apply"], ledger: futureArchive).0 == 1, "an unsupported archive schema stops recovery") + expect(try Data(contentsOf: futureArchive.url) == originalFutureArchive, "unsupported archives are preserved byte for byte") + + let portableCopy = root.appendingPathComponent("portable.sqlite3") + try FileManager.default.copyItem(at: backups[0], to: portableCopy) + expect(try tokens(UsageLedger(url: portableCopy).savedSnapshot(source: .claude)) == 1300, + "copying only the backup database is sufficient for a read-only restore") + print("PASS all \(checks) Claude recovery checks") + } +} diff --git a/Tests/LocalProviderCostTests.swift b/Tests/LocalProviderCostTests.swift index e2c9846..135f32f 100644 --- a/Tests/LocalProviderCostTests.swift +++ b/Tests/LocalProviderCostTests.swift @@ -78,6 +78,13 @@ struct LocalProviderCostTests { let stream = dir.appendingPathComponent("updates.jsonl") try (grok + Data([10]) + grok + Data([10])).write(to: stream) expect(GrokLogReader.scan(root: dir, now: now).events.count == 1, "repeated Grok prompt totals deduplicated") + let nextYear = now.addingTimeInterval(400 * 86400) + expect(AntigravityLogReader.scan(root: dir, now: nextYear).events.isEmpty + && AntigravityLogReader.scan(lookbackDays: nil, root: dir, now: nextYear).events.count == 1, + "all-time Antigravity scan includes records outside the normal lookback") + expect(GrokLogReader.scan(root: dir, now: nextYear).events.isEmpty + && GrokLogReader.scan(lookbackDays: nil, root: dir, now: nextYear).events.count == 1, + "all-time Grok scan includes records outside the normal lookback") let incomplete = Data(String(decoding: grok, as: UTF8.self).replacingOccurrences(of: "\"promptId\"", with: "\"usageIsIncomplete\":true,\"promptId\"").utf8) expect(GrokLogReader.parse(incomplete).isEmpty, "incomplete Grok aggregate never presented as complete cost") expect(Pricing.canonicalModelName("claude-opus-4-6-thinking") == "claude-opus-4-6", "Antigravity thinking alias shares base model pricing") diff --git a/Tests/UsageLedgerTests.swift b/Tests/UsageLedgerTests.swift new file mode 100644 index 0000000..8312ba4 --- /dev/null +++ b/Tests/UsageLedgerTests.swift @@ -0,0 +1,276 @@ +import Foundation +import SQLite3 + +enum L10n { + static let locale = Locale(identifier: "en_US") + static func tr(_ text: String, _ args: CVarArg...) -> String { String(format: text, arguments: args) } +} + +@main +struct UsageLedgerTests { + static let now = Date(timeIntervalSince1970: 1_789_070_400) + static var checks = 0 + + static func expect(_ condition: Bool, _ label: String) { + guard condition else { print("FAIL \(label)"); exit(1) } + checks += 1 + print("PASS \(label)") + } + + static func event(_ id: String, input: Int = 100, output: Int = 20, + provider: TokenEvent.Provider = .claude, date: Date = now) -> TokenEvent { + TokenEvent(provider: provider, timestamp: date, model: "claude-sonnet-4-6", + inputTokens: input, outputTokens: output, cacheCreationTokens: 30, + cacheReadTokens: 400, recordID: id) + } + + static func total(_ events: [TokenEvent]) -> Int { + events.reduce(0) { $0 + $1.inputTokens + $1.outputTokens + $1.cacheCreationTokens + $1.cacheReadTokens } + } + + static func execute(_ url: URL, sql: String) throws { + var database: OpaquePointer? + guard sqlite3_open(url.path, &database) == SQLITE_OK, let database else { throw TestError.database } + defer { sqlite3_close(database) } + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { throw TestError.database } + } + + static func count(_ url: URL) throws -> Int { + var database: OpaquePointer? + guard sqlite3_open_v2(url.path, &database, SQLITE_OPEN_READONLY, nil) == SQLITE_OK, let database else { throw TestError.database } + defer { sqlite3_close(database) } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, "SELECT COUNT(*) FROM usage_events", -1, &statement, nil) == SQLITE_OK, + let statement else { throw TestError.database } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { throw TestError.database } + return Int(sqlite3_column_int64(statement, 0)) + } + + private enum TestError: Error { case database } + + static func main() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("usage-ledger-tests-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let url = root.appendingPathComponent("archive/usage.sqlite3") + let ledger = UsageLedger(url: url) + let first = ledger.retain([event("one")], source: .claude, now: now) + expect(first.saveError == nil && total(first.events) == 550, "capture preserves every token category") + expect(ledger.retain([event("one")], source: .claude, now: now).events.count == 1, "repeated scans do not add usage") + expect(ledger.retain(first.events, source: .claude, now: now).events.count == 1, "saved rows can be read and retained idempotently") + let reopened = UsageLedger(url: url).retain([], source: .claude, now: now) + expect(reopened.saveError == nil && total(reopened.events) == 550, "new instance reads saved counts without any provider records") + let later = now.addingTimeInterval(500 * 86400) + expect(total(ledger.retain([], source: .claude, now: later).events) == 550, "history survives year boundaries without expiry") + let added = ledger.retain([event("two", input: 200, date: later)], source: .claude, now: later) + expect(added.events.count == 2 && total(added.events) == 1200, "new usage adds to history after old source logs disappear") + let corrected = ledger.retain([event("one", input: 50)], source: .claude, now: later) + expect(corrected.events.count == 2 && total(corrected.events) == 1150, "corrected counts replace the same call instead of adding a second call") + let summary = CostSummary.summarize(events: corrected.events, now: later, includeAllHistory: true) + expect(summary.dailyTokens.reduce(0) { $0 + $1.tokens } == 1150, "saved records feed the real all-time card aggregation") + expect(abs(summary.dailyTokens.compactMap(\.dollars).reduce(0, +) + - corrected.events.reduce(0) { $0 + Pricing.cost(for: $1) }) < 0.000001, + "API estimates remain derived from separate raw token categories") + expect(ledger.retain([event("one")], source: .openCode, now: now).events.count == 1, + "independent clients with matching IDs stay separate") + expect(ledger.retain([event("one", provider: .codex)], source: .openCode, now: now).events.count == 2, + "provider identity prevents cross-provider collisions") + let fallback = TokenEvent(provider: .claude, timestamp: now, model: "unknown", + inputTokens: 1, outputTokens: 1, cacheCreationTokens: 0, cacheReadTokens: 0) + let fallbackURL = root.appendingPathComponent("fallback.sqlite3") + let fallbackLedger = UsageLedger(url: fallbackURL) + expect(fallbackLedger.retain([fallback, fallback], source: .claude, now: now).events.count == 2, + "distinct identical records without IDs are not collapsed") + expect(fallbackLedger.retain([fallback, fallback], source: .claude, now: now).events.count == 2, + "fallback identities stay stable on repeated scans") + + let forkLedger = UsageLedger(url: root.appendingPathComponent("fork.sqlite3")) + var original = event("original") + original.recordAliases = ["same-model-call"] + var fork = event("fork") + fork.recordAliases = original.recordAliases + _ = forkLedger.retain([original], source: .openCode, now: now) + expect(forkLedger.retain([fork], source: .openCode, now: now).events.count == 1, + "a surviving fork cannot recount a deleted original message") + var updatedFork = event("fork", input: 200) + updatedFork.recordAliases = ["updated-model-call"] + let forkResult = forkLedger.retain([updatedFork], source: .openCode, now: now) + expect(forkResult.events.count == 1 && total(forkResult.events) == 650, + "aliases retain call identity when a fork's usage is corrected") + + let raceLedger = UsageLedger(url: root.appendingPathComponent("scan-order.sqlite3")) + _ = raceLedger.retain([event("call", input: 200)], source: .claude, now: later, observedAt: later) + let stale = raceLedger.retain([event("call", input: 100), event("older-log")], source: .claude, + now: later, observedAt: now) + expect(stale.events.count == 2 && total(stale.events) == 1200, + "a slow earlier scan adds missing history without overwriting newer counts") + let correctedLater = raceLedger.retain([event("call", input: 50)], source: .claude, + now: later, observedAt: later.addingTimeInterval(1)) + expect(total(correctedLater.events) == 1050, "a newer correction can still reduce a previously recorded count") + + let invalid = ledger.retain([event("negative", input: -1), event("future", date: later.addingTimeInterval(1)), + event("missing-date", date: .distantPast)], source: .grok, now: later) + expect(invalid.events.isEmpty, "invalid and future records do not create fabricated history") + try execute(url, sql: "CREATE TRIGGER reject_fixture BEFORE INSERT ON usage_events WHEN NEW.input_tokens=9999 BEGIN SELECT RAISE(ABORT, 'fixture'); END;") + let beforeFailure = try count(url) + let failed = ledger.retain([event("three"), event("rejected", input: 9999)], source: .claude, now: later) + expect(failed.saveError != nil, "storage failures are reported") + expect(try count(url) == beforeFailure, "a failed batch rolls back all writes") + expect(failed.events.count == 4, "failed writes still show saved history and current live counts") + try execute(url, sql: "DROP TRIGGER reject_fixture;") + expect(ledger.retain([event("three")], source: .claude, now: later).saveError == nil, + "saving recovers after a transient storage failure") + + let brokenURL = root.appendingPathComponent("broken.sqlite3") + let originalBytes = Data("not a database; preserve me".utf8) + try originalBytes.write(to: brokenURL) + let broken = UsageLedger(url: brokenURL).retain([event("live")], source: .claude, now: now) + expect(broken.saveError != nil && broken.events.count == 1, "unreadable archive preserves current live usage") + expect(try Data(contentsOf: brokenURL) == originalBytes, "unreadable archive is never reset or overwritten") + try execute(url, sql: "PRAGMA user_version=99;") + expect(ledger.retain([event("future-schema")], source: .claude, now: later).saveError != nil, + "unsupported future schemas are not overwritten") + expect(try count(url) == beforeFailure + 1, "unsupported schema keeps existing saved events") + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + expect(attributes[.posixPermissions] as? Int == 0o600, "token database is readable only by its owner") + + let concurrentURL = root.appendingPathComponent("concurrent.sqlite3") + _ = UsageLedger(url: concurrentURL).retain([], source: .claude, now: now) + DispatchQueue.concurrentPerform(iterations: 8) { index in + _ = UsageLedger(url: concurrentURL).retain([event("concurrent-\(index)")], source: .claude, now: now) + } + expect(try count(concurrentURL) == 8, "independent writers preserve each other's counts") + + let logs = root.appendingPathComponent("grok-session") + try FileManager.default.createDirectory(at: logs, withIntermediateDirectories: true) + let line = """ + {"timestamp":"2026-09-08T17:00:00Z","_meta":{"promptId":"call-1","modelUsage":{"grok-test":{"inputTokens":5000,"outputTokens":200,"cacheReadTokens":4000,"cacheCreationTokens":0}}}} + """ + let sourceFile = logs.appendingPathComponent("updates.jsonl") + try Data(line.utf8).write(to: sourceFile) + let integrationURL = root.appendingPathComponent("integration.sqlite3") + let captured = UsageLedger(url: integrationURL).retain(GrokLogReader.scan(lookbackDays: nil, root: logs, now: later).events, + source: .grok, now: later) + expect(captured.saveError == nil && total(captured.events) == 5200, "real provider parser archives the recorded token counts") + try FileManager.default.removeItem(at: sourceFile) + let rescanned = GrokLogReader.scan(lookbackDays: nil, root: logs, now: later) + expect(rescanned.events.isEmpty, "provider fixture reproduces source-log deletion") + let retained = UsageLedger(url: integrationURL).retain(rescanned.events, source: .grok, now: later) + let afterDeletion = CostSummary.summarize(events: retained.events, now: later, includeAllHistory: true) + expect(afterDeletion.dailyTokens.reduce(0) { $0 + $1.tokens } == 5200, + "provider-log deletion and archive reopen leave chart totals intact") + + let claudeRoot = root.appendingPathComponent("claude-project") + try FileManager.default.createDirectory(at: claudeRoot, withIntermediateDirectories: true) + func claudeRow(_ id: String, input: Int = 100, output: Int, timestamp: String = "2026-01-09T01:00:00Z") -> String { + """ + {"type":"assistant","timestamp":"\(timestamp)","requestId":"\(id)","message":{"id":"\(id)","model":"claude-sonnet-4-6","usage":{"input_tokens":\(input),"output_tokens":\(output),"cache_creation_input_tokens":30,"cache_read_input_tokens":400}}} + """ + } + let claudeFile = claudeRoot.appendingPathComponent("session.jsonl") + try Data([claudeRow("call", output: 10), claudeRow("call", output: 30), claudeRow("call", output: 10)].joined(separator: "\n").utf8).write(to: claudeFile) + let finalClaude = ClaudeLogReader.scan(lookbackDays: nil, roots: [claudeRoot]) + expect(finalClaude.count == 1 && finalClaude.first?.outputTokens == 30, + "Claude streaming repeats retain the most complete actual usage row") + try Data([claudeRow("call", output: 30), claudeRow("call", input: 200, output: 20)].joined(separator: "\n").utf8).write(to: claudeFile) + let conflicting = ClaudeLogReader.scan(lookbackDays: nil, roots: [claudeRoot]) + expect(conflicting.first?.inputTokens == 100 && conflicting.first?.outputTokens == 30, + "incomparable Claude rows never synthesize a larger token vector") + try Data([claudeRow("", output: 10), claudeRow("", output: 30, timestamp: "2026-01-09T02:00:00Z")].joined(separator: "\n").utf8).write(to: claudeFile) + let anonymous = ClaudeLogReader.scan(lookbackDays: nil, roots: [claudeRoot]) + let anonymousLedger = UsageLedger(url: root.appendingPathComponent("claude.sqlite3")) + _ = anonymousLedger.retain(anonymous, source: .claude, now: now) + try Data(claudeRow("", output: 30, timestamp: "2026-01-09T02:00:00Z").utf8).write(to: claudeFile) + let truncatedClaude = anonymousLedger.retain(ClaudeLogReader.scan(lookbackDays: nil, roots: [claudeRoot]), source: .claude, now: now) + expect(truncatedClaude.events.count == 2 && total(truncatedClaude.events) == total(anonymous), + "Claude records without message IDs survive partial log truncation without recounting") + + let codexRoot = root.appendingPathComponent("codex-sessions") + try FileManager.default.createDirectory(at: codexRoot, withIntermediateDirectories: true) + let context = "{\"type\":\"turn_context\",\"payload\":{\"model\":\"gpt-5.4\"}}" + func codexRow(_ timestamp: String, output: Int) -> String { + """ + {"type":"event_msg","timestamp":"\(timestamp)","payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":100,"cached_input_tokens":60,"output_tokens":\(output)}}}} + """ + } + let codexFile = codexRoot.appendingPathComponent("rollout-session.jsonl") + let codexFirst = codexRow("2026-01-09T01:00:00Z", output: 20) + let codexSecond = codexRow("2026-01-09T02:00:00Z", output: 40) + try Data([context, codexFirst, codexSecond].joined(separator: "\n").utf8).write(to: codexFile) + let codexLedger = UsageLedger(url: root.appendingPathComponent("codex.sqlite3")) + _ = codexLedger.retain(CodexLogReader.scan(lookbackDays: nil, root: codexRoot), source: .codex, now: now) + try Data([context, codexSecond].joined(separator: "\n").utf8).write(to: codexFile) + let retainedCodex = codexLedger.retain(CodexLogReader.scan(lookbackDays: nil, root: codexRoot), source: .codex, now: now) + expect(retainedCodex.events.count == 2 && total(retainedCodex.events) == 260, + "Codex event identity survives truncated source logs") + + let start = Date(timeIntervalSince1970: 1_767_884_400) + let historicalDay = HistoricalUsageDay(provider: .claude, sourceIdentity: "verified-old-cache-day", + intervalStart: start, intervalEnd: start.addingTimeInterval(86400), + sourceLocalDate: "2026-01-09", sourceUTCOffsetSeconds: 32400, + tokensIncludingCache: 1000, inputPlusOutputTokens: 100) + expect(historicalDay.isValid, "historical daily interval matches its original calendar date") + let historyURL = root.appendingPathComponent("historical.sqlite3") + let historyLedger = UsageLedger(url: historyURL) + try historyLedger.importHistoricalDays([historicalDay], capturedAt: now) + try historyLedger.importHistoricalDays([historicalDay], capturedAt: now) + let historicalSaved = UsageLedger(url: historyURL).retain([], source: .claude, now: now) + expect(historicalSaved.saveError == nil && historicalSaved.historicalDays.count == 1, + "historical aggregates persist separately and import idempotently") + var chicago = Calendar(identifier: .gregorian) + chicago.timeZone = TimeZone(identifier: "America/Chicago") ?? .current + let exactCall = event("recovered-call", date: start.addingTimeInterval(3600)) + let supplements = HistoricalUsageDay.supplements(historicalSaved.historicalDays, events: [exactCall], calendar: chicago) + expect(supplements.count == 1 && supplements.first?.tokens == 450 && supplements.first?.billableTokens == 0, + "only the aggregate residual is added after subtracting detailed overlap") + expect(supplements.first.map { chicago.component(.day, from: $0.dayStart) } == 9, + "recovered daily totals retain January 9 instead of being guessed into Chicago January 8") + let noOverlap = HistoricalUsageDay.supplements([historicalDay], events: [event("outside", date: start.addingTimeInterval(-1))], calendar: chicago) + expect(noOverlap.first?.tokens == 1000, "overlap uses the original UTC interval, not the display date") + let fullDetail = HistoricalUsageDay.supplements([historicalDay], events: [event("more", input: 1000, date: start)], calendar: chicago) + expect(fullDetail.isEmpty, "complete or larger detailed usage supersedes the historical aggregate") + let recoveredSummary = CostSummary.summarize(events: [exactCall], now: now, includeAllHistory: true, historicalDays: [historicalDay]) + expect(recoveredSummary.dailyTokens.reduce(0) { $0 + $1.tokens } == 1000, + "the chart does not add a recovered daily total to the same detailed usage twice") + expect(recoveredSummary.dailyTokens.compactMap(\.recoveredTokens).reduce(0, +) == 450 + && recoveredSummary.dailyTokens.compactMap(\.unpricedTokens).reduce(0, +) == 450, + "recovered remainder has no invented model price") + let overlapDay = HistoricalUsageDay(provider: .claude, sourceIdentity: "overlap", + intervalStart: start.addingTimeInterval(43200), intervalEnd: start.addingTimeInterval(129600), + sourceLocalDate: "2026-01-09", sourceUTCOffsetSeconds: -10800, + tokensIncludingCache: 2000, inputPlusOutputTokens: 200) + var rejectedOverlap = false + do { try historyLedger.importHistoricalDays([overlapDay], capturedAt: now) } catch { rejectedOverlap = true } + expect(rejectedOverlap && historyLedger.retain([], source: .claude, now: now).historicalDays.count == 1, + "overlapping recovered intervals fail without modifying saved history") + var rejectedDuplicates = false + do { try historyLedger.importHistoricalDays([historicalDay, historicalDay], capturedAt: now) } catch { rejectedDuplicates = true } + expect(rejectedDuplicates, "duplicate recovery intervals are rejected without a crash") + let staleImportLedger = UsageLedger(url: root.appendingPathComponent("stale-import.sqlite3")) + let longer = HistoricalUsageDay(provider: .claude, sourceIdentity: "longer", intervalStart: start, + intervalEnd: start.addingTimeInterval(90000), sourceLocalDate: "2026-01-09", + sourceUTCOffsetSeconds: 32400, tokensIncludingCache: 1000, inputPlusOutputTokens: 100) + let shorter = HistoricalUsageDay(provider: .claude, sourceIdentity: "shorter", intervalStart: start, + intervalEnd: start.addingTimeInterval(82800), sourceLocalDate: "2026-01-09", + sourceUTCOffsetSeconds: 32400, tokensIncludingCache: 900, inputPlusOutputTokens: 90) + let adjacent = HistoricalUsageDay(provider: .claude, sourceIdentity: "adjacent", + intervalStart: start.addingTimeInterval(82800), intervalEnd: start.addingTimeInterval(169200), + sourceLocalDate: "2026-01-10", sourceUTCOffsetSeconds: 36000, + tokensIncludingCache: 800, inputPlusOutputTokens: 80) + try staleImportLedger.importHistoricalDays([longer], capturedAt: now) + var rejectedStaleOverlap = false + do { + try staleImportLedger.importHistoricalDays([shorter, adjacent], capturedAt: now.addingTimeInterval(-1)) + } catch { rejectedStaleOverlap = true } + expect(rejectedStaleOverlap && staleImportLedger.retain([], source: .claude, now: now).historicalDays.count == 1, + "a stale shorter interval cannot hide overlap with the actual saved interval") + let historicalNow = start.addingTimeInterval(72000) + let futureWithinDay = event("future-inside-history", date: historicalNow.addingTimeInterval(3600)) + let clockSafe = CostSummary.summarize(events: [futureWithinDay], now: historicalNow, includeAllHistory: true, + historicalDays: [historicalDay]) + expect(clockSafe.dailyTokens.reduce(0) { $0 + $1.tokens } == 1000, + "excluded future events cannot suppress a recovered aggregate") + print("PASS: \(checks) usage-ledger checks") + } +} diff --git a/build.sh b/build.sh index 05c2f6f..9c87357 100755 --- a/build.sh +++ b/build.sh @@ -44,6 +44,7 @@ cp ./Resources/ThirdPartyNotices.txt "$RES_DIR/ThirdPartyNotices.txt" cp ./Resources/antigravity_logo.png "$RES_DIR/antigravity_logo.png" cp ./Resources/codexisland_logo.png "$RES_DIR/codexisland_logo.png" cp ./Resources/CodexIsland.icns "$RES_DIR/CodexIsland.icns" +cp ./scripts/recover-claude-usage.sh "$RES_DIR/recover-claude-usage.sh" find ./Resources -maxdepth 1 -type d -name '*.lproj' -exec cp -R {} "$RES_DIR/" \; # Embed Sparkle.framework. -a preserves the symlinks inside Versions/. diff --git a/docs/USAGE-HISTORY.md b/docs/USAGE-HISTORY.md new file mode 100644 index 0000000..854296c --- /dev/null +++ b/docs/USAGE-HISTORY.md @@ -0,0 +1,159 @@ +# Local usage history + +CodexIsland retains captured token counts in its own SQLite database at +`~/Library/Application Support/dev.codexisland.CodexIsland/usage-history.sqlite3`. +This is durable application data, separate from the disposable parser caches +and provider-owned session files. The app never expires saved usage or deletes +it when an original log disappears. + +## Capture + +The existing local refresh reads all currently available records from Claude +Code, locally mirrored Claude Cowork sessions, Codex, OpenCode, Grok, and +Antigravity. Parser caches still avoid re-reading unchanged Claude and Codex +files. Capture runs at launch and on the existing refresh schedule; it does +not add network requests or change quota polling intervals. + +Each call keeps its provider, model, timestamp, disjoint input/output/cache +counts, and a hashed identity. Calls with stable provider IDs update the same +entry across scans. Codex and older Claude records use the session filename, +timestamp, and repeated-timestamp ordinal. OpenCode's existing duplicate-call +fingerprints are retained as aliases so a surviving fork does not recount an +already saved call. Claude streaming repeats use a real recorded row whose +counts dominate the earlier row, never a synthesized sum of multiple rows. + +Writes use SQLite transactions, WAL, and full synchronization. Separate +writers cannot overwrite each other's history, and an older in-flight scan +cannot replace a newer observation. The database is owner-readable only. +Failed writes preserve the file and keep live totals visible with a saving +error; the app never silently resets an unreadable archive. + +## Recovered daily totals + +Historical daily snapshots can be imported separately from individual calls. +They retain their original date, UTC interval, offset, token totals, and +input-plus-output totals. They are not converted into fabricated messages or +assigned a guessed model. + +For each original interval, the app subtracts overlapping detailed usage and +adds only a positive remainder. More complete detailed records therefore +replace the aggregate contribution without being counted twice. This is a +conservative lower bound when the two sources may cover different calls. +The remainder keeps the original calendar date; moving it between hourly or +current-timezone daily buckets would invent a distribution the source did not +record. Overlapping aggregate intervals are rejected transactionally. + +Recovered counts with no model breakdown remain unpriced. Cached zero days do +not establish that no activity occurred. + +## Recover your Claude usage + +Open **Settings → General → Recover Claude usage…**. The app scans local +records and shows the current total, the total after recovery, and the +additional tokens it found. Nothing is saved during this preview. + +If older daily snapshots need a timezone, choose the timezone in which they +were recorded and click **Scan again**. Leave it unspecified if you do not +know. Expand **Older backups** to add an extracted Claude projects folder or +an old CodexIsland preferences file. Changing any selection clears the old +preview, so you must scan again before importing. + +Click **Import recovery** to save the reviewed records. The app backs up the +archive, imports the exact records from the preview, and refreshes usage +history. Records that appear in source files after the preview are not silently +included. If the app has already captured the same usage, it is not counted +again. + +The optional terminal script uses the same recovery code. No Xcode, Python, +login, or downloads are required. Start with a preview: + +```sh +bash /Applications/CodexIsland.app/Contents/Resources/recover-claude-usage.sh +``` + +The preview shows the saved message and daily counts it found, your current +Claude total, and how many tokens recovery would add. It leaves your saved +counts unchanged. To save the verified records, run it again with `--apply`: + +```sh +bash /Applications/CodexIsland.app/Contents/Resources/recover-claude-usage.sh --apply +``` + +Refresh CodexIsland to include the recovered counts. You can repeat recovery +safely. Copied Claude messages +are deduplicated by message and request IDs, and overlapping daily snapshots +contribute only the part not covered by detailed records. Recovery leaves +already captured messages intact and never reduces saved daily totals. + +The default scan follows the app's Claude configuration, including locally +mirrored Cowork logs in the default search. To include another surviving copy, +point `--projects` at its extracted projects folder. Repeat the option for +multiple backups; archives such as `.tar.gz` must be extracted first: + +```sh +bash /Applications/CodexIsland.app/Contents/Resources/recover-claude-usage.sh \ + --projects "/Volumes/Backup/old-home/.claude/projects" +``` + +Old CodexIsland daily snapshots are also checked. They may survive after +Claude's original logs are gone, but they do not record a timezone name. Add +`--time-zone` with the timezone where those snapshots were collected, then +preview again. For example, use `Asia/Seoul` only for snapshots recorded there: + +```sh +bash /Applications/CodexIsland.app/Contents/Resources/recover-claude-usage.sh \ + --time-zone Asia/Seoul +``` + +Use `--preferences "/path/to/dev.codexisland.CodexIsland.plist"` to also check +a preferences file from an old backup. Repeat the option for multiple files. +Supported snapshots are `MacIsland.costCache.v4` through `v7`. A snapshot whose +daily boundaries do not match the supplied timezone is skipped. Snapshots +that already contain recovered display aggregates are also skipped, so the +same recovery is not reinterpreted under a different timezone. Add `--apply` +to the reviewed command when ready to save it. + +Before an import changes an existing archive, the script saves a consistent +SQLite backup, including any pending WAL data, under +`~/Library/Application Support/dev.codexisland.CodexIsland/Recovery Backups/`. +The script prints its exact path; Settings offers **Show backup in Finder**. +Each backup is a standalone database that can be opened read-only without +copying WAL or shared-memory sidecars. These backups and the archive are +private to your user account. Original logs and preferences are never edited. +The terminal command does not start the app's UI, quota requests, or updater. + +Each recovery commits its messages and daily snapshots in one transaction. +A storage failure rolls back the entire import. The added-token count is +measured inside that same transaction, so ordinary app captures happening +during recovery are not reported as recovered tokens. + +Recovery cannot reconstruct deleted counters with no surviving source. +Undated session summaries, quota percentages, monthly totals without daily +records, conflicting daily counters, and messages without both usage IDs are +not imported. The ordinary app capture still supports older messages without +IDs; the recovery command requires IDs so copied backups cannot inflate totals. +Recovered daily totals include cache tokens but do not invent model prices. + +From a source checkout, run `bash scripts/recover-claude-usage.sh`. It uses the +local build when available, then checks the standard Applications folders. +For another app location, set `CODEXISLAND_APP` to its `.app` path. The installed +app must include this recovery command; the script will not launch an older +app that lacks it. Run `--help` for all options. + +## Limits + +The database protects counts after capture. CodexIsland must be running while +provider records are still available to capture them; records already deleted +beforehand require another surviving source. Cloud-only activity or usage on +another computer is not automatically included. The database itself still +needs the user's normal device backup. It contains no prompts, responses, +tool payloads, or credentials, and it is not uploaded anywhere. + +Run `bash scripts/test-usage-ledger.sh` to verify persistence across source +deletion, reopening, repeated scans, corrections, concurrent writers, storage +errors, and recovered aggregate overlap. +Run `bash scripts/test-claude-recovery.sh` for preview isolation, copied backup +deduplication, source preservation, repeat imports, archive backups, and +historical timezone checks. It also checks whole-import rollback, frozen +previews, concurrent capture attribution, excessive counters, unsupported +archives, and the Settings loading/preview/import states. diff --git a/scripts/recover-claude-usage.sh b/scripts/recover-claude-usage.sh new file mode 100755 index 0000000..a186c15 --- /dev/null +++ b/scripts/recover-claude-usage.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -euo pipefail + +RECOVERY_SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +if [[ -n "${CODEXISLAND_APP:-}" ]]; then + RECOVERY_APPS=("$CODEXISLAND_APP") +else + RECOVERY_APPS=( + "$RECOVERY_SCRIPT_DIR/../.." + "$RECOVERY_SCRIPT_DIR/../build/CodexIsland.app" + "/Applications/CodexIsland.app" + "$HOME/Applications/CodexIsland.app" + ) +fi + +for RECOVERY_APP in "${RECOVERY_APPS[@]}"; do + if [[ -x "$RECOVERY_APP/Contents/MacOS/CodexIsland" && -f "$RECOVERY_APP/Contents/Resources/recover-claude-usage.sh" ]]; then + exec "$RECOVERY_APP/Contents/MacOS/CodexIsland" --recover-claude "$@" + fi +done + +echo "Install a CodexIsland version with usage recovery, then run this script again." >&2 +echo "For a custom app location, set CODEXISLAND_APP to its .app path." >&2 +exit 1 diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 90d8a23..bc75922 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -169,7 +169,7 @@ swiftc \ Sources/Cost/LogParseCache.swift \ Sources/Cost/CostUsage.swift \ Sources/Cost/CostBucketing.swift \ - Sources/Cost/CostSummary.swift \ + Sources/Cost/HistoricalUsageDay.swift Sources/Cost/CostSummary.swift \ Sources/Cost/PricingCatalog.swift \ Sources/Cost/Pricing.swift \ Tests/LocalProviderCostTests.swift @@ -188,3 +188,6 @@ swiftc \ Tests/ProviderSessionRecoveryTests.swift "$OUT_DIR/provider-session-recovery-tests" + +bash scripts/test-usage-ledger.sh +bash scripts/test-claude-recovery.sh diff --git a/scripts/test-claude-recovery.sh b/scripts/test-claude-recovery.sh new file mode 100755 index 0000000..944097f --- /dev/null +++ b/scripts/test-claude-recovery.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +RECOVERY_TEST_DIR=$(mktemp -d) +trap 'rm -rf "$RECOVERY_TEST_DIR"' EXIT +swiftc -parse-as-library -o "$RECOVERY_TEST_DIR/claude-recovery-tests" \ + Sources/Cost/TokenEvent.swift Sources/Cost/UsageLedger.swift \ + Sources/Cost/LogParseCache.swift Sources/Cost/ClaudeLogReader.swift \ + Sources/Cost/CostUsage.swift Sources/Cost/CostBucketing.swift Sources/Cost/HistoricalUsageDay.swift \ + Sources/Recovery/ClaudeUsageRecovery.swift Tests/ClaudeUsageRecoveryTests.swift +"$RECOVERY_TEST_DIR/claude-recovery-tests" + +swiftc -parse-as-library -o "$RECOVERY_TEST_DIR/recovery-model-tests" \ + Sources/Cost/TokenEvent.swift Sources/Cost/UsageLedger.swift \ + Sources/Cost/LogParseCache.swift Sources/Cost/ClaudeLogReader.swift \ + Sources/Cost/CostUsage.swift Sources/Cost/CostBucketing.swift Sources/Cost/HistoricalUsageDay.swift \ + Sources/Recovery/ClaudeUsageRecovery.swift Sources/Recovery/ClaudeRecoveryModel.swift \ + Tests/ClaudeRecoveryModelTests.swift +"$RECOVERY_TEST_DIR/recovery-model-tests" diff --git a/scripts/test-usage-ledger.sh b/scripts/test-usage-ledger.sh new file mode 100644 index 0000000..0cab297 --- /dev/null +++ b/scripts/test-usage-ledger.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +LEDGER_TEST_DIR=$(mktemp -d) +trap 'rm -rf "$LEDGER_TEST_DIR"' EXIT +swiftc -parse-as-library -o "$LEDGER_TEST_DIR/usage-ledger-tests" \ + Sources/Cost/TokenEvent.swift Sources/Cost/UsageLedger.swift \ + Sources/Cost/GrokLogReader.swift Sources/Cost/LocalCostScan.swift Sources/Cost/LogParseCache.swift \ + Sources/Cost/ClaudeLogReader.swift Sources/Cost/CodexLogReader.swift \ + Sources/Cost/CostUsage.swift Sources/Cost/CostBucketing.swift Sources/Cost/HistoricalUsageDay.swift Sources/Cost/CostSummary.swift \ + Sources/Cost/PricingCatalog.swift Sources/Cost/Pricing.swift \ + Tests/UsageLedgerTests.swift +"$LEDGER_TEST_DIR/usage-ledger-tests"