diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1357d4..d7b5aae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,13 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v4 + - name: Run Swift tests + run: swift test --package-path TokenStepSwift - name: Run usage collector fixture checks run: ./script/test_ccswitch_proxy_collector.sh + - name: Run Codex cumulative accounting checks + run: ./script/test_codex_cumulative_collector.sh + - name: Run accounting migration checks + run: ./script/test_usage_recalibration_migration.sh - name: Build TokenStep run: ./script/build_swiftui_and_run.sh --no-launch diff --git a/TokenStepSwift/Package.swift b/TokenStepSwift/Package.swift index 8e49c9c..0e38d56 100644 --- a/TokenStepSwift/Package.swift +++ b/TokenStepSwift/Package.swift @@ -7,25 +7,12 @@ let package = Package( .macOS(.v14) ], products: [ - .executable(name: "TokenStepSwift", targets: ["TokenStepSwift"]), - .executable(name: "TokenStepHelper", targets: ["TokenStepHelper"]) + .executable(name: "TokenStepSwift", targets: ["TokenStepSwift"]) ], targets: [ + // TokenStepHelper is bundled by script/build_swiftui_and_run.sh because it + // intentionally shares internal app sources that SwiftPM cannot own twice. .executableTarget(name: "TokenStepSwift"), - .executableTarget( - name: "TokenStepHelper", - path: "Sources", - sources: [ - "TokenStepSwift/Support/AppPaths.swift", - "TokenStepSwift/Support/Localization.swift", - "TokenStepSwift/Support/MemoryPressure.swift", - "TokenStepSwift/Support/Theme.swift", - "TokenStepSwift/Models/UsageModels.swift", - "TokenStepSwift/Services/UsageCollector.swift", - "TokenStepSwift/Services/DataService.swift", - "TokenStepHelper/main.swift" - ] - ), .testTarget( name: "TokenStepSwiftTests", dependencies: ["TokenStepSwift"] diff --git a/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift b/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift index d412e75..7078f56 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift @@ -6,6 +6,7 @@ struct UsageSnapshot: Codable { var totals: UsageTotals var daily: [DailyUsage] var rhythms: [DailyRhythm] + var agentWork: [DailyAgentWork] var tools: [ToolUsage] var models: [ModelUsage] var sources: [String: SourceInfo] @@ -16,6 +17,7 @@ struct UsageSnapshot: Codable { case totals case daily case rhythms + case agentWork = "agent_work" case tools case models case sources @@ -27,6 +29,7 @@ struct UsageSnapshot: Codable { totals: UsageTotals, daily: [DailyUsage], rhythms: [DailyRhythm] = [], + agentWork: [DailyAgentWork] = [], tools: [ToolUsage], models: [ModelUsage], sources: [String: SourceInfo] @@ -36,6 +39,7 @@ struct UsageSnapshot: Codable { self.totals = totals self.daily = daily self.rhythms = rhythms + self.agentWork = agentWork self.tools = tools self.models = models self.sources = sources @@ -48,6 +52,7 @@ struct UsageSnapshot: Codable { totals = try container.decode(UsageTotals.self, forKey: .totals) daily = try container.decodeIfPresent([DailyUsage].self, forKey: .daily) ?? [] rhythms = try container.decodeIfPresent([DailyRhythm].self, forKey: .rhythms) ?? [] + agentWork = try container.decodeIfPresent([DailyAgentWork].self, forKey: .agentWork) ?? [] tools = try container.decodeIfPresent([ToolUsage].self, forKey: .tools) ?? [] models = try container.decodeIfPresent([ModelUsage].self, forKey: .models) ?? [] sources = try container.decodeIfPresent([String: SourceInfo].self, forKey: .sources) ?? [:] @@ -57,12 +62,17 @@ struct UsageSnapshot: Codable { rhythms.first { $0.date == date && $0.totalTokens > 0 } } + func agentWork(for date: String) -> DailyAgentWork? { + agentWork.first { $0.date == date && $0.totalTokens > 0 } + } + static let empty = UsageSnapshot( generatedAt: nil, timezone: "Asia/Shanghai", totals: UsageTotals(tokens: 0, cost: 0, activeDays: 0), daily: [], rhythms: [], + agentWork: [], tools: [], models: [], sources: [:] @@ -208,6 +218,171 @@ struct HourlyTokenBucket: Codable, Identifiable { var tokens: Int } +struct DailyAgentWork: Codable, Identifiable { + var id: String { date } + var date: String + var totalTokens: Int + var inputTokens: Int + var cachedInputTokens: Int + var outputTokens: Int + var cacheCoverageComplete: Bool + var activeHours: Int + var modelRequestCount: Int + var toolCallCount: Int + var sources: [AgentWorkSource] + var hourlyBuckets: [AgentWorkHourBucket] + var unbucketedTokens: Int + + enum CodingKeys: String, CodingKey { + case date + case totalTokens = "total_tokens" + case inputTokens = "input_tokens" + case cachedInputTokens = "cached_input_tokens" + case outputTokens = "output_tokens" + case cacheCoverageComplete = "cache_coverage_complete" + case activeHours = "active_hours" + case modelRequestCount = "model_request_count" + case toolCallCount = "tool_call_count" + case sources + case hourlyBuckets = "hourly_buckets" + case unbucketedTokens = "unbucketed_tokens" + } + + init( + date: String, + totalTokens: Int, + activeHours: Int, + modelRequestCount: Int, + toolCallCount: Int, + sources: [AgentWorkSource], + inputTokens: Int = 0, + cachedInputTokens: Int = 0, + outputTokens: Int = 0, + cacheCoverageComplete: Bool = false, + hourlyBuckets: [AgentWorkHourBucket] = [], + unbucketedTokens: Int? = nil + ) { + self.date = date + self.totalTokens = totalTokens + self.inputTokens = inputTokens + self.cachedInputTokens = cachedInputTokens + self.outputTokens = outputTokens + self.cacheCoverageComplete = cacheCoverageComplete + self.activeHours = activeHours + self.modelRequestCount = modelRequestCount + self.toolCallCount = toolCallCount + self.sources = sources + self.hourlyBuckets = Self.normalizedHourlyBuckets(hourlyBuckets) + self.unbucketedTokens = max( + 0, + unbucketedTokens ?? (totalTokens - self.hourlyBuckets.map(\.totalTokens).reduce(0, +)) + ) + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decode(String.self, forKey: .date) + totalTokens = try container.decode(Int.self, forKey: .totalTokens) + inputTokens = try container.decodeIfPresent(Int.self, forKey: .inputTokens) ?? 0 + cachedInputTokens = try container.decodeIfPresent(Int.self, forKey: .cachedInputTokens) ?? 0 + outputTokens = try container.decodeIfPresent(Int.self, forKey: .outputTokens) ?? 0 + cacheCoverageComplete = try container.decodeIfPresent(Bool.self, forKey: .cacheCoverageComplete) ?? false + activeHours = try container.decodeIfPresent(Int.self, forKey: .activeHours) ?? 0 + modelRequestCount = try container.decodeIfPresent(Int.self, forKey: .modelRequestCount) ?? 0 + toolCallCount = try container.decodeIfPresent(Int.self, forKey: .toolCallCount) ?? 0 + sources = try container.decodeIfPresent([AgentWorkSource].self, forKey: .sources) ?? [] + let decodedBuckets = try container.decodeIfPresent([AgentWorkHourBucket].self, forKey: .hourlyBuckets) + hourlyBuckets = Self.normalizedHourlyBuckets(decodedBuckets ?? []) + unbucketedTokens = max( + 0, + try container.decodeIfPresent(Int.self, forKey: .unbucketedTokens) + ?? (totalTokens - hourlyBuckets.map(\.totalTokens).reduce(0, +)) + ) + } + + var cacheHitRate: Double? { + guard cacheCoverageComplete, + inputTokens > 0, + cachedInputTokens >= 0, + cachedInputTokens <= inputTokens + else { + return nil + } + return Double(cachedInputTokens) / Double(inputTokens) + } + + func bucket(hour: Int) -> AgentWorkHourBucket { + hourlyBuckets.first { $0.hour == hour } ?? AgentWorkHourBucket(hour: hour, sources: []) + } + + private static var emptyHourlyBuckets: [AgentWorkHourBucket] { + (0..<24).map { AgentWorkHourBucket(hour: $0, sources: []) } + } + + private static func normalizedHourlyBuckets(_ buckets: [AgentWorkHourBucket]) -> [AgentWorkHourBucket] { + let byHour = Dictionary( + buckets.filter { (0..<24).contains($0.hour) }.map { ($0.hour, $0) }, + uniquingKeysWith: { _, latest in latest } + ) + return (0..<24).map { byHour[$0] ?? AgentWorkHourBucket(hour: $0, sources: []) } + } +} + +struct AgentWorkSource: Codable, Identifiable { + var id: String { source } + var source: String + var tokens: Int + var modelRequestCount: Int + var toolCallCount: Int + + enum CodingKeys: String, CodingKey { + case source + case tokens + case modelRequestCount = "model_request_count" + case toolCallCount = "tool_call_count" + } +} + +struct AgentWorkHourBucket: Codable, Identifiable { + var id: Int { hour } + var hour: Int + var sources: [AgentWorkHourlySource] + + var totalTokens: Int { + sources.map(\.tokens).reduce(0, +) + } +} + +struct AgentWorkHourlySource: Codable, Identifiable { + var id: String { source } + var source: String + var tokens: Int + var inputTokens: Int + var cachedInputTokens: Int + var outputTokens: Int + var cacheCoverageComplete: Bool + + enum CodingKeys: String, CodingKey { + case source + case tokens + case inputTokens = "input_tokens" + case cachedInputTokens = "cached_input_tokens" + case outputTokens = "output_tokens" + case cacheCoverageComplete = "cache_coverage_complete" + } + + var cacheHitRate: Double? { + guard cacheCoverageComplete, + inputTokens > 0, + cachedInputTokens >= 0, + cachedInputTokens <= inputTokens + else { + return nil + } + return Double(cachedInputTokens) / Double(inputTokens) + } +} + enum RhythmTag: String, Codable { case earlyStarter = "early_starter" case morningPlanner = "morning_planner" @@ -313,6 +488,16 @@ struct SourceInfo: Codable { var dedupedRecords: Int? var skippedRecords: Int? var strategy: String? + var exactRecords: Int? + var legacyRecords: Int? + var duplicateRecords: Int? + var counterResets: Int? + var inheritedRecords: Int? + var inheritedTokens: Int? + var unknownBreakdownRecords: Int? + var accountingRevision: Int? + var recalibratedFromRevision: Int? + var tokenBreakdown: SourceTokenBreakdown? enum CodingKeys: String, CodingKey { case status @@ -322,6 +507,34 @@ struct SourceInfo: Codable { case dedupedRecords = "deduped_records" case skippedRecords = "skipped_records" case strategy + case exactRecords = "exact_records" + case legacyRecords = "legacy_records" + case duplicateRecords = "duplicate_records" + case counterResets = "counter_resets" + case inheritedRecords = "inherited_records" + case inheritedTokens = "inherited_tokens" + case unknownBreakdownRecords = "unknown_breakdown_records" + case accountingRevision = "accounting_revision" + case recalibratedFromRevision = "recalibrated_from_revision" + case tokenBreakdown = "token_breakdown" + } +} + +struct SourceTokenBreakdown: Codable, Equatable { + var processedTokens: Int + var inputTokens: Int + var cachedInputTokens: Int + var uncachedInputTokens: Int + var outputTokens: Int + var reasoningTokens: Int + + enum CodingKeys: String, CodingKey { + case processedTokens = "processed_tokens" + case inputTokens = "input_tokens" + case cachedInputTokens = "cached_input_tokens" + case uncachedInputTokens = "uncached_input_tokens" + case outputTokens = "output_tokens" + case reasoningTokens = "reasoning_tokens" } } @@ -552,6 +765,7 @@ struct TokenStepSettings: Codable { var tokenIslandPlacement: TokenIslandDisplayPlacement var showCodexQuota: Bool var showTokenRank: Bool + var showExperimentalAgentSources: Bool var tokenRankUserID: String var language: TokenStepLanguage var skippedUpdateVersion: String? @@ -568,6 +782,7 @@ struct TokenStepSettings: Codable { case tokenIslandPlacement = "token_island_placement" case showCodexQuota = "show_codex_quota" case showTokenRank = "show_token_rank" + case showExperimentalAgentSources = "show_experimental_agent_sources" case tokenRankUserID = "token_rank_user_id" case language case skippedUpdateVersion = "skipped_update_version" @@ -585,6 +800,7 @@ struct TokenStepSettings: Codable { tokenIslandPlacement: .menuBar, showCodexQuota: false, showTokenRank: false, + showExperimentalAgentSources: false, tokenRankUserID: "", language: .system, skippedUpdateVersion: nil @@ -602,6 +818,7 @@ struct TokenStepSettings: Codable { tokenIslandPlacement: TokenIslandDisplayPlacement, showCodexQuota: Bool, showTokenRank: Bool, + showExperimentalAgentSources: Bool, tokenRankUserID: String, language: TokenStepLanguage, skippedUpdateVersion: String? @@ -617,6 +834,7 @@ struct TokenStepSettings: Codable { self.tokenIslandPlacement = tokenIslandPlacement self.showCodexQuota = showCodexQuota self.showTokenRank = showTokenRank + self.showExperimentalAgentSources = showExperimentalAgentSources self.tokenRankUserID = Self.cleanedTokenRankUserID(tokenRankUserID) self.language = language self.skippedUpdateVersion = skippedUpdateVersion @@ -650,6 +868,7 @@ struct TokenStepSettings: Codable { } showCodexQuota = try container.decodeIfPresent(Bool.self, forKey: .showCodexQuota) ?? defaults.showCodexQuota showTokenRank = try container.decodeIfPresent(Bool.self, forKey: .showTokenRank) ?? defaults.showTokenRank + showExperimentalAgentSources = try container.decodeIfPresent(Bool.self, forKey: .showExperimentalAgentSources) ?? defaults.showExperimentalAgentSources let decodedTokenRankUserID = try container.decodeIfPresent(String.self, forKey: .tokenRankUserID) ?? defaults.tokenRankUserID tokenRankUserID = Self.cleanedTokenRankUserID(decodedTokenRankUserID) language = try container.decodeIfPresent(TokenStepLanguage.self, forKey: .language) ?? defaults.language diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift index f635f67..f54aaef 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/DataService.swift @@ -33,7 +33,69 @@ enum DataService { static func runCollector(historyDays: Int = TokenStepSettings.defaults.historyDays) throws { defer { MemoryPressure.relieveAllocatorPressure() } - let snapshot = UsageCollector.collect(historyDays: historyDays) + let settings = loadSettings() + let previousSnapshot = try? loadSnapshot() + let collectedSnapshot = UsageCollector.collect( + historyDays: historyDays, + includeExperimentalAgentSources: settings.showExperimentalAgentSources + ) + try validateRecalibrationCandidate( + collectedSnapshot, + previousSnapshot: previousSnapshot + ) + let snapshot = snapshotWithMigrationMetadata( + collectedSnapshot, + previousSnapshot: previousSnapshot + ) + try persist(snapshot: snapshot) + } + + private static func snapshotWithMigrationMetadata( + _ collectedSnapshot: UsageSnapshot, + previousSnapshot: UsageSnapshot? + ) -> UsageSnapshot { + var snapshot = collectedSnapshot + if snapshot.sources["Codex"]?.recalibratedFromRevision == nil, + let previousCodex = previousSnapshot?.sources["Codex"], + (previousCodex.records ?? 0) > 0, + let currentRevision = snapshot.sources["Codex"]?.accountingRevision { + let previousRevision = previousCodex.accountingRevision ?? 5 + if previousRevision < currentRevision { + snapshot.sources["Codex"]?.recalibratedFromRevision = previousRevision + } + } + return snapshot + } + + private static func validateRecalibrationCandidate( + _ collectedSnapshot: UsageSnapshot, + previousSnapshot: UsageSnapshot? + ) throws { + guard let previousCodex = previousSnapshot?.sources["Codex"], + (previousCodex.records ?? 0) > 0 + else { + return + } + + let previousRevision = previousCodex.accountingRevision ?? 5 + let requiredRevision = UsageCollector.codexAccountingRevision + guard previousRevision < requiredRevision else { return } + let currentCodex = collectedSnapshot.sources["Codex"] + guard currentCodex?.accountingRevision == requiredRevision, + (currentCodex?.records ?? 0) > 0, + currentCodex?.status == "ok" + else { + throw NSError( + domain: "TokenStepCollector", + code: 2, + userInfo: [ + NSLocalizedDescriptionKey: L("Token 重新校准未完成,已保留原统计。") + ] + ) + } + } + + private static func persist(snapshot: UsageSnapshot) throws { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] let data = try encoder.encode(snapshot) @@ -42,6 +104,40 @@ enum DataService { withIntermediateDirectories: true ) try data.write(to: AppPaths.usageJSON, options: .atomic) + if let previousRevision = snapshot.sources["Codex"]?.recalibratedFromRevision, + let currentRevision = snapshot.sources["Codex"]?.accountingRevision, + previousRevision < currentRevision, + (snapshot.sources["Codex"]?.records ?? 0) > 0 { + try FileManager.default.createDirectory( + at: AppPaths.usageRecalibrationNoticeMarker.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try String(currentRevision).write( + to: AppPaths.usageRecalibrationNoticeMarker, + atomically: true, + encoding: .utf8 + ) + } + } + +#if TOKENSTEP_TESTING + static func persistSnapshotForMigrationTests( + _ snapshot: UsageSnapshot, + previousSnapshot: UsageSnapshot? + ) throws -> UsageSnapshot { + try validateRecalibrationCandidate(snapshot, previousSnapshot: previousSnapshot) + let prepared = snapshotWithMigrationMetadata(snapshot, previousSnapshot: previousSnapshot) + try persist(snapshot: prepared) + return prepared + } +#endif + + static var hasPendingUsageRecalibrationNotice: Bool { + FileManager.default.fileExists(atPath: AppPaths.usageRecalibrationNoticeMarker.path) + } + + static func acknowledgeUsageRecalibrationNotice() { + try? FileManager.default.removeItem(at: AppPaths.usageRecalibrationNoticeMarker) } static func runCollectorInHelper(historyDays: Int = TokenStepSettings.defaults.historyDays) throws { @@ -124,6 +220,7 @@ enum DataService { tokenIslandPlacement: placement, showCodexQuota: settings.showCodexQuota, showTokenRank: settings.showTokenRank, + showExperimentalAgentSources: settings.showExperimentalAgentSources, tokenRankUserID: TokenStepSettings.cleanedTokenRankUserID(settings.tokenRankUserID), language: settings.language, skippedUpdateVersion: settings.skippedUpdateVersion diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift index c2503cd..291979d 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/UsageCollector.swift @@ -1,6 +1,8 @@ import Foundation enum UsageCollector { + static let codexAccountingRevision = 8 + private static let timezone = TimeZone(identifier: "Asia/Shanghai") ?? .current private static let maxRelevantLineBytes = 1_048_576 private static let ccSwitchSourceName = "CC Switch Proxy" @@ -8,16 +10,31 @@ enum UsageCollector { static func collect( historyDays: Int = TokenStepSettings.defaults.historyDays, includeCCSwitchProxyUsage: Bool = true, - ccSwitchDatabaseURL: URL? = nil + ccSwitchDatabaseURL: URL? = nil, + includeExperimentalAgentSources: Bool = false, + zCodeDatabaseURL: URL? = nil, + hermesDatabaseURL: URL? = nil, + workBuddyRootURLs: [URL]? = nil ) -> UsageSnapshot { - var cache = loadCache() + let cacheLoad = loadCache() + var cache = cacheLoad.cache var livePaths = Set() let sourceCutoff = sourceFileCutoffDate(historyDays: historyDays) - let codex = collectCodex(cache: &cache, livePaths: &livePaths, modifiedSince: sourceCutoff) + var codex = collectCodex(cache: &cache, livePaths: &livePaths, modifiedSince: sourceCutoff) + codex.source.recalibratedFromRevision = cacheLoad.recalibratedFromRevision let claude = collectClaudeCode(cache: &cache, livePaths: &livePaths, modifiedSince: sourceCutoff) var ccSwitch = includeCCSwitchProxyUsage ? collectCCSwitchProxyUsage(databaseURL: ccSwitchDatabaseURL) : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0)) + let zCode = includeExperimentalAgentSources + ? collectZCodeUsage(databaseURL: zCodeDatabaseURL) + : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0)) + let hermes = includeExperimentalAgentSources + ? collectHermesUsage(databaseURL: hermesDatabaseURL) + : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0)) + let workBuddy = includeExperimentalAgentSources + ? discoverWorkBuddyUsage(rootURLs: workBuddyRootURLs) + : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0)) cache.files = cache.files.filter { livePaths.contains($0.key) } saveCache(cache) @@ -29,12 +46,20 @@ enum UsageCollector { if includeCCSwitchProxyUsage { ccSwitch.source = sourceInfo(ccSwitch.source, annotatedWith: deduped) } + let records = recordsInHistoryWindow( + deduped.records + zCode.records + hermes.records, + historyDays: historyDays, + now: Date() + ) return aggregate( - records: deduped.records, + records: records, sources: [ "Codex": codex.source, "Claude Code": claude.source, - ccSwitchSourceName: ccSwitch.source + ccSwitchSourceName: ccSwitch.source, + "ZCode": zCode.source, + "Hermes Agent": hermes.source, + "WorkBuddy": workBuddy.source ] ) } @@ -54,8 +79,8 @@ enum UsageCollector { return aggregate(records: result.records, sources: ["Claude Code": result.source]) } - static func collectCodexUsageSnapshotForTests(homeURL: URL) -> UsageSnapshot { - var cache = CollectorCache() + static func collectCodexUsageSnapshotForTests(homeURL: URL, cacheURL: URL? = nil) -> UsageSnapshot { + var cache = cacheURL.map(loadCurrentCache(at:)) ?? CollectorCache() var livePaths = Set() let result = collectCodexFromJSONL( cache: &cache, @@ -63,13 +88,27 @@ enum UsageCollector { modifiedSince: nil, homeURL: homeURL ) + if let cacheURL { + cache.files = cache.files.filter { livePaths.contains($0.key) } + saveCache(cache, to: cacheURL) + } return aggregate(records: result.records, sources: ["Codex": result.source]) } + static func collectorCacheRecalibrationRevisionForTests(cacheURL: URL) -> Int? { + loadCache(at: cacheURL).recalibratedFromRevision + } + static func collectUsageSnapshotForTests( codexRoots: [URL] = [], claudeRootURL: URL? = nil, - ccSwitchDatabaseURL: URL? = nil + ccSwitchDatabaseURL: URL? = nil, + zCodeDatabaseURL: URL? = nil, + hermesDatabaseURL: URL? = nil, + workBuddyRootURLs: [URL]? = nil, + includeExperimentalAgentSources: Bool = false, + historyDays: Int? = nil, + now: Date = Date() ) -> UsageSnapshot { var cache = CollectorCache() var livePaths = Set() @@ -87,17 +126,33 @@ enum UsageCollector { var ccSwitch = ccSwitchDatabaseURL.map { collectCCSwitchProxyUsage(databaseURL: $0) } ?? CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0)) + let zCode = includeExperimentalAgentSources + ? zCodeDatabaseURL.map { collectZCodeUsage(databaseURL: $0) } ?? CollectorResult(records: [], source: SourceInfo(status: "missing_db", files: 0, records: 0)) + : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0)) + let hermes = includeExperimentalAgentSources + ? hermesDatabaseURL.map { collectHermesUsage(databaseURL: $0) } ?? CollectorResult(records: [], source: SourceInfo(status: "missing_db", files: 0, records: 0)) + : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0)) + let workBuddy = includeExperimentalAgentSources + ? discoverWorkBuddyUsage(rootURLs: workBuddyRootURLs ?? []) + : CollectorResult(records: [], source: SourceInfo(status: "disabled", files: nil, records: 0)) let deduped = deduplicateCrossSource( nativeRecords: codex.records + claude.records, proxyRecords: ccSwitch.records ) ccSwitch.source = sourceInfo(ccSwitch.source, annotatedWith: deduped) + let allRecords = deduped.records + zCode.records + hermes.records + let records = historyDays.map { + recordsInHistoryWindow(allRecords, historyDays: $0, now: now) + } ?? allRecords return aggregate( - records: deduped.records, + records: records, sources: [ "Codex": codex.source, "Claude Code": claude.source, - ccSwitchSourceName: ccSwitch.source + ccSwitchSourceName: ccSwitch.source, + "ZCode": zCode.source, + "Hermes Agent": hermes.source, + "WorkBuddy": workBuddy.source ] ) } @@ -180,33 +235,120 @@ enum UsageCollector { roots: [URL]? = nil ) -> CollectorResult { let roots = roots ?? defaultCodexSessionRoots(homeURL: homeURL) - let paths = roots.flatMap { jsonlFiles(under: $0, modifiedSince: cutoffDate) } - var records: [UsageRecord] = [] - var seen = Set() + let paths = roots + .flatMap { jsonlFiles(under: $0, modifiedSince: cutoffDate) } + .sorted { $0.path < $1.path } + var scans: [CodexSessionScan] = [] - for path in paths.sorted(by: { $0.path < $1.path }) { + for path in paths { livePaths.insert(path.path) - if let cached = cachedRecords(for: path, tool: "Codex", cache: cache) { - records.append(contentsOf: cached) + if let cached = cachedCodexScan(for: path, cache: cache) { + scans.append(cached) continue } - var fileRecords: [UsageRecord] = [] - var sessionID = path.deletingPathExtension().lastPathComponent - var currentModel = "unknown" - var eventIndex = 0 - var lineNumber = 0 - guard FileManager.default.isReadableFile(atPath: path.path) else { continue } + guard var result = stableCodexScan(at: path) else { continue } + if !result.isStable, let retry = stableCodexScan(at: path) { + result = retry + } + scans.append(result.scan) + if result.isStable { + updateCodexCache(path: path, scan: result.scan, metadata: result.metadata, cache: &cache) + } + } - try? forEachLine(in: path, matchingAny: ["session_meta", "turn_context", "token_count"]) { line in + let scansBySessionID = Dictionary( + scans.map { ($0.canonicalSessionID, $0) }, + uniquingKeysWith: { first, _ in first } + ) + var records: [UsageRecord] = [] + var diagnostics = CodexCollectionDiagnostics() + var seenRequestIDs = Set() + for scan in scans.sorted(by: { $0.sourcePath < $1.sourcePath }) { + let parentAnchor = codexForkAnchor(for: scan, scansBySessionID: scansBySessionID) + let result = codexDeltaRecords( + from: scan, + parentAnchor: parentAnchor, + seenRequestIDs: &seenRequestIDs + ) + records.append(contentsOf: result.records) + diagnostics.add(result.diagnostics) + } + + let breakdown = records.reduce(into: TokenUsageCounts()) { partial, record in + partial.add(record.usage) + } + + return CollectorResult( + records: records, + source: SourceInfo( + status: records.isEmpty ? "missing" : "ok", + files: paths.count, + records: records.count, + rawRecords: diagnostics.rawRecords, + dedupedRecords: diagnostics.duplicateRecords + diagnostics.inheritedRecords, + skippedRecords: diagnostics.skippedRecords, + strategy: "total_token_usage_delta_v6_with_legacy_fallback", + exactRecords: diagnostics.exactRecords, + legacyRecords: diagnostics.legacyRecords, + duplicateRecords: diagnostics.duplicateRecords, + counterResets: diagnostics.counterResets, + inheritedRecords: diagnostics.inheritedRecords, + inheritedTokens: diagnostics.inheritedTokens, + unknownBreakdownRecords: diagnostics.unknownBreakdownRecords, + accountingRevision: CollectorCache.currentVersion, + tokenBreakdown: SourceTokenBreakdown( + processedTokens: breakdown.totalTokens, + inputTokens: breakdown.inputTokens, + cachedInputTokens: breakdown.cacheReadInputTokens, + uncachedInputTokens: max( + 0, + breakdown.inputTokens + - breakdown.cacheReadInputTokens + - breakdown.cacheCreationInputTokens + ), + outputTokens: breakdown.outputTokens, + reasoningTokens: breakdown.reasoningOutputTokens + ) + ) + ) + } + + private static func stableCodexScan( + at path: URL + ) -> (scan: CodexSessionScan, isStable: Bool, metadata: (size: UInt64, modificationTime: TimeInterval))? { + guard let before = fileMetadata(for: path), + let scan = scanCodexSessionFile(at: path), + let after = fileMetadata(for: path) + else { + return nil + } + return (scan, metadata(before, matches: after), after) + } + + private static func scanCodexSessionFile(at path: URL) -> CodexSessionScan? { + guard FileManager.default.isReadableFile(atPath: path.path) else { return nil } + var canonicalSessionID: String? + var createdAt: String? + var parentSessionID: String? + var currentModel = "unknown" + var events: [CodexTokenEvent] = [] + var relevantLineNumber = 0 + + do { + try forEachLine(in: path, matchingAny: ["session_meta", "turn_context", "token_count"]) { line in autoreleasepool { - lineNumber += 1 + relevantLineNumber += 1 guard let obj = jsonObject(line) else { return } let type = obj["type"] as? String let payload = obj["payload"] as? [String: Any] - if type == "session_meta", let id = payload?["id"] as? String, !id.isEmpty { - sessionID = id + if type == "session_meta", canonicalSessionID == nil, + let id = nonEmptyString(payload?["id"] as? String) { + canonicalSessionID = id + createdAt = nonEmptyString(obj["timestamp"] as? String) + ?? nonEmptyString(payload?["timestamp"] as? String) + parentSessionID = codexParentSessionID(from: payload) } if type == "turn_context" { currentModel = modelKey(payload?["model"] as? String ?? currentModel) @@ -218,48 +360,310 @@ enum UsageCollector { return } - let usage = normalizeUsage(info["last_token_usage"] as? [String: Any]) - guard usage.totalTokens > 0, - let timestamp = obj["timestamp"] as? String, - let day = dayString(fromISO: timestamp) - else { - return - } - - eventIndex += 1 - let key = "\(sessionID)|\(timestamp)|\(eventIndex)|\(usage.totalTokens)" - guard !seen.contains(key) else { return } - seen.insert(key) - fileRecords.append( - UsageRecord( - date: day, - timestamp: timestamp, - tool: "Codex", + let cumulativePresent = info.keys.contains("total_token_usage") + let cumulative = (info["total_token_usage"] as? [String: Any]).map(normalizeCodexUsage) + let last = (info["last_token_usage"] as? [String: Any]).map(normalizeCodexUsage) + events.append( + CodexTokenEvent( + timestamp: nonEmptyString(obj["timestamp"] as? String), model: currentModel, - usage: usage, - source: .nativeCodex, - requestID: key, - sessionID: sessionID, - sourcePath: path.path, - lineNumber: lineNumber + cumulativePresent: cumulativePresent, + cumulative: cumulative, + last: last, + modelContextWindow: integerValue(info["model_context_window"] as Any), + lineNumber: relevantLineNumber ) ) } } - records.append(contentsOf: fileRecords) - updateCache(path: path, tool: "Codex", records: fileRecords, cache: &cache) + } catch { + return nil } - return CollectorResult( - records: records, - source: SourceInfo( - status: records.isEmpty ? "missing" : "ok", - files: paths.count, - records: records.count + return CodexSessionScan( + canonicalSessionID: canonicalSessionID ?? path.deletingPathExtension().lastPathComponent, + createdAt: createdAt, + parentSessionID: parentSessionID, + sourcePath: path.path, + events: events + ) + } + + private static func codexParentSessionID(from payload: [String: Any]?) -> String? { + if let source = payload?["source"] as? [String: Any], + let subagent = source["subagent"] as? [String: Any], + let threadSpawn = subagent["thread_spawn"] as? [String: Any], + let parent = nonEmptyString(threadSpawn["parent_thread_id"] as? String) { + return parent + } + return [ + payload?["parent_thread_id"] as? String, + payload?["forked_from_id"] as? String + ].compactMap(nonEmptyString).first + } + + private static func codexForkAnchor( + for scan: CodexSessionScan, + scansBySessionID: [String: CodexSessionScan] + ) -> TokenUsageCounts? { + guard let parentID = scan.parentSessionID, + let parent = scansBySessionID[parentID], + let childCreatedAt = scan.createdAt.flatMap(parseISO) + else { + return nil + } + return parent.events.last(where: { event in + guard event.cumulativePresent, + let usage = event.cumulative, + usage.totalTokens > 0, + let timestamp = event.timestamp.flatMap(parseISO) + else { + return false + } + return timestamp <= childCreatedAt + })?.cumulative + } + + private static func codexDeltaRecords( + from scan: CodexSessionScan, + parentAnchor: TokenUsageCounts?, + seenRequestIDs: inout Set + ) -> (records: [UsageRecord], diagnostics: CodexCollectionDiagnostics) { + var diagnostics = CodexCollectionDiagnostics(rawRecords: scan.events.count) + var records: [UsageRecord] = [] + let hasCumulativeSchema = scan.events.contains { $0.cumulativePresent } + + if !hasCumulativeSchema { + for event in scan.events { + guard let usage = event.last, + usage.totalTokens > 0, + let timestamp = event.timestamp, + let day = dayString(fromISO: timestamp) + else { + diagnostics.skippedRecords += 1 + continue + } + let requestID = "codex:legacy:\(scan.canonicalSessionID):\(timestamp):\(usage.fingerprint)" + guard seenRequestIDs.insert(requestID).inserted else { + diagnostics.duplicateRecords += 1 + continue + } + records.append( + codexUsageRecord( + scan: scan, + event: event, + day: day, + usage: usage, + requestID: requestID, + dataSource: "codex_last_usage_legacy_estimate" + ) + ) + diagnostics.legacyRecords += 1 + if !isCodexBreakdownConsistent(usage, total: usage.totalTokens) { + diagnostics.unknownBreakdownRecords += 1 + } + } + return (records, diagnostics) + } + + var startIndex = 0 + var previous: TokenUsageCounts? + if let parentAnchor, + parentAnchor.totalTokens > 0, + let anchorIndex = scan.events.firstIndex(where: { + $0.cumulativePresent && $0.cumulative == parentAnchor + }) { + previous = parentAnchor + startIndex = anchorIndex + 1 + diagnostics.inheritedRecords = scan.events[...anchorIndex].filter(\.cumulativePresent).count + diagnostics.inheritedTokens = parentAnchor.totalTokens + } + + var epoch = 0 + for index in startIndex.. 0, + let timestamp = event.timestamp, + let day = dayString(fromISO: timestamp) + else { + diagnostics.skippedRecords += 1 + continue + } + + let deltaTotal: Int + let isReset: Bool + if let previous { + if current.totalTokens == previous.totalTokens { + diagnostics.duplicateRecords += 1 + continue + } + if current.totalTokens > previous.totalTokens { + deltaTotal = current.totalTokens - previous.totalTokens + isReset = false + } else if isCodexContextWindowSentinel(event) { + diagnostics.skippedRecords += 1 + continue + } else if isCredibleCodexReset( + at: index, + events: scan.events, + current: current, + previous: previous + ) { + epoch += 1 + diagnostics.counterResets += 1 + deltaTotal = current.totalTokens + isReset = true + } else { + diagnostics.skippedRecords += 1 + continue + } + } else { + deltaTotal = current.totalTokens + isReset = false + } + + guard deltaTotal > 0 else { continue } + let componentResult = codexIncrementUsage( + current: current, + previous: isReset ? nil : previous, + last: event.last, + total: deltaTotal ) + let requestID = "codex:cumulative:\(scan.canonicalSessionID):\(epoch):\(current.totalTokens)" + guard seenRequestIDs.insert(requestID).inserted else { + diagnostics.duplicateRecords += 1 + previous = current + continue + } + records.append( + codexUsageRecord( + scan: scan, + event: event, + day: day, + usage: componentResult.usage, + requestID: requestID, + dataSource: componentResult.hasKnownBreakdown + ? "codex_total_usage_delta" + : "codex_total_usage_delta_unknown_breakdown" + ) + ) + diagnostics.exactRecords += 1 + if !componentResult.hasKnownBreakdown { + diagnostics.unknownBreakdownRecords += 1 + } + previous = current + } + return (records, diagnostics) + } + + private static func codexUsageRecord( + scan: CodexSessionScan, + event: CodexTokenEvent, + day: String, + usage: TokenUsageCounts, + requestID: String, + dataSource: String + ) -> UsageRecord { + UsageRecord( + date: day, + timestamp: event.timestamp, + tool: "Codex", + model: event.model, + usage: usage, + source: .nativeCodex, + requestID: requestID, + sessionID: scan.canonicalSessionID, + sourcePath: scan.sourcePath, + lineNumber: event.lineNumber, + dataSource: dataSource ) } + private static func codexIncrementUsage( + current: TokenUsageCounts, + previous: TokenUsageCounts?, + last: TokenUsageCounts?, + total: Int + ) -> (usage: TokenUsageCounts, hasKnownBreakdown: Bool) { + if let last, + last.totalTokens == total, + isCodexBreakdownConsistent(last, total: total) { + var result = last + result.totalTokens = total + return (result, true) + } + + let previous = previous ?? TokenUsageCounts() + guard current.inputTokens >= previous.inputTokens, + current.outputTokens >= previous.outputTokens, + current.cacheCreationInputTokens >= previous.cacheCreationInputTokens, + current.cacheReadInputTokens >= previous.cacheReadInputTokens, + current.reasoningOutputTokens >= previous.reasoningOutputTokens + else { + return (TokenUsageCounts(totalTokens: total), false) + } + var result = TokenUsageCounts( + inputTokens: current.inputTokens - previous.inputTokens, + outputTokens: current.outputTokens - previous.outputTokens, + cacheCreationInputTokens: current.cacheCreationInputTokens - previous.cacheCreationInputTokens, + cacheReadInputTokens: current.cacheReadInputTokens - previous.cacheReadInputTokens, + reasoningOutputTokens: current.reasoningOutputTokens - previous.reasoningOutputTokens, + totalTokens: total + ) + guard isCodexBreakdownConsistent(result, total: total) else { + result = TokenUsageCounts(totalTokens: total) + return (result, false) + } + return (result, true) + } + + private static func isCodexBreakdownConsistent(_ usage: TokenUsageCounts, total: Int) -> Bool { + usage.inputTokens >= 0 + && usage.outputTokens >= 0 + && usage.cacheCreationInputTokens >= 0 + && usage.cacheReadInputTokens >= 0 + && usage.reasoningOutputTokens >= 0 + && usage.inputTokens + usage.outputTokens == total + && usage.cacheCreationInputTokens + usage.cacheReadInputTokens <= usage.inputTokens + && usage.reasoningOutputTokens <= usage.outputTokens + } + + private static func isCodexContextWindowSentinel(_ event: CodexTokenEvent) -> Bool { + guard let current = event.cumulative else { return false } + return current.inputTokens == 0 + && current.outputTokens == 0 + && current.cacheCreationInputTokens == 0 + && current.cacheReadInputTokens == 0 + && current.reasoningOutputTokens == 0 + && (event.last?.totalTokens ?? 0) == 0 + && event.modelContextWindow > 0 + && current.totalTokens == event.modelContextWindow + } + + private static func isCredibleCodexReset( + at index: Int, + events: [CodexTokenEvent], + current: TokenUsageCounts, + previous: TokenUsageCounts + ) -> Bool { + if let last = events[index].last, + last.totalTokens == current.totalTokens, + isCodexBreakdownConsistent(last, total: current.totalTokens) { + return true + } + for candidate in events.dropFirst(index + 1) where candidate.cumulativePresent { + guard let next = candidate.cumulative, next.totalTokens > 0 else { continue } + if next.totalTokens == current.totalTokens { continue } + return next.totalTokens > current.totalTokens && next.totalTokens < previous.totalTokens + } + return false + } + private static func defaultCodexSessionRoots(homeURL: URL) -> [URL] { // archived_sessions may contain restored historical logs with rewritten timestamps. // Only live Codex sessions should count as current usage. @@ -410,6 +814,9 @@ enum UsageCollector { } let sessionColumn = availableColumns.contains("session_id") ? "session_id" : "null" + let inputSemanticsColumn = availableColumns.contains("input_token_semantics") + ? "coalesce(input_token_semantics, 0)" + : "0" let query = """ select request_id, @@ -422,6 +829,7 @@ enum UsageCollector { coalesce(output_tokens, 0) as output_tokens, coalesce(cache_read_tokens, 0) as cache_read_tokens, coalesce(cache_creation_tokens, 0) as cache_creation_tokens, + \(inputSemanticsColumn) as input_token_semantics, cast(coalesce(nullif(total_cost_usd, ''), '0') as real) as total_cost_usd from proxy_request_logs where status_code >= 200 @@ -448,21 +856,30 @@ enum UsageCollector { return nil } - var usage = TokenUsageCounts() - usage.inputTokens = integerValue(row["input_tokens"] as Any) - usage.outputTokens = integerValue(row["output_tokens"] as Any) - usage.cacheReadInputTokens = integerValue(row["cache_read_tokens"] as Any) - usage.cacheCreationInputTokens = integerValue(row["cache_creation_tokens"] as Any) - usage.totalTokens = usage.inputTokens - + usage.outputTokens - + usage.cacheReadInputTokens - + usage.cacheCreationInputTokens + let appType = row["app_type"] as? String + let rawInputTokens = integerValue(row["input_tokens"] as Any) + let cacheReadTokens = integerValue(row["cache_read_tokens"] as Any) + let cacheCreationTokens = integerValue(row["cache_creation_tokens"] as Any) + let freshInputTokens = ccSwitchFreshInputTokens( + rawInputTokens: rawInputTokens, + cacheReadTokens: cacheReadTokens, + cacheCreationTokens: cacheCreationTokens, + appType: appType, + inputTokenSemantics: integerValue(row["input_token_semantics"] as Any) + ) + let usage = canonicalUsageCounts( + rawInputTokens: freshInputTokens, + outputTokens: integerValue(row["output_tokens"] as Any), + cacheCreationInputTokens: cacheCreationTokens, + cacheReadInputTokens: cacheReadTokens, + inputIncludesCachedTokens: false + ) guard usage.totalTokens > 0 else { return nil } return UsageRecord( date: day, timestamp: isoString(fromEpoch: row["created_at"] as Any), - tool: ccSwitchToolName(appType: row["app_type"] as? String), + tool: ccSwitchToolName(appType: appType), model: modelKey(row["display_model"] as? String), usage: usage, costUSD: doubleValue(row["total_cost_usd"] as Any), @@ -483,41 +900,358 @@ enum UsageCollector { ) } + private static func collectZCodeUsage(databaseURL: URL? = nil) -> CollectorResult { + let database = databaseURL ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".zcode/cli/db/db.sqlite") + + guard FileManager.default.fileExists(atPath: database.path) else { + return CollectorResult(records: [], source: SourceInfo(status: "missing_db", files: 0, records: 0)) + } + guard FileManager.default.isReadableFile(atPath: database.path) else { + return CollectorResult(records: [], source: SourceInfo(status: "unreadable_db", files: 1, records: 0)) + } + guard let columns = sqliteJSONRows(database: database, query: "pragma table_info(model_usage)") else { + return CollectorResult(records: [], source: SourceInfo(status: "schema_unreadable", files: 1, records: 0)) + } + guard !columns.isEmpty else { + return CollectorResult(records: [], source: SourceInfo(status: "missing_table", files: 1, records: 0)) + } + + let availableColumns = Set(columns.compactMap { $0["name"] as? String }) + let requiredColumns: Set = [ + "id", + "session_id", + "status", + "started_at", + "model_id", + "input_tokens", + "output_tokens", + "reasoning_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", + "computed_total_tokens", + "tool_call_count" + ] + guard requiredColumns.isSubset(of: availableColumns) else { + return CollectorResult(records: [], source: SourceInfo(status: "schema_mismatch", files: 1, records: 0)) + } + + let providerTotalExpression = availableColumns.contains("provider_total_tokens") + ? "coalesce(provider_total_tokens, 0)" + : "0" + let query = """ + select + id, + session_id, + started_at, + coalesce(nullif(model_id, ''), 'unknown') as display_model, + coalesce(input_tokens, 0) as input_tokens, + coalesce(output_tokens, 0) as output_tokens, + coalesce(reasoning_tokens, 0) as reasoning_tokens, + coalesce(cache_creation_input_tokens, 0) as cache_creation_input_tokens, + coalesce(cache_read_input_tokens, 0) as cache_read_input_tokens, + coalesce(computed_total_tokens, 0) as computed_total_tokens, + \(providerTotalExpression) as provider_total_tokens, + coalesce(tool_call_count, 0) as tool_call_count + from model_usage + where status = 'completed' + and ( + coalesce(computed_total_tokens, 0) > 0 + or \(providerTotalExpression) > 0 + or ( + coalesce(input_tokens, 0) + + coalesce(output_tokens, 0) + + coalesce(reasoning_tokens, 0) + + coalesce(cache_creation_input_tokens, 0) + + coalesce(cache_read_input_tokens, 0) + ) > 0 + ) + order by started_at, id + """ + + guard let rows = sqliteJSONRows(database: database, query: query) else { + return CollectorResult(records: [], source: SourceInfo(status: "query_failed", files: 1, records: 0)) + } + + let records = rows.compactMap { row -> UsageRecord? in + guard let day = dayString(fromEpoch: row["started_at"] as Any) else { return nil } + let computedTotal = integerValue(row["computed_total_tokens"] as Any) + let providerTotal = integerValue(row["provider_total_tokens"] as Any) + let usage = canonicalUsageCounts( + rawInputTokens: integerValue(row["input_tokens"] as Any), + outputTokens: integerValue(row["output_tokens"] as Any), + cacheCreationInputTokens: integerValue(row["cache_creation_input_tokens"] as Any), + cacheReadInputTokens: integerValue(row["cache_read_input_tokens"] as Any), + reasoningOutputTokens: integerValue(row["reasoning_tokens"] as Any), + inputIncludesCachedTokens: true, + explicitTotalTokens: computedTotal > 0 ? computedTotal : providerTotal + ) + guard usage.totalTokens > 0 else { return nil } + + return UsageRecord( + date: day, + timestamp: isoString(fromEpoch: row["started_at"] as Any), + tool: "ZCode", + model: modelKey(row["display_model"] as? String), + usage: usage, + source: .zcode, + requestID: nonEmptyString(row["id"] as? String), + sessionID: nonEmptyString(row["session_id"] as? String), + modelRequestCount: 1, + toolCallCount: integerValue(row["tool_call_count"] as Any) + ) + } + + return CollectorResult( + records: records, + source: SourceInfo(status: records.isEmpty ? "missing_valid_rows" : "ok", files: 1, records: records.count) + ) + } + + private static func collectHermesUsage(databaseURL: URL? = nil) -> CollectorResult { + let database = databaseURL ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".hermes/state.db") + + guard FileManager.default.fileExists(atPath: database.path) else { + return CollectorResult(records: [], source: SourceInfo(status: "missing_db", files: 0, records: 0)) + } + guard FileManager.default.isReadableFile(atPath: database.path) else { + return CollectorResult(records: [], source: SourceInfo(status: "unreadable_db", files: 1, records: 0)) + } + guard let columns = sqliteJSONRows(database: database, query: "pragma table_info(sessions)") else { + return CollectorResult(records: [], source: SourceInfo(status: "schema_unreadable", files: 1, records: 0)) + } + guard !columns.isEmpty else { + return CollectorResult(records: [], source: SourceInfo(status: "missing_table", files: 1, records: 0)) + } + + let availableColumns = Set(columns.compactMap { $0["name"] as? String }) + let requiredColumns: Set = [ + "id", + "source", + "model", + "started_at", + "input_tokens", + "output_tokens", + "cache_read_tokens", + "cache_write_tokens", + "reasoning_tokens", + "tool_call_count", + "api_call_count", + "actual_cost_usd", + "estimated_cost_usd", + "cost_status" + ] + guard requiredColumns.isSubset(of: availableColumns) else { + return CollectorResult(records: [], source: SourceInfo(status: "schema_mismatch", files: 1, records: 0)) + } + + let query = """ + select + id, + source, + model, + started_at, + coalesce(input_tokens, 0) as input_tokens, + coalesce(output_tokens, 0) as output_tokens, + coalesce(cache_read_tokens, 0) as cache_read_tokens, + coalesce(cache_write_tokens, 0) as cache_write_tokens, + coalesce(reasoning_tokens, 0) as reasoning_tokens, + coalesce(tool_call_count, 0) as tool_call_count, + coalesce(api_call_count, 0) as api_call_count, + coalesce(actual_cost_usd, 0) as actual_cost_usd, + coalesce(estimated_cost_usd, 0) as estimated_cost_usd, + coalesce(cost_status, '') as cost_status + from sessions + where ( + coalesce(input_tokens, 0) + + coalesce(output_tokens, 0) + + coalesce(cache_read_tokens, 0) + + coalesce(cache_write_tokens, 0) + + coalesce(reasoning_tokens, 0) + ) > 0 + order by started_at, id + """ + + guard let rows = sqliteJSONRows(database: database, query: query) else { + return CollectorResult(records: [], source: SourceInfo(status: "query_failed", files: 1, records: 0)) + } + + let records = rows.compactMap { row -> UsageRecord? in + guard let day = dayString(fromEpoch: row["started_at"] as Any) else { return nil } + let usage = canonicalUsageCounts( + rawInputTokens: integerValue(row["input_tokens"] as Any), + outputTokens: integerValue(row["output_tokens"] as Any), + cacheCreationInputTokens: integerValue(row["cache_write_tokens"] as Any), + cacheReadInputTokens: integerValue(row["cache_read_tokens"] as Any), + reasoningOutputTokens: integerValue(row["reasoning_tokens"] as Any), + inputIncludesCachedTokens: false + ) + guard usage.totalTokens > 0 else { return nil } + + let actualCost = doubleValue(row["actual_cost_usd"] as Any) + let estimatedCost = doubleValue(row["estimated_cost_usd"] as Any) + let cost: Double? + if actualCost > 0 { + cost = actualCost + } else if estimatedCost > 0 { + cost = estimatedCost + } else { + cost = nil + } + let requestCount = integerValue(row["api_call_count"] as Any) + + return UsageRecord( + date: day, + timestamp: isoString(fromEpoch: row["started_at"] as Any), + tool: "Hermes Agent", + model: modelKey(row["model"] as? String), + usage: usage, + costUSD: cost, + source: .hermes, + requestID: nonEmptyString(row["id"] as? String), + sessionID: nonEmptyString(row["id"] as? String), + dataSource: nonEmptyString(row["source"] as? String), + modelRequestCount: requestCount, + toolCallCount: integerValue(row["tool_call_count"] as Any) + ) + } + + return CollectorResult( + records: records, + source: SourceInfo(status: records.isEmpty ? "missing_valid_rows" : "ok", files: 1, records: records.count) + ) + } + + private static func discoverWorkBuddyUsage(rootURLs: [URL]? = nil) -> CollectorResult { + let home = FileManager.default.homeDirectoryForCurrentUser + let roots = rootURLs ?? [ + home.appendingPathComponent(".workbuddy", isDirectory: true), + home.appendingPathComponent("Library/Application Support/WorkBuddyExtension", isDirectory: true) + ] + let discovered = roots.filter { FileManager.default.fileExists(atPath: $0.path) } + return CollectorResult( + records: [], + source: SourceInfo( + status: discovered.isEmpty ? "missing" : "discovered_no_usage", + files: discovered.count, + records: 0 + ) + ) + } + private static func deduplicateCrossSource( nativeRecords: [UsageRecord], proxyRecords: [UsageRecord] ) -> CrossSourceDedupeResult { var enrichedNativeRecords = nativeRecords - var keptProxyRecords: [UsageRecord] = [] - var dedupedProxyRecords = 0 + let deduplicableProxyIndices = proxyRecords.indices.filter { + isDeduplicableProxyRecord(proxyRecords[$0]) + } + var matchedProxyIndices = Set() + var matchedNativeIndices = Set() let skippedProxyRecords = 0 - for proxyRecord in proxyRecords { - guard isDeduplicableProxyRecord(proxyRecord) else { - keptProxyRecords.append(proxyRecord) - continue - } + let exactPairs = uniqueDedupePairs( + proxyIndices: deduplicableProxyIndices, + nativeIndices: Array(nativeRecords.indices) + ) { proxyIndex, nativeIndex in + isSameDedupeDomain( + proxyRecord: proxyRecords[proxyIndex], + nativeRecord: nativeRecords[nativeIndex] + ) && hasExactIdentifierMatch( + proxyRecord: proxyRecords[proxyIndex], + nativeRecord: nativeRecords[nativeIndex] + ) + } + applyDedupePairs( + exactPairs, + proxyRecords: proxyRecords, + enrichedNativeRecords: &enrichedNativeRecords, + matchedProxyIndices: &matchedProxyIndices, + matchedNativeIndices: &matchedNativeIndices + ) - if let nativeIndex = nativeRecords.firstIndex(where: { isDuplicate(proxyRecord: proxyRecord, nativeRecord: $0) }) { - enrichedNativeRecords[nativeIndex] = enrichedRecord( - enrichedNativeRecords[nativeIndex], - withProxyCostFrom: proxyRecord - ) - dedupedProxyRecords += 1 - } else { - keptProxyRecords.append(proxyRecord) - } + // Similar timing/model/token vectors alone are not proof of identity: concurrent + // requests can legitimately look the same. A shared session is the minimum + // fallback correlation when request/response IDs are unavailable. + let remainingProxyIndices = deduplicableProxyIndices.filter { !matchedProxyIndices.contains($0) } + let remainingNativeIndices = nativeRecords.indices.filter { !matchedNativeIndices.contains($0) } + let sessionPairs = uniqueDedupePairs( + proxyIndices: remainingProxyIndices, + nativeIndices: Array(remainingNativeIndices) + ) { proxyIndex, nativeIndex in + isSameDedupeDomain( + proxyRecord: proxyRecords[proxyIndex], + nativeRecord: nativeRecords[nativeIndex] + ) && hasSessionIdentityMatch( + proxyRecord: proxyRecords[proxyIndex], + nativeRecord: nativeRecords[nativeIndex] + ) } + applyDedupePairs( + sessionPairs, + proxyRecords: proxyRecords, + enrichedNativeRecords: &enrichedNativeRecords, + matchedProxyIndices: &matchedProxyIndices, + matchedNativeIndices: &matchedNativeIndices + ) + let keptProxyRecords = proxyRecords.indices + .filter { !matchedProxyIndices.contains($0) } + .map { proxyRecords[$0] } return CrossSourceDedupeResult( records: enrichedNativeRecords + keptProxyRecords, rawProxyRecords: proxyRecords.count, keptProxyRecords: keptProxyRecords.count, - dedupedProxyRecords: dedupedProxyRecords, + dedupedProxyRecords: matchedProxyIndices.count, skippedProxyRecords: skippedProxyRecords ) } + private static func uniqueDedupePairs( + proxyIndices: [Int], + nativeIndices: [Int], + matches: (Int, Int) -> Bool + ) -> [(proxy: Int, native: Int)] { + var nativeCandidatesByProxy: [Int: [Int]] = [:] + var proxyCandidateCountByNative: [Int: Int] = [:] + for proxyIndex in proxyIndices { + let candidates = nativeIndices.filter { matches(proxyIndex, $0) } + nativeCandidatesByProxy[proxyIndex] = candidates + for nativeIndex in candidates { + proxyCandidateCountByNative[nativeIndex, default: 0] += 1 + } + } + return proxyIndices.compactMap { proxyIndex in + guard let candidates = nativeCandidatesByProxy[proxyIndex], + candidates.count == 1, + let nativeIndex = candidates.first, + proxyCandidateCountByNative[nativeIndex] == 1 + else { + return nil + } + return (proxy: proxyIndex, native: nativeIndex) + } + } + + private static func applyDedupePairs( + _ pairs: [(proxy: Int, native: Int)], + proxyRecords: [UsageRecord], + enrichedNativeRecords: inout [UsageRecord], + matchedProxyIndices: inout Set, + matchedNativeIndices: inout Set + ) { + for pair in pairs { + enrichedNativeRecords[pair.native] = enrichedRecord( + enrichedNativeRecords[pair.native], + withProxyCostFrom: proxyRecords[pair.proxy] + ) + matchedProxyIndices.insert(pair.proxy) + matchedNativeIndices.insert(pair.native) + } + } + private static func sourceInfo( _ source: SourceInfo, annotatedWith result: CrossSourceDedupeResult @@ -543,7 +1277,7 @@ enum UsageCollector { return family == "claude" || family == "codex" } - private static func isDuplicate(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool { + private static func isSameDedupeDomain(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool { guard proxyRecord.date == nativeRecord.date, let proxyFamily = toolFamily(for: proxyRecord.tool), let nativeFamily = toolFamily(for: nativeRecord.tool), @@ -552,39 +1286,28 @@ enum UsageCollector { else { return false } - - if hasExactIdentityMatch(proxyRecord: proxyRecord, nativeRecord: nativeRecord) { - return true - } - - return hasStrongUsageMatch(proxyRecord: proxyRecord, nativeRecord: nativeRecord) + return true } - private static func hasExactIdentityMatch(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool { + private static func hasExactIdentifierMatch(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool { let proxyIDs = Set([proxyRecord.requestID, proxyRecord.responseID].compactMap(nonEmptyString)) let nativeIDs = Set([nativeRecord.requestID, nativeRecord.responseID].compactMap(nonEmptyString)) - if !proxyIDs.isDisjoint(with: nativeIDs) { - return true - } + return !proxyIDs.isDisjoint(with: nativeIDs) + } + private static func hasSessionIdentityMatch(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool { guard let proxySessionID = nonEmptyString(proxyRecord.sessionID), let nativeSessionID = nonEmptyString(nativeRecord.sessionID), proxySessionID == nativeSessionID, areTimestampsClose(proxyRecord.timestamp, nativeRecord.timestamp, seconds: 10), modelsCompatible(proxyRecord.model, nativeRecord.model), - usageVectorsClose(proxyRecord.usage, nativeRecord.usage) + usageVectorsClose(proxyRecord: proxyRecord, nativeRecord: nativeRecord) else { return false } return true } - private static func hasStrongUsageMatch(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool { - areTimestampsClose(proxyRecord.timestamp, nativeRecord.timestamp, seconds: 30) - && modelsCompatible(proxyRecord.model, nativeRecord.model) - && usageVectorsClose(proxyRecord.usage, nativeRecord.usage) - } - private static func enrichedRecord( _ nativeRecord: UsageRecord, withProxyCostFrom proxyRecord: UsageRecord @@ -653,6 +1376,37 @@ enum UsageCollector { } } + private static func usageVectorsClose(proxyRecord: UsageRecord, nativeRecord: UsageRecord) -> Bool { + guard toolFamily(for: proxyRecord.tool) == "codex", + toolFamily(for: nativeRecord.tool) == "codex" + else { + return usageVectorsClose(proxyRecord.usage, nativeRecord.usage) + } + + let proxy = proxyRecord.usage + let native = nativeRecord.usage + guard tokenValuesClose(proxy.outputTokens, native.outputTokens), + tokenValuesClose(proxy.cacheReadInputTokens, native.cacheReadInputTokens), + tokenValuesClose(proxy.cacheCreationInputTokens, native.cacheCreationInputTokens) + else { + return false + } + + // Native Codex reports cached input as a subset of input. CC Switch versions + // have emitted input both inclusive and exclusive of cached input, so compare + // both canonical interpretations without changing either source's stored data. + let nativeUncachedInput = max(0, native.inputTokens - native.cacheReadInputTokens) + let inputMatches = tokenValuesClose(proxy.inputTokens, native.inputTokens) + || tokenValuesClose(proxy.inputTokens, nativeUncachedInput) + guard inputMatches else { return false } + + let proxyProcessedCandidates = [ + proxy.inputTokens + proxy.outputTokens, + proxy.inputTokens + proxy.cacheReadInputTokens + proxy.cacheCreationInputTokens + proxy.outputTokens + ] + return proxyProcessedCandidates.contains { tokenValuesClose($0, native.totalTokens) } + } + private static func tokenValuesClose(_ lhs: Int, _ rhs: Int) -> Bool { if lhs == rhs { return true } let baseline = max(lhs, rhs) @@ -664,16 +1418,22 @@ enum UsageCollector { private static func aggregate(records: [UsageRecord], sources: [String: SourceInfo]) -> UsageSnapshot { var daily = [String: DailyAccumulator]() var rhythms = [String: RhythmAccumulator]() + var agentWork = [String: AgentWorkAccumulator]() var tools = [String: UsageAccumulator]() var models = [ModelKey: UsageAccumulator]() for record in records { let cost = record.costUSD ?? estimateCost(usage: record.usage, tool: record.tool, model: record.model) daily[record.date, default: DailyAccumulator(date: record.date)].add(record: record, cost: cost) - if let hour = hour(fromISO: record.timestamp) { + let recordHour = hour(fromISO: record.timestamp) + if let hour = recordHour { rhythms[record.date, default: RhythmAccumulator(date: record.date)] .add(tokens: record.usage.totalTokens, hour: hour) } + if isAgentWorkRecord(record) { + agentWork[record.date, default: AgentWorkAccumulator(date: record.date)] + .add(record: record, hour: recordHour) + } tools[record.tool, default: UsageAccumulator()].add(record.usage, cost: cost) models[ModelKey(tool: record.tool, model: record.model), default: UsageAccumulator()].add(record.usage, cost: cost) } @@ -698,6 +1458,11 @@ enum UsageCollector { .filter { $0.totalTokens > 0 } .sorted { $0.date < $1.date } + let agentWorkRows = agentWork.values + .map(\.dailyAgentWork) + .filter { $0.totalTokens > 0 } + .sorted { $0.date < $1.date } + let toolRows = tools .sorted { $0.value.usage.totalTokens > $1.value.usage.totalTokens } .map { tool, item in @@ -729,12 +1494,22 @@ enum UsageCollector { ), daily: dailyRows, rhythms: rhythmRows, + agentWork: agentWorkRows, tools: toolRows, models: modelRows, sources: sources ) } + private static func isAgentWorkRecord(_ record: UsageRecord) -> Bool { + switch record.source { + case .nativeCodex, .nativeCodexSQLite, .nativeClaudeCode, .ccSwitchProxy, .zcode, .hermes: + return true + case .unknown: + return false + } + } + private static func jsonlFiles(under root: URL, modifiedSince cutoffDate: Date? = nil) -> [URL] { guard FileManager.default.fileExists(atPath: root.path), let enumerator = FileManager.default.enumerator( @@ -765,23 +1540,68 @@ enum UsageCollector { private static func cachedRecords(for url: URL, tool: String, cache: CollectorCache) -> [UsageRecord]? { guard let metadata = fileMetadata(for: url), + let fingerprint = contentFingerprint(for: url, size: metadata.size), let cached = cache.files[url.path], cached.tool == tool, cached.size == metadata.size, - abs(cached.modificationTime - metadata.modificationTime) < 0.001 + abs(cached.modificationTime - metadata.modificationTime) < 0.001, + cached.contentFingerprint == fingerprint else { return nil } return cached.records } + private static func cachedCodexScan(for url: URL, cache: CollectorCache) -> CodexSessionScan? { + guard let metadata = fileMetadata(for: url), + let fingerprint = contentFingerprint(for: url, size: metadata.size), + let cached = cache.files[url.path], + cached.tool == "Codex", + cached.size == metadata.size, + abs(cached.modificationTime - metadata.modificationTime) < 0.001, + cached.contentFingerprint == fingerprint + else { + return nil + } + return cached.codexScan + } + private static func updateCache(path: URL, tool: String, records: [UsageRecord], cache: inout CollectorCache) { - guard let metadata = fileMetadata(for: path) else { return } + guard let metadata = fileMetadata(for: path), + let fingerprint = contentFingerprint(for: path, size: metadata.size) + else { + return + } cache.files[path.path] = CachedUsageFile( tool: tool, size: metadata.size, modificationTime: metadata.modificationTime, - records: records + records: records, + contentFingerprint: fingerprint + ) + } + + private static func updateCodexCache( + path: URL, + scan: CodexSessionScan, + metadata: (size: UInt64, modificationTime: TimeInterval), + cache: inout CollectorCache + ) { + guard let currentMetadata = fileMetadata(for: path), + UsageCollector.metadata(metadata, matches: currentMetadata), + let fingerprint = contentFingerprint(for: path, size: currentMetadata.size), + let finalMetadata = fileMetadata(for: path), + UsageCollector.metadata(currentMetadata, matches: finalMetadata) + else { + return + } + cache.files[path.path] = CachedUsageFile( + tool: "Codex", + size: finalMetadata.size, + modificationTime: finalMetadata.modificationTime, + records: [], + codexScan: scan, + contentFingerprint: fingerprint ) } @@ -795,8 +1615,64 @@ enum UsageCollector { return (UInt64(max(0, size)), modificationDate.timeIntervalSince1970) } - private static func loadCache() -> CollectorCache { - guard let data = try? Data(contentsOf: AppPaths.collectorCacheJSON), + private static func metadata( + _ lhs: (size: UInt64, modificationTime: TimeInterval), + matches rhs: (size: UInt64, modificationTime: TimeInterval) + ) -> Bool { + lhs.size == rhs.size && abs(lhs.modificationTime - rhs.modificationTime) < 0.001 + } + + private static func contentFingerprint(for url: URL, size: UInt64) -> String? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + + let chunkSize = 4_096 + var hash: UInt64 = 14_695_981_039_346_656_037 + func include(_ data: Data) { + for byte in data { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + } + + do { + include(withUnsafeBytes(of: size.littleEndian) { Data($0) }) + include(try handle.read(upToCount: chunkSize) ?? Data()) + if size > UInt64(chunkSize) { + try handle.seek(toOffset: size - UInt64(chunkSize)) + include(try handle.read(upToCount: chunkSize) ?? Data()) + } + return String(format: "%016llx", hash) + } catch { + return nil + } + } + + private static func loadCache() -> CollectorCacheLoad { + loadCache(at: AppPaths.collectorCacheJSON) + } + + private static func loadCache(at url: URL) -> CollectorCacheLoad { + guard let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(CollectorCache.self, from: data) + else { + return CollectorCacheLoad(cache: CollectorCache(), recalibratedFromRevision: nil) + } + guard decoded.version == CollectorCache.currentVersion else { + return CollectorCacheLoad( + cache: CollectorCache(), + recalibratedFromRevision: decoded.version < CollectorCache.currentVersion ? decoded.version : nil + ) + } + return CollectorCacheLoad(cache: decoded, recalibratedFromRevision: nil) + } + + private static func saveCache(_ cache: CollectorCache) { + saveCache(cache, to: AppPaths.collectorCacheJSON) + } + + private static func loadCurrentCache(at url: URL) -> CollectorCache { + guard let data = try? Data(contentsOf: url), let cache = try? JSONDecoder().decode(CollectorCache.self, from: data), cache.version == CollectorCache.currentVersion else { @@ -805,23 +1681,44 @@ enum UsageCollector { return cache } - private static func saveCache(_ cache: CollectorCache) { + private static func saveCache(_ cache: CollectorCache, to url: URL) { do { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] let data = try encoder.encode(cache) try FileManager.default.createDirectory( - at: AppPaths.collectorCacheJSON.deletingLastPathComponent(), + at: url.deletingLastPathComponent(), withIntermediateDirectories: true ) - try data.write(to: AppPaths.collectorCacheJSON, options: .atomic) + try data.write(to: url, options: .atomic) } catch { // Cache misses should never prevent the app from showing fresh usage. } } private static func sourceFileCutoffDate(historyDays: Int) -> Date? { - Calendar.current.date(byAdding: .day, value: -max(7, historyDays + 1), to: Date()) + calendar.date(byAdding: .day, value: -max(7, historyDays + 1), to: Date()) + } + + private static func recordsInHistoryWindow( + _ records: [UsageRecord], + historyDays: Int, + now: Date + ) -> [UsageRecord] { + let inclusiveDays = max(1, historyDays) + let today = calendar.startOfDay(for: now) + guard let firstDay = calendar.date( + byAdding: .day, + value: -(inclusiveDays - 1), + to: today + ) else { + return records + } + let firstDayString = dayFormatter.string(from: firstDay) + let todayString = dayFormatter.string(from: today) + return records.filter { + $0.date >= firstDayString && $0.date <= todayString + } } private static func forEachLine(in url: URL, matchingAny markers: [String] = [], _ body: (String) -> Void) throws { @@ -899,44 +1796,81 @@ enum UsageCollector { private static func normalizeUsage(_ raw: [String: Any]?) -> TokenUsageCounts { guard let raw else { return TokenUsageCounts() } - var usage = TokenUsageCounts() - let aliases = [ - "input": "inputTokens", - "output": "outputTokens", - "cached": "cacheReadInputTokens", - "thoughts": "reasoningOutputTokens", - "total": "totalTokens", - "input_tokens": "inputTokens", - "output_tokens": "outputTokens", - "cache_creation_input_tokens": "cacheCreationInputTokens", - "cache_read_input_tokens": "cacheReadInputTokens", - "cached_input_tokens": "cacheReadInputTokens", - "reasoning_output_tokens": "reasoningOutputTokens", - "total_tokens": "totalTokens" - ] + func value(_ keys: [String]) -> Int { + for key in keys where raw.keys.contains(key) { + return max(0, integerValue(raw[key] as Any)) + } + return 0 + } + + let explicitTotal = ["total_tokens", "total"].first(where: { raw.keys.contains($0) }) + .map { max(0, integerValue(raw[$0] as Any)) } + return canonicalUsageCounts( + rawInputTokens: value(["input_tokens", "input"]), + outputTokens: value(["output_tokens", "output"]), + cacheCreationInputTokens: value(["cache_creation_input_tokens"]), + cacheReadInputTokens: value(["cache_read_input_tokens", "cached_input_tokens", "cached"]), + reasoningOutputTokens: value(["reasoning_output_tokens", "reasoning_tokens", "thoughts"]), + inputIncludesCachedTokens: false, + explicitTotalTokens: explicitTotal + ) + } - for (key, value) in raw { - guard let mapped = aliases[key] else { continue } - let intValue = integerValue(value) - switch mapped { - case "inputTokens": usage.inputTokens += intValue - case "outputTokens": usage.outputTokens += intValue - case "cacheCreationInputTokens": usage.cacheCreationInputTokens += intValue - case "cacheReadInputTokens": usage.cacheReadInputTokens += intValue - case "reasoningOutputTokens": usage.reasoningOutputTokens += intValue - case "totalTokens": usage.totalTokens += intValue - default: break + private static func normalizeCodexUsage(_ raw: [String: Any]) -> TokenUsageCounts { + func value(_ keys: [String]) -> Int { + for key in keys where raw.keys.contains(key) { + return max(0, integerValue(raw[key] as Any)) } - } + return 0 + } + + let input = value(["input_tokens", "input"]) + let output = value(["output_tokens", "output"]) + let cached = value(["cached_input_tokens", "cache_read_input_tokens", "cached"]) + let reasoning = value(["reasoning_output_tokens", "reasoning_tokens", "thoughts"]) + let explicitTotal = ["total_tokens", "total"].first(where: { raw.keys.contains($0) }) + .map { max(0, integerValue(raw[$0] as Any)) } + return canonicalUsageCounts( + rawInputTokens: input, + outputTokens: output, + cacheCreationInputTokens: value(["cache_creation_input_tokens", "cache_write_input_tokens"]), + cacheReadInputTokens: cached, + reasoningOutputTokens: reasoning, + inputIncludesCachedTokens: true, + explicitTotalTokens: explicitTotal, + explicitTotalIsAuthoritative: true + ) + } - if usage.totalTokens <= 0 { - usage.totalTokens = usage.inputTokens - + usage.outputTokens - + usage.cacheCreationInputTokens - + usage.cacheReadInputTokens - + usage.reasoningOutputTokens - } - return usage + private static func canonicalUsageCounts( + rawInputTokens: Int, + outputTokens: Int, + cacheCreationInputTokens: Int = 0, + cacheReadInputTokens: Int = 0, + reasoningOutputTokens: Int = 0, + inputIncludesCachedTokens: Bool, + explicitTotalTokens: Int? = nil, + explicitTotalIsAuthoritative: Bool = false + ) -> TokenUsageCounts { + let rawInput = max(0, rawInputTokens) + let output = max(0, outputTokens) + let cacheCreation = max(0, cacheCreationInputTokens) + let cacheRead = max(0, cacheReadInputTokens) + let reasoning = max(0, reasoningOutputTokens) + let input = rawInput + (inputIncludesCachedTokens ? 0 : cacheCreation + cacheRead) + let derivedTotal = input + output + let explicitTotal = max(0, explicitTotalTokens ?? 0) + let total = explicitTotalIsAuthoritative && explicitTotal > 0 + ? explicitTotal + : (derivedTotal > 0 ? derivedTotal : explicitTotal) + return TokenUsageCounts( + inputTokens: input, + outputTokens: output, + cacheCreationInputTokens: cacheCreation, + cacheReadInputTokens: cacheRead, + reasoningOutputTokens: reasoning, + totalTokens: total + ) } private static func integerValue(_ value: Any) -> Int { @@ -1066,6 +2000,40 @@ enum UsageCollector { } } + private static func ccSwitchFreshInputTokens( + rawInputTokens: Int, + cacheReadTokens: Int, + cacheCreationTokens: Int, + appType: String?, + inputTokenSemantics: Int + ) -> Int { + let rawInput = max(0, rawInputTokens) + let cacheRead = max(0, cacheReadTokens) + let cacheCreation = max(0, cacheCreationTokens) + let normalizedAppType = (appType ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let cacheInclusiveAppTypes: Set = ["codex", "gemini", "grokbuild"] + guard cacheInclusiveAppTypes.contains(normalizedAppType) else { + return rawInput + } + + switch inputTokenSemantics { + case 2: + // FRESH: input excludes both cache-read and cache-write buckets. + return rawInput + case 1 where rawInput >= cacheRead + cacheCreation: + // TOTAL: input already includes both cache buckets. + return rawInput - cacheRead - cacheCreation + case 0 where rawInput >= cacheRead: + // LEGACY: cache reads were included, cache writes were separate. + return rawInput - cacheRead + default: + // Malformed or future semantics stay conservative instead of going negative. + return rawInput + } + } + private static func sqliteJSONRows(database: URL, query: String) -> [[String: Any]]? { let outputURL = FileManager.default.temporaryDirectory .appendingPathComponent("tokenstep-sqlite-\(UUID().uuidString).json") @@ -1124,10 +2092,18 @@ enum UsageCollector { output: Double ) -> Double { let cached = max(0, usage.cacheReadInputTokens) - let uncachedInput = max(0, usage.inputTokens - cached) - return Double(uncachedInput + usage.cacheCreationInputTokens) / 1_000_000 * input + let cacheCreation = max(0, usage.cacheCreationInputTokens) + let uncachedInput = max(0, usage.inputTokens - cached - cacheCreation) + if uncachedInput == 0, + cached == 0, + cacheCreation == 0, + usage.outputTokens == 0, + usage.totalTokens > 0 { + return Double(usage.totalTokens) / 1_000_000 * input + } + return Double(uncachedInput + cacheCreation) / 1_000_000 * input + Double(cached) / 1_000_000 * cachedInput - + Double(usage.outputTokens + usage.reasoningOutputTokens) / 1_000_000 * output + + Double(usage.outputTokens) / 1_000_000 * output } private static func costByParts( @@ -1137,11 +2113,14 @@ enum UsageCollector { cacheCreation: Double, cacheRead: Double ) -> Double { - Double(usage.inputTokens) / 1_000_000 * input + let uncachedInput = max( + 0, + usage.inputTokens - usage.cacheCreationInputTokens - usage.cacheReadInputTokens + ) + return Double(uncachedInput) / 1_000_000 * input + Double(usage.outputTokens) / 1_000_000 * output + Double(usage.cacheCreationInputTokens) / 1_000_000 * cacheCreation + Double(usage.cacheReadInputTokens) / 1_000_000 * cacheRead - + Double(usage.reasoningOutputTokens) / 1_000_000 * output } private static func percent(_ value: Int, of total: Int) -> Double { @@ -1188,17 +2167,66 @@ private struct CollectorResult { } private struct CollectorCache: Codable { - static let currentVersion = 4 + static let currentVersion = UsageCollector.codexAccountingRevision var version = currentVersion var files: [String: CachedUsageFile] = [:] } +private struct CollectorCacheLoad { + var cache: CollectorCache + var recalibratedFromRevision: Int? +} + private struct CachedUsageFile: Codable { var tool: String var size: UInt64 var modificationTime: TimeInterval var records: [UsageRecord] + var codexScan: CodexSessionScan? = nil + var contentFingerprint: String? = nil +} + +private struct CodexSessionScan: Codable { + var canonicalSessionID: String + var createdAt: String? + var parentSessionID: String? + var sourcePath: String + var events: [CodexTokenEvent] +} + +private struct CodexTokenEvent: Codable { + var timestamp: String? + var model: String + var cumulativePresent: Bool + var cumulative: TokenUsageCounts? + var last: TokenUsageCounts? + var modelContextWindow: Int + var lineNumber: Int +} + +private struct CodexCollectionDiagnostics { + var rawRecords = 0 + var exactRecords = 0 + var legacyRecords = 0 + var duplicateRecords = 0 + var counterResets = 0 + var inheritedRecords = 0 + var inheritedTokens = 0 + var skippedRecords = 0 + var unknownBreakdownRecords = 0 + + mutating func add(_ other: CodexCollectionDiagnostics) { + rawRecords += other.rawRecords + exactRecords += other.exactRecords + legacyRecords += other.legacyRecords + duplicateRecords += other.duplicateRecords + counterResets += other.counterResets + inheritedRecords += other.inheritedRecords + inheritedTokens += other.inheritedTokens + skippedRecords += other.skippedRecords + unknownBreakdownRecords += other.unknownBreakdownRecords + } } private struct UsageRecord: Codable { @@ -1215,6 +2243,79 @@ private struct UsageRecord: Codable { var sourcePath: String? = nil var lineNumber: Int? = nil var dataSource: String? = nil + var modelRequestCount = 1 + var toolCallCount = 0 + + enum CodingKeys: String, CodingKey { + case date + case timestamp + case tool + case model + case usage + case costUSD + case source + case requestID + case sessionID + case responseID + case sourcePath + case lineNumber + case dataSource + case modelRequestCount + case toolCallCount + } + + init( + date: String, + timestamp: String?, + tool: String, + model: String, + usage: TokenUsageCounts, + costUSD: Double? = nil, + source: UsageRecordSource = .unknown, + requestID: String? = nil, + sessionID: String? = nil, + responseID: String? = nil, + sourcePath: String? = nil, + lineNumber: Int? = nil, + dataSource: String? = nil, + modelRequestCount: Int = 1, + toolCallCount: Int = 0 + ) { + self.date = date + self.timestamp = timestamp + self.tool = tool + self.model = model + self.usage = usage + self.costUSD = costUSD + self.source = source + self.requestID = requestID + self.sessionID = sessionID + self.responseID = responseID + self.sourcePath = sourcePath + self.lineNumber = lineNumber + self.dataSource = dataSource + self.modelRequestCount = modelRequestCount + self.toolCallCount = toolCallCount + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decode(String.self, forKey: .date) + timestamp = try container.decodeIfPresent(String.self, forKey: .timestamp) + tool = try container.decode(String.self, forKey: .tool) + model = try container.decode(String.self, forKey: .model) + usage = try container.decode(TokenUsageCounts.self, forKey: .usage) + costUSD = try container.decodeIfPresent(Double.self, forKey: .costUSD) + source = try container.decodeIfPresent(UsageRecordSource.self, forKey: .source) ?? .unknown + requestID = try container.decodeIfPresent(String.self, forKey: .requestID) + sessionID = try container.decodeIfPresent(String.self, forKey: .sessionID) + responseID = try container.decodeIfPresent(String.self, forKey: .responseID) + sourcePath = try container.decodeIfPresent(String.self, forKey: .sourcePath) + lineNumber = try container.decodeIfPresent(Int.self, forKey: .lineNumber) + dataSource = try container.decodeIfPresent(String.self, forKey: .dataSource) + modelRequestCount = try container.decodeIfPresent(Int.self, forKey: .modelRequestCount) ?? 1 + toolCallCount = try container.decodeIfPresent(Int.self, forKey: .toolCallCount) ?? 0 + } } private enum UsageRecordSource: String, Codable { @@ -1222,6 +2323,8 @@ private enum UsageRecordSource: String, Codable { case nativeCodexSQLite case nativeClaudeCode case ccSwitchProxy + case zcode + case hermes case unknown } @@ -1279,7 +2382,7 @@ private struct ClaudeUsageCandidate { } } -private struct TokenUsageCounts: Codable { +private struct TokenUsageCounts: Codable, Equatable { var inputTokens = 0 var outputTokens = 0 var cacheCreationInputTokens = 0 @@ -1295,6 +2398,28 @@ private struct TokenUsageCounts: Codable { reasoningOutputTokens += other.reasoningOutputTokens totalTokens += other.totalTokens } + + var fingerprint: String { + [ + totalTokens, + inputTokens, + cacheReadInputTokens, + outputTokens, + reasoningOutputTokens, + cacheCreationInputTokens + ].map(String.init).joined(separator: ":") + } + + var cacheCoverageComplete: Bool { + inputTokens >= 0 + && outputTokens >= 0 + && cacheCreationInputTokens >= 0 + && cacheReadInputTokens >= 0 + && reasoningOutputTokens >= 0 + && totalTokens == inputTokens + outputTokens + && cacheCreationInputTokens + cacheReadInputTokens <= inputTokens + && reasoningOutputTokens <= outputTokens + } } private struct UsageAccumulator { @@ -1327,6 +2452,125 @@ private struct DailyAccumulator { } } +private struct AgentWorkAccumulator { + var date: String + var totalTokens = 0 + var inputTokens = 0 + var cachedInputTokens = 0 + var outputTokens = 0 + var cacheCoverageComplete = true + var unbucketedTokens = 0 + var activeHours = Set() + var modelRequestCount = 0 + var toolCallCount = 0 + var sources: [String: AgentWorkSourceAccumulator] = [:] + var hourlySources: [Int: [String: AgentWorkHourlySourceAccumulator]] = [:] + + mutating func add(record: UsageRecord, hour: Int?) { + totalTokens += record.usage.totalTokens + inputTokens += record.usage.inputTokens + cachedInputTokens += record.usage.cacheReadInputTokens + outputTokens += record.usage.outputTokens + cacheCoverageComplete = cacheCoverageComplete && record.usage.cacheCoverageComplete + if let hour { + activeHours.insert(hour) + var sourceRows = hourlySources[hour] ?? [:] + sourceRows[record.tool, default: AgentWorkHourlySourceAccumulator(source: record.tool)] + .add(record: record) + hourlySources[hour] = sourceRows + } else { + unbucketedTokens += record.usage.totalTokens + } + modelRequestCount += max(0, record.modelRequestCount) + toolCallCount += max(0, record.toolCallCount) + sources[record.tool, default: AgentWorkSourceAccumulator(source: record.tool)] + .add(record: record) + } + + var dailyAgentWork: DailyAgentWork { + DailyAgentWork( + date: date, + totalTokens: totalTokens, + activeHours: activeHours.count, + modelRequestCount: modelRequestCount, + toolCallCount: toolCallCount, + sources: sources.values + .filter { $0.tokens > 0 } + .sorted { $0.tokens > $1.tokens } + .map(\.agentWorkSource), + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens, + cacheCoverageComplete: cacheCoverageComplete, + hourlyBuckets: (0..<24).map { hour in + AgentWorkHourBucket( + hour: hour, + sources: (hourlySources[hour] ?? [:]).values + .filter { $0.tokens > 0 } + .sorted { + if $0.tokens == $1.tokens { + return $0.source < $1.source + } + return $0.tokens > $1.tokens + } + .map(\.hourlySource) + ) + }, + unbucketedTokens: unbucketedTokens + ) + } +} + +private struct AgentWorkSourceAccumulator { + var source: String + var tokens = 0 + var modelRequestCount = 0 + var toolCallCount = 0 + + mutating func add(record: UsageRecord) { + tokens += record.usage.totalTokens + modelRequestCount += max(0, record.modelRequestCount) + toolCallCount += max(0, record.toolCallCount) + } + + var agentWorkSource: AgentWorkSource { + AgentWorkSource( + source: source, + tokens: tokens, + modelRequestCount: modelRequestCount, + toolCallCount: toolCallCount + ) + } +} + +private struct AgentWorkHourlySourceAccumulator { + var source: String + var tokens = 0 + var inputTokens = 0 + var cachedInputTokens = 0 + var outputTokens = 0 + var cacheCoverageComplete = true + + mutating func add(record: UsageRecord) { + tokens += record.usage.totalTokens + inputTokens += record.usage.inputTokens + cachedInputTokens += record.usage.cacheReadInputTokens + outputTokens += record.usage.outputTokens + cacheCoverageComplete = cacheCoverageComplete && record.usage.cacheCoverageComplete + } + + var hourlySource: AgentWorkHourlySource { + AgentWorkHourlySource( + source: source, + tokens: tokens, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens, + cacheCoverageComplete: cacheCoverageComplete + ) + } +} + private struct RhythmAccumulator { var date: String var hourlyTokens = Array(repeating: 0, count: 24) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift index e095736..2f5e4e5 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift @@ -21,9 +21,11 @@ final class AppState: ObservableObject { @Published private(set) var lastUpdateCheckAt: Date? @Published private(set) var updateDownloadedURL: URL? @Published private(set) var tokenIslandAvailable = TokenIslandDisplayDetector.isAvailable + @Published private(set) var showsUsageRecalibrationNotice = false @Published var lastError: String? private var timer: Timer? + private var pendingRefreshAfterCurrent = false init() { load() @@ -41,10 +43,18 @@ final class AppState: ObservableObject { var today: DailyUsage { let key = DateFormatter.tokenStepDay.string(from: Date()) return snapshot.daily.last(where: { $0.date == key }) - ?? snapshot.daily.last ?? DailyUsage(date: key, tools: [:], totalTokens: 0, cost: 0) } + var todayAgentWork: DailyAgentWork { + let key = DateFormatter.tokenStepDay.string(from: Date()) + return agentWork(for: key) + } + + var sevenDayAgentAverage: Int { + sevenDayAgentAverage(endingAt: DateFormatter.tokenStepDay.string(from: Date())) + } + var progress: Double { guard settings.dailyGoalTokens > 0 else { return 0 } return Double(today.totalTokens) / Double(settings.dailyGoalTokens) @@ -55,9 +65,17 @@ final class AppState: ObservableObject { } var monthAverage: Int { - let rows = Array(snapshot.daily.suffix(30)) - guard !rows.isEmpty else { return 0 } - return rows.map(\.totalTokens).reduce(0, +) / rows.count + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current + let endDate = calendar.startOfDay(for: Date()) + let values = (0..<30).map { offset -> Int in + guard let date = calendar.date(byAdding: .day, value: -offset, to: endDate) else { + return 0 + } + let key = DateFormatter.tokenStepDay.string(from: date) + return snapshot.daily.last(where: { $0.date == key })?.totalTokens ?? 0 + } + return values.reduce(0, +) / 30 } var goalDays: Int { @@ -107,6 +125,7 @@ final class AppState: ObservableObject { TokenStepThemeRuntime.apply(loadedSettings.theme) settings = loadedSettings snapshot = (try? DataService.loadSnapshot()) ?? .empty + showsUsageRecalibrationNotice = DataService.hasPendingUsageRecalibrationNotice if !loadedSettings.showCodexQuota { codexQuota = .unavailable claudeQuota = .unavailable @@ -118,7 +137,10 @@ final class AppState: ObservableObject { } func refresh() { - guard !isRefreshing else { return } + guard !isRefreshing else { + pendingRefreshAfterCurrent = true + return + } isRefreshing = true lastError = nil let historyDays = settings.historyDays @@ -134,6 +156,10 @@ final class AppState: ObservableObject { isRefreshing = false refreshCodexQuota() refreshTokenRank(force: true) + if pendingRefreshAfterCurrent { + pendingRefreshAfterCurrent = false + refresh() + } } } @@ -182,10 +208,41 @@ final class AppState: ObservableObject { } } + func agentWork(for date: String) -> DailyAgentWork { + snapshot.agentWork(for: date) + ?? DailyAgentWork( + date: date, + totalTokens: 0, + activeHours: 0, + modelRequestCount: 0, + toolCallCount: 0, + sources: [] + ) + } + + func sevenDayAgentAverage(endingAt dateKey: String) -> Int { + guard let endDate = DateFormatter.tokenStepDay.date(from: dateKey) else { return 0 } + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current + let total = (0..<7).reduce(0) { partial, offset in + guard let date = calendar.date(byAdding: .day, value: -offset, to: endDate) else { + return partial + } + let key = DateFormatter.tokenStepDay.string(from: date) + return partial + agentWork(for: key).totalTokens + } + return total / 7 + } + func clearError() { lastError = nil } + func dismissUsageRecalibrationNotice() { + DataService.acknowledgeUsageRecalibrationNotice() + showsUsageRecalibrationNotice = false + } + func refreshTokenIslandAvailability() { tokenIslandAvailable = TokenIslandDisplayDetector.isAvailable(for: settings.tokenIslandPlacement, size: TokenIslandWindowPresenter.collapsedSize) } @@ -255,6 +312,12 @@ final class AppState: ObservableObject { } } + func setExperimentalAgentSourcesVisible(_ visible: Bool) { + settings.showExperimentalAgentSources = visible + saveSettingsAndReload() + refresh() + } + func refreshTokenRank(force: Bool = false) { guard settings.showTokenRank else { clearTokenRankState() diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/AppPaths.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/AppPaths.swift index 8f80de3..8b327fd 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/AppPaths.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/AppPaths.swift @@ -2,6 +2,12 @@ import Foundation enum AppPaths { static let appSupportRoot: URL = { +#if TOKENSTEP_TESTING + if let override = ProcessInfo.processInfo.environment["TOKENSTEP_TEST_APP_SUPPORT_ROOT"], + !override.isEmpty { + return URL(fileURLWithPath: override, isDirectory: true) + } +#endif let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true) return base.appendingPathComponent("TokenStep", isDirectory: true) @@ -12,6 +18,7 @@ enum AppPaths { static let claudeQuotaCacheJSON = appSupportRoot.appendingPathComponent("cache/claude-quota-cache.json") static let settingsJSON = appSupportRoot.appendingPathComponent("config/settings.json") static let autostartDefaultMarker = appSupportRoot.appendingPathComponent("config/autostart-default-applied") + static let usageRecalibrationNoticeMarker = appSupportRoot.appendingPathComponent("config/usage-recalibration-v6-pending") static let updates = appSupportRoot.appendingPathComponent("updates", isDirectory: true) static let logs = appSupportRoot.appendingPathComponent("logs", isDirectory: true) } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Formatters.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Formatters.swift index 8720642..4e6efa3 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Formatters.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Formatters.swift @@ -96,6 +96,7 @@ extension DateFormatter { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current formatter.dateFormat = "yyyy-MM-dd" return formatter }() diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift index 0442197..aed523e 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift @@ -53,6 +53,9 @@ enum TokenStepLocalization { "设置": "Settings", "等待同步": "Waiting to sync", "Token 采集超时,请稍后重试": "Token collection timed out. Please try again later.", + "Token 重新校准未完成,已保留原统计。": "Token recalibration did not complete. The previous totals were preserved.", + "TokenStep 已按真实增量重新校准历史 Token。数字可能变小,但历史记录没有丢失。": "TokenStep recalibrated historical Token usage using true increments. The number may be lower, but no history was lost.", + "关闭": "Close", "手动": "Manual", "%d 分钟": "%d min", "1 分钟": "1 min", @@ -282,6 +285,21 @@ enum TokenStepLocalization { "今日榜单": "Today board", "榜单": "Board", "上榜:": "Ranked:", + "实验 Agent 来源": "Experimental Agent Sources", + "启用 ZCode / Hermes": "Enable ZCode / Hermes", + "只读取本地 usage 字段,不读取对话正文。": "Only reads local usage fields, never message content.", + "已计入实验统计": "Included as experimental", + "已发现,暂不可统计": "Found, not countable yet", + "刷新中": "Refreshing", + "等待刷新": "Waiting for refresh", + "未发现数据源": "No data source found", + "暂无可用 usage": "No usable usage yet", + "结构待适配": "Schema needs support", + "无法读取": "Unreadable", + "Agent 工作强度": "Agent Work Intensity", + "近 7 日均": "7-day avg", + "打开今日页": "Open Today", + "Agent Token": "Agent Tokens", "5 小时": "5 hours", "7 天": "7 days", "剩余 %@": "%@ left", @@ -382,6 +400,9 @@ enum TokenStepLocalization { "设置": "設定", "等待同步": "等待同步", "Token 采集超时,请稍后重试": "Token 採集逾時,請稍後重試", + "Token 重新校准未完成,已保留原统计。": "Token 重新校準未完成,已保留原統計。", + "TokenStep 已按真实增量重新校准历史 Token。数字可能变小,但历史记录没有丢失。": "TokenStep 已按真實增量重新校準歷史 Token。數字可能變小,但歷史記錄沒有遺失。", + "关闭": "關閉", "手动": "手動", "%d 分钟": "%d 分鐘", "1 分钟": "1 分鐘", @@ -611,6 +632,21 @@ enum TokenStepLocalization { "今日榜单": "今日榜單", "榜单": "榜單", "上榜:": "上榜:", + "实验 Agent 来源": "實驗 Agent 來源", + "启用 ZCode / Hermes": "啟用 ZCode / Hermes", + "只读取本地 usage 字段,不读取对话正文。": "只讀取本地 usage 欄位,不讀取對話正文。", + "已计入实验统计": "已計入實驗統計", + "已发现,暂不可统计": "已發現,暫不可統計", + "刷新中": "刷新中", + "等待刷新": "等待刷新", + "未发现数据源": "未發現資料來源", + "暂无可用 usage": "暫無可用 usage", + "结构待适配": "結構待適配", + "无法读取": "無法讀取", + "Agent 工作强度": "Agent 工作強度", + "近 7 日均": "近 7 日均", + "打开今日页": "開啟今日頁", + "Agent Token": "Agent Token", "5 小时": "5 小時", "7 天": "7 天", "剩余 %@": "剩餘 %@", diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/MainWindowPresenter.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/MainWindowPresenter.swift index fd88e1c..3ea0ce5 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/MainWindowPresenter.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/MainWindowPresenter.swift @@ -1,13 +1,29 @@ import AppKit import SwiftUI +final class MainWindowNavigation: ObservableObject { + @Published private(set) var section: AppSection + + init(section: AppSection = .today) { + self.section = section + } + + func select(_ section: AppSection) { + self.section = section + } +} + @MainActor final class MainWindowPresenter { static let shared = MainWindowPresenter() private var window: NSWindow? + private let navigation = MainWindowNavigation() - func show(appState: AppState) { + func show(appState: AppState, section: AppSection? = nil) { + if let section { + navigation.select(section) + } let window = self.window ?? makeWindow(appState: appState) self.window = window @@ -18,7 +34,7 @@ final class MainWindowPresenter { } private func makeWindow(appState: AppState) -> NSWindow { - let rootView = MainWindowView() + let rootView = MainWindowView(navigation: navigation) .environmentObject(appState) .frame(minWidth: 1080, minHeight: 720) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/AgentWorkViews.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/AgentWorkViews.swift new file mode 100644 index 0000000..1435090 --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/AgentWorkViews.swift @@ -0,0 +1,1058 @@ +import SwiftUI + +struct TodayAgentWorkCard: View { + @EnvironmentObject private var appState: AppState + @State private var period: AgentWorkPeriod = .today + @State private var sourceFilter: AgentWorkSourceFilter = .all + + private var work: DailyAgentWork { + appState.todayAgentWork + } + + var body: some View { + TokenCard { + VStack(alignment: .leading, spacing: 16) { + header + sourceFilterRow + metricStrip + + AgentWorkActivityChart( + hours: chartHours, + legendSources: legendSources, + period: period, + unbucketedTokens: selectedUnbucketedTokens + ) + } + } + } + + private var header: some View { + HStack(alignment: .top, spacing: 20) { + VStack(alignment: .leading, spacing: 5) { + Text(L("Agent 工作强度")) + .font(.title3.weight(.heavy)) + .foregroundStyle(Color.tokenInk) + Text(AgentWorkCopy.disclaimer) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 12) + + HStack(spacing: 4) { + ForEach(AgentWorkPeriod.allCases) { item in + AgentWorkSegmentButton( + title: item.title, + selected: period == item + ) { + withAnimation(.spring(response: 0.28, dampingFraction: 0.88)) { + period = item + } + } + } + } + .padding(4) + .background(Color.tokenTrack.opacity(0.42), in: Capsule()) + } + } + + private var sourceFilterRow: some View { + HStack(spacing: 8) { + ForEach(AgentWorkSourceFilter.allCases) { item in + AgentWorkFilterButton( + title: item.title, + selected: sourceFilter == item, + color: item.tint + ) { + withAnimation(.spring(response: 0.28, dampingFraction: 0.88)) { + sourceFilter = item + } + } + } + Spacer(minLength: 0) + } + } + + private var metricStrip: some View { + HStack(spacing: 10) { + AgentWorkMetricTile( + title: L("Agent Token"), + value: TokenStepFormat.tokens(selectedPeriodTokens, compact: true), + detail: period.metricDetail, + symbol: "bolt.horizontal.circle.fill", + color: .tokenGreen + ) + AgentWorkMetricTile( + title: AgentWorkCopy.recordedHours, + value: "\(selectedActiveHours)/\(period.maximumHours)", + detail: AgentWorkCopy.recordedHourDetail, + symbol: "clock.fill", + color: Color(red: 0.20, green: 0.52, blue: 0.92) + ) + AgentWorkMetricTile( + title: L("近 7 日均"), + value: TokenStepFormat.tokens(selectedSevenDayAverage, compact: true), + detail: AgentWorkCopy.calendarDayAverage, + symbol: "calendar.badge.clock", + color: Color(red: 0.50, green: 0.28, blue: 0.92) + ) + AgentWorkMetricTile( + title: AgentWorkCopy.cacheHitRate, + value: cacheRateText(selectedCacheHitRate), + detail: selectedCacheHitRate == nil + ? AgentWorkCopy.coverageUnavailable + : AgentWorkCopy.completeCoverageOnly, + symbol: "arrow.triangle.2.circlepath", + color: .tokenGreenDark + ) + } + } + + private var trailingSevenDayWorks: [DailyAgentWork] { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current + let todayKey = DateFormatter.tokenStepDay.string(from: Date()) + guard let today = DateFormatter.tokenStepDay.date(from: todayKey) else { + return [work] + } + return (0..<7).reversed().compactMap { offset in + guard let date = calendar.date(byAdding: .day, value: -offset, to: today) else { + return nil + } + return appState.agentWork(for: DateFormatter.tokenStepDay.string(from: date)) + } + } + + private var periodWorks: [DailyAgentWork] { + switch period { + case .today: + return [work] + case .sevenDays: + return trailingSevenDayWorks + } + } + + private var selectedPeriodTokens: Int { + periodWorks.map(selectedTokens(in:)).reduce(0, +) + } + + private var selectedSevenDayAverage: Int { + trailingSevenDayWorks.map(selectedTokens(in:)).reduce(0, +) / 7 + } + + private var selectedActiveHours: Int { + periodWorks.reduce(0) { partial, work in + partial + work.hourlyBuckets.filter { selectedTokens(in: $0) > 0 }.count + } + } + + private var selectedBucketedTokens: Int { + periodWorks + .flatMap(\.hourlyBuckets) + .map(selectedTokens(in:)) + .reduce(0, +) + } + + private var selectedUnbucketedTokens: Int { + max(0, selectedPeriodTokens - selectedBucketedTokens) + } + + private var selectedCacheHitRate: Double? { + if sourceFilter == .all { + let activeWorks = periodWorks.filter { $0.totalTokens > 0 } + guard !activeWorks.isEmpty, + activeWorks.allSatisfy(\.cacheCoverageComplete) + else { + return nil + } + let totalInput = activeWorks.map(\.inputTokens).reduce(0, +) + guard totalInput > 0 else { return nil } + let cachedInput = activeWorks.map(\.cachedInputTokens).reduce(0, +) + return Double(cachedInput) / Double(totalInput) + } + + guard selectedUnbucketedTokens == 0 else { return nil } + let sources = periodWorks + .flatMap(\.hourlyBuckets) + .flatMap(\.sources) + .filter { sourceFilter.includes($0.source) && $0.tokens > 0 } + guard !sources.isEmpty, + sources.allSatisfy(\.cacheCoverageComplete) + else { + return nil + } + let totalInput = sources.map(\.inputTokens).reduce(0, +) + guard totalInput > 0 else { return nil } + return Double(sources.map(\.cachedInputTokens).reduce(0, +)) / Double(totalInput) + } + + private var chartHours: [AgentWorkChartHour] { + var hourly = Array(repeating: [String: AgentWorkChartSource](), count: 24) + + for work in periodWorks { + for bucket in work.hourlyBuckets where (0..<24).contains(bucket.hour) { + for source in bucket.sources where sourceFilter.includes(source.source) { + var aggregate = hourly[bucket.hour][source.source] + ?? AgentWorkChartSource( + source: source.source, + tokens: 0, + inputTokens: 0, + cachedInputTokens: 0, + outputTokens: 0, + cacheCoverageComplete: true + ) + aggregate.tokens += max(0, source.tokens) + aggregate.inputTokens += max(0, source.inputTokens) + aggregate.cachedInputTokens += max(0, source.cachedInputTokens) + aggregate.outputTokens += max(0, source.outputTokens) + aggregate.cacheCoverageComplete = aggregate.cacheCoverageComplete + && source.cacheCoverageComplete + hourly[bucket.hour][source.source] = aggregate + } + } + } + + return (0..<24).map { hour in + AgentWorkChartHour( + hour: hour, + sources: hourly[hour].values + .filter { $0.tokens > 0 } + .sorted { + if $0.tokens == $1.tokens { + return $0.source < $1.source + } + return $0.tokens > $1.tokens + } + ) + } + } + + private var legendSources: [AgentWorkLegendSource] { + var tokensBySource: [String: Int] = [:] + for work in periodWorks { + for source in work.sources where sourceFilter.includes(source.source) { + tokensBySource[source.source, default: 0] += source.tokens + } + } + return tokensBySource + .filter { $0.value > 0 } + .map { AgentWorkLegendSource(source: $0.key, tokens: $0.value) } + .sorted { + if $0.tokens == $1.tokens { + return $0.source < $1.source + } + return $0.tokens > $1.tokens + } + } + + private func selectedTokens(in work: DailyAgentWork) -> Int { + guard sourceFilter != .all else { return work.totalTokens } + return work.sources + .filter { sourceFilter.includes($0.source) } + .map(\.tokens) + .reduce(0, +) + } + + private func selectedTokens(in bucket: AgentWorkHourBucket) -> Int { + bucket.sources + .filter { sourceFilter.includes($0.source) } + .map(\.tokens) + .reduce(0, +) + } +} + +struct PopoverAgentWorkStrip: View { + @EnvironmentObject private var appState: AppState + static let destination: AppSection = .today + + private var work: DailyAgentWork { + appState.todayAgentWork + } + + var body: some View { + Button { + MainWindowPresenter.shared.show(appState: appState, section: Self.destination) + } label: { + HStack(spacing: 8) { + Label(AgentWorkCopy.agentActivity, systemImage: "bolt.horizontal.circle.fill") + .labelStyle(.titleAndIcon) + Text(popoverSummary) + .lineLimit(1) + .minimumScaleFactor(0.72) + .monospacedDigit() + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .heavy)) + .foregroundStyle(.secondary) + } + .font(.caption.weight(.heavy)) + .foregroundStyle(Color.tokenInk.opacity(0.78)) + .padding(.horizontal, 13) + .frame(height: 40) + .background(Color.tokenSurface, in: RoundedRectangle(cornerRadius: 15, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 15, style: .continuous).stroke(Color.black.opacity(0.055))) + .contentShape(RoundedRectangle(cornerRadius: 15, style: .continuous)) + } + .buttonStyle(.plain) + .help(L("打开今日页")) + } + + private var popoverSummary: String { + [ + TokenStepFormat.tokens(work.totalTokens, compact: true), + AgentWorkCopy.recordedShort("\(work.activeHours)/24"), + "\(AgentWorkCopy.cacheShort) \(cacheRateText(work.cacheHitRate))" + ] + .joined(separator: " · ") + } +} + +private struct AgentWorkMetricTile: View { + var title: String + var value: String + var detail: String + var symbol: String + var color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + Image(systemName: symbol) + .font(.caption.weight(.black)) + .foregroundStyle(color) + Text(title) + .font(.caption.weight(.heavy)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Text(value) + .font(.system(size: 23, weight: .heavy, design: .rounded)) + .foregroundStyle(Color.tokenInk) + .lineLimit(1) + .minimumScaleFactor(0.62) + .monospacedDigit() + Text(detail) + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.68) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color.tokenTrack.opacity(0.24), in: RoundedRectangle(cornerRadius: 15, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 15, style: .continuous) + .stroke(Color.black.opacity(0.025)) + ) + } +} + +private struct AgentWorkActivityChart: View { + var hours: [AgentWorkChartHour] + var legendSources: [AgentWorkLegendSource] + var period: AgentWorkPeriod + var unbucketedTokens: Int + + @State private var hoveredHour: Int? + + private var maxTokens: Int { + max(1, hours.map(\.tokens).max() ?? 0) + } + + private var hasBucketedData: Bool { + hours.contains { $0.tokens > 0 } + } + + private var hasCacheLine: Bool { + hours.contains { $0.cacheHitRate != nil } + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(period.chartTitle) + .font(.callout.weight(.heavy)) + .foregroundStyle(Color.tokenInk) + Spacer(minLength: 10) + Text(chartContext) + .font(.caption.weight(.bold)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.72) + .monospacedDigit() + } + + if hasBucketedData { + ZStack(alignment: .trailing) { + AgentWorkPlot( + hours: hours, + maxTokens: maxTokens, + hasCacheLine: hasCacheLine, + displayDivisor: period == .sevenDays ? 7 : 1, + hoveredHour: $hoveredHour + ) + .padding(.trailing, hasCacheLine ? 34 : 0) + + if hasCacheLine { + AgentWorkCacheAxis() + } + } + .frame(height: 184) + + HStack { + AgentWorkAxisLabel("00") + Spacer() + AgentWorkAxisLabel("06") + Spacer() + AgentWorkAxisLabel("12") + Spacer() + AgentWorkAxisLabel("18") + Spacer() + AgentWorkAxisLabel("24") + } + .padding(.trailing, hasCacheLine ? 34 : 0) + } else { + HStack(spacing: 9) { + Image(systemName: "questionmark.circle.fill") + .font(.callout.weight(.heavy)) + Text(unbucketedTokens > 0 ? AgentWorkCopy.noTimedData : AgentWorkCopy.noAgentWork) + .font(.callout.weight(.semibold)) + } + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, minHeight: 184) + .background(Color.tokenTrack.opacity(0.18), in: RoundedRectangle(cornerRadius: 15, style: .continuous)) + } + + footer + } + .padding(14) + .background(Color.tokenSurface.opacity(0.64), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.black.opacity(0.055)) + ) + .animation(.easeOut(duration: 0.24), value: hours.map(\.tokens)) + } + + private var chartContext: String { + guard let hoveredHour, + let hour = hours.first(where: { $0.hour == hoveredHour }) + else { + return hasCacheLine ? AgentWorkCopy.chartLegendHint : AgentWorkCopy.tokenBarHint + } + let displayedTokens = period == .sevenDays + ? max(hour.tokens > 0 ? 1 : 0, Int((Double(hour.tokens) / 7).rounded())) + : hour.tokens + var parts = [ + String(format: "%02d:00", hour.hour), + TokenStepFormat.tokens(displayedTokens, compact: true) + + (period == .sevenDays ? AgentWorkCopy.averageSuffix : "") + ] + let sources = agentWorkSourceBreakdown( + hour.sources, + displayDivisor: period == .sevenDays ? 7 : 1, + limit: 2 + ) + if !sources.isEmpty { + parts.append(sources) + } + if let rate = hour.cacheHitRate { + parts.append("\(AgentWorkCopy.cacheShort) \(cacheRateText(rate))") + } + return parts.joined(separator: " · ") + } + + private var footer: some View { + HStack(spacing: 12) { + ForEach(Array(legendSources.prefix(4))) { source in + HStack(spacing: 5) { + Circle() + .fill(tokenToolColor(source.source)) + .frame(width: 8, height: 8) + Text(source.source) + .lineLimit(1) + } + } + + if legendSources.count > 4 { + Text("+\(legendSources.count - 4)") + } + + if hasCacheLine { + HStack(spacing: 5) { + Capsule() + .fill(Color.tokenGreenDark) + .frame(width: 15, height: 2) + Text(AgentWorkCopy.cacheHitRate) + } + } + + Spacer(minLength: 8) + + if unbucketedTokens > 0 { + Text( + period == .sevenDays + ? AgentWorkCopy.unbucketedSevenDayTotal( + TokenStepFormat.tokens(unbucketedTokens, compact: true) + ) + : AgentWorkCopy.unbucketed( + TokenStepFormat.tokens(unbucketedTokens, compact: true) + ) + ) + .foregroundStyle(Color.orange) + .help(AgentWorkCopy.unbucketedHelp) + } + } + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.68) + } +} + +private struct AgentWorkPlot: View { + var hours: [AgentWorkChartHour] + var maxTokens: Int + var hasCacheLine: Bool + var displayDivisor: Int + @Binding var hoveredHour: Int? + + var body: some View { + GeometryReader { proxy in + let slotWidth = proxy.size.width / 24 + + ZStack { + AgentWorkGrid() + + if let hoveredHour { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(Color.tokenMint.opacity(0.20)) + .frame(width: max(6, slotWidth - 2), height: proxy.size.height) + .position( + x: slotWidth * (CGFloat(hoveredHour) + 0.5), + y: proxy.size.height / 2 + ) + } + + ForEach(hours) { hour in + let height = hour.tokens > 0 + ? max(3, proxy.size.height * CGFloat(hour.tokens) / CGFloat(max(maxTokens, 1))) + : 2 + AgentWorkStackedBar( + sources: hour.sources, + totalTokens: hour.tokens, + empty: hour.tokens == 0 + ) + .frame( + width: min(18, max(5, slotWidth * 0.58)), + height: height + ) + .position( + x: slotWidth * (CGFloat(hour.hour) + 0.5), + y: proxy.size.height - height / 2 + ) + } + + if hasCacheLine { + AgentCacheHitLineShape(values: hours.map(\.cacheHitRate)) + .stroke( + Color.tokenGreenDark, + style: StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round) + ) + .shadow(color: Color.tokenGreen.opacity(0.16), radius: 4) + + ForEach(hours) { hour in + if let rate = hour.cacheHitRate { + Circle() + .fill(Color.tokenSurface) + .overlay(Circle().stroke(Color.tokenGreenDark, lineWidth: 2)) + .frame(width: 7, height: 7) + .position( + x: slotWidth * (CGFloat(hour.hour) + 0.5), + y: cachePointY(rate: rate, height: proxy.size.height) + ) + } + } + } + + ForEach(hours) { hour in + Color.clear + .contentShape(Rectangle()) + .frame(width: slotWidth, height: proxy.size.height) + .position( + x: slotWidth * (CGFloat(hour.hour) + 0.5), + y: proxy.size.height / 2 + ) + .onHover { hovering in + if hovering { + hoveredHour = hour.hour + } else if hoveredHour == hour.hour { + hoveredHour = nil + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityText(for: hour)) + } + } + } + } + + private func accessibilityText(for hour: AgentWorkChartHour) -> String { + let displayedTokens = max( + hour.tokens > 0 ? 1 : 0, + Int((Double(hour.tokens) / Double(max(displayDivisor, 1))).rounded()) + ) + var text = "\(String(format: "%02d:00", hour.hour)), \(TokenStepFormat.tokens(displayedTokens))" + if displayDivisor > 1 { + text += AgentWorkCopy.averageSuffix + } + let sources = agentWorkSourceBreakdown( + hour.sources, + displayDivisor: displayDivisor, + limit: nil + ) + if !sources.isEmpty { + text += ", \(sources)" + } + if let rate = hour.cacheHitRate { + text += ", \(AgentWorkCopy.cacheHitRate) \(cacheRateText(rate))" + } + return text + } +} + +private struct AgentWorkGrid: View { + var body: some View { + VStack(spacing: 0) { + ForEach(0..<5, id: \.self) { index in + Rectangle() + .fill(Color.tokenTrack.opacity(index == 4 ? 0.72 : 0.44)) + .frame(height: 1) + if index < 4 { + Spacer() + } + } + } + } +} + +private struct AgentWorkStackedBar: View { + var sources: [AgentWorkChartSource] + var totalTokens: Int + var empty: Bool + + var body: some View { + if empty { + Capsule() + .fill(Color.tokenTrack.opacity(0.72)) + } else { + GeometryReader { proxy in + VStack(spacing: 0) { + ForEach(Array(sources.reversed())) { source in + Rectangle() + .fill(tokenToolColor(source.source)) + .frame( + height: proxy.size.height + * CGFloat(source.tokens) + / CGFloat(max(totalTokens, 1)) + ) + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .stroke(Color.white.opacity(0.42), lineWidth: 0.5) + ) + } + } +} + +private struct AgentCacheHitLineShape: Shape { + var values: [Double?] + + func path(in rect: CGRect) -> Path { + var path = Path() + var hasCurrentSegment = false + let slotWidth = rect.width / CGFloat(max(values.count, 1)) + + for (index, value) in values.enumerated() { + guard let value else { + hasCurrentSegment = false + continue + } + let point = CGPoint( + x: slotWidth * (CGFloat(index) + 0.5), + y: cachePointY(rate: value, height: rect.height) + ) + if hasCurrentSegment { + path.addLine(to: point) + } else { + path.move(to: point) + hasCurrentSegment = true + } + } + return path + } +} + +private struct AgentWorkCacheAxis: View { + var body: some View { + VStack { + Text("100%") + Spacer() + Text("0%") + } + .font(.system(size: 9, weight: .bold, design: .rounded)) + .foregroundStyle(Color.tokenGreenDark.opacity(0.72)) + .monospacedDigit() + .frame(width: 30) + .padding(.vertical, 1) + .accessibilityHidden(true) + } +} + +private struct AgentWorkAxisLabel: View { + var title: String + + init(_ title: String) { + self.title = title + } + + var body: some View { + Text(title) + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + .monospacedDigit() + } +} + +private struct AgentWorkSegmentButton: View { + var title: String + var selected: Bool + var action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.caption.weight(.heavy)) + .foregroundStyle(selected ? Color.tokenSurface : Color.tokenInk.opacity(0.62)) + .padding(.horizontal, 12) + .frame(height: 28) + .background(selected ? Color.tokenInk : Color.clear, in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selected ? [.isSelected] : []) + } +} + +private struct AgentWorkFilterButton: View { + var title: String + var selected: Bool + var color: Color + var action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Circle() + .fill(selected ? Color.tokenSurface : color) + .frame(width: 7, height: 7) + Text(title) + .lineLimit(1) + } + .font(.caption.weight(.heavy)) + .foregroundStyle(selected ? Color.tokenSurface : Color.tokenInk.opacity(0.68)) + .padding(.horizontal, 11) + .frame(height: 30) + .background( + selected ? color : Color.tokenTrack.opacity(0.25), + in: Capsule() + ) + .overlay( + Capsule() + .stroke(selected ? Color.clear : Color.black.opacity(0.045)) + ) + } + .buttonStyle(.plain) + .accessibilityAddTraits(selected ? [.isSelected] : []) + } +} + +private struct AgentWorkChartSource: Identifiable { + var id: String { source } + var source: String + var tokens: Int + var inputTokens: Int + var cachedInputTokens: Int + var outputTokens: Int + var cacheCoverageComplete: Bool +} + +private struct AgentWorkChartHour: Identifiable { + var id: Int { hour } + var hour: Int + var sources: [AgentWorkChartSource] + + var tokens: Int { + sources.map(\.tokens).reduce(0, +) + } + + var cacheHitRate: Double? { + let activeSources = sources.filter { $0.tokens > 0 } + guard !activeSources.isEmpty, + activeSources.allSatisfy(\.cacheCoverageComplete) + else { + return nil + } + let input = activeSources.map(\.inputTokens).reduce(0, +) + guard input > 0 else { return nil } + return Double(activeSources.map(\.cachedInputTokens).reduce(0, +)) / Double(input) + } +} + +private struct AgentWorkLegendSource: Identifiable { + var id: String { source } + var source: String + var tokens: Int +} + +private enum AgentWorkPeriod: String, CaseIterable, Identifiable { + case today + case sevenDays + + var id: String { rawValue } + + var title: String { + switch self { + case .today: return AgentWorkCopy.today + case .sevenDays: return AgentWorkCopy.sevenDays + } + } + + var metricDetail: String { + switch self { + case .today: return AgentWorkCopy.today + case .sevenDays: return AgentWorkCopy.sevenDayTotal + } + } + + var maximumHours: Int { + switch self { + case .today: return 24 + case .sevenDays: return 168 + } + } + + var chartTitle: String { + switch self { + case .today: return AgentWorkCopy.hourlyDistribution + case .sevenDays: return AgentWorkCopy.sevenDayHourlyDistribution + } + } +} + +private enum AgentWorkSourceFilter: String, CaseIterable, Identifiable { + case all + case codex + case hermes + case other + + var id: String { rawValue } + + var title: String { + switch self { + case .all: return AgentWorkCopy.all + case .codex: return "Codex" + case .hermes: return "Hermes" + case .other: return AgentWorkCopy.other + } + } + + var tint: Color { + switch self { + case .all: return Color.tokenInk + case .codex: return tokenToolColor("Codex") + case .hermes: return tokenToolColor("Hermes Agent") + case .other: return Color(red: 0.20, green: 0.52, blue: 0.92) + } + } + + func includes(_ source: String) -> Bool { + let normalized = source.lowercased() + let isCodex = normalized == "codex" || normalized.hasPrefix("codex via") + let isHermes = normalized.contains("hermes") + switch self { + case .all: + return true + case .codex: + return isCodex + case .hermes: + return isHermes + case .other: + return !isCodex && !isHermes + } + } +} + +enum AgentWorkCopy { + static var today: String { + localized("今日", "Today", "今日") + } + + static var sevenDays: String { + localized("近 7 天", "Last 7 Days", "近 7 天") + } + + static var sevenDayTotal: String { + localized("7 个日历日合计", "7 calendar days total", "7 個日曆日合計") + } + + static var all: String { + localized("全部", "All", "全部") + } + + static var other: String { + localized("其他", "Other", "其他") + } + + static var disclaimer: String { + localized( + "按本机 Token 记录展示活跃节奏,不代表实际工时或生产力。", + "Shows local Token activity, not actual work time or productivity.", + "按本機 Token 記錄展示活躍節奏,不代表實際工時或生產力。" + ) + } + + static var agentActivity: String { + localized("Agent 活跃", "Agent Activity", "Agent 活躍") + } + + static var recordedHours: String { + localized("有记录小时", "Hours with Records", "有記錄小時") + } + + static var recordedHourDetail: String { + localized("有 Token 记录,非工时", "Token records, not work time", "有 Token 記錄,非工時") + } + + static func recordedShort(_ value: String) -> String { + localized("记录 \(value)", "Recorded \(value)", "記錄 \(value)") + } + + static var calendarDayAverage: String { + localized("按 7 个日历日折算", "Across 7 calendar days", "按 7 個日曆日折算") + } + + static var cacheHitRate: String { + localized("缓存命中率", "Cache Hit Rate", "快取命中率") + } + + static var cacheShort: String { + localized("缓存", "Cache", "快取") + } + + static var completeCoverageOnly: String { + localized("仅展示完整口径", "Complete coverage only", "僅展示完整口徑") + } + + static var coverageUnavailable: String { + localized("口径不完整", "Incomplete coverage", "口徑不完整") + } + + static var hourlyDistribution: String { + localized("24 小时 Token 记录", "24-Hour Token Records", "24 小時 Token 記錄") + } + + static var sevenDayHourlyDistribution: String { + localized("近 7 天分时日均", "7-Day Hourly Average", "近 7 天分時日均") + } + + static var chartLegendHint: String { + localized("柱形为 Token · 折线为缓存", "Bars: Tokens · Line: Cache", "柱形為 Token · 折線為快取") + } + + static var tokenBarHint: String { + localized("柱形为 Token", "Bars: Tokens", "柱形為 Token") + } + + static var noTimedData: String { + localized("已有 Token,但缺少可用时段", "Tokens found, but hourly timing is unavailable", "已有 Token,但缺少可用時段") + } + + static var noAgentWork: String { + localized("这个时段还没有可统计的 Agent Token", "No countable Agent Tokens in this period", "這個時段還沒有可統計的 Agent Token") + } + + static var averageSuffix: String { + localized(" 日均", " daily avg", " 日均") + } + + static var unbucketedHelp: String { + localized( + "这些 Token 有总量,但缺少可靠时间戳,因此没有放进小时柱。", + "These Tokens have totals but no reliable timestamps, so they are excluded from hourly bars.", + "這些 Token 有總量,但缺少可靠時間戳,因此沒有放進小時柱。" + ) + } + + static func unbucketed(_ value: String) -> String { + localized("未分时 \(value)", "Unbucketed \(value)", "未分時 \(value)") + } + + static func unbucketedSevenDayTotal(_ value: String) -> String { + localized( + "未分时 7 天合计 \(value)", + "Unbucketed 7d total \(value)", + "未分時 7 天合計 \(value)" + ) + } + + static func hourLabel(_ hour: Int) -> String { + localized("\(hour)时", "\(hour)h", "\(hour)時") + } + + private static func localized(_ zhHans: String, _ en: String, _ zhHant: String) -> String { + switch TokenStepLocalization.language { + case .en: + return en + case .zhHant: + return zhHant + case .zhHans, .system: + return zhHans + } + } +} + +private func cacheRateText(_ rate: Double?) -> String { + guard let rate else { return "--" } + return TokenStepFormat.percent(min(max(rate, 0), 1) * 100) +} + +private func agentWorkSourceBreakdown( + _ sources: [AgentWorkChartSource], + displayDivisor: Int, + limit: Int? +) -> String { + let activeSources = sources.filter { $0.tokens > 0 } + let visibleCount = min(limit ?? activeSources.count, activeSources.count) + var parts = activeSources.prefix(visibleCount).map { source in + let displayedTokens = max( + 1, + Int((Double(source.tokens) / Double(max(displayDivisor, 1))).rounded()) + ) + return "\(source.source) \(TokenStepFormat.tokens(displayedTokens, compact: true))" + } + if visibleCount < activeSources.count { + parts.append("+\(activeSources.count - visibleCount)") + } + return parts.joined(separator: " + ") +} + +private func cachePointY(rate: Double, height: CGFloat) -> CGFloat { + let inset: CGFloat = 6 + let clamped = min(max(rate, 0), 1) + return height - inset - CGFloat(clamped) * max(1, height - inset * 2) +} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift index a91d6fa..8858a3a 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift @@ -166,6 +166,35 @@ struct ErrorBanner: View { } } +struct UsageRecalibrationNotice: View { + var dismiss: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.tokenGreen) + .padding(.top, 1) + Text(L("TokenStep 已按真实增量重新校准历史 Token。数字可能变小,但历史记录没有丢失。")) + .font(.callout.weight(.semibold)) + .foregroundStyle(Color.tokenInk.opacity(0.82)) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + Button(action: dismiss) { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .accessibilityLabel(L("关闭")) + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .background(Color.tokenMint.opacity(0.20), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.tokenGreen.opacity(0.18)) + ) + } +} + struct ProgressRingView: View { var progress: Double var lineWidth: CGFloat = 18 @@ -629,13 +658,17 @@ func tokenToolColor(_ tool: String) -> Color { return Color(red: 0.88, green: 0.42, blue: 0.24) case "Hermes", "Hermes Agent": return Color(red: 0.50, green: 0.28, blue: 0.92) + case "ZCode": + return Color(red: 0.20, green: 0.52, blue: 0.92) + case "Codex via CC Switch", "Claude Code via CC Switch", "Gemini via CC Switch": + return Color(red: 0.10, green: 0.64, blue: 0.72) default: return Color.tokenInk.opacity(0.44) } } func orderedToolEntries(_ tools: [String: Int]) -> [(name: String, tokens: Int)] { - let preferred = ["Codex", "Claude Code", "Hermes", "Hermes Agent"] + let preferred = ["Codex", "Claude Code", "ZCode", "Hermes", "Hermes Agent", "Codex via CC Switch", "Claude Code via CC Switch"] var entries: [(name: String, tokens: Int)] = preferred.compactMap { name in guard let value = tools[name], value > 0 else { return nil } return (name, value) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift index 6d51736..92d9c69 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift @@ -57,7 +57,7 @@ enum AppSection: String, CaseIterable, Identifiable { struct MainWindowView: View { @EnvironmentObject private var appState: AppState - @State private var selection: AppSection = .today + @ObservedObject var navigation: MainWindowNavigation var body: some View { HStack(spacing: 0) { @@ -103,10 +103,10 @@ struct MainWindowView: View { ForEach(AppSection.allCases) { section in SidebarNavButton( section: section, - selected: selection == section + selected: navigation.section == section ) { withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { - selection = section + navigation.select(section) } } } @@ -145,6 +145,11 @@ struct MainWindowView: View { appState.clearError() } } + if appState.showsUsageRecalibrationNotice { + UsageRecalibrationNotice { + appState.dismissUsageRecalibrationNotice() + } + } detailView } .frame(maxWidth: 1160, alignment: .leading) @@ -160,10 +165,10 @@ struct MainWindowView: View { private var pageHeader: some View { HStack(alignment: .center, spacing: 18) { VStack(alignment: .leading, spacing: 7) { - Text(selection.title) + Text(navigation.section.title) .font(.system(size: 42, weight: .heavy, design: .rounded)) .foregroundStyle(Color.tokenInk) - Text(selection.subtitle) + Text(navigation.section.subtitle) .font(.headline.weight(.semibold)) .foregroundStyle(.secondary) } @@ -186,7 +191,7 @@ struct MainWindowView: View { ScreenshotMenuButton( copyTitle: L("复制当前页截图"), - saveTitle: selection.saveScreenshotTitle, + saveTitle: navigation.section.saveScreenshotTitle, help: L("截取当前页"), copyAction: copyCurrentPageScreenshot, saveAction: saveCurrentPageScreenshot @@ -201,7 +206,7 @@ struct MainWindowView: View { } private var currentPageScreenshot: some View { - DashboardScreenshotView(section: selection) + DashboardScreenshotView(section: navigation.section) .environmentObject(appState) .environment(\.isScreenshotRendering, true) } @@ -218,7 +223,7 @@ struct MainWindowView: View { do { try ScreenshotExporter.save( currentPageScreenshot, - suggestedFileName: ScreenshotExporter.suggestedFileName(prefix: selection.screenshotFilePrefix) + suggestedFileName: ScreenshotExporter.suggestedFileName(prefix: navigation.section.screenshotFilePrefix) ) } catch { appState.lastError = error.localizedDescription @@ -227,7 +232,7 @@ struct MainWindowView: View { @ViewBuilder private var detailView: some View { - switch selection { + switch navigation.section { case .today: TodayView() case .history: diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift index e86313d..52afe49 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift @@ -13,7 +13,13 @@ struct PopoverPanelView: View { appState.clearError() } } + if appState.showsUsageRecalibrationNotice { + UsageRecalibrationNotice { + appState.dismissUsageRecalibrationNotice() + } + } PopoverTodayRingCard() + PopoverAgentWorkStrip() if appState.settings.showCodexQuota { PopoverQuotaCard() } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDisplayRefreshCards.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDisplayRefreshCards.swift index 4fa6ee4..7f54ee3 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDisplayRefreshCards.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsDisplayRefreshCards.swift @@ -150,3 +150,75 @@ struct SettingsTokenRankCard: View { } } } + +struct SettingsExperimentalAgentSourcesCard: View { + @EnvironmentObject private var appState: AppState + + var body: some View { + SettingsCard(title: L("实验 Agent 来源"), symbol: "point.3.connected.trianglepath.dotted", height: 282) { + VStack(alignment: .leading, spacing: 13) { + SettingsToggleRow( + title: L("启用 ZCode / Hermes"), + isOn: Binding( + get: { appState.settings.showExperimentalAgentSources }, + set: { appState.setExperimentalAgentSourcesVisible($0) } + ) + ) + + Text(L("只读取本地 usage 字段,不读取对话正文。")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + VStack(spacing: 8) { + experimentalSourceLine(name: "ZCode", sourceKey: "ZCode") + experimentalSourceLine(name: "Hermes Agent", sourceKey: "Hermes Agent") + experimentalSourceLine(name: "WorkBuddy", sourceKey: "WorkBuddy") + } + + Spacer(minLength: 0) + } + } + } + + private func experimentalSourceLine(name: String, sourceKey: String) -> some View { + let rawStatus = appState.snapshot.sources[sourceKey]?.status + let status = normalizedExperimentalStatus(rawStatus) + let active = status == "ok" + let discoveredOnly = status == "discovered_no_usage" + return StatusLine( + symbol: active ? "checkmark.circle.fill" : discoveredOnly ? "magnifyingglass.circle.fill" : "circle.dashed", + title: name, + value: statusText(status), + tint: active ? .tokenGreen : discoveredOnly ? .orange : .gray + ) + } + + private func normalizedExperimentalStatus(_ status: String?) -> String { + guard appState.settings.showExperimentalAgentSources else { + return "disabled" + } + if appState.isRefreshing { + return "refreshing" + } + if status == nil || status == "disabled" { + return "pending_refresh" + } + return status ?? "missing" + } + + private func statusText(_ status: String?) -> String { + switch status { + case "ok": return L("已计入实验统计") + case "discovered_no_usage": return L("已发现,暂不可统计") + case "refreshing": return L("刷新中") + case "pending_refresh": return L("等待刷新") + case "disabled": return L("默认关闭") + case "missing_db", "missing": return L("未发现数据源") + case "missing_valid_rows": return L("暂无可用 usage") + case "schema_mismatch", "schema_unreadable", "missing_table": return L("结构待适配") + case "unreadable_db": return L("无法读取") + default: return L("等待同步") + } + } +} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift index 7fa4f20..3fb5f9d 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift @@ -70,8 +70,8 @@ struct SettingsView: View { SettingsAutostartCard() } HStack(alignment: .top, spacing: 18) { + SettingsExperimentalAgentSourcesCard() SettingsPrivacyCard() - Spacer(minLength: 0) } } } @@ -166,6 +166,7 @@ struct SettingsView: View { appState.setCodexQuotaVisible(TokenStepSettings.defaults.showCodexQuota) appState.setTokenRankVisible(TokenStepSettings.defaults.showTokenRank) appState.setTokenRankUserID(TokenStepSettings.defaults.tokenRankUserID) + appState.setExperimentalAgentSourcesVisible(TokenStepSettings.defaults.showExperimentalAgentSources) appState.setAutostart(true) } label: { Text(L("恢复默认")) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareRhythmCardView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareRhythmCardView.swift index 55c6481..236d354 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareRhythmCardView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareRhythmCardView.swift @@ -1,6 +1,8 @@ import SwiftUI struct ShareRhythmCardView: View { + @EnvironmentObject private var appState: AppState + var day: DailyUsage var rhythm: DailyRhythm var previousDay: DailyUsage? @@ -155,11 +157,16 @@ struct ShareRhythmCardView: View { private var bottomMetrics: some View { HStack(spacing: 0) { - RhythmBottomMetric(symbol: "clock.fill", title: L("活跃时段"), value: LFormat("%d 个时段", rhythm.activeHours), color: palette.accent) + RhythmBottomMetric( + symbol: "clock.fill", + title: AgentWorkCopy.recordedHours, + value: "\(dayAgentWork.activeHours)/24", + color: palette.accent + ) RhythmMetricDivider() - RhythmBottomMetric(symbol: "moon.stars.fill", title: L("夜间占比"), value: TokenStepFormat.percent(nightShare * 100), color: palette.night) + RhythmBottomMetric(symbol: "cpu.fill", title: L("Agent Token"), value: TokenStepFormat.tokens(dayAgentWork.totalTokens, compact: true), color: palette.secondary) RhythmMetricDivider() - RhythmBottomMetric(symbol: "timer", title: L("最长连续"), value: LFormat("%d 小时", longestActiveStreak), color: palette.accent) + RhythmBottomMetric(symbol: "calendar.badge.clock", title: L("近 7 日均"), value: TokenStepFormat.tokens(agentSevenDayAverage, compact: true), color: palette.accent) } .frame(height: 70) } @@ -182,6 +189,7 @@ struct ShareRhythmCardView: View { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current formatter.dateFormat = "yyyy.MM.dd" guard let date = DateFormatter.tokenStepDay.date(from: day.date) else { return day.date } return formatter.string(from: date) @@ -192,6 +200,7 @@ struct ShareRhythmCardView: View { let formatter = DateFormatter() formatter.calendar = Calendar(identifier: .gregorian) formatter.locale = TokenStepLocalization.locale + formatter.timeZone = TimeZone(identifier: "Asia/Shanghai") ?? .current formatter.dateFormat = TokenStepLocalization.language == .en ? "EEE" : "EEEE" return formatter.string(from: date) } @@ -201,24 +210,12 @@ struct ShareRhythmCardView: View { return String(format: "%02d:00-%02d:00", peakHour, (peakHour + 1) % 24) } - private var nightShare: Double { - guard rhythm.totalTokens > 0 else { return 0 } - let nightTokens = rhythm.tokens(in: 21...23) + rhythm.tokens(in: 0...2) - return Double(nightTokens) / Double(rhythm.totalTokens) + private var dayAgentWork: DailyAgentWork { + appState.agentWork(for: day.date) } - private var longestActiveStreak: Int { - var best = 0 - var current = 0 - for bucket in rhythm.buckets { - if rhythm.isSignificant(bucket) { - current += 1 - best = max(best, current) - } else { - current = 0 - } - } - return max(best, rhythm.activeHours > 0 ? 1 : 0) + private var agentSevenDayAverage: Int { + appState.sevenDayAgentAverage(endingAt: day.date) } } @@ -309,7 +306,7 @@ private struct RhythmAxisLabel: View { var body: some View { VStack(spacing: 4) { - Text(hour == 24 ? "24时" : "\(hour)时") + Text(AgentWorkCopy.hourLabel(hour)) .font(.caption.weight(.heavy)) .foregroundStyle(Color.white.opacity(0.48)) if let symbol { diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/TodayView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/TodayView.swift index 6ccf092..dbef7c1 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/TodayView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/TodayView.swift @@ -7,6 +7,7 @@ struct TodayView: View { VStack(spacing: 22) { hero todayBreakdownStrip + TodayAgentWorkCard() metricStrip } } diff --git a/TokenStepSwift/Tests/Fixtures/AgentWorkCardRender.swift b/TokenStepSwift/Tests/Fixtures/AgentWorkCardRender.swift new file mode 100644 index 0000000..edb6e61 --- /dev/null +++ b/TokenStepSwift/Tests/Fixtures/AgentWorkCardRender.swift @@ -0,0 +1,376 @@ +import AppKit +import SwiftUI + +@main +struct AgentWorkCardRender { + @MainActor + static func main() throws { + try validateIsolatedAppSupport() + try validatePopoverNavigation() + try seedAppSupport() + + let appState = AppState() + let outputURL = URL( + fileURLWithPath: ProcessInfo.processInfo.environment["TOKENSTEP_AGENT_WORK_CARD_RENDER_PATH"] + ?? "/tmp/tokenstep-agent-work-card.png" + ).standardizedFileURL + + try FileManager.default.createDirectory( + at: outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + let content = ZStack { + TokenStepBackdrop() + TodayAgentWorkCard() + .environmentObject(appState) + .frame(width: 776) + .padding(20) + } + .frame(width: 816) + .fixedSize(horizontal: false, vertical: true) + .environment(\.colorScheme, .light) + .environment(\.isScreenshotRendering, true) + + let renderer = ImageRenderer(content: content) + renderer.scale = 2 + guard let image = renderer.nsImage, + let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let png = bitmap.representation(using: .png, properties: [:]) + else { + throw NSError( + domain: "AgentWorkCardRender", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Unable to render Agent work card"] + ) + } + + try png.write(to: outputURL, options: .atomic) + print(outputURL.path) + } + + @MainActor + private static func validatePopoverNavigation() throws { + let navigation = MainWindowNavigation(section: .history) + navigation.select(PopoverAgentWorkStrip.destination) + guard navigation.section == .today else { + throw NSError( + domain: "AgentWorkCardRender", + code: 4, + userInfo: [NSLocalizedDescriptionKey: "Popover Agent entry must select the Today section"] + ) + } + } + + private static func validateIsolatedAppSupport() throws { + guard let override = ProcessInfo.processInfo.environment["TOKENSTEP_TEST_APP_SUPPORT_ROOT"], + !override.isEmpty + else { + throw NSError( + domain: "AgentWorkCardRender", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "TOKENSTEP_TEST_APP_SUPPORT_ROOT is required"] + ) + } + + let expected = URL(fileURLWithPath: override, isDirectory: true).standardizedFileURL + guard AppPaths.appSupportRoot.standardizedFileURL == expected else { + throw NSError( + domain: "AgentWorkCardRender", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "Fixture refused to use non-test App Support"] + ) + } + } + + @MainActor + private static func seedAppSupport() throws { + let works = makeAgentWorks() + let daily = works.map { work in + DailyUsage( + date: work.date, + tools: Dictionary( + uniqueKeysWithValues: work.sources.map { ($0.source, $0.tokens) } + ), + models: ["gpt-5.6-sol": work.totalTokens], + totalTokens: work.totalTokens, + cost: 0 + ) + } + let codexTotal = works + .flatMap(\.sources) + .filter { $0.source == "Codex" } + .map(\.tokens) + .reduce(0, +) + let hermesTotal = works + .flatMap(\.sources) + .filter { $0.source == "Hermes Agent" } + .map(\.tokens) + .reduce(0, +) + let total = codexTotal + hermesTotal + + let snapshot = UsageSnapshot( + generatedAt: ISO8601DateFormatter().string(from: Date()), + timezone: "Asia/Shanghai", + totals: UsageTotals(tokens: total, cost: 0, activeDays: works.count), + daily: daily, + agentWork: works, + tools: [ + ToolUsage( + tool: "Codex", + tokens: codexTotal, + percent: total > 0 ? Double(codexTotal) * 100 / Double(total) : 0 + ), + ToolUsage( + tool: "Hermes Agent", + tokens: hermesTotal, + percent: total > 0 ? Double(hermesTotal) * 100 / Double(total) : 0 + ) + ], + models: [ + ModelUsage(model: "gpt-5.6-sol", tool: nil, tokens: total, percent: 100) + ], + sources: [ + "Codex": SourceInfo( + status: "ok", + files: 665, + records: 2_440, + accountingRevision: 6 + ), + "Hermes Agent": SourceInfo( + status: "ok", + files: 12, + records: 149 + ) + ] + ) + + let settings = TokenStepSettings( + dailyGoalTokens: 100_000_000, + refreshIntervalSeconds: 0, + historyDays: 30, + theme: .green, + autoUpdateEnabled: false, + askBeforeDownloadingUpdates: true, + requireVerifiedUpdates: true, + tokenIslandEnabled: false, + tokenIslandPlacement: .menuBar, + showCodexQuota: false, + showTokenRank: false, + showExperimentalAgentSources: true, + tokenRankUserID: "", + language: .zhHans, + skippedUpdateVersion: nil + ) + + try writeJSON(snapshot, to: AppPaths.usageJSON) + try writeJSON(settings, to: AppPaths.settingsJSON) + } + + private static func makeAgentWorks() -> [DailyAgentWork] { + let calendar = Calendar(identifier: .gregorian) + let todayKey = DateFormatter.tokenStepDay.string(from: Date()) + let today = DateFormatter.tokenStepDay.date(from: todayKey) ?? Date() + let historicalTotals: [(codex: Int, hermes: Int)] = [ + (214_000_000, 8_400_000), + (356_000_000, 11_800_000), + (188_000_000, 6_900_000), + (421_000_000, 13_600_000), + (279_000_000, 9_700_000), + (334_000_000, 12_200_000) + ] + + var works: [DailyAgentWork] = [] + for offset in stride(from: 6, through: 1, by: -1) { + guard let date = calendar.date(byAdding: .day, value: -offset, to: today) else { + continue + } + let totals = historicalTotals[6 - offset] + works.append( + historicalWork( + date: DateFormatter.tokenStepDay.string(from: date), + codexTokens: totals.codex, + hermesTokens: totals.hermes, + peakHour: 13 + (offset % 5) + ) + ) + } + works.append(todayWork(date: todayKey)) + return works + } + + private static func todayWork(date: String) -> DailyAgentWork { + let codexTokens = [ + 64_000_000, 40_000_000, 49_000_000, 32_000_000, + 700_000, 16_000_000, 1_100_000, 7_000_000, + 500_000, 0, 6_000_000, 350_000, + 0, 800_000, 53_000_000, 4_000_000, + 300_000, 8_000_000, 45_000_000, 0, + 12_000_000, 0, 0, 0 + ] + let hermesTokens = [ + 600_000, 420_000, 510_000, 380_000, + 0, 750_000, 0, 260_000, + 0, 0, 0, 830_000, + 0, 0, 1_640_000, 0, + 0, 540_000, 2_300_000, 0, + 1_100_000, 0, 0, 0 + ] + let missingCacheHours: Set = [3, 11, 15, 20] + + let buckets = (0..<24).map { hour -> AgentWorkHourBucket in + var sources: [AgentWorkHourlySource] = [] + if codexTokens[hour] > 0 { + sources.append( + hourlySource( + name: "Codex", + tokens: codexTokens[hour], + cacheRatio: 0.91 + Double(hour % 4) * 0.015, + cacheCoverageComplete: !missingCacheHours.contains(hour) + ) + ) + } + if hermesTokens[hour] > 0 { + sources.append( + hourlySource( + name: "Hermes Agent", + tokens: hermesTokens[hour], + cacheRatio: 0.84 + Double(hour % 3) * 0.025, + cacheCoverageComplete: !missingCacheHours.contains(hour) + ) + ) + } + return AgentWorkHourBucket(hour: hour, sources: sources) + } + + return work( + date: date, + buckets: buckets, + modelRequestCount: 2_589, + toolCallCount: 3_886 + ) + } + + private static func historicalWork( + date: String, + codexTokens: Int, + hermesTokens: Int, + peakHour: Int + ) -> DailyAgentWork { + let hours = [max(0, peakHour - 4), peakHour, min(23, peakHour + 4)] + let codexParts = split(codexTokens) + let hermesParts = split(hermesTokens) + let buckets = (0..<24).map { hour -> AgentWorkHourBucket in + guard let index = hours.firstIndex(of: hour) else { + return AgentWorkHourBucket(hour: hour, sources: []) + } + return AgentWorkHourBucket( + hour: hour, + sources: [ + hourlySource( + name: "Codex", + tokens: codexParts[index], + cacheRatio: 0.92, + cacheCoverageComplete: true + ), + hourlySource( + name: "Hermes Agent", + tokens: hermesParts[index], + cacheRatio: 0.86, + cacheCoverageComplete: true + ) + ] + ) + } + + return work( + date: date, + buckets: buckets, + modelRequestCount: max(60, (codexTokens + hermesTokens) / 190_000), + toolCallCount: max(90, (codexTokens + hermesTokens) / 125_000) + ) + } + + private static func work( + date: String, + buckets: [AgentWorkHourBucket], + modelRequestCount: Int, + toolCallCount: Int + ) -> DailyAgentWork { + let hourlySources = buckets.flatMap(\.sources) + let sourceNames = Array(Set(hourlySources.map(\.source))).sorted() + let sources = sourceNames.map { name in + AgentWorkSource( + source: name, + tokens: hourlySources + .filter { $0.source == name } + .map(\.tokens) + .reduce(0, +), + modelRequestCount: name == "Codex" + ? Int(Double(modelRequestCount) * 0.94) + : max(1, Int(Double(modelRequestCount) * 0.06)), + toolCallCount: name == "Codex" + ? Int(Double(toolCallCount) * 0.96) + : max(1, Int(Double(toolCallCount) * 0.04)) + ) + } + let totalTokens = hourlySources.map(\.tokens).reduce(0, +) + let inputTokens = hourlySources.map(\.inputTokens).reduce(0, +) + let cachedInputTokens = hourlySources.map(\.cachedInputTokens).reduce(0, +) + let outputTokens = hourlySources.map(\.outputTokens).reduce(0, +) + let activeHours = buckets.filter { $0.totalTokens > 0 }.count + + return DailyAgentWork( + date: date, + totalTokens: totalTokens, + activeHours: activeHours, + modelRequestCount: modelRequestCount, + toolCallCount: toolCallCount, + sources: sources, + inputTokens: inputTokens, + cachedInputTokens: cachedInputTokens, + outputTokens: outputTokens, + cacheCoverageComplete: hourlySources + .filter { $0.tokens > 0 } + .allSatisfy(\.cacheCoverageComplete), + hourlyBuckets: buckets, + unbucketedTokens: 0 + ) + } + + private static func hourlySource( + name: String, + tokens: Int, + cacheRatio: Double, + cacheCoverageComplete: Bool + ) -> AgentWorkHourlySource { + let output = max(1, Int(Double(tokens) * 0.025)) + let input = max(0, tokens - output) + let cached = min(input, max(0, Int(Double(input) * cacheRatio))) + return AgentWorkHourlySource( + source: name, + tokens: tokens, + inputTokens: input, + cachedInputTokens: cached, + outputTokens: output, + cacheCoverageComplete: cacheCoverageComplete + ) + } + + private static func split(_ value: Int) -> [Int] { + let first = Int(Double(value) * 0.28) + let second = Int(Double(value) * 0.47) + return [first, second, value - first - second] + } + + private static func writeJSON(_ value: T, to url: URL) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(value).write(to: url, options: .atomic) + } +} diff --git a/TokenStepSwift/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift index d272f6c..212cd5a 100644 --- a/TokenStepSwift/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift +++ b/TokenStepSwift/Tests/Fixtures/CCSwitchProxyFixtureCheck.swift @@ -8,6 +8,10 @@ struct CCSwitchProxyFixtureCheck { try runClaudeOpusCostCheck() try runCodexArchivedSessionChecks() try runCrossSourceDedupeChecks() + try runAmbiguousFuzzyDedupeCheck() + try runShanghaiHistoryWindowCheck() + try runExperimentalAgentChecks() + try runLegacyAgentWorkDecodeCheck() print("Usage collector fixture checks passed") } @@ -31,6 +35,77 @@ struct CCSwitchProxyFixtureCheck { try assertNil(snapshot.daily.first?.models["codex-session-priced"], "codex session model tokens") try assertEqual(snapshot.daily.first?.models["gpt-5.4"], 13, "model fallback tokens") + let semanticsDatabase = try makeSemanticsFixtureDatabase() + defer { + try? FileManager.default.removeItem(at: semanticsDatabase.deletingLastPathComponent()) + } + let semanticsSnapshot = UsageCollector.collectCCSwitchProxyUsageSnapshot( + databaseURL: semanticsDatabase + ) + try assertEqual( + semanticsSnapshot.sources["CC Switch Proxy"]?.records, + 4, + "all input-token semantics rows are collected" + ) + try assertEqual(semanticsSnapshot.totals.tokens, 480, "input-token semantics total") + let semanticsWork = try unwrap( + semanticsSnapshot.agentWork.first, + "input-token semantics agent work" + ) + try assertEqual( + semanticsWork.totalTokens, + 480, + "TOTAL, FRESH, LEGACY, and Claude rows preserve processed total" + ) + try assertEqual( + semanticsWork.inputTokens, + 400, + "all input-token semantics normalize to canonical input" + ) + try assertEqual( + semanticsWork.cachedInputTokens, + 240, + "cache-read tokens stay a subset of canonical input" + ) + try assertEqual( + semanticsWork.outputTokens, + 80, + "output tokens are never duplicated" + ) + try assertApprox( + semanticsWork.cacheHitRate, + 0.6, + "cache hit uses cache reads over canonical input" + ) + + let missingSemanticsDatabase = try makeFixtureDatabase(rowsSQL: """ + insert into proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + total_cost_usd, status_code, created_at, data_source, request_model, pricing_model + ) values + ('missing-column-codex', 'provider-a', 'codex', 'gpt-5.4', 90, 20, 60, 10, '0', 200, 1717200000, 'proxy', 'gpt-5.4', ''), + ('missing-column-claude', 'provider-b', 'claude', 'claude-raw', 30, 20, 60, 10, '0', 200, 1717203600, 'proxy', 'claude-raw', ''); + """) + defer { + try? FileManager.default.removeItem( + at: missingSemanticsDatabase.deletingLastPathComponent() + ) + } + let missingSemanticsSnapshot = UsageCollector.collectCCSwitchProxyUsageSnapshot( + databaseURL: missingSemanticsDatabase + ) + try assertEqual( + missingSemanticsSnapshot.totals.tokens, + 240, + "a missing semantics column preserves the official LEGACY behavior" + ) + try assertApprox( + missingSemanticsSnapshot.agentWork.first?.cacheHitRate, + 0.6, + "legacy schema cache hit remains canonical" + ) + let emptyDatabase = try makeFixtureDatabase(rowsSQL: """ insert into proxy_request_logs ( request_id, provider_id, app_type, model, @@ -186,7 +261,152 @@ struct CCSwitchProxyFixtureCheck { private static func runCrossSourceDedupeChecks() throws { try runClaudeProxyDedupeChecks() - try runCodexProxyDedupeChecks() + try runCodexSimilarIndependentRequestCheck() + try runCodexSharedSessionDedupeCheck() + } + + private static func runAmbiguousFuzzyDedupeCheck() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("TokenStepAmbiguousDedupe-\(UUID().uuidString)", isDirectory: true) + let project = root.appendingPathComponent("project", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + try claudeAssistantLine( + uuid: "ambiguous-native", + messageID: "ambiguous-native-message", + timestamp: "2026-06-21T08:00:00Z", + model: "claude-opus-4-20250514", + stopReason: "end_turn", + input: 10, + output: 3, + cacheRead: 100 + ).write( + to: project.appendingPathComponent("session.jsonl"), + atomically: true, + encoding: .utf8 + ) + + let database = try makeFixtureDatabase(rowsSQL: """ + insert into proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + total_cost_usd, status_code, created_at, data_source, request_model, pricing_model + ) values + ('ambiguous-proxy-1', 'provider-a', 'claude', 'claude-opus-4-20250514', 10, 3, 100, 0, '0.10', 200, 1782028800, 'proxy', 'claude-opus-4-20250514', ''), + ('ambiguous-proxy-2', 'provider-a', 'claude', 'claude-opus-4-20250514', 10, 3, 100, 0, '0.10', 200, 1782028801, 'proxy', 'claude-opus-4-20250514', ''); + """) + defer { try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) } + + let snapshot = UsageCollector.collectUsageSnapshotForTests( + claudeRootURL: root, + ccSwitchDatabaseURL: database + ) + let source = snapshot.sources["CC Switch Proxy"] + try assertEqual(source?.records, 2, "ambiguous fuzzy candidates are both kept") + try assertEqual(source?.dedupedRecords, 0, "ambiguous fuzzy candidates are not deduplicated") + try assertEqual(snapshot.totals.tokens, 339, "one native plus two ambiguous proxy rows") + } + + private static func runShanghaiHistoryWindowCheck() throws { + let formatter = ISO8601DateFormatter() + func epoch(_ value: String) -> Int { + Int(formatter.date(from: value)!.timeIntervalSince1970) + } + let database = try makeFixtureDatabase(rowsSQL: """ + insert into proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + total_cost_usd, status_code, created_at, data_source, request_model, pricing_model + ) values + ('outside-before', 'provider-a', 'codex', 'gpt-5.4', 1, 1, 0, 0, '0', 200, \(epoch("2026-07-17T15:59:59Z")), 'proxy', 'gpt-5.4', ''), + ('inside-first-second', 'provider-a', 'codex', 'gpt-5.4', 1, 1, 0, 0, '0', 200, \(epoch("2026-07-17T16:00:00Z")), 'proxy', 'gpt-5.4', ''), + ('inside-last-second', 'provider-a', 'codex', 'gpt-5.4', 1, 1, 0, 0, '0', 200, \(epoch("2026-07-19T15:59:59Z")), 'proxy', 'gpt-5.4', ''), + ('outside-after', 'provider-a', 'codex', 'gpt-5.4', 1, 1, 0, 0, '0', 200, \(epoch("2026-07-19T16:00:00Z")), 'proxy', 'gpt-5.4', ''); + """) + defer { try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) } + + let snapshot = UsageCollector.collectUsageSnapshotForTests( + ccSwitchDatabaseURL: database, + historyDays: 2, + now: formatter.date(from: "2026-07-19T04:00:00Z")! + ) + try assertEqual(snapshot.totals.tokens, 4, "Shanghai two-day window is calendar-inclusive") + try assertEqual(snapshot.daily.map(\.date), ["2026-07-18", "2026-07-19"], "Shanghai window dates") + } + + private static func runExperimentalAgentChecks() throws { + let zCodeDB = try makeZCodeFixtureDatabase() + let hermesDB = try makeHermesFixtureDatabase() + let workBuddyRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("TokenStepWorkBuddyFixture-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: workBuddyRoot, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: zCodeDB.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: hermesDB.deletingLastPathComponent()) + try? FileManager.default.removeItem(at: workBuddyRoot) + } + + let disabled = UsageCollector.collectUsageSnapshotForTests( + zCodeDatabaseURL: zCodeDB, + hermesDatabaseURL: hermesDB, + workBuddyRootURLs: [workBuddyRoot] + ) + try assertEqual(disabled.sources["ZCode"]?.status, "disabled", "zcode disabled status") + try assertEqual(disabled.sources["Hermes Agent"]?.status, "disabled", "hermes disabled status") + try assertEqual(disabled.sources["WorkBuddy"]?.status, "disabled", "workbuddy disabled status") + try assertEqual(disabled.totals.tokens, 0, "experimental disabled total") + try assertEqual(disabled.agentWork.count, 0, "experimental disabled agent work") + + let snapshot = UsageCollector.collectUsageSnapshotForTests( + zCodeDatabaseURL: zCodeDB, + hermesDatabaseURL: hermesDB, + workBuddyRootURLs: [workBuddyRoot], + includeExperimentalAgentSources: true + ) + try assertEqual(snapshot.sources["ZCode"]?.status, "ok", "zcode source status") + try assertEqual(snapshot.sources["ZCode"]?.records, 1, "zcode source records") + try assertEqual(snapshot.sources["Hermes Agent"]?.status, "ok", "hermes source status") + try assertEqual(snapshot.sources["Hermes Agent"]?.records, 1, "hermes source records") + try assertEqual(snapshot.sources["WorkBuddy"]?.status, "discovered_no_usage", "workbuddy discovered status") + try assertEqual(snapshot.sources["WorkBuddy"]?.records, 0, "workbuddy records") + try assertEqual(snapshot.totals.tokens, 72, "experimental total tokens") + try assertEqual(snapshot.daily.first?.tools["ZCode"], 40, "zcode tool tokens") + try assertEqual(snapshot.daily.first?.tools["Hermes Agent"], 32, "hermes tool tokens") + try assertEqual(snapshot.totals.cost, 0.42, "hermes actual cost") + + let work = try unwrap(snapshot.agentWork.first, "experimental agent work") + try assertEqual(work.totalTokens, 72, "experimental agent work tokens") + try assertEqual(work.modelRequestCount, 3, "experimental model requests") + try assertEqual(work.toolCallCount, 10, "experimental tool calls") + try assertEqual(work.sources.count, 2, "experimental source count") + try assertEqual(work.hourlyBuckets.count, 24, "agent work always exposes 24 hourly buckets") + try assertEqual( + work.hourlyBuckets.map(\.totalTokens).reduce(0, +) + work.unbucketedTokens, + work.totalTokens, + "hourly and unbucketed agent tokens reconcile to the day" + ) + try assertEqual(work.unbucketedTokens, 0, "timestamped agent rows are fully bucketed") + try assertEqual(work.cacheCoverageComplete, true, "experimental cache coverage is complete") + try assertApprox(work.cacheHitRate, 7.0 / 47.0, "daily cache hit uses canonical input denominator") + } + + private static func runLegacyAgentWorkDecodeCheck() throws { + let legacyJSON = Data(""" + { + "date": "2026-07-18", + "total_tokens": 123, + "active_hours": 2, + "model_request_count": 3, + "tool_call_count": 4, + "sources": [] + } + """.utf8) + let work = try JSONDecoder().decode(DailyAgentWork.self, from: legacyJSON) + try assertEqual(work.hourlyBuckets.count, 24, "legacy agent work gains 24 empty buckets") + try assertEqual(work.hourlyBuckets.map(\.totalTokens).reduce(0, +), 0, "legacy buckets are empty") + try assertEqual(work.unbucketedTokens, 123, "legacy total is preserved as unbucketed") + try assertNil(work.cacheHitRate, "legacy cache hit is unavailable") } private static func runCodexArchivedSessionChecks() throws { @@ -214,6 +434,7 @@ struct CCSwitchProxyFixtureCheck { try assertEqual(snapshot.totals.tokens, 120, "codex archived total tokens") try assertEqual(snapshot.daily.first?.date, "2026-06-22", "codex archived daily date") try assertEqual(snapshot.daily.first?.tools["Codex"], 120, "codex archived tool tokens") + try assertNil(snapshot.agentWork.first?.cacheHitRate, "total-only Codex cache hit is unavailable") } private static func runClaudeProxyDedupeChecks() throws { @@ -274,7 +495,7 @@ struct CCSwitchProxyFixtureCheck { try assertEqual(snapshot.daily.first?.tools["Gemini via CC Switch"], 6, "gemini proxy residual tokens") } - private static func runCodexProxyDedupeChecks() throws { + private static func runCodexSimilarIndependentRequestCheck() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("TokenStepCodexDedupeFixture-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) @@ -317,7 +538,7 @@ struct CCSwitchProxyFixtureCheck { input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, total_cost_usd, status_code, created_at, data_source, request_model, pricing_model ) values - ('proxy-codex-strong-match', 'provider-a', 'codex', 'gpt-5.4', 30, 5, 10, 0, '0.45', 200, 1782032400, 'proxy', 'gpt-5.4', ''); + ('independent-proxy-codex-request', 'provider-a', 'codex', 'gpt-5.4', 30, 5, 10, 0, '0.45', 200, 1782032400, 'proxy', 'gpt-5.4', ''); """) defer { try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) @@ -329,14 +550,86 @@ struct CCSwitchProxyFixtureCheck { ) let source = snapshot.sources["CC Switch Proxy"] - try assertEqual(source?.status, "all_deduped", "codex dedupe source status") - try assertEqual(source?.rawRecords, 1, "codex dedupe raw proxy records") - try assertEqual(source?.records, 0, "codex dedupe kept proxy records") - try assertEqual(source?.dedupedRecords, 1, "codex dedupe duplicate proxy records") - try assertEqual(snapshot.totals.tokens, 45, "codex dedupe total tokens") - try assertEqual(snapshot.totals.cost, 0.45, "codex dedupe total cost") - try assertEqual(snapshot.daily.first?.tools["Codex"], 45, "codex native tokens") - try assertNil(snapshot.daily.first?.tools["Codex via CC Switch"], "codex proxy duplicate tokens") + try assertEqual(source?.status, "ok", "independent Codex proxy source status") + try assertEqual(source?.rawRecords, 1, "independent Codex raw proxy records") + try assertEqual(source?.records, 1, "independent Codex proxy record is kept") + try assertEqual(source?.dedupedRecords, 0, "similar requests without a shared ID are not deduplicated") + try assertEqual(snapshot.totals.tokens, 70, "native and independent proxy Codex requests both count") + try assertEqual(snapshot.totals.cost, 0.45, "independent Codex request total cost") + try assertEqual(snapshot.daily.first?.tools["Codex"], 35, "independent native Codex tokens") + try assertEqual( + snapshot.daily.first?.tools["Codex via CC Switch"], + 35, + "independent proxy Codex tokens" + ) + } + + private static func runCodexSharedSessionDedupeCheck() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("TokenStepCodexSessionDedupeFixture-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: root) + } + + let timestamp = "2026-06-21T10:00:00Z" + let sessionID = "shared-codex-session" + let log = root.appendingPathComponent("session.jsonl") + let lines = [ + jsonLine([ + "type": "session_meta", + "timestamp": timestamp, + "payload": ["id": sessionID] + ]), + jsonLine([ + "type": "turn_context", + "timestamp": timestamp, + "payload": ["model": "gpt-5.4"] + ]), + jsonLine([ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 30, + "output_tokens": 5, + "cache_read_input_tokens": 10 + ] + ] + ] + ]) + ] + try lines.joined(separator: "\n").write(to: log, atomically: true, encoding: .utf8) + + let createdAt = Int(ISO8601DateFormatter().date(from: timestamp)!.timeIntervalSince1970) + let database = try makeFixtureDatabase( + rowsSQL: """ + insert into proxy_request_logs ( + request_id, session_id, provider_id, app_type, model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + total_cost_usd, status_code, created_at, data_source, request_model, pricing_model + ) values + ('different-proxy-request-id', '\(sessionID)', 'provider-a', 'codex', 'gpt-5.4', + 30, 5, 10, 0, '0.45', 200, \(createdAt), 'proxy', 'gpt-5.4', ''); + """, + includeSessionID: true + ) + defer { + try? FileManager.default.removeItem(at: database.deletingLastPathComponent()) + } + + let snapshot = UsageCollector.collectUsageSnapshotForTests( + codexRoots: [root], + ccSwitchDatabaseURL: database + ) + let source = snapshot.sources["CC Switch Proxy"] + try assertEqual(source?.status, "all_deduped", "shared-session Codex source status") + try assertEqual(source?.records, 0, "shared-session proxy record is removed") + try assertEqual(source?.dedupedRecords, 1, "shared-session proxy record is deduplicated") + try assertEqual(snapshot.totals.tokens, 35, "shared-session request counts once") + try assertEqual(snapshot.totals.cost, 0.45, "shared-session proxy cost enriches native record") } private static func codexLines(sessionID: String, totalTokens: Int) -> [String] { @@ -366,7 +659,125 @@ struct CCSwitchProxyFixtureCheck { ] } - private static func makeFixtureDatabase(rowsSQL: String = defaultRowsSQL) throws -> URL { + private static func makeZCodeFixtureDatabase() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TokenStepZCodeFixture-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let database = directory.appendingPathComponent("zcode.sqlite") + try runSQLite(database: database, sql: """ + create table model_usage ( + id text primary key, + logical_request_id text not null, + attempt_index integer not null default 0, + session_id text not null, + turn_id text, + trace_id text, + span_id text, + assistant_message_id text, + parent_user_message_id text, + query_source text not null, + provider_id text not null, + model_id text not null, + variant text, + agent text, + mode text, + task_type text, + status text not null, + started_at integer not null, + first_token_at integer, + completed_at integer, + duration_ms integer, + time_to_first_token_ms integer, + finish_reason text, + tool_call_count integer not null default 0, + input_tokens integer not null default 0, + output_tokens integer not null default 0, + reasoning_tokens integer not null default 0, + cache_creation_input_tokens integer not null default 0, + cache_read_input_tokens integer not null default 0, + provider_total_tokens integer, + computed_total_tokens integer not null default 0, + retry_count integer not null default 0, + retryable integer not null default 0, + cancelled_by_user integer not null default 0, + context_exceeded integer not null default 0, + error_type text, + error_code text, + error_message text, + raw_usage_json text, + provider_metadata_json text + ); + insert into model_usage ( + id, logical_request_id, session_id, query_source, provider_id, model_id, status, started_at, + input_tokens, output_tokens, reasoning_tokens, cache_creation_input_tokens, cache_read_input_tokens, + computed_total_tokens, tool_call_count + ) values + ('z-ok', 'logical-1', 'session-z', 'cli', 'openai', 'gpt-5', 'completed', 1717200000000, + 30, 10, 5, 3, 2, 0, 4), + ('z-error', 'logical-2', 'session-z', 'cli', 'openai', 'gpt-5', 'error', 1717203600000, + 100, 100, 0, 0, 0, 200, 99); + """) + return database + } + + private static func makeHermesFixtureDatabase() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TokenStepHermesFixture-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let database = directory.appendingPathComponent("hermes.sqlite") + try runSQLite(database: database, sql: """ + create table sessions ( + id text primary key, + source text not null, + user_id text, + model text, + model_config text, + system_prompt text, + parent_session_id text, + started_at real not null, + ended_at real, + end_reason text, + message_count integer default 0, + tool_call_count integer default 0, + input_tokens integer default 0, + output_tokens integer default 0, + cache_read_tokens integer default 0, + cache_write_tokens integer default 0, + reasoning_tokens integer default 0, + billing_provider text, + billing_base_url text, + billing_mode text, + estimated_cost_usd real, + actual_cost_usd real, + cost_status text, + cost_source text, + pricing_version text, + title text, + api_call_count integer default 0 + ); + create table messages ( + id integer primary key autoincrement, + session_id text not null, + role text not null, + content text, + timestamp real not null + ); + insert into sessions ( + id, source, model, started_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + reasoning_tokens, tool_call_count, api_call_count, actual_cost_usd, estimated_cost_usd, cost_status + ) values + ('h-ok', 'feishu', 'gpt-5.5', 1717203600.5, 10, 15, 5, 2, 3, 6, 2, 0.42, 0.30, 'included'), + ('h-zero', 'cli', 'gpt-5.5', 1717207200, 0, 0, 0, 0, 0, 3, 1, 9.99, 9.99, 'included'); + insert into messages (session_id, role, content, timestamp) values + ('h-ok', 'user', 'must not be read by TokenStep', 1717203600.5); + """) + return database + } + + private static func makeFixtureDatabase( + rowsSQL: String = defaultRowsSQL, + includeSessionID: Bool = false + ) throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("TokenStepCCSwitchFixture-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -374,6 +785,7 @@ struct CCSwitchProxyFixtureCheck { try runSQLite(database: database, sql: """ create table proxy_request_logs ( request_id text primary key, + \(includeSessionID ? "session_id text," : "") provider_id text not null, app_type text not null, model text not null, @@ -393,6 +805,43 @@ struct CCSwitchProxyFixtureCheck { return database } + private static func makeSemanticsFixtureDatabase() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TokenStepCCSwitchSemantics-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let database = directory.appendingPathComponent("cc-switch.db") + try runSQLite(database: database, sql: """ + create table proxy_request_logs ( + request_id text primary key, + provider_id text not null, + app_type text not null, + model text not null, + input_tokens integer not null default 0, + output_tokens integer not null default 0, + cache_read_tokens integer not null default 0, + cache_creation_tokens integer not null default 0, + input_token_semantics integer not null default 0, + total_cost_usd text not null default '0', + status_code integer not null, + created_at integer not null, + data_source text not null default 'proxy', + request_model text, + pricing_model text + ); + insert into proxy_request_logs ( + request_id, provider_id, app_type, model, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + input_token_semantics, + total_cost_usd, status_code, created_at, data_source, request_model, pricing_model + ) values + ('semantics-total', 'provider-a', 'codex', 'gpt-5.4', 100, 20, 60, 10, 1, '0', 200, 1717200000, 'proxy', 'gpt-5.4', ''), + ('semantics-fresh', 'provider-a', 'codex', 'gpt-5.4', 30, 20, 60, 10, 2, '0', 200, 1717203600, 'proxy', 'gpt-5.4', ''), + ('semantics-legacy', 'provider-a', 'codex', 'gpt-5.4', 90, 20, 60, 10, 0, '0', 200, 1717207200, 'proxy', 'gpt-5.4', ''), + ('semantics-claude', 'provider-b', 'claude', 'claude-raw', 30, 20, 60, 10, 2, '0', 200, 1717210800, 'proxy', 'claude-raw', ''); + """) + return database + } + private static func makeLegacyDatabaseWithoutDataSource() throws -> URL { let directory = FileManager.default.temporaryDirectory .appendingPathComponent("TokenStepCCSwitchLegacyFixture-\(UUID().uuidString)", isDirectory: true) @@ -431,7 +880,7 @@ struct CCSwitchProxyFixtureCheck { total_cost_usd, status_code, created_at, data_source, request_model, pricing_model ) values ('proxy-1', 'provider-a', 'claude', 'claude-raw', 100, 20, 30, 5, '0.12', 200, 1717200000, 'proxy', 'claude-request', 'claude-priced'), - ('proxy-2', 'provider-b', 'codex', 'gpt-5.4', 10, 3, 0, 0, '0.34', 201, 1717203600, 'proxy', 'gpt-5-request', ''), + ('proxy-2', 'provider-b', 'codex', 'gpt-5.4', 10, 3, 8, 0, '0.34', 201, 1717203600, 'proxy', 'gpt-5-request', ''), ('session-import', 'provider-c', 'claude', 'claude-session-raw', 21, 19, 0, 0, '0.10', 200, 1717207200, 'session_log', 'claude-session-request', 'claude-session-priced'), ('codex-import', 'provider-c', 'codex', 'codex-session-raw', 99, 1, 0, 0, '0.20', 200, 1717207200, 'codex_session', 'codex-session-request', 'codex-session-priced'), ('failed-proxy', 'provider-d', 'gemini', 'ignored-gemini', 1000, 1000, 0, 0, '8.88', 500, 1717207200, 'proxy', 'ignored', 'ignored'), @@ -532,6 +981,19 @@ struct CCSwitchProxyFixtureCheck { throw FixtureError.message("\(label): expected nil, got \(String(describing: actual))") } } + + private static func assertApprox(_ actual: Double?, _ expected: Double, _ label: String) throws { + guard let actual, abs(actual - expected) < 0.000_001 else { + throw FixtureError.message("\(label): expected \(expected), got \(String(describing: actual))") + } + } + + private static func unwrap(_ value: T?, _ label: String) throws -> T { + guard let value else { + throw FixtureError.message("\(label): expected value, got nil") + } + return value + } } private enum FixtureError: Error, CustomStringConvertible { diff --git a/TokenStepSwift/Tests/Fixtures/CodexCumulativeFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/CodexCumulativeFixtureCheck.swift new file mode 100644 index 0000000..844437c --- /dev/null +++ b/TokenStepSwift/Tests/Fixtures/CodexCumulativeFixtureCheck.swift @@ -0,0 +1,818 @@ +import Darwin +import Foundation + +@main +struct CodexCumulativeFixtureCheck { + static func main() { + do { + try checkCumulativeDeduplicationAndCompaction() + try checkCredibleCounterReset() + try checkLegacyFallback() + try checkResumedSessionExplicitBaseline() + try checkReplayChildWithSecondMetadata() + try checkReplayChildWithSingleMetadata() + try checkIsolatedChild() + try checkParallelChildren() + try checkNestedChild() + try checkRescanAppendAndRebuildStability() + try checkParentAnchorCacheDependency() + try checkLegacyCacheRevisionTriggersRecalibration() + try checkReasoningAndCachedSubsetsAreNotDoublePriced() + try checkShanghaiMidnightBoundary() + print("Codex cumulative collector fixture checks passed") + } catch { + fputs("Codex cumulative collector fixture failed: \(error)\n", stderr) + exit(1) + } + } + + private static func checkCumulativeDeduplicationAndCompaction() throws { + try withFixtureHome("cumulative") { home in + let v100 = UsageVector(input: 80, output: 20, cached: 60, reasoning: 5) + let v160 = UsageVector(input: 125, output: 35, cached: 90, reasoning: 10) + let v230 = UsageVector(input: 180, output: 50, cached: 120, reasoning: 15) + let d60 = UsageVector(input: 45, output: 15, cached: 30, reasoning: 5) + let d70 = UsageVector(input: 55, output: 15, cached: 30, reasoning: 5) + let hugeLast = UsageVector( + input: 1_600_000, + output: 366_220, + cached: 1_200_000, + reasoning: 300_000 + ) + let compactedLast = UsageVector(input: 7_000, output: 1_271, cached: 5_000, reasoning: 1_000) + + try writeSession( + home: home, + filename: "normal-and-repeated.jsonl", + lines: [ + sessionMeta(id: "normal-session", timestamp: "2026-07-13T08:00:00Z"), + turnContext(model: "gpt-5", timestamp: "2026-07-13T08:00:01Z"), + tokenCount(timestamp: "2026-07-13T08:01:00Z", cumulative: v100, last: v100), + tokenCount(timestamp: "2026-07-13T08:02:00Z", cumulative: v160, last: d60), + tokenCount(timestamp: "2026-07-13T08:03:00Z", cumulative: v160, last: d60, marker: "turn_complete"), + tokenCount(timestamp: "2026-07-13T08:04:00Z", cumulative: v160, last: d60, marker: "paused"), + tokenCount(timestamp: "2026-07-13T08:05:00Z", cumulative: v160, last: hugeLast, marker: "context_compacted"), + tokenCount( + timestamp: "2026-07-13T08:05:01Z", + cumulative: nil, + last: compactedLast, + marker: "compaction_last_only" + ), + tokenCount(timestamp: "2026-07-13T08:06:00Z", cumulative: v230, last: d70, marker: "resumed"), + tokenCount(timestamp: "2026-07-13T08:07:00Z", cumulative: v230, last: d70, marker: "task_complete") + ] + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + try expectEqual(snapshot.totals.tokens, 230, "cumulative total uses only positive deltas") + try expectEqual(snapshot.sources["Codex"]?.exactRecords, 3, "three positive cumulative increments") + try expectEqual(snapshot.sources["Codex"]?.legacyRecords, 0, "mixed-schema last-only event is not legacy fallback") + try expectEqual(snapshot.sources["Codex"]?.duplicateRecords, 4, "same cumulative snapshots are duplicates") + try expectEqual(snapshot.sources["Codex"]?.skippedRecords, 1, "last-only compaction event is skipped") + try expectEqual( + snapshot.sources["Codex"]?.tokenBreakdown, + SourceTokenBreakdown( + processedTokens: 230, + inputTokens: 180, + cachedInputTokens: 120, + uncachedInputTokens: 60, + outputTokens: 50, + reasoningTokens: 15 + ), + "component deltas remain intact" + ) + } + } + + private static func checkCredibleCounterReset() throws { + try withFixtureHome("reset") { home in + let v50 = UsageVector(input: 40, output: 10, cached: 25, reasoning: 3) + let v80 = UsageVector(input: 60, output: 20, cached: 40, reasoning: 8) + let v10 = UsageVector(input: 8, output: 2, cached: 5, reasoning: 1) + let v30 = UsageVector(input: 20, output: 10, cached: 15, reasoning: 4) + try writeSession( + home: home, + filename: "counter-reset.jsonl", + lines: [ + sessionMeta(id: "reset-session", timestamp: "2026-07-13T09:00:00Z"), + turnContext(model: "gpt-5", timestamp: "2026-07-13T09:00:01Z"), + tokenCount(timestamp: "2026-07-13T09:01:00Z", cumulative: v50, last: v50), + tokenCount( + timestamp: "2026-07-13T09:02:00Z", + cumulative: v80, + last: UsageVector(input: 20, output: 10, cached: 15, reasoning: 5) + ), + contextWindowSentinel(timestamp: "2026-07-13T09:02:30Z", contextWindow: 40), + tokenCount(timestamp: "2026-07-13T09:03:00Z", cumulative: v10, last: v10, marker: "counter_reset"), + tokenCount( + timestamp: "2026-07-13T09:04:00Z", + cumulative: v30, + last: UsageVector(input: 12, output: 8, cached: 10, reasoning: 3) + ) + ] + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + try expectEqual(snapshot.totals.tokens, 110, "credible reset starts a new cumulative epoch") + try expectEqual(snapshot.sources["Codex"]?.counterResets, 1, "one reset is diagnosed") + try expectEqual(snapshot.sources["Codex"]?.skippedRecords, 1, "context-window sentinel is ignored without moving the counter") + try expectEqual(snapshot.sources["Codex"]?.exactRecords, 4, "all positive increments across epochs survive") + try expectEqual(snapshot.sources["Codex"]?.tokenBreakdown?.inputTokens, 80, "reset input components") + try expectEqual(snapshot.sources["Codex"]?.tokenBreakdown?.outputTokens, 30, "reset output components") + } + } + + private static func checkLegacyFallback() throws { + try withFixtureHome("legacy") { home in + let first = UsageVector(input: 80, output: 20, cached: 50, reasoning: 5) + let second = UsageVector(input: 35, output: 15, cached: 20, reasoning: 4) + try writeSession( + home: home, + filename: "legacy-last-only.jsonl", + lines: [ + sessionMeta(id: "legacy-session", timestamp: "2026-07-13T10:00:00Z"), + turnContext(model: "gpt-5", timestamp: "2026-07-13T10:00:01Z"), + tokenCount(timestamp: "2026-07-13T10:01:00Z", cumulative: nil, last: first), + tokenCount(timestamp: "2026-07-13T10:02:00Z", cumulative: nil, last: second) + ] + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + try expectEqual(snapshot.totals.tokens, 150, "legacy last-only usage remains available") + try expectEqual(snapshot.sources["Codex"]?.exactRecords, 0, "legacy events are not presented as exact") + try expectEqual(snapshot.sources["Codex"]?.legacyRecords, 2, "legacy records are explicitly diagnosed") + } + } + + private static func checkResumedSessionExplicitBaseline() throws { + try withFixtureHome("resumed-explicit-baseline") { home in + let resumedBaseline = UsageVector( + input: 80, + output: 20, + cached: 60, + reasoning: 5, + explicitTotal: 250 + ) + let resumedNext = UsageVector( + input: 125, + output: 35, + cached: 90, + reasoning: 10, + explicitTotal: 310 + ) + let delta = UsageVector(input: 45, output: 15, cached: 30, reasoning: 5) + try writeSession( + home: home, + filename: "resumed-explicit-baseline.jsonl", + lines: [ + sessionMeta(id: "resumed-explicit", timestamp: "2026-07-13T10:30:00Z"), + turnContext(model: "gpt-5", timestamp: "2026-07-13T10:30:01Z"), + tokenCount( + timestamp: "2026-07-13T10:31:00Z", + cumulative: resumedBaseline, + last: UsageVector(input: 80, output: 20, cached: 60, reasoning: 5) + ), + tokenCount( + timestamp: "2026-07-13T10:32:00Z", + cumulative: resumedNext, + last: delta + ) + ] + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + try expectEqual( + snapshot.totals.tokens, + 310, + "explicit cumulative total preserves a resumed-session historical baseline" + ) + try expectEqual( + snapshot.sources["Codex"]?.unknownBreakdownRecords, + 1, + "an unreconciled baseline is retained as total-only" + ) + try expectEqual( + snapshot.sources["Codex"]?.tokenBreakdown, + SourceTokenBreakdown( + processedTokens: 310, + inputTokens: 45, + cachedInputTokens: 30, + uncachedInputTokens: 15, + outputTokens: 15, + reasoningTokens: 5 + ), + "known post-resume deltas keep their canonical breakdown" + ) + try expectEqual( + snapshot.agentWork.first?.cacheHitRate, + nil, + "an unknown historical baseline must not be presented as a zero cache rate" + ) + } + } + + private static func checkReplayChildWithSecondMetadata() throws { + try withFixtureHome("replay-second-meta") { home in + let parentID = "parent-with-replay" + try writeParentWithPostForkUsage(home: home, filename: "99-parent.jsonl", id: parentID) + try writeSession( + home: home, + filename: "00-child.jsonl", + lines: replayChildLines( + id: "replay-child", + parentID: parentID, + includeSecondParentMetadata: true, + terminalTotals: [360, 420] + ) + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + try expectEqual(snapshot.totals.tokens, 570, "parent 450 plus child-exclusive 120") + try expectEqual(snapshot.sources["Codex"]?.inheritedTokens, 300, "replayed parent anchor is diagnosed") + try expectEqual(snapshot.sources["Codex"]?.files, 2, "child filename sorting before parent is safe") + } + } + + private static func checkReplayChildWithSingleMetadata() throws { + try withFixtureHome("replay-single-meta") { home in + let parentID = "parent-single-meta" + try writeSimpleParent(home: home, filename: "parent.jsonl", id: parentID) + try writeSession( + home: home, + filename: "child.jsonl", + lines: replayChildLines( + id: "single-meta-child", + parentID: parentID, + includeSecondParentMetadata: false, + replayTotals: [300], + terminalTotals: [360] + ) + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + try expectEqual(snapshot.totals.tokens, 360, "single-meta replay child contributes only 60") + try expectEqual(snapshot.sources["Codex"]?.inheritedTokens, 300, "single-meta anchor is recognized") + } + } + + private static func checkIsolatedChild() throws { + try withFixtureHome("isolated-child") { home in + let parentID = "parent-isolated" + try writeSimpleParent(home: home, filename: "parent.jsonl", id: parentID) + try writeSession( + home: home, + filename: "child.jsonl", + lines: [ + sessionMeta(id: "isolated-child", timestamp: "2026-07-13T00:04:00Z", parentID: parentID), + turnContext(model: "gpt-5", timestamp: "2026-07-13T00:04:01Z"), + tokenCount(timestamp: "2026-07-13T00:05:00Z", cumulative: vector(total: 20), last: vector(total: 20)), + tokenCount(timestamp: "2026-07-13T00:06:00Z", cumulative: vector(total: 50), last: vector(total: 30)) + ] + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + try expectEqual(snapshot.totals.tokens, 350, "isolated child starts at zero and keeps all 50") + try expectEqual(snapshot.sources["Codex"]?.inheritedTokens, 0, "isolated child is not force-subtracted") + } + } + + private static func checkParallelChildren() throws { + try withFixtureHome("parallel-children") { home in + let parentID = "parallel-parent" + try writeSimpleParent(home: home, filename: "parent.jsonl", id: parentID) + try writeSession( + home: home, + filename: "child-a.jsonl", + lines: replayChildLines( + id: "parallel-child-a", + parentID: parentID, + includeSecondParentMetadata: false, + replayTotals: [300], + terminalTotals: [350] + ) + ) + try writeSession( + home: home, + filename: "child-b.jsonl", + lines: replayChildLines( + id: "parallel-child-b", + parentID: parentID, + includeSecondParentMetadata: true, + terminalTotals: [370] + ) + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + try expectEqual(snapshot.totals.tokens, 420, "parallel children retain independent 50 and 70 deltas") + try expectEqual(snapshot.sources["Codex"]?.inheritedTokens, 600, "each sibling has its own parent anchor") + } + } + + private static func checkNestedChild() throws { + try withFixtureHome("nested-child") { home in + let rootID = "nested-root" + let childID = "nested-child" + try writeSimpleParent(home: home, filename: "root.jsonl", id: rootID) + try writeSession( + home: home, + filename: "child.jsonl", + lines: replayChildLines( + id: childID, + parentID: rootID, + includeSecondParentMetadata: false, + replayTotals: [300], + terminalTotals: [360, 420] + ) + ) + try writeSession( + home: home, + filename: "grandchild.jsonl", + lines: [ + sessionMeta(id: "nested-grandchild", timestamp: "2026-07-13T00:08:00Z", parentID: childID), + turnContext(model: "gpt-5", timestamp: "2026-07-13T00:08:01Z"), + tokenCount(timestamp: "2026-07-13T00:08:10Z", cumulative: vector(total: 300), last: vector(total: 300)), + tokenCount(timestamp: "2026-07-13T00:08:20Z", cumulative: vector(total: 420), last: vector(total: 120)), + tokenCount(timestamp: "2026-07-13T00:09:00Z", cumulative: vector(total: 455), last: vector(total: 35)), + tokenCount(timestamp: "2026-07-13T00:09:10Z", cumulative: vector(total: 455), last: vector(total: 35), marker: "task_complete"), + tokenCount(timestamp: "2026-07-13T00:10:00Z", cumulative: vector(total: 500), last: vector(total: 45)) + ] + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + try expectEqual(snapshot.totals.tokens, 500, "root 300 + child 120 + grandchild 80") + try expectEqual(snapshot.sources["Codex"]?.inheritedTokens, 720, "nested child anchors to immediate parent raw cumulative") + try expectEqual(snapshot.sources["Codex"]?.duplicateRecords, 1, "nested repeated snapshot is ignored") + } + } + + private static func checkRescanAppendAndRebuildStability() throws { + try withFixtureHome("stable-rescan") { home in + let cacheURL = home.appendingPathComponent("fixture-cache/collector-cache-v8.json") + let initialLines = [ + sessionMeta(id: "stable-session", timestamp: "2026-07-13T11:00:00Z"), + turnContext(model: "gpt-5", timestamp: "2026-07-13T11:00:01Z"), + tokenCount(timestamp: "2026-07-13T11:01:00Z", cumulative: vector(total: 100), last: vector(total: 100)), + tokenCount(timestamp: "2026-07-13T11:02:00Z", cumulative: vector(total: 160), last: vector(total: 60)) + ] + let log = try writeSession(home: home, filename: "stable.jsonl", lines: initialLines) + + let first = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home, cacheURL: cacheURL) + let firstCacheData = try Data(contentsOf: cacheURL) + let repeated = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home, cacheURL: cacheURL) + let repeatedCacheData = try Data(contentsOf: cacheURL) + try expectEqual(first.totals.tokens, 160, "initial scan total") + try expectEqual(snapshotSignature(repeated), snapshotSignature(first), "unchanged repeated scan is deterministic") + try expectEqual(repeatedCacheData, firstCacheData, "unchanged cache hit is byte-stable") + try expectEqual(try cacheVersion(at: cacheURL), 8, "fixture uses the v8 cache schema") + + let originalMetadata = try FileManager.default.attributesOfItem(atPath: log.path) + let originalModificationDate = originalMetadata[.modificationDate] as? Date + let rewrittenLines = [ + initialLines[0], + initialLines[1], + initialLines[2], + tokenCount(timestamp: "2026-07-13T11:02:00Z", cumulative: vector(total: 190), last: vector(total: 90)) + ] + let originalSize = try Data(contentsOf: log).count + try (rewrittenLines.joined(separator: "\n") + "\n").write(to: log, atomically: true, encoding: .utf8) + try expectEqual(try Data(contentsOf: log).count, originalSize, "rewrite preserves file size") + if let originalModificationDate { + try FileManager.default.setAttributes([.modificationDate: originalModificationDate], ofItemAtPath: log.path) + let restoredAttributes = try FileManager.default.attributesOfItem(atPath: log.path) + let restoredModificationDate = restoredAttributes[.modificationDate] as? Date + guard let restoredModificationDate, + abs( + restoredModificationDate.timeIntervalSince1970 + - originalModificationDate.timeIntervalSince1970 + ) < 0.001 + else { + throw FixtureFailure( + "rewrite restores the cached modification time within filesystem precision" + ) + } + } + let afterSameMetadataRewrite = UsageCollector.collectCodexUsageSnapshotForTests( + homeURL: home, + cacheURL: cacheURL + ) + try expectEqual( + afterSameMetadataRewrite.totals.tokens, + 190, + "content fingerprint invalidates same-size same-mtime rewrites" + ) + + let appended = tokenCount( + timestamp: "2026-07-13T11:03:00Z", + cumulative: vector(total: 230), + last: vector(total: 70) + ) + try appendLine(appended, to: log) + let afterAppend = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home, cacheURL: cacheURL) + try expectEqual(afterAppend.totals.tokens, 230, "append advances only to final cumulative total") + + try FileManager.default.removeItem(at: cacheURL) + let afterCacheRebuild = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home, cacheURL: cacheURL) + try expectEqual( + snapshotSignature(afterCacheRebuild), + snapshotSignature(afterAppend), + "deleting and rebuilding the cache preserves accounting" + ) + + try withFixtureHome("rebuilt-copy") { rebuiltHome in + try writeSession(home: rebuiltHome, filename: "renamed-after-rebuild.jsonl", lines: initialLines + [appended]) + let rebuilt = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: rebuiltHome) + try expectEqual( + snapshotSignature(rebuilt), + snapshotSignature(afterAppend), + "fresh cache-equivalent rebuild has identical accounting" + ) + } + } + } + + private static func checkParentAnchorCacheDependency() throws { + try withFixtureHome("parent-cache-dependency") { home in + let cacheURL = home.appendingPathComponent("fixture-cache/collector-cache-v8.json") + let parentLines = [ + sessionMeta(id: "cache-parent", timestamp: "2026-07-13T00:00:00Z"), + turnContext(model: "gpt-5", timestamp: "2026-07-13T00:00:01Z"), + tokenCount(timestamp: "2026-07-13T00:01:00Z", cumulative: vector(total: 100), last: vector(total: 100)), + tokenCount(timestamp: "2026-07-13T00:02:00Z", cumulative: vector(total: 200), last: vector(total: 100)) + ] + let parentLog = try writeSession(home: home, filename: "99-parent.jsonl", lines: parentLines) + try writeSession( + home: home, + filename: "00-child.jsonl", + lines: replayChildLines( + id: "cache-child", + parentID: "cache-parent", + includeSecondParentMetadata: false, + replayTotals: [100, 200, 300], + terminalTotals: [360] + ) + ) + + let beforeParentAppend = UsageCollector.collectCodexUsageSnapshotForTests( + homeURL: home, + cacheURL: cacheURL + ) + try expectEqual(beforeParentAppend.totals.tokens, 360, "initial parent 200 plus child-exclusive 160") + try expectEqual(beforeParentAppend.sources["Codex"]?.inheritedTokens, 200, "initial cached fork anchor") + + try appendLine( + tokenCount( + timestamp: "2026-07-13T00:03:00Z", + cumulative: vector(total: 300), + last: vector(total: 100) + ), + to: parentLog + ) + let afterParentAppend = UsageCollector.collectCodexUsageSnapshotForTests( + homeURL: home, + cacheURL: cacheURL + ) + try expectEqual(afterParentAppend.totals.tokens, 360, "parent anchor update does not replay cached child history") + try expectEqual(afterParentAppend.sources["Codex"]?.inheritedTokens, 300, "unchanged child cache uses refreshed parent anchor") + } + } + + private static func checkLegacyCacheRevisionTriggersRecalibration() throws { + try withFixtureHome("legacy-cache-revision") { home in + let cacheURL = home.appendingPathComponent("fixture-cache/collector-cache-v7.json") + try FileManager.default.createDirectory( + at: cacheURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let legacyCache = """ + { + "version": 7, + "files": { + "/tmp/legacy.jsonl": { + "tool": "Codex", + "size": 1, + "modificationTime": 1, + "records": [ + { + "date": "2026-07-13", + "timestamp": "2026-07-13T00:01:00Z", + "tool": "Codex", + "model": "gpt-5", + "usage": { + "inputTokens": 80, + "outputTokens": 20, + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 60, + "reasoningOutputTokens": 5, + "totalTokens": 100 + }, + "source": "nativeCodex" + } + ] + } + } + } + """ + try legacyCache.write(to: cacheURL, atomically: true, encoding: .utf8) + try expectEqual( + UsageCollector.collectorCacheRecalibrationRevisionForTests(cacheURL: cacheURL), + 7, + "v7 cache records without Agent counters still preserve the revision and request v8 recalibration" + ) + } + } + + private static func checkReasoningAndCachedSubsetsAreNotDoublePriced() throws { + try withFixtureHome("gpt-54-cost") { home in + let usage = UsageVector( + input: 1_000_000, + output: 200_000, + cached: 400_000, + reasoning: 100_000 + ) + try writeSession( + home: home, + filename: "gpt-54.jsonl", + lines: [ + sessionMeta(id: "gpt-54-cost", timestamp: "2026-07-13T12:00:00Z"), + turnContext(model: "gpt-5.4", timestamp: "2026-07-13T12:00:01Z"), + tokenCount(timestamp: "2026-07-13T12:01:00Z", cumulative: usage, last: usage) + ] + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + try expectEqual(snapshot.totals.tokens, 1_200_000, "processed total is input plus output only") + try expectEqual(snapshot.totals.cost, 4.6, "cached and reasoning subsets are not double-priced") + try expectEqual(snapshot.sources["Codex"]?.tokenBreakdown?.cachedInputTokens, 400_000, "cached subset retained") + try expectEqual(snapshot.sources["Codex"]?.tokenBreakdown?.reasoningTokens, 100_000, "reasoning subset retained") + try expectEqual(snapshot.sources["Codex"]?.tokenBreakdown?.uncachedInputTokens, 600_000, "uncached input derived once") + } + } + + private static func checkShanghaiMidnightBoundary() throws { + try withFixtureHome("shanghai-midnight") { home in + try writeSession( + home: home, + filename: "midnight.jsonl", + lines: [ + sessionMeta(id: "midnight-session", timestamp: "2026-06-21T15:59:00Z"), + turnContext(model: "gpt-5", timestamp: "2026-06-21T15:59:01Z"), + tokenCount(timestamp: "2026-06-21T15:59:59Z", cumulative: vector(total: 100), last: vector(total: 100)), + tokenCount(timestamp: "2026-06-21T16:00:00Z", cumulative: vector(total: 160), last: vector(total: 60)) + ] + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + try expectEqual(snapshot.daily.count, 2, "UTC 16:00 crosses Shanghai midnight") + try expectEqual(dailyTokens(snapshot, date: "2026-06-21"), 100, "pre-midnight delta stays on prior Shanghai day") + try expectEqual(dailyTokens(snapshot, date: "2026-06-22"), 60, "post-midnight delta moves to next Shanghai day") + try expectEqual(snapshot.rhythm(for: "2026-06-21")?.bucket(hour: 23).tokens, 100, "23:59 Shanghai rhythm bucket") + try expectEqual(snapshot.rhythm(for: "2026-06-22")?.bucket(hour: 0).tokens, 60, "00:00 Shanghai rhythm bucket") + } + } + + private static func writeParentWithPostForkUsage(home: URL, filename: String, id: String) throws { + try writeSession( + home: home, + filename: filename, + lines: [ + sessionMeta(id: id, timestamp: "2026-07-13T00:00:00Z"), + turnContext(model: "gpt-5", timestamp: "2026-07-13T00:00:01Z"), + tokenCount(timestamp: "2026-07-13T00:01:00Z", cumulative: vector(total: 100), last: vector(total: 100)), + tokenCount(timestamp: "2026-07-13T00:02:00Z", cumulative: vector(total: 200), last: vector(total: 100)), + tokenCount(timestamp: "2026-07-13T00:03:00Z", cumulative: vector(total: 300), last: vector(total: 100)), + tokenCount(timestamp: "2026-07-13T00:10:00Z", cumulative: vector(total: 450), last: vector(total: 150)) + ] + ) + } + + private static func writeSimpleParent(home: URL, filename: String, id: String) throws { + try writeSession( + home: home, + filename: filename, + lines: [ + sessionMeta(id: id, timestamp: "2026-07-13T00:00:00Z"), + turnContext(model: "gpt-5", timestamp: "2026-07-13T00:00:01Z"), + tokenCount(timestamp: "2026-07-13T00:01:00Z", cumulative: vector(total: 100), last: vector(total: 100)), + tokenCount(timestamp: "2026-07-13T00:02:00Z", cumulative: vector(total: 200), last: vector(total: 100)), + tokenCount(timestamp: "2026-07-13T00:03:00Z", cumulative: vector(total: 300), last: vector(total: 100)) + ] + ) + } + + private static func replayChildLines( + id: String, + parentID: String, + includeSecondParentMetadata: Bool, + replayTotals: [Int] = [100, 200, 300], + terminalTotals: [Int] + ) -> [String] { + var lines = [ + sessionMeta(id: id, timestamp: "2026-07-13T00:04:00Z", parentID: parentID) + ] + if includeSecondParentMetadata { + lines.append(sessionMeta(id: parentID, timestamp: "2026-07-13T00:04:01Z")) + } + lines.append(turnContext(model: "gpt-5", timestamp: "2026-07-13T00:04:02Z")) + var previous = 0 + for (index, total) in (replayTotals + terminalTotals).enumerated() { + let increment = max(0, total - previous) + lines.append( + tokenCount( + timestamp: String(format: "2026-07-13T00:%02d:00Z", 5 + index), + cumulative: vector(total: total), + last: vector(total: increment) + ) + ) + previous = total + } + return lines + } + + private static func sessionMeta(id: String, timestamp: String, parentID: String? = nil) -> String { + var payload: [String: Any] = ["id": id] + if let parentID { + payload["source"] = [ + "subagent": [ + "thread_spawn": ["parent_thread_id": parentID] + ] + ] + } + return jsonLine([ + "type": "session_meta", + "timestamp": timestamp, + "payload": payload + ]) + } + + private static func turnContext(model: String, timestamp: String) -> String { + jsonLine([ + "type": "turn_context", + "timestamp": timestamp, + "payload": ["model": model] + ]) + } + + private static func tokenCount( + timestamp: String, + cumulative: UsageVector?, + last: UsageVector?, + marker: String? = nil + ) -> String { + var info: [String: Any] = [:] + if let cumulative { + info["total_token_usage"] = cumulative.dictionary + } + if let last { + info["last_token_usage"] = last.dictionary + } + var payload: [String: Any] = [ + "type": "token_count", + "info": info + ] + if let marker { + payload["status"] = marker + } + return jsonLine([ + "type": "event_msg", + "timestamp": timestamp, + "payload": payload + ]) + } + + private static func contextWindowSentinel(timestamp: String, contextWindow: Int) -> String { + let zeroUsage: [String: Any] = [ + "input_tokens": 0, + "output_tokens": 0, + "cached_input_tokens": 0, + "reasoning_output_tokens": 0, + "total_tokens": 0 + ] + var cumulative = zeroUsage + cumulative["total_tokens"] = contextWindow + return jsonLine([ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": [ + "total_token_usage": cumulative, + "last_token_usage": zeroUsage, + "model_context_window": contextWindow + ] + ] + ]) + } + + private static func vector(total: Int) -> UsageVector { + let output = max(1, total / 5) + return UsageVector( + input: total - output, + output: output, + cached: min(total - output, total / 2), + reasoning: min(output, total / 10) + ) + } + + @discardableResult + private static func writeSession(home: URL, filename: String, lines: [String]) throws -> URL { + let root = home.appendingPathComponent(".codex/sessions/2026/07/13", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let url = root.appendingPathComponent(filename) + try (lines.joined(separator: "\n") + "\n").write(to: url, atomically: true, encoding: .utf8) + return url + } + + private static func appendLine(_ line: String, to url: URL) throws { + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + try handle.seekToEnd() + try handle.write(contentsOf: Data((line + "\n").utf8)) + try handle.synchronize() + } + + private static func withFixtureHome( + _ label: String, + body: (URL) throws -> Void + ) throws { + let home = URL(fileURLWithPath: "/tmp", isDirectory: true) + .appendingPathComponent("TokenStepCodex-\(label)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: home) } + try body(home) + } + + private static func dailyTokens(_ snapshot: UsageSnapshot, date: String) -> Int? { + snapshot.daily.first(where: { $0.date == date })?.totalTokens + } + + private static func cacheVersion(at url: URL) throws -> Int? { + let data = try Data(contentsOf: url) + let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] + return object?["version"] as? Int + } + + private static func snapshotSignature(_ snapshot: UsageSnapshot) -> SnapshotSignature { + SnapshotSignature( + tokens: snapshot.totals.tokens, + cost: snapshot.totals.cost, + daily: snapshot.daily.map { "\($0.date):\($0.totalTokens)" }, + exact: snapshot.sources["Codex"]?.exactRecords, + legacy: snapshot.sources["Codex"]?.legacyRecords, + duplicates: snapshot.sources["Codex"]?.duplicateRecords, + resets: snapshot.sources["Codex"]?.counterResets, + breakdown: snapshot.sources["Codex"]?.tokenBreakdown + ) + } + + private static func jsonLine(_ object: [String: Any]) -> String { + let data = try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + return String(data: data, encoding: .utf8)! + } + + private static func expectEqual( + _ actual: T, + _ expected: T, + _ label: String + ) throws { + guard actual == expected else { + throw FixtureFailure("\(label): expected \(expected), got \(actual)") + } + } +} + +private struct UsageVector { + var input: Int + var output: Int + var cached: Int + var reasoning: Int + var explicitTotal: Int? = nil + + var dictionary: [String: Any] { + [ + "input_tokens": input, + "output_tokens": output, + "cached_input_tokens": cached, + "reasoning_output_tokens": reasoning, + "total_tokens": explicitTotal ?? (input + output) + ] + } +} + +private struct SnapshotSignature: Equatable { + var tokens: Int + var cost: Double + var daily: [String] + var exact: Int? + var legacy: Int? + var duplicates: Int? + var resets: Int? + var breakdown: SourceTokenBreakdown? +} + +private struct FixtureFailure: Error, CustomStringConvertible { + var description: String + + init(_ description: String) { + self.description = description + } +} diff --git a/TokenStepSwift/Tests/Fixtures/UsageRecalibrationMigrationFixtureCheck.swift b/TokenStepSwift/Tests/Fixtures/UsageRecalibrationMigrationFixtureCheck.swift new file mode 100644 index 0000000..da335f0 --- /dev/null +++ b/TokenStepSwift/Tests/Fixtures/UsageRecalibrationMigrationFixtureCheck.swift @@ -0,0 +1,138 @@ +import Foundation + +@main +struct UsageRecalibrationMigrationFixtureCheck { + static func main() throws { + let root = AppPaths.appSupportRoot + try? FileManager.default.removeItem(at: root) + defer { try? FileManager.default.removeItem(at: root) } + + let legacy = snapshot(accountingRevision: nil, records: 1) + let current = snapshot(accountingRevision: 8, records: 2) + let migrated = try DataService.persistSnapshotForMigrationTests( + current, + previousSnapshot: legacy + ) + try expect( + migrated.sources["Codex"]?.recalibratedFromRevision == 5, + "legacy snapshots should be treated as accounting revision 5" + ) + try expect( + DataService.hasPendingUsageRecalibrationNotice, + "successful legacy migration should create the pending notice marker" + ) + try expect( + try String(contentsOf: AppPaths.usageRecalibrationNoticeMarker, encoding: .utf8) == "8", + "pending marker should identify accounting revision 8" + ) + + DataService.acknowledgeUsageRecalibrationNotice() + try expect( + !DataService.hasPendingUsageRecalibrationNotice, + "acknowledging the notice should remove its marker" + ) + + try? FileManager.default.removeItem(at: root) + _ = try DataService.persistSnapshotForMigrationTests(current, previousSnapshot: nil) + try expect( + !DataService.hasPendingUsageRecalibrationNotice, + "a new installation must not see a migration notice" + ) + + let alreadyCurrent = snapshot(accountingRevision: 8, records: 1) + _ = try DataService.persistSnapshotForMigrationTests(current, previousSnapshot: alreadyCurrent) + try expect( + !DataService.hasPendingUsageRecalibrationNotice, + "an already-current snapshot must not recreate the notice" + ) + + try? FileManager.default.removeItem(at: root) + _ = try DataService.persistSnapshotForMigrationTests(legacy, previousSnapshot: nil) + let failedRecalibration = snapshot(accountingRevision: 8, records: 0) + do { + _ = try DataService.persistSnapshotForMigrationTests( + failedRecalibration, + previousSnapshot: legacy + ) + throw NSError( + domain: "UsageRecalibrationMigrationFixture", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "an empty recalibration must fail"] + ) + } catch let error as NSError where error.domain == "TokenStepCollector" { + // Expected: the old snapshot must stay intact until a valid v8 scan exists. + } + let preserved = try DataService.loadSnapshot() + try expect( + preserved.totals.tokens == legacy.totals.tokens, + "a failed recalibration must preserve the previous usage snapshot" + ) + try expect( + !DataService.hasPendingUsageRecalibrationNotice, + "a failed recalibration must not create a migration notice" + ) + + let sqliteFallback = snapshot( + accountingRevision: nil, + records: 1, + status: "ok_sqlite" + ) + do { + _ = try DataService.persistSnapshotForMigrationTests( + sqliteFallback, + previousSnapshot: legacy + ) + throw NSError( + domain: "UsageRecalibrationMigrationFixture", + code: 3, + userInfo: [NSLocalizedDescriptionKey: "a SQLite fallback must not complete recalibration"] + ) + } catch let error as NSError where error.domain == "TokenStepCollector" { + // Expected: an approximate fallback cannot overwrite an older exact snapshot. + } + let preservedAfterFallback = try DataService.loadSnapshot() + try expect( + preservedAfterFallback.totals.tokens == legacy.totals.tokens, + "a SQLite fallback must preserve the previous usage snapshot" + ) + try expect( + !DataService.hasPendingUsageRecalibrationNotice, + "a SQLite fallback must not create a migration notice" + ) + + print("PASS: usage recalibration migration marker and failure preservation") + } + + private static func snapshot( + accountingRevision: Int?, + records: Int, + status: String = "ok" + ) -> UsageSnapshot { + UsageSnapshot( + generatedAt: "2026-07-13T00:00:00Z", + timezone: "Asia/Shanghai", + totals: UsageTotals(tokens: records * 100, cost: 0, activeDays: records > 0 ? 1 : 0), + daily: [], + tools: [], + models: [], + sources: [ + "Codex": SourceInfo( + status: status, + files: records > 0 ? 1 : 0, + records: records, + accountingRevision: accountingRevision + ) + ] + ) + } + + private static func expect(_ condition: @autoclosure () throws -> Bool, _ message: String) throws { + guard try condition() else { + throw NSError( + domain: "UsageRecalibrationMigrationFixture", + code: 1, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } + } +} diff --git a/TokenStepSwift/Tests/Fixtures/UsageRecalibrationNoticeRender.swift b/TokenStepSwift/Tests/Fixtures/UsageRecalibrationNoticeRender.swift new file mode 100644 index 0000000..9c8f66b --- /dev/null +++ b/TokenStepSwift/Tests/Fixtures/UsageRecalibrationNoticeRender.swift @@ -0,0 +1,33 @@ +import AppKit +import SwiftUI + +@main +struct UsageRecalibrationNoticeRender { + @MainActor + static func main() throws { + TokenStepLocalization.apply(.zhHans) + let outputPath = ProcessInfo.processInfo.environment["TOKENSTEP_NOTICE_RENDER_PATH"] + ?? FileManager.default.temporaryDirectory + .appendingPathComponent("tokenstep-usage-recalibration-notice.png") + .path + let content = UsageRecalibrationNotice(dismiss: {}) + .padding(20) + .frame(width: 412) + .background(Color.tokenCanvas) + let renderer = ImageRenderer(content: content) + renderer.scale = 2 + guard let image = renderer.nsImage, + let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let png = bitmap.representation(using: .png, properties: [:]) + else { + throw NSError( + domain: "UsageRecalibrationNoticeRender", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Unable to render migration notice"] + ) + } + try png.write(to: URL(fileURLWithPath: outputPath), options: .atomic) + print(outputPath) + } +} diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/MainWindowNavigationTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/MainWindowNavigationTests.swift new file mode 100644 index 0000000..82a238c --- /dev/null +++ b/TokenStepSwift/Tests/TokenStepSwiftTests/MainWindowNavigationTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import TokenStepSwift + +final class MainWindowNavigationTests: XCTestCase { + func testPopoverAgentDestinationOverridesExistingSectionWithToday() { + let navigation = MainWindowNavigation(section: .history) + + navigation.select(PopoverAgentWorkStrip.destination) + + XCTAssertEqual(navigation.section, .today) + } +} diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorCCSwitchTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorCCSwitchTests.swift index dbd02ed..8462152 100644 --- a/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorCCSwitchTests.swift +++ b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorCCSwitchTests.swift @@ -11,7 +11,7 @@ final class UsageCollectorCCSwitchTests: XCTestCase { total_cost_usd, status_code, created_at, data_source, request_model, pricing_model ) values ('proxy-1', 'provider-a', 'claude', 'claude-raw', 100, 20, 30, 5, '0.12', 200, 1717200000, 'proxy', 'claude-request', 'claude-priced'), - ('proxy-2', 'provider-b', 'codex', 'gpt-5.4', 10, 3, 0, 0, '0.34', 201, 1717203600, 'proxy', 'gpt-5-request', ''), + ('proxy-2', 'provider-b', 'codex', 'gpt-5.4', 10, 3, 8, 0, '0.34', 201, 1717203600, 'proxy', 'gpt-5-request', ''), ('session-import', 'provider-c', 'claude', 'claude-session-raw', 21, 19, 0, 0, '0.10', 200, 1717207200, 'session_log', 'claude-session-request', 'claude-session-priced'), ('codex-import', 'provider-c', 'codex', 'codex-session-raw', 99, 1, 0, 0, '0.20', 200, 1717207200, 'codex_session', 'codex-session-request', 'codex-session-priced'), ('failed-proxy', 'provider-d', 'gemini', 'ignored-gemini', 1000, 1000, 0, 0, '8.88', 500, 1717207200, 'proxy', 'ignored', 'ignored'), diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorCodexTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorCodexTests.swift index cda4697..bcfe051 100644 --- a/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorCodexTests.swift +++ b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorCodexTests.swift @@ -3,6 +3,180 @@ import XCTest @testable import TokenStepSwift final class UsageCollectorCodexTests: XCTestCase { + func testCodexCollectorUsesCumulativeDeltasAcrossRepeatedLifecycleEvents() throws { + let home = try makeTemporaryHome("cumulative") + let root = home.appendingPathComponent(".codex/sessions/2026/07/13", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + + let v100 = CodexUsageParts(input: 80, output: 20, cached: 60, reasoning: 5) + let v160 = CodexUsageParts(input: 125, output: 35, cached: 90, reasoning: 10) + let v230 = CodexUsageParts(input: 180, output: 50, cached: 120, reasoning: 15) + let hugeLast = CodexUsageParts(input: 1_600_000, output: 366_220, cached: 1_200_000, reasoning: 300_000) + let lines = [ + codexMetaLine(id: "cumulative-session", timestamp: "2026-07-13T08:00:00Z"), + codexContextLine(model: "gpt-5", timestamp: "2026-07-13T08:00:01Z"), + codexTokenLine(timestamp: "2026-07-13T08:01:00Z", cumulative: v100, last: v100), + codexTokenLine( + timestamp: "2026-07-13T08:02:00Z", + cumulative: v160, + last: CodexUsageParts(input: 45, output: 15, cached: 30, reasoning: 5) + ), + codexTokenLine(timestamp: "2026-07-13T08:03:00Z", cumulative: v160, last: hugeLast), + codexTokenLine(timestamp: "2026-07-13T08:04:00Z", cumulative: nil, last: hugeLast), + codexTokenLine( + timestamp: "2026-07-13T08:05:00Z", + cumulative: v230, + last: CodexUsageParts(input: 55, output: 15, cached: 30, reasoning: 5) + ), + codexTokenLine(timestamp: "2026-07-13T08:06:00Z", cumulative: v230, last: hugeLast) + ] + try writeCodexSession(lines, to: root.appendingPathComponent("cumulative.jsonl")) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + + XCTAssertEqual(snapshot.totals.tokens, 230) + XCTAssertEqual(snapshot.sources["Codex"]?.exactRecords, 3) + XCTAssertEqual(snapshot.sources["Codex"]?.legacyRecords, 0) + XCTAssertEqual(snapshot.sources["Codex"]?.duplicateRecords, 2) + XCTAssertEqual(snapshot.sources["Codex"]?.skippedRecords, 1) + XCTAssertEqual( + snapshot.sources["Codex"]?.tokenBreakdown, + SourceTokenBreakdown( + processedTokens: 230, + inputTokens: 180, + cachedInputTokens: 120, + uncachedInputTokens: 60, + outputTokens: 50, + reasoningTokens: 15 + ) + ) + } + + func testCodexCollectorSeparatesReplayAndIsolatedSubagents() throws { + let home = try makeTemporaryHome("subagents") + let root = home.appendingPathComponent(".codex/sessions/2026/07/13", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let parentID = "xctest-parent" + + try writeCodexSession( + [ + codexMetaLine(id: parentID, timestamp: "2026-07-13T00:00:00Z"), + codexContextLine(model: "gpt-5", timestamp: "2026-07-13T00:00:01Z"), + codexTokenLine(timestamp: "2026-07-13T00:01:00Z", cumulative: codexVector(total: 100), last: codexVector(total: 100)), + codexTokenLine(timestamp: "2026-07-13T00:02:00Z", cumulative: codexVector(total: 200), last: codexVector(total: 100)), + codexTokenLine(timestamp: "2026-07-13T00:03:00Z", cumulative: codexVector(total: 300), last: codexVector(total: 100)) + ], + to: root.appendingPathComponent("99-parent.jsonl") + ) + try writeCodexSession( + [ + codexMetaLine(id: "xctest-replay", timestamp: "2026-07-13T00:04:00Z", parentID: parentID), + codexMetaLine(id: parentID, timestamp: "2026-07-13T00:04:01Z"), + codexContextLine(model: "gpt-5", timestamp: "2026-07-13T00:04:02Z"), + codexTokenLine(timestamp: "2026-07-13T00:05:00Z", cumulative: codexVector(total: 100), last: codexVector(total: 100)), + codexTokenLine(timestamp: "2026-07-13T00:06:00Z", cumulative: codexVector(total: 200), last: codexVector(total: 100)), + codexTokenLine(timestamp: "2026-07-13T00:07:00Z", cumulative: codexVector(total: 300), last: codexVector(total: 100)), + codexTokenLine(timestamp: "2026-07-13T00:08:00Z", cumulative: codexVector(total: 360), last: codexVector(total: 60)) + ], + to: root.appendingPathComponent("00-replay-child.jsonl") + ) + try writeCodexSession( + [ + codexMetaLine(id: "xctest-isolated", timestamp: "2026-07-13T00:04:00Z", parentID: parentID), + codexContextLine(model: "gpt-5", timestamp: "2026-07-13T00:04:02Z"), + codexTokenLine(timestamp: "2026-07-13T00:05:00Z", cumulative: codexVector(total: 20), last: codexVector(total: 20)), + codexTokenLine(timestamp: "2026-07-13T00:06:00Z", cumulative: codexVector(total: 50), last: codexVector(total: 30)) + ], + to: root.appendingPathComponent("01-isolated-child.jsonl") + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + + XCTAssertEqual(snapshot.totals.tokens, 410) + XCTAssertEqual(snapshot.sources["Codex"]?.inheritedTokens, 300) + XCTAssertGreaterThan(snapshot.sources["Codex"]?.inheritedRecords ?? 0, 0) + } + + func testCodexCollectorCacheHitAppendAndRebuildRemainStable() throws { + let home = try makeTemporaryHome("cache") + let root = home.appendingPathComponent(".codex/sessions/2026/07/13", isDirectory: true) + let cache = home.appendingPathComponent("cache/collector-v8.json") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let file = root.appendingPathComponent("stable.jsonl") + let initial = [ + codexMetaLine(id: "cache-session", timestamp: "2026-07-13T11:00:00Z"), + codexContextLine(model: "gpt-5", timestamp: "2026-07-13T11:00:01Z"), + codexTokenLine(timestamp: "2026-07-13T11:01:00Z", cumulative: codexVector(total: 100), last: codexVector(total: 100)), + codexTokenLine(timestamp: "2026-07-13T11:02:00Z", cumulative: codexVector(total: 160), last: codexVector(total: 60)) + ] + try writeCodexSession(initial, to: file) + + let first = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home, cacheURL: cache) + let firstCache = try Data(contentsOf: cache) + let repeated = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home, cacheURL: cache) + XCTAssertEqual(first.totals.tokens, 160) + XCTAssertEqual(repeated.totals.tokens, first.totals.tokens) + XCTAssertEqual(try Data(contentsOf: cache), firstCache) + + try writeCodexSession( + initial + [ + codexTokenLine(timestamp: "2026-07-13T11:03:00Z", cumulative: codexVector(total: 230), last: codexVector(total: 70)) + ], + to: file + ) + let appended = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home, cacheURL: cache) + XCTAssertEqual(appended.totals.tokens, 230) + + try FileManager.default.removeItem(at: cache) + let rebuilt = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home, cacheURL: cache) + XCTAssertEqual(rebuilt.totals.tokens, appended.totals.tokens) + XCTAssertEqual(rebuilt.sources["Codex"]?.tokenBreakdown, appended.sources["Codex"]?.tokenBreakdown) + } + + func testCodexGPT54PricingTreatsCachedAndReasoningAsSubsets() throws { + let home = try makeTemporaryHome("pricing") + let root = home.appendingPathComponent(".codex/sessions/2026/07/13", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let usage = CodexUsageParts(input: 1_000_000, output: 200_000, cached: 400_000, reasoning: 100_000) + try writeCodexSession( + [ + codexMetaLine(id: "pricing-session", timestamp: "2026-07-13T12:00:00Z"), + codexContextLine(model: "gpt-5.4", timestamp: "2026-07-13T12:00:01Z"), + codexTokenLine(timestamp: "2026-07-13T12:01:00Z", cumulative: usage, last: usage) + ], + to: root.appendingPathComponent("pricing.jsonl") + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + + XCTAssertEqual(snapshot.totals.tokens, 1_200_000) + XCTAssertEqual(snapshot.totals.cost, 4.6, accuracy: 0.0001) + XCTAssertEqual(snapshot.sources["Codex"]?.tokenBreakdown?.cachedInputTokens, 400_000) + XCTAssertEqual(snapshot.sources["Codex"]?.tokenBreakdown?.reasoningTokens, 100_000) + } + + func testCodexCollectorSplitsDeltasAtShanghaiMidnight() throws { + let home = try makeTemporaryHome("midnight") + let root = home.appendingPathComponent(".codex/sessions/2026/06/21", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try writeCodexSession( + [ + codexMetaLine(id: "midnight-session", timestamp: "2026-06-21T15:59:00Z"), + codexContextLine(model: "gpt-5", timestamp: "2026-06-21T15:59:01Z"), + codexTokenLine(timestamp: "2026-06-21T15:59:59Z", cumulative: codexVector(total: 100), last: codexVector(total: 100)), + codexTokenLine(timestamp: "2026-06-21T16:00:00Z", cumulative: codexVector(total: 160), last: codexVector(total: 60)) + ], + to: root.appendingPathComponent("midnight.jsonl") + ) + + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + + XCTAssertEqual(snapshot.daily.first(where: { $0.date == "2026-06-21" })?.totalTokens, 100) + XCTAssertEqual(snapshot.daily.first(where: { $0.date == "2026-06-22" })?.totalTokens, 60) + XCTAssertEqual(snapshot.rhythm(for: "2026-06-21")?.bucket(hour: 23).tokens, 100) + XCTAssertEqual(snapshot.rhythm(for: "2026-06-22")?.bucket(hour: 0).tokens, 60) + } + func testDefaultCodexCollectorIgnoresArchivedSessions() throws { let home = FileManager.default.temporaryDirectory .appendingPathComponent("TokenStepCodexTests-\(UUID().uuidString)", isDirectory: true) @@ -54,7 +228,9 @@ final class UsageCollectorCodexTests: XCTestCase { let rhythm = try XCTUnwrap(snapshot.rhythm(for: "2026-06-21")) XCTAssertEqual(rhythm.totalTokens, 850) - XCTAssertEqual(rhythm.activeHours, 3) + // The 100-token night trace remains visible in its bucket, but is below + // the 30%-of-peak significance threshold used for active-hour labeling. + XCTAssertEqual(rhythm.activeHours, 2) XCTAssertEqual(rhythm.peakHour, 15) XCTAssertEqual(rhythm.peakTokens, 400) XCTAssertEqual(rhythm.bucket(hour: 15).tokens, 400) @@ -127,4 +303,91 @@ final class UsageCollectorCodexTests: XCTestCase { let data = try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) return String(data: data, encoding: .utf8)! } + + private func makeTemporaryHome(_ label: String) throws -> URL { + let home = URL(fileURLWithPath: "/tmp", isDirectory: true) + .appendingPathComponent("TokenStepCodexXCTest-\(label)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: home, withIntermediateDirectories: true) + addTeardownBlock { + try? FileManager.default.removeItem(at: home) + } + return home + } + + private func writeCodexSession(_ lines: [String], to url: URL) throws { + try (lines.joined(separator: "\n") + "\n").write(to: url, atomically: true, encoding: .utf8) + } + + private func codexMetaLine(id: String, timestamp: String, parentID: String? = nil) -> String { + var payload: [String: Any] = ["id": id] + if let parentID { + payload["source"] = [ + "subagent": [ + "thread_spawn": ["parent_thread_id": parentID] + ] + ] + } + return jsonLine([ + "type": "session_meta", + "timestamp": timestamp, + "payload": payload + ]) + } + + private func codexContextLine(model: String, timestamp: String) -> String { + jsonLine([ + "type": "turn_context", + "timestamp": timestamp, + "payload": ["model": model] + ]) + } + + private func codexTokenLine( + timestamp: String, + cumulative: CodexUsageParts?, + last: CodexUsageParts? + ) -> String { + var info: [String: Any] = [:] + if let cumulative { + info["total_token_usage"] = cumulative.dictionary + } + if let last { + info["last_token_usage"] = last.dictionary + } + return jsonLine([ + "type": "event_msg", + "timestamp": timestamp, + "payload": [ + "type": "token_count", + "info": info + ] + ]) + } + + private func codexVector(total: Int) -> CodexUsageParts { + let output = max(1, total / 5) + return CodexUsageParts( + input: total - output, + output: output, + cached: min(total - output, total / 2), + reasoning: min(output, total / 10) + ) + } + + private struct CodexUsageParts { + var input: Int + var output: Int + var cached: Int + var reasoning: Int + + var dictionary: [String: Any] { + [ + "input_tokens": input, + "output_tokens": output, + "cached_input_tokens": cached, + "reasoning_output_tokens": reasoning, + "total_tokens": input + output + ] + } + } } diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorExperimentalAgentTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorExperimentalAgentTests.swift new file mode 100644 index 0000000..4f906af --- /dev/null +++ b/TokenStepSwift/Tests/TokenStepSwiftTests/UsageCollectorExperimentalAgentTests.swift @@ -0,0 +1,249 @@ +import Foundation +import XCTest +@testable import TokenStepSwift + +final class UsageCollectorExperimentalAgentTests: XCTestCase { + func testExperimentalSourcesAreDisabledByDefault() throws { + let zCodeDB = try makeZCodeDatabase(rowsSQL: """ + insert into model_usage ( + id, logical_request_id, session_id, query_source, provider_id, model_id, status, started_at, + input_tokens, output_tokens, reasoning_tokens, cache_creation_input_tokens, cache_read_input_tokens, + computed_total_tokens, tool_call_count + ) values + ('z-1', 'logical-1', 'session-z', 'cli', 'openai', 'gpt-5', 'completed', 1717200000000, + 10, 20, 5, 3, 2, 40, 4); + """) + let hermesDB = try makeHermesDatabase(rowsSQL: """ + insert into sessions ( + id, source, model, started_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + reasoning_tokens, tool_call_count, api_call_count, actual_cost_usd, estimated_cost_usd, cost_status + ) values + ('h-1', 'cli', 'gpt-5', 1717203600, 10, 15, 5, 2, 3, 6, 2, 0.42, 0.30, 'included'); + """) + + let snapshot = UsageCollector.collectUsageSnapshotForTests( + zCodeDatabaseURL: zCodeDB, + hermesDatabaseURL: hermesDB + ) + + XCTAssertEqual(snapshot.sources["ZCode"]?.status, "disabled") + XCTAssertEqual(snapshot.sources["Hermes Agent"]?.status, "disabled") + XCTAssertEqual(snapshot.totals.tokens, 0) + XCTAssertTrue(snapshot.agentWork.isEmpty) + } + + func testZCodeCollectorUsesCompletedModelUsageRowsOnly() throws { + let database = try makeZCodeDatabase(rowsSQL: """ + insert into model_usage ( + id, logical_request_id, session_id, query_source, provider_id, model_id, status, started_at, + input_tokens, output_tokens, reasoning_tokens, cache_creation_input_tokens, cache_read_input_tokens, + computed_total_tokens, tool_call_count + ) values + ('z-ok', 'logical-1', 'session-z', 'cli', 'openai', 'gpt-5', 'completed', 1717200000000, + 30, 10, 5, 3, 2, 0, 4), + ('z-error', 'logical-2', 'session-z', 'cli', 'openai', 'gpt-5', 'error', 1717203600000, + 100, 100, 0, 0, 0, 200, 99), + ('z-cancel', 'logical-3', 'session-z', 'cli', 'openai', 'gpt-5', 'cancelled', 1717207200000, + 100, 100, 0, 0, 0, 200, 99); + """) + + let snapshot = UsageCollector.collectUsageSnapshotForTests( + zCodeDatabaseURL: database, + includeExperimentalAgentSources: true + ) + + XCTAssertEqual(snapshot.sources["ZCode"]?.status, "ok") + XCTAssertEqual(snapshot.sources["ZCode"]?.records, 1) + XCTAssertEqual(snapshot.totals.tokens, 40) + XCTAssertEqual(snapshot.daily.first?.tools["ZCode"], 40) + XCTAssertEqual(snapshot.daily.first?.models["gpt-5"], 40) + + let work = try XCTUnwrap(snapshot.agentWork.first) + XCTAssertEqual(work.totalTokens, 40) + XCTAssertEqual(work.modelRequestCount, 1) + XCTAssertEqual(work.toolCallCount, 4) + XCTAssertEqual(work.sources.first?.source, "ZCode") + XCTAssertEqual(work.sources.first?.tokens, 40) + XCTAssertEqual(work.hourlyBuckets.count, 24) + XCTAssertEqual(work.hourlyBuckets.map(\.totalTokens).reduce(0, +) + work.unbucketedTokens, 40) + XCTAssertEqual(try XCTUnwrap(work.cacheHitRate), 2.0 / 30.0, accuracy: 0.000_001) + } + + func testHermesCollectorReadsSessionUsageWithoutMessageContent() throws { + let database = try makeHermesDatabase(rowsSQL: """ + insert into sessions ( + id, source, model, started_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, + reasoning_tokens, tool_call_count, api_call_count, actual_cost_usd, estimated_cost_usd, cost_status + ) values + ('h-ok', 'feishu', 'gpt-5.5', 1717203600.5, 10, 15, 5, 2, 3, 6, 2, 0.42, 0.30, 'included'), + ('h-zero', 'cli', 'gpt-5.5', 1717207200, 0, 0, 0, 0, 0, 3, 1, 9.99, 9.99, 'included'); + """) + + let snapshot = UsageCollector.collectUsageSnapshotForTests( + hermesDatabaseURL: database, + includeExperimentalAgentSources: true + ) + + XCTAssertEqual(snapshot.sources["Hermes Agent"]?.status, "ok") + XCTAssertEqual(snapshot.sources["Hermes Agent"]?.records, 1) + XCTAssertEqual(snapshot.totals.tokens, 32) + XCTAssertEqual(snapshot.totals.cost, 0.42) + XCTAssertEqual(snapshot.daily.first?.tools["Hermes Agent"], 32) + XCTAssertEqual(snapshot.daily.first?.models["gpt-5.5"], 32) + + let work = try XCTUnwrap(snapshot.agentWork.first) + XCTAssertEqual(work.modelRequestCount, 2) + XCTAssertEqual(work.toolCallCount, 6) + XCTAssertEqual(work.sources.first?.source, "Hermes Agent") + XCTAssertEqual(work.inputTokens, 17) + XCTAssertEqual(work.cachedInputTokens, 5) + XCTAssertEqual(work.outputTokens, 15) + XCTAssertEqual(try XCTUnwrap(work.cacheHitRate), 5.0 / 17.0, accuracy: 0.000_001) + } + + func testWorkBuddyDiscoveryDoesNotEnterTotals() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TokenStepWorkBuddy-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { + try? FileManager.default.removeItem(at: directory) + } + + let snapshot = UsageCollector.collectUsageSnapshotForTests( + workBuddyRootURLs: [directory], + includeExperimentalAgentSources: true + ) + + XCTAssertEqual(snapshot.sources["WorkBuddy"]?.status, "discovered_no_usage") + XCTAssertEqual(snapshot.sources["WorkBuddy"]?.records, 0) + XCTAssertEqual(snapshot.totals.tokens, 0) + XCTAssertTrue(snapshot.daily.isEmpty) + XCTAssertTrue(snapshot.agentWork.isEmpty) + } + + private func makeZCodeDatabase(rowsSQL: String) throws -> URL { + let database = try fixtureDatabase(prefix: "TokenStepZCode") + try runSQLite(database: database, sql: """ + create table model_usage ( + id text primary key, + logical_request_id text not null, + attempt_index integer not null default 0, + session_id text not null, + turn_id text, + trace_id text, + span_id text, + assistant_message_id text, + parent_user_message_id text, + query_source text not null, + provider_id text not null, + model_id text not null, + variant text, + agent text, + mode text, + task_type text, + status text not null, + started_at integer not null, + first_token_at integer, + completed_at integer, + duration_ms integer, + time_to_first_token_ms integer, + finish_reason text, + tool_call_count integer not null default 0, + input_tokens integer not null default 0, + output_tokens integer not null default 0, + reasoning_tokens integer not null default 0, + cache_creation_input_tokens integer not null default 0, + cache_read_input_tokens integer not null default 0, + provider_total_tokens integer, + computed_total_tokens integer not null default 0, + retry_count integer not null default 0, + retryable integer not null default 0, + cancelled_by_user integer not null default 0, + context_exceeded integer not null default 0, + error_type text, + error_code text, + error_message text, + raw_usage_json text, + provider_metadata_json text + ); + \(rowsSQL) + """) + return database + } + + private func makeHermesDatabase(rowsSQL: String) throws -> URL { + let database = try fixtureDatabase(prefix: "TokenStepHermes") + try runSQLite(database: database, sql: """ + create table sessions ( + id text primary key, + source text not null, + user_id text, + model text, + model_config text, + system_prompt text, + parent_session_id text, + started_at real not null, + ended_at real, + end_reason text, + message_count integer default 0, + tool_call_count integer default 0, + input_tokens integer default 0, + output_tokens integer default 0, + cache_read_tokens integer default 0, + cache_write_tokens integer default 0, + reasoning_tokens integer default 0, + billing_provider text, + billing_base_url text, + billing_mode text, + estimated_cost_usd real, + actual_cost_usd real, + cost_status text, + cost_source text, + pricing_version text, + title text, + api_call_count integer default 0 + ); + create table messages ( + id integer primary key autoincrement, + session_id text not null, + role text not null, + content text, + timestamp real not null + ); + \(rowsSQL) + """) + return database + } + + private func fixtureDatabase(prefix: String) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { + try? FileManager.default.removeItem(at: directory) + } + return directory.appendingPathComponent("fixture.sqlite") + } + + private func runSQLite(database: URL, sql: String) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/sqlite3") + process.arguments = [database.path, sql] + process.standardOutput = Pipe() + let standardError = Pipe() + process.standardError = standardError + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + let data = standardError.fileHandleForReading.readDataToEndOfFile() + let message = String(data: data, encoding: .utf8) ?? "sqlite fixture failed" + throw NSError( + domain: "UsageCollectorExperimentalAgentTests", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: message] + ) + } + } +} diff --git a/script/audit_codex_accounting.py b/script/audit_codex_accounting.py new file mode 100755 index 0000000..ca46431 --- /dev/null +++ b/script/audit_codex_accounting.py @@ -0,0 +1,1381 @@ +#!/usr/bin/env python3 +"""Build and audit an isolated Codex accounting-only history snapshot. + +Safety contract: +- The source tree is opened read-only. +- Every generated file, Swift module cache, harness, and report stays under /tmp. +- The frozen copy preserves relative session paths and verbatim relevant JSONL lines. +- TokenStep's production cache/App Support and installed/running app are never touched. + +The Python implementation intentionally mirrors the current UsageCollector.swift +accounting revision. By default the script also compiles a temporary Swift harness from +the current working tree, runs the real collector twice against the frozen copy, and +checks the two implementations agree. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import datetime as dt +import hashlib +import json +import os +import platform +import subprocess +import sys +from collections import Counter +from pathlib import Path +from typing import Any, Iterable, Optional + + +SCHEMA_VERSION = 1 +ACCOUNTING_REVISION = 8 +MAX_SWIFT_LINE_BYTES = 1_048_576 +READ_CHUNK_BYTES = 1_048_576 +RELEVANT_TYPES = {"session_meta", "turn_context", "token_count", "context_compacted"} + + +def utc_now() -> str: + return dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z") + + +def canonical_json(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def digest_json(value: Any) -> str: + return hashlib.sha256(canonical_json(value)).hexdigest() + + +def write_json(path: Path, value: Any) -> None: + path.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def is_under(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def integer_value(value: Any) -> int: + if isinstance(value, bool): + return 0 + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + try: + return int(value) + except ValueError: + return 0 + return 0 + + +def nonempty_string(value: Any) -> Optional[str]: + if isinstance(value, str) and value: + return value + return None + + +def parse_iso(value: Optional[str]) -> Optional[dt.datetime]: + if not value: + return None + try: + parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=dt.timezone.utc) + return parsed.astimezone(dt.timezone.utc) + except ValueError: + return None + + +@dataclasses.dataclass(frozen=True) +class Usage: + input_tokens: int = 0 + output_tokens: int = 0 + cache_creation_input_tokens: int = 0 + cached_input_tokens: int = 0 + reasoning_output_tokens: int = 0 + total_tokens: int = 0 + + def add(self, other: "Usage") -> "Usage": + return Usage( + input_tokens=self.input_tokens + other.input_tokens, + output_tokens=self.output_tokens + other.output_tokens, + cache_creation_input_tokens=self.cache_creation_input_tokens + other.cache_creation_input_tokens, + cached_input_tokens=self.cached_input_tokens + other.cached_input_tokens, + reasoning_output_tokens=self.reasoning_output_tokens + other.reasoning_output_tokens, + total_tokens=self.total_tokens + other.total_tokens, + ) + + @property + def fingerprint(self) -> str: + return ":".join( + map( + str, + ( + self.total_tokens, + self.input_tokens, + self.cached_input_tokens, + self.output_tokens, + self.reasoning_output_tokens, + self.cache_creation_input_tokens, + ), + ) + ) + + def report_dict(self) -> dict[str, int]: + return { + "processed_tokens": self.total_tokens, + "input_tokens": self.input_tokens, + "cached_input_tokens": self.cached_input_tokens, + "uncached_input_tokens": max( + 0, + self.input_tokens - self.cached_input_tokens - self.cache_creation_input_tokens, + ), + "output_tokens": self.output_tokens, + "reasoning_tokens": self.reasoning_output_tokens, + "cache_creation_input_tokens": self.cache_creation_input_tokens, + } + + +def normalize_codex(raw: Any) -> Optional[Usage]: + if not isinstance(raw, dict): + return None + + def value(keys: Iterable[str]) -> int: + for key in keys: + if key in raw: + return max(0, integer_value(raw.get(key))) + return 0 + + input_tokens = value(("input_tokens", "input")) + output_tokens = value(("output_tokens", "output")) + explicit_total_key = next((key for key in ("total_tokens", "total") if key in raw), None) + total = max(0, integer_value(raw.get(explicit_total_key))) if explicit_total_key else input_tokens + output_tokens + return Usage( + input_tokens=input_tokens, + output_tokens=output_tokens, + cache_creation_input_tokens=value(("cache_creation_input_tokens", "cache_write_input_tokens")), + cached_input_tokens=value(("cached_input_tokens", "cache_read_input_tokens", "cached")), + reasoning_output_tokens=value(("reasoning_output_tokens", "reasoning_tokens", "thoughts")), + total_tokens=total, + ) + + +def normalize_old(raw: Any) -> Usage: + if not isinstance(raw, dict): + return Usage() + aliases = { + "input": "input", + "output": "output", + "cached": "cached", + "thoughts": "reasoning", + "total": "total", + "input_tokens": "input", + "output_tokens": "output", + "cache_creation_input_tokens": "cache_creation", + "cache_read_input_tokens": "cached", + "cached_input_tokens": "cached", + "reasoning_output_tokens": "reasoning", + "total_tokens": "total", + } + values = Counter() + for key, raw_value in raw.items(): + mapped = aliases.get(key) + if mapped: + values[mapped] += integer_value(raw_value) + total = values["total"] + if total <= 0: + total = values["input"] + values["output"] + values["cache_creation"] + values["cached"] + values["reasoning"] + return Usage( + input_tokens=values["input"], + output_tokens=values["output"], + cache_creation_input_tokens=values["cache_creation"], + cached_input_tokens=values["cached"], + reasoning_output_tokens=values["reasoning"], + total_tokens=total, + ) + + +@dataclasses.dataclass +class TokenEvent: + timestamp: Optional[str] + cumulative_present: bool + cumulative: Optional[Usage] + last: Optional[Usage] + old_last: Usage + old_session_id: str + model_context_window: int + line_number: int + + +@dataclasses.dataclass +class ParsedFile: + relative_path: str + fallback_id: str + canonical_session_id: str + created_at: Optional[str] + parent_session_id: Optional[str] + events: list[TokenEvent] + context_compacted_events: int + session_meta_events: int + + +def parent_session_id(payload: Any) -> Optional[str]: + if not isinstance(payload, dict): + return None + source = payload.get("source") + if isinstance(source, dict): + subagent = source.get("subagent") + if isinstance(subagent, dict): + thread_spawn = subagent.get("thread_spawn") + if isinstance(thread_spawn, dict): + parent = nonempty_string(thread_spawn.get("parent_thread_id")) + if parent: + return parent + return nonempty_string(payload.get("parent_thread_id")) or nonempty_string(payload.get("forked_from_id")) + + +def relevant_object(obj: Any) -> tuple[bool, Optional[str]]: + if not isinstance(obj, dict): + return False, None + top_type = obj.get("type") + payload = obj.get("payload") + payload_type = payload.get("type") if isinstance(payload, dict) else None + event_type = payload_type if payload_type in RELEVANT_TYPES else top_type + return top_type in RELEVANT_TYPES or payload_type in RELEVANT_TYPES, event_type if isinstance(event_type, str) else None + + +def initial_source_entries(source_root: Path) -> list[tuple[Path, os.stat_result]]: + entries: list[tuple[Path, os.stat_result]] = [] + for path in source_root.rglob("*.jsonl"): + try: + stat = path.stat() + except OSError: + continue + if path.is_file(): + entries.append((path, stat)) + return sorted(entries, key=lambda item: str(item[0])) + + +def iter_prefix_lines(path: Path, byte_limit: int): + """Yield (raw_line, oversized) while hashing exactly the enumerated prefix.""" + source_hash = hashlib.sha256() + remaining = byte_limit + pending = bytearray() + oversized = False + line_number = 0 + with path.open("rb") as handle: + while remaining > 0: + chunk = handle.read(min(READ_CHUNK_BYTES, remaining)) + if not chunk: + break + remaining -= len(chunk) + source_hash.update(chunk) + start = 0 + while start < len(chunk): + newline = chunk.find(b"\n", start) + end = len(chunk) if newline < 0 else newline + 1 + piece = chunk[start:end] + if not oversized: + if len(pending) + len(piece) <= MAX_SWIFT_LINE_BYTES: + pending.extend(piece) + else: + pending.clear() + oversized = True + if newline >= 0: + line_number += 1 + yield line_number, bytes(pending), oversized + pending.clear() + oversized = False + start = end + if pending or oversized: + line_number += 1 + yield line_number, bytes(pending), oversized + if remaining != 0: + raise RuntimeError(f"source truncated while freezing: {path} ({remaining} bytes missing)") + return source_hash.hexdigest() + + +def freeze_accounting_copy(source_root: Path, output_root: Path) -> dict[str, Any]: + frozen_sessions = output_root / "frozen-home" / ".codex" / "sessions" + frozen_sessions.mkdir(parents=True) + entries = initial_source_entries(source_root) + manifest_files: list[dict[str, Any]] = [] + totals = Counter() + event_counts = Counter() + + for index, (source, initial_stat) in enumerate(entries, start=1): + relative = source.relative_to(source_root) + target = frozen_sessions / relative + target_handle = None + filtered_hash = hashlib.sha256() + source_hash = hashlib.sha256() + source_lines = kept_lines = filtered_bytes = oversized_lines = invalid_candidate_lines = 0 + remaining = initial_stat.st_size + pending = bytearray() + oversized = False + + try: + with source.open("rb") as handle: + while remaining > 0: + chunk = handle.read(min(READ_CHUNK_BYTES, remaining)) + if not chunk: + raise RuntimeError(f"source truncated while freezing: {source}") + remaining -= len(chunk) + source_hash.update(chunk) + start = 0 + while start < len(chunk): + newline = chunk.find(b"\n", start) + end = len(chunk) if newline < 0 else newline + 1 + piece = chunk[start:end] + if not oversized: + if len(pending) + len(piece) <= MAX_SWIFT_LINE_BYTES: + pending.extend(piece) + else: + pending.clear() + oversized = True + if newline >= 0: + source_lines += 1 + if oversized: + oversized_lines += 1 + else: + raw_line = bytes(pending) + try: + obj = json.loads(raw_line.rstrip(b"\r\n")) + except (json.JSONDecodeError, UnicodeDecodeError): + if any(marker.encode() in raw_line for marker in RELEVANT_TYPES): + invalid_candidate_lines += 1 + else: + relevant, event_type = relevant_object(obj) + if relevant: + if target_handle is None: + target.parent.mkdir(parents=True, exist_ok=True) + target_handle = target.open("wb") + target_handle.write(raw_line) + filtered_hash.update(raw_line) + kept_lines += 1 + filtered_bytes += len(raw_line) + if event_type: + event_counts[event_type] += 1 + pending.clear() + oversized = False + start = end + if pending or oversized: + source_lines += 1 + if oversized: + oversized_lines += 1 + else: + raw_line = bytes(pending) + try: + obj = json.loads(raw_line.rstrip(b"\r\n")) + except (json.JSONDecodeError, UnicodeDecodeError): + if any(marker.encode() in raw_line for marker in RELEVANT_TYPES): + invalid_candidate_lines += 1 + else: + relevant, event_type = relevant_object(obj) + if relevant: + if target_handle is None: + target.parent.mkdir(parents=True, exist_ok=True) + target_handle = target.open("wb") + target_handle.write(raw_line) + filtered_hash.update(raw_line) + kept_lines += 1 + filtered_bytes += len(raw_line) + if event_type: + event_counts[event_type] += 1 + finally: + if target_handle is not None: + target_handle.close() + + try: + after = source.stat() + after_size = after.st_size + after_mtime_ns = after.st_mtime_ns + except OSError: + after_size = None + after_mtime_ns = None + changed = after_size != initial_stat.st_size or after_mtime_ns != initial_stat.st_mtime_ns + manifest_files.append( + { + "relative_path": relative.as_posix(), + "source_prefix_bytes": initial_stat.st_size, + "source_prefix_sha256": source_hash.hexdigest(), + "source_mtime_ns_at_enumeration": initial_stat.st_mtime_ns, + "source_size_after_copy": after_size, + "source_mtime_ns_after_copy": after_mtime_ns, + "source_changed_after_enumeration": changed, + "source_lines_in_prefix": source_lines, + "frozen_lines": kept_lines, + "frozen_bytes": filtered_bytes, + "frozen_sha256": filtered_hash.hexdigest() if kept_lines else None, + "oversized_lines_skipped_like_swift": oversized_lines, + "invalid_relevant_candidate_lines": invalid_candidate_lines, + } + ) + totals["source_prefix_bytes"] += initial_stat.st_size + totals["source_lines"] += source_lines + totals["frozen_lines"] += kept_lines + totals["frozen_bytes"] += filtered_bytes + totals["oversized_lines"] += oversized_lines + totals["invalid_candidate_lines"] += invalid_candidate_lines + totals["changed_files"] += int(changed) + totals["frozen_files"] += int(kept_lines > 0) + if index % 100 == 0: + print(f"freeze: {index}/{len(entries)} files", file=sys.stderr, flush=True) + + post_count = len(initial_source_entries(source_root)) + manifest = { + "schema_version": SCHEMA_VERSION, + "created_at": utc_now(), + "copy_kind": "semantic-complete accounting-only Codex JSONL prefix snapshot", + "semantic_contract": ( + "All valid JSON objects consumed by TokenStep's current Codex accounting are copied verbatim and in source order: " + "session_meta, turn_context, event_msg/token_count. event_msg/context_compacted is additionally retained for audit classification. " + "Other event types are intentionally omitted. Lines over TokenStep's 1 MiB collector limit are omitted and counted." + ), + "snapshot_contract": ( + "The file list and byte size of every source file are captured before copying. Each copy is bounded to that enumerated prefix, " + "so appends during the audit cannot change the frozen result." + ), + "source_root": str(source_root), + "frozen_sessions_root": str(frozen_sessions), + "source_file_count_at_enumeration": len(entries), + "source_file_count_after_copy": post_count, + "frozen_file_count": totals["frozen_files"], + "source_prefix_bytes": totals["source_prefix_bytes"], + "source_lines_in_prefix": totals["source_lines"], + "frozen_lines": totals["frozen_lines"], + "frozen_bytes": totals["frozen_bytes"], + "source_changed_after_enumeration_files": totals["changed_files"], + "oversized_lines_skipped_like_swift": totals["oversized_lines"], + "invalid_relevant_candidate_lines": totals["invalid_candidate_lines"], + "event_counts": dict(sorted(event_counts.items())), + "files": manifest_files, + } + manifest_path = output_root / "manifest.json" + write_json(manifest_path, manifest) + manifest_hash = hashlib.sha256(manifest_path.read_bytes()).hexdigest() + (output_root / "manifest.sha256").write_text(f"{manifest_hash} manifest.json\n", encoding="utf-8") + manifest["manifest_path"] = str(manifest_path) + manifest["manifest_sha256"] = manifest_hash + return manifest + + +def parse_frozen_sessions(frozen_root: Path) -> list[ParsedFile]: + parsed_files: list[ParsedFile] = [] + for path in sorted(frozen_root.rglob("*.jsonl"), key=lambda item: str(item)): + relative = path.relative_to(frozen_root).as_posix() + fallback_id = path.stem + canonical_id: Optional[str] = None + created_at: Optional[str] = None + parent_id: Optional[str] = None + old_session_id = fallback_id + events: list[TokenEvent] = [] + compactions = metas = 0 + with path.open("rb") as handle: + for line_number, raw_line in enumerate(handle, start=1): + try: + obj = json.loads(raw_line) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + top_type = obj.get("type") if isinstance(obj, dict) else None + payload = obj.get("payload") if isinstance(obj, dict) else None + payload = payload if isinstance(payload, dict) else {} + payload_type = payload.get("type") + if top_type == "session_meta": + metas += 1 + meta_id = nonempty_string(payload.get("id")) + if meta_id: + old_session_id = meta_id + if canonical_id is None: + canonical_id = meta_id + created_at = nonempty_string(obj.get("timestamp")) or nonempty_string(payload.get("timestamp")) + parent_id = parent_session_id(payload) + if top_type == "event_msg" and payload_type == "context_compacted": + compactions += 1 + if not (top_type == "event_msg" and payload_type == "token_count"): + continue + info = payload.get("info") + if not isinstance(info, dict): + continue + events.append( + TokenEvent( + timestamp=nonempty_string(obj.get("timestamp")), + cumulative_present="total_token_usage" in info, + cumulative=normalize_codex(info.get("total_token_usage")), + last=normalize_codex(info.get("last_token_usage")), + old_last=normalize_old(info.get("last_token_usage")), + old_session_id=old_session_id, + model_context_window=integer_value(info.get("model_context_window")), + line_number=line_number, + ) + ) + parsed_files.append( + ParsedFile( + relative_path=relative, + fallback_id=fallback_id, + canonical_session_id=canonical_id or fallback_id, + created_at=created_at, + parent_session_id=parent_id, + events=events, + context_compacted_events=compactions, + session_meta_events=metas, + ) + ) + return parsed_files + + +def empty_file_result(scan: ParsedFile) -> dict[str, Any]: + return { + "relative_path": scan.relative_path, + "canonical_session_id": scan.canonical_session_id, + "parent_session_id": scan.parent_session_id, + "context_compacted_events": scan.context_compacted_events, + "raw_token_events": len(scan.events), + "tokens": 0, + "records": 0, + "components": Usage().report_dict(), + "diagnostics": {}, + } + + +def old_accounting(scans: list[ParsedFile]) -> dict[str, Any]: + seen: set[str] = set() + total = Usage() + records = 0 + skipped = duplicates = 0 + per_file: dict[str, dict[str, Any]] = {} + for scan in scans: + file_usage = Usage() + file_records = 0 + event_index = 0 + for event in scan.events: + usage = event.old_last + if usage.total_tokens <= 0 or parse_iso(event.timestamp) is None: + skipped += 1 + continue + event_index += 1 + key = f"{event.old_session_id}|{event.timestamp}|{event_index}|{usage.total_tokens}" + if key in seen: + duplicates += 1 + continue + seen.add(key) + records += 1 + file_records += 1 + total = total.add(usage) + file_usage = file_usage.add(usage) + row = empty_file_result(scan) + row.update(tokens=file_usage.total_tokens, records=file_records, components=file_usage.report_dict()) + per_file[scan.relative_path] = row + return { + "algorithm": "v5_last_token_usage_sum", + "tokens": total.total_tokens, + "records": records, + "components": total.report_dict(), + "diagnostics": {"skipped_records": skipped, "duplicate_records": duplicates}, + "per_file": per_file, + } + + +def is_breakdown_consistent(usage: Usage, total: int) -> bool: + return ( + min( + usage.input_tokens, + usage.output_tokens, + usage.cache_creation_input_tokens, + usage.cached_input_tokens, + usage.reasoning_output_tokens, + ) + >= 0 + and usage.input_tokens + usage.output_tokens == total + and usage.cache_creation_input_tokens + usage.cached_input_tokens <= usage.input_tokens + and usage.reasoning_output_tokens <= usage.output_tokens + ) + + +def is_context_window_sentinel(event: TokenEvent) -> bool: + current = event.cumulative + return bool( + current + and current.input_tokens == 0 + and current.output_tokens == 0 + and current.cache_creation_input_tokens == 0 + and current.cached_input_tokens == 0 + and current.reasoning_output_tokens == 0 + and (event.last.total_tokens if event.last else 0) == 0 + and event.model_context_window > 0 + and current.total_tokens == event.model_context_window + ) + + +def is_credible_reset(index: int, events: list[TokenEvent], current: Usage, previous: Usage) -> bool: + last = events[index].last + if last and last.total_tokens == current.total_tokens and is_breakdown_consistent(last, current.total_tokens): + return True + for candidate in events[index + 1 :]: + if not candidate.cumulative_present: + continue + next_usage = candidate.cumulative + if not next_usage or next_usage.total_tokens <= 0: + continue + if next_usage.total_tokens == current.total_tokens: + continue + return next_usage.total_tokens > current.total_tokens and next_usage.total_tokens < previous.total_tokens + return False + + +def increment_usage(current: Usage, previous: Optional[Usage], last: Optional[Usage], total: int) -> tuple[Usage, bool]: + if last and last.total_tokens == total and is_breakdown_consistent(last, total): + return dataclasses.replace(last, total_tokens=total), True + baseline = previous or Usage() + if ( + current.input_tokens < baseline.input_tokens + or current.output_tokens < baseline.output_tokens + or current.cache_creation_input_tokens < baseline.cache_creation_input_tokens + or current.cached_input_tokens < baseline.cached_input_tokens + or current.reasoning_output_tokens < baseline.reasoning_output_tokens + ): + return Usage(total_tokens=total), False + result = Usage( + input_tokens=current.input_tokens - baseline.input_tokens, + output_tokens=current.output_tokens - baseline.output_tokens, + cache_creation_input_tokens=( + current.cache_creation_input_tokens - baseline.cache_creation_input_tokens + ), + cached_input_tokens=current.cached_input_tokens - baseline.cached_input_tokens, + reasoning_output_tokens=current.reasoning_output_tokens - baseline.reasoning_output_tokens, + total_tokens=total, + ) + if not is_breakdown_consistent(result, total): + return Usage(total_tokens=total), False + return result, True + + +def run_self_test() -> None: + previous = Usage( + input_tokens=100, + output_tokens=20, + cache_creation_input_tokens=30, + cached_input_tokens=40, + reasoning_output_tokens=5, + total_tokens=120, + ) + current = Usage( + input_tokens=160, + output_tokens=30, + cache_creation_input_tokens=50, + cached_input_tokens=70, + reasoning_output_tokens=7, + total_tokens=190, + ) + expected_increment = Usage( + input_tokens=60, + output_tokens=10, + cache_creation_input_tokens=20, + cached_input_tokens=30, + reasoning_output_tokens=2, + total_tokens=70, + ) + increment, known = increment_usage(current, previous, None, 70) + if not known or increment != expected_increment: + raise AssertionError(f"cache-creation delta mismatch: {increment!r}, known={known}") + if increment.report_dict()["uncached_input_tokens"] != 10: + raise AssertionError(f"uncached input mismatch: {increment.report_dict()!r}") + if not is_breakdown_consistent(increment, increment.total_tokens): + raise AssertionError("valid cache-creation breakdown was rejected") + + invalid_breakdown = Usage( + input_tokens=10, + output_tokens=0, + cache_creation_input_tokens=6, + cached_input_tokens=5, + total_tokens=10, + ) + if is_breakdown_consistent(invalid_breakdown, invalid_breakdown.total_tokens): + raise AssertionError("overlapping cache buckets were accepted as a valid input subset") + + cache_creation_sentinel = TokenEvent( + timestamp="2026-07-19T00:00:00Z", + cumulative_present=True, + cumulative=Usage(cache_creation_input_tokens=1, total_tokens=100), + last=Usage(), + old_last=Usage(), + old_session_id="self-test", + model_context_window=100, + line_number=1, + ) + if is_context_window_sentinel(cache_creation_sentinel): + raise AssertionError("nonzero cache-creation usage was discarded as a context-window sentinel") + + regressed_cache_creation = dataclasses.replace( + current, + input_tokens=200, + output_tokens=40, + cache_creation_input_tokens=20, + cached_input_tokens=80, + total_tokens=240, + ) + unknown_increment, regressed_known = increment_usage( + regressed_cache_creation, + current, + None, + 50, + ) + if regressed_known or unknown_increment != Usage(total_tokens=50): + raise AssertionError( + "a regressed cache-creation counter was not downgraded to unknown breakdown" + ) + + +def fork_anchor(scan: ParsedFile, by_session: dict[str, ParsedFile]) -> Optional[Usage]: + if not scan.parent_session_id: + return None + parent = by_session.get(scan.parent_session_id) + child_created = parse_iso(scan.created_at) + if not parent or child_created is None: + return None + anchor = None + for event in parent.events: + timestamp = parse_iso(event.timestamp) + if ( + event.cumulative_present + and event.cumulative + and event.cumulative.total_tokens > 0 + and timestamp is not None + and timestamp <= child_created + ): + anchor = event.cumulative + return anchor + + +def add_diagnostics(target: Counter, source: Counter) -> None: + for key, value in source.items(): + target[key] += value + + +def new_accounting(scans: list[ParsedFile]) -> dict[str, Any]: + by_session: dict[str, ParsedFile] = {} + for scan in scans: + by_session.setdefault(scan.canonical_session_id, scan) + seen: set[str] = set() + total = Usage() + total_records = 0 + all_diagnostics = Counter() + per_file: dict[str, dict[str, Any]] = {} + + for scan in scans: + diagnostics = Counter(raw_records=len(scan.events)) + file_usage = Usage() + file_records = 0 + has_cumulative_schema = any(event.cumulative_present for event in scan.events) + + if not has_cumulative_schema: + for event in scan.events: + usage = event.last + if not usage or usage.total_tokens <= 0 or parse_iso(event.timestamp) is None: + diagnostics["skipped_records"] += 1 + continue + request_id = f"codex:legacy:{scan.canonical_session_id}:{event.timestamp}:{usage.fingerprint}" + if request_id in seen: + diagnostics["duplicate_records"] += 1 + continue + seen.add(request_id) + file_usage = file_usage.add(usage) + total = total.add(usage) + file_records += 1 + total_records += 1 + diagnostics["legacy_records"] += 1 + if not is_breakdown_consistent(usage, usage.total_tokens): + diagnostics["unknown_breakdown_records"] += 1 + else: + start_index = 0 + previous: Optional[Usage] = None + anchor = fork_anchor(scan, by_session) + if anchor and anchor.total_tokens > 0: + anchor_index = next( + ( + index + for index, event in enumerate(scan.events) + if event.cumulative_present and event.cumulative == anchor + ), + None, + ) + if anchor_index is not None: + previous = anchor + start_index = anchor_index + 1 + diagnostics["inherited_records"] = sum( + 1 for event in scan.events[: anchor_index + 1] if event.cumulative_present + ) + diagnostics["inherited_tokens"] = anchor.total_tokens + + epoch = 0 + for index in range(start_index, len(scan.events)): + event = scan.events[index] + if not event.cumulative_present: + diagnostics["skipped_records"] += 1 + continue + current = event.cumulative + if not current or current.total_tokens <= 0 or parse_iso(event.timestamp) is None: + diagnostics["skipped_records"] += 1 + continue + reset = False + if previous: + if current.total_tokens == previous.total_tokens: + diagnostics["duplicate_records"] += 1 + continue + if current.total_tokens > previous.total_tokens: + delta_total = current.total_tokens - previous.total_tokens + elif is_context_window_sentinel(event): + diagnostics["skipped_records"] += 1 + continue + elif is_credible_reset(index, scan.events, current, previous): + epoch += 1 + diagnostics["counter_resets"] += 1 + delta_total = current.total_tokens + reset = True + else: + diagnostics["skipped_records"] += 1 + continue + else: + delta_total = current.total_tokens + if delta_total <= 0: + continue + usage, known = increment_usage(current, None if reset else previous, event.last, delta_total) + request_id = f"codex:cumulative:{scan.canonical_session_id}:{epoch}:{current.total_tokens}" + if request_id in seen: + diagnostics["duplicate_records"] += 1 + previous = current + continue + seen.add(request_id) + file_usage = file_usage.add(usage) + total = total.add(usage) + file_records += 1 + total_records += 1 + diagnostics["exact_records"] += 1 + if not known: + diagnostics["unknown_breakdown_records"] += 1 + previous = current + + row = empty_file_result(scan) + row.update( + tokens=file_usage.total_tokens, + records=file_records, + components=file_usage.report_dict(), + diagnostics=dict(sorted(diagnostics.items())), + ) + per_file[scan.relative_path] = row + add_diagnostics(all_diagnostics, diagnostics) + + return { + "algorithm": "total_token_usage_delta_v6_with_legacy_fallback", + "accounting_revision": ACCOUNTING_REVISION, + "tokens": total.total_tokens, + "records": total_records, + "components": total.report_dict(), + "diagnostics": dict(sorted(all_diagnostics.items())), + "per_file": per_file, + } + + +def stable_projection(result: dict[str, Any]) -> dict[str, Any]: + return { + "algorithm": result["algorithm"], + "accounting_revision": result["accounting_revision"], + "tokens": result["tokens"], + "records": result["records"], + "components": result["components"], + "diagnostics": result["diagnostics"], + "per_file": result["per_file"], + } + + +def strict_normal_control(scan: ParsedFile, duplicate_ids: set[str]) -> bool: + if ( + scan.parent_session_id + or scan.context_compacted_events + or scan.canonical_session_id in duplicate_ids + or not scan.events + or not all(event.cumulative_present for event in scan.events) + ): + return False + previous = 0 + for event in scan.events: + current = event.cumulative + if not current or current.total_tokens <= previous or parse_iso(event.timestamp) is None: + return False + if event.old_last.total_tokens != current.total_tokens - previous: + return False + previous = current.total_tokens + return True + + +def comparison_report(scans: list[ParsedFile], old: dict[str, Any], new: dict[str, Any], top_count: int) -> dict[str, Any]: + per_file: list[dict[str, Any]] = [] + for scan in scans: + old_row = old["per_file"][scan.relative_path] + new_row = new["per_file"][scan.relative_path] + per_file.append( + { + "relative_path": scan.relative_path, + "canonical_session_id": scan.canonical_session_id, + "parent_session_id": scan.parent_session_id, + "context_compacted_events": scan.context_compacted_events, + "old_tokens": old_row["tokens"], + "new_tokens": new_row["tokens"], + "old_minus_new": old_row["tokens"] - new_row["tokens"], + "new_diagnostics": new_row["diagnostics"], + } + ) + top_differences = sorted(per_file, key=lambda row: abs(row["old_minus_new"]), reverse=True)[:top_count] + + compaction_rows = [row for row in per_file if row["context_compacted_events"] > 0] + child_rows = [row for row in per_file if row["parent_session_id"]] + replay_rows = [row for row in child_rows if row["new_diagnostics"].get("inherited_records", 0) > 0] + isolated_rows = [row for row in child_rows if row["new_diagnostics"].get("inherited_records", 0) == 0] + reset_rows = [row for row in per_file if row["new_diagnostics"].get("counter_resets", 0) > 0] + legacy_rows = [row for row in per_file if row["new_diagnostics"].get("legacy_records", 0) > 0] + + id_counts = Counter(scan.canonical_session_id for scan in scans) + duplicate_ids = {key for key, count in id_counts.items() if count > 1} + controls = [scan for scan in scans if strict_normal_control(scan, duplicate_ids)] + control_rows = [next(row for row in per_file if row["relative_path"] == scan.relative_path) for scan in controls] + control_matches = [row for row in control_rows if row["old_tokens"] == row["new_tokens"]] + control_mismatches = [row for row in control_rows if row["old_tokens"] != row["new_tokens"]] + + def cohort(rows: list[dict[str, Any]]) -> dict[str, Any]: + return { + "files": len(rows), + "old_tokens": sum(row["old_tokens"] for row in rows), + "new_tokens": sum(row["new_tokens"] for row in rows), + "old_minus_new": sum(row["old_minus_new"] for row in rows), + } + + old_tokens = old["tokens"] + new_tokens = new["tokens"] + return { + "old_total_tokens": old_tokens, + "new_total_tokens": new_tokens, + "new_minus_old": new_tokens - old_tokens, + "old_minus_new": old_tokens - new_tokens, + "new_to_old_ratio": (new_tokens / old_tokens) if old_tokens else None, + "repeated_cumulative_events": new["diagnostics"].get("duplicate_records", 0), + "counter_resets": new["diagnostics"].get("counter_resets", 0), + "legacy_fallback_records": new["diagnostics"].get("legacy_records", 0), + "inherited_replay_records_removed": new["diagnostics"].get("inherited_records", 0), + "inherited_parent_baseline_tokens": new["diagnostics"].get("inherited_tokens", 0), + "compaction_cohort": { + **cohort(compaction_rows), + "context_compacted_events": sum(row["context_compacted_events"] for row in compaction_rows), + "top_differences": sorted(compaction_rows, key=lambda row: abs(row["old_minus_new"]), reverse=True)[:top_count], + }, + "subagent_cohort": { + **cohort(child_rows), + "replay_child_files": len(replay_rows), + "isolated_child_files": len(isolated_rows), + "replay_old_tokens": sum(row["old_tokens"] for row in replay_rows), + "replay_new_tokens": sum(row["new_tokens"] for row in replay_rows), + "inherited_parent_baseline_tokens": sum( + row["new_diagnostics"].get("inherited_tokens", 0) for row in replay_rows + ), + "top_differences": sorted(child_rows, key=lambda row: abs(row["old_minus_new"]), reverse=True)[:top_count], + }, + "reset_cohort": {**cohort(reset_rows), "details": reset_rows[:top_count]}, + "legacy_cohort": {**cohort(legacy_rows), "details": legacy_rows[:top_count]}, + "strict_normal_no_repeat_control": { + "definition": ( + "root sessions with no compaction, no duplicate canonical ID, cumulative data on every token event, " + "strictly increasing totals, and last_token_usage exactly equal to each cumulative delta" + ), + "files": len(control_rows), + "matching_files": len(control_matches), + "mismatching_files": len(control_mismatches), + "old_tokens": sum(row["old_tokens"] for row in control_rows), + "new_tokens": sum(row["new_tokens"] for row in control_rows), + "mismatches": control_mismatches[:top_count], + }, + "top_session_differences": top_differences, + } + + +SWIFT_HARNESS = r''' +import Foundation + +@main +struct TokenStepAccountingAuditHarness { + static func main() throws { + guard CommandLine.arguments.count == 2 else { + fputs("usage: audit-harness \n", stderr) + exit(64) + } + let home = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) + let snapshot = UsageCollector.collectCodexUsageSnapshotForTests(homeURL: home) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + FileHandle.standardOutput.write(try encoder.encode(snapshot)) + FileHandle.standardOutput.write(Data([0x0A])) + } +} +''' + + +def normalized_swift_snapshot(snapshot: dict[str, Any]) -> dict[str, Any]: + normalized = json.loads(json.dumps(snapshot)) + normalized.pop("generated_at", None) + return normalized + + +def compile_and_run_swift(repo_root: Path, output_root: Path, frozen_home: Path) -> dict[str, Any]: + swift_dir = repo_root / "TokenStepSwift" + source_paths = [ + swift_dir / "Sources/TokenStepSwift/Support/AppPaths.swift", + swift_dir / "Sources/TokenStepSwift/Support/Localization.swift", + swift_dir / "Sources/TokenStepSwift/Support/Theme.swift", + swift_dir / "Sources/TokenStepSwift/Models/UsageModels.swift", + swift_dir / "Sources/TokenStepSwift/Services/UsageCollector.swift", + ] + for source in source_paths: + if not source.is_file(): + raise RuntimeError(f"missing Swift source: {source}") + harness_dir = output_root / "swift-harness" + module_cache = harness_dir / "module-cache" + overlay_dir = harness_dir / "vfs-overlay" + harness_dir.mkdir() + module_cache.mkdir() + overlay_dir.mkdir() + harness_source = harness_dir / "main.swift" + harness_source.write_text(SWIFT_HARNESS, encoding="utf-8") + empty_modulemap = overlay_dir / "empty.modulemap" + empty_modulemap.write_text("// Intentionally empty.\n", encoding="utf-8") + overlay = overlay_dir / "overlay.yaml" + write_json( + overlay, + { + "version": 0, + "roots": [ + { + "type": "directory", + "name": "/Library/Developer/CommandLineTools/usr/include/swift", + "contents": [ + { + "type": "file", + "name": "module.modulemap", + "external-contents": str(empty_modulemap), + } + ], + } + ], + }, + ) + source_hashes_before = {str(path.relative_to(repo_root)): hashlib.sha256(path.read_bytes()).hexdigest() for path in source_paths} + executable = harness_dir / "tokenstep-accounting-audit" + machine = platform.machine() + arch = "arm64" if machine == "arm64" else "x86_64" + command = [ + "swiftc", + "-target", + f"{arch}-apple-macos14.0", + "-vfsoverlay", + str(overlay), + "-Xcc", + "-ivfsoverlay", + "-Xcc", + str(overlay), + "-parse-as-library", + *map(str, source_paths), + str(harness_source), + "-o", + str(executable), + ] + env = os.environ.copy() + env.update( + { + "TMPDIR": str(harness_dir / "tmp"), + "CLANG_MODULE_CACHE_PATH": str(module_cache), + "SWIFT_MODULECACHE_PATH": str(module_cache), + "HOME": str(frozen_home), + } + ) + Path(env["TMPDIR"]).mkdir() + compile_run = subprocess.run(command, cwd=repo_root, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + (harness_dir / "compile.log").write_text(compile_run.stdout, encoding="utf-8") + if compile_run.returncode != 0: + raise RuntimeError(f"Swift harness compile failed; see {harness_dir / 'compile.log'}") + source_hashes_after = {str(path.relative_to(repo_root)): hashlib.sha256(path.read_bytes()).hexdigest() for path in source_paths} + if source_hashes_before != source_hashes_after: + raise RuntimeError("Swift collector sources changed during harness compilation; rerun the audit") + + snapshots = [] + hashes = [] + for run_index in (1, 2): + run = subprocess.run( + [str(executable), str(frozen_home)], + cwd=repo_root, + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + (harness_dir / f"run-{run_index}.stderr.log").write_text(run.stderr, encoding="utf-8") + if run.returncode != 0: + raise RuntimeError(f"Swift harness run {run_index} failed: {run.stderr.strip()}") + try: + snapshot = json.loads(run.stdout) + except json.JSONDecodeError as error: + raise RuntimeError(f"Swift harness run {run_index} returned invalid JSON: {error}") from error + write_json(harness_dir / f"run-{run_index}.json", snapshot) + snapshots.append(snapshot) + hashes.append(digest_json(normalized_swift_snapshot(snapshot))) + return { + "compiled_from_worktree": True, + "compile_command": command, + "source_sha256": source_hashes_after, + "run_1_stable_sha256": hashes[0], + "run_2_stable_sha256": hashes[1], + "two_runs_identical": hashes[0] == hashes[1], + "snapshot": snapshots[0], + "artifacts": str(harness_dir), + } + + +def swift_python_parity(swift: dict[str, Any], python_new: dict[str, Any]) -> dict[str, Any]: + snapshot = swift["snapshot"] + source = snapshot.get("sources", {}).get("Codex", {}) + fields = { + "accounting_revision": (source.get("accounting_revision"), python_new["accounting_revision"]), + "strategy": (source.get("strategy"), python_new["algorithm"]), + "tokens": (snapshot.get("totals", {}).get("tokens"), python_new["tokens"]), + "records": (source.get("records"), python_new["records"]), + "raw_records": (source.get("raw_records"), python_new["diagnostics"].get("raw_records", 0)), + "exact_records": (source.get("exact_records"), python_new["diagnostics"].get("exact_records", 0)), + "legacy_records": (source.get("legacy_records"), python_new["diagnostics"].get("legacy_records", 0)), + "duplicate_records": (source.get("duplicate_records"), python_new["diagnostics"].get("duplicate_records", 0)), + "counter_resets": (source.get("counter_resets"), python_new["diagnostics"].get("counter_resets", 0)), + "inherited_records": (source.get("inherited_records"), python_new["diagnostics"].get("inherited_records", 0)), + "inherited_tokens": (source.get("inherited_tokens"), python_new["diagnostics"].get("inherited_tokens", 0)), + "skipped_records": (source.get("skipped_records"), python_new["diagnostics"].get("skipped_records", 0)), + "unknown_breakdown_records": ( + source.get("unknown_breakdown_records"), + python_new["diagnostics"].get("unknown_breakdown_records", 0), + ), + } + swift_breakdown = source.get("token_breakdown", {}) + for key in ("processed_tokens", "input_tokens", "cached_input_tokens", "uncached_input_tokens", "output_tokens", "reasoning_tokens"): + fields[f"breakdown.{key}"] = (swift_breakdown.get(key), python_new["components"].get(key)) + mismatches = {key: {"swift": values[0], "python": values[1]} for key, values in fields.items() if values[0] != values[1]} + return {"matches": not mismatches, "checked_fields": len(fields), "mismatches": mismatches} + + +def n(value: Any) -> str: + return f"{value:,}" if isinstance(value, int) else str(value) + + +def markdown_report(report: dict[str, Any]) -> str: + freeze = report["freeze"] + compare = report["comparison"] + validation = report["validation"] + compaction = compare["compaction_cohort"] + subagent = compare["subagent_cohort"] + control = compare["strict_normal_no_repeat_control"] + lines = [ + "# TokenStep Codex 历史 Token 记账审计", + "", + f"生成时间:`{report['generated_at']}`", + "", + "## 结论", + "", + f"- 旧口径(逐条累加 `last_token_usage`):**{n(compare['old_total_tokens'])}** Token", + f"- 当前新口径(`total_token_usage` 真实累计增量):**{n(compare['new_total_tokens'])}** Token", + f"- 校准差额(旧 - 新):**{n(compare['old_minus_new'])}** Token", + f"- 新 / 旧比例:**{compare['new_to_old_ratio']:.4f}**" if compare["new_to_old_ratio"] is not None else "- 新 / 旧比例:N/A", + "", + "## 安全与冻结证据", + "", + f"- 原日志只读目录:`{freeze['source_root']}`", + f"- accounting-only 副本:`{freeze['frozen_sessions_root']}`", + f"- 枚举源文件:**{n(freeze['source_file_count_at_enumeration'])}**;冻结文件:**{n(freeze['frozen_file_count'])}**", + f"- 冻结相关行:**{n(freeze['frozen_lines'])}**;副本大小:**{n(freeze['frozen_bytes'])}** bytes", + f"- Manifest SHA-256:`{freeze['manifest_sha256']}`", + f"- 枚举后发生追加/变化的源文件:**{n(freeze['source_changed_after_enumeration_files'])}**(按枚举时 byte prefix 冻结,不影响结果)", + f"- 超过 Swift 1 MiB 限制并按真实 collector 行为跳过的行:**{n(freeze['oversized_lines_skipped_like_swift'])}**", + f"- 疑似相关但无效 JSON 行:**{n(freeze['invalid_relevant_candidate_lines'])}**", + "", + "该副本保留当前记账所需的全部有效 `session_meta`、`turn_context`、`token_count`,并额外保留 `context_compacted` 用于审计分类;其余对记账无影响的内容已省略。", + "", + "## 关键异常量化", + "", + f"- 重复累计事件(同累计记 0):**{n(compare['repeated_cumulative_events'])}**", + f"- 可信 counter reset:**{n(compare['counter_resets'])}**", + f"- legacy fallback 记录:**{n(compare['legacy_fallback_records'])}**", + f"- 子 Agent replay 移除记录:**{n(compare['inherited_replay_records_removed'])}**", + f"- 子 Agent 父基线锚点合计:**{n(compare['inherited_parent_baseline_tokens'])}** Token", + "", + "### Context compaction 会话", + "", + f"- 文件:**{n(compaction['files'])}**;`context_compacted` 事件:**{n(compaction['context_compacted_events'])}**", + f"- 旧:**{n(compaction['old_tokens'])}**;新:**{n(compaction['new_tokens'])}**;旧 - 新:**{n(compaction['old_minus_new'])}**", + "", + "### 子 Agent baseline 去重", + "", + f"- 子 Agent 文件:**{n(subagent['files'])}**;确认 replay:**{n(subagent['replay_child_files'])}**;独立计数器:**{n(subagent['isolated_child_files'])}**", + f"- 子 Agent 旧:**{n(subagent['old_tokens'])}**;新:**{n(subagent['new_tokens'])}**;旧 - 新:**{n(subagent['old_minus_new'])}**", + "", + "### 正常无重复对照组", + "", + f"- 严格对照文件:**{n(control['files'])}**;旧新一致:**{n(control['matching_files'])}**;不一致:**{n(control['mismatching_files'])}**", + f"- 对照组旧:**{n(control['old_tokens'])}**;新:**{n(control['new_tokens'])}**", + "", + "## 可重复性与实现一致性", + "", + f"- Python 当前口径镜像连续两次一致:**{'PASS' if validation['python_two_runs_identical'] else 'FAIL'}**", + ] + swift = validation.get("swift") + if swift: + lines.extend( + [ + f"- 当前工作树 Swift 当前口径连续两次一致:**{'PASS' if swift['two_runs_identical'] else 'FAIL'}**", + f"- Python 镜像与 Swift 关键字段一致:**{'PASS' if validation['swift_python_parity']['matches'] else 'FAIL'}**", + f"- Swift 稳定结果 SHA-256:`{swift['run_1_stable_sha256']}`", + ] + ) + else: + lines.append("- Swift harness:跳过") + lines.extend(["", "## 差异最大的会话文件", "", "| 文件 | 旧 Token | 新 Token | 旧 - 新 | compaction | parent |", "|---|---:|---:|---:|---:|---|"]) + for row in compare["top_session_differences"]: + parent = row["parent_session_id"] or "" + lines.append( + f"| `{row['relative_path']}` | {n(row['old_tokens'])} | {n(row['new_tokens'])} | {n(row['old_minus_new'])} | {n(row['context_compacted_events'])} | `{parent}` |" + ) + lines.extend( + [ + "", + "## 验收状态", + "", + f"**{'PASS' if report['overall_pass'] else 'FAIL'}**", + "", + "完整机器可读数据见同目录 `report.json`;真实 Swift 两次输出及编译日志见 `swift-harness/`。", + "", + ] + ) + return "\n".join(lines) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-root", type=Path, default=Path.home() / ".codex" / "sessions") + parser.add_argument("--output-root", type=Path) + parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--top", type=int, default=20) + parser.add_argument("--skip-swift", action="store_true", help="Run only the Python mirror (not sufficient for release proof).") + parser.add_argument( + "--self-test", + action="store_true", + help="Verify cache-creation accounting semantics without reading local Codex data.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + if args.self_test: + run_self_test() + print("audit_codex_accounting self-test passed") + return 0 + + source_root = args.source_root.expanduser().resolve() + repo_root = args.repo_root.expanduser().resolve() + tmp_root = Path("/tmp").resolve() + if args.output_root: + output_root = args.output_root.expanduser().resolve() + else: + stamp = dt.datetime.now().strftime("%Y%m%dT%H%M%S") + output_root = (tmp_root / f"tokenstep-codex-accounting-audit-{stamp}-{os.getpid()}").resolve() + if not source_root.is_dir(): + raise SystemExit(f"source root does not exist: {source_root}") + if not is_under(output_root, tmp_root): + raise SystemExit(f"refusing non-/tmp output: {output_root}") + if output_root.exists(): + raise SystemExit(f"refusing to overwrite existing output: {output_root}") + if is_under(output_root, source_root) or is_under(source_root, output_root): + raise SystemExit("source and output trees must not overlap") + output_root.mkdir(parents=True) + + print(f"audit output: {output_root}", file=sys.stderr, flush=True) + print("phase 1/4: freezing accounting-only source prefixes", file=sys.stderr, flush=True) + freeze = freeze_accounting_copy(source_root, output_root) + frozen_root = Path(freeze["frozen_sessions_root"]) + + print("phase 2/4: running old algorithm and current Python accounting twice", file=sys.stderr, flush=True) + scans_1 = parse_frozen_sessions(frozen_root) + old = old_accounting(scans_1) + new_1 = new_accounting(scans_1) + scans_2 = parse_frozen_sessions(frozen_root) + new_2 = new_accounting(scans_2) + python_hash_1 = digest_json(stable_projection(new_1)) + python_hash_2 = digest_json(stable_projection(new_2)) + comparison = comparison_report(scans_1, old, new_1, max(1, args.top)) + + swift_result = None + parity = None + swift_error = None + if not args.skip_swift: + print("phase 3/4: compiling and running isolated current Swift accounting harness twice", file=sys.stderr, flush=True) + try: + swift_result = compile_and_run_swift(repo_root, output_root, output_root / "frozen-home") + parity = swift_python_parity(swift_result, new_1) + except Exception as error: # Preserve Python audit evidence even when the harness fails. + swift_error = str(error) + (output_root / "swift-error.txt").write_text(swift_error + "\n", encoding="utf-8") + + print("phase 4/4: writing reports and evaluating gates", file=sys.stderr, flush=True) + validation = { + "python_run_1_sha256": python_hash_1, + "python_run_2_sha256": python_hash_2, + "python_two_runs_identical": python_hash_1 == python_hash_2, + "swift": ({key: value for key, value in swift_result.items() if key != "snapshot"} if swift_result else None), + "swift_error": swift_error, + "swift_python_parity": parity, + } + overall_pass = ( + validation["python_two_runs_identical"] + and comparison["strict_normal_no_repeat_control"]["mismatching_files"] == 0 + and freeze["invalid_relevant_candidate_lines"] == 0 + and (args.skip_swift or (swift_result is not None and swift_result["two_runs_identical"] and parity and parity["matches"])) + ) + report = { + "schema_version": SCHEMA_VERSION, + "generated_at": utc_now(), + "safety": { + "source_opened_read_only": True, + "writes_confined_to_tmp": True, + "production_tokenstep_cache_touched": False, + "installed_or_running_app_touched": False, + }, + "freeze": {key: value for key, value in freeze.items() if key != "files"}, + "old_algorithm": {key: value for key, value in old.items() if key != "per_file"}, + "new_algorithm": {key: value for key, value in new_1.items() if key != "per_file"}, + "comparison": comparison, + "validation": validation, + "overall_pass": bool(overall_pass), + "artifacts": { + "root": str(output_root), + "manifest": str(output_root / "manifest.json"), + "manifest_hash": str(output_root / "manifest.sha256"), + "json_report": str(output_root / "report.json"), + "markdown_report": str(output_root / "report.md"), + }, + } + write_json(output_root / "report.json", report) + (output_root / "report.md").write_text(markdown_report(report), encoding="utf-8") + print(f"report: {output_root / 'report.md'}", file=sys.stderr) + print(f"result: {'PASS' if overall_pass else 'FAIL'}", file=sys.stderr) + return 0 if overall_pass else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/script/render_agent_work_card.sh b/script/render_agent_work_card.sh new file mode 100755 index 0000000..4413800 --- /dev/null +++ b/script/render_agent_work_card.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SWIFT_DIR="$ROOT_DIR/TokenStepSwift" +BUILD_DIR="$SWIFT_DIR/.build/agent-work-card-render" +OVERLAY_DIR="$BUILD_DIR/vfs-overlay" +OVERLAY_FILE="$OVERLAY_DIR/overlay.yaml" +EMPTY_MODULEMAP="$OVERLAY_DIR/empty.modulemap" +EXECUTABLE="$BUILD_DIR/agent-work-card-render" +OUTPUT_PATH="${1:-/tmp/tokenstep-agent-work-card.png}" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/tokenstep-agent-work-card.XXXXXX")" +trap 'rm -rf "$TEST_ROOT"' EXIT + +mkdir -p "$BUILD_DIR" "$OVERLAY_DIR" +cat > "$EMPTY_MODULEMAP" <<'EOF' +// Intentionally empty. +EOF +cat > "$OVERLAY_FILE" < "$EMPTY_MODULEMAP" <<'EOF' +// Intentionally empty. +EOF +cat > "$OVERLAY_FILE" < "$EMPTY_MODULEMAP" <<'EOF' +// Intentionally empty. +EOF +cat > "$OVERLAY_FILE" </dev/null 2>&1; then + swiftc \ + -target arm64-apple-macos14.0 \ + -vfsoverlay "$OVERLAY_FILE" \ + -Xcc -ivfsoverlay \ + -Xcc "$OVERLAY_FILE" \ + -parse-as-library \ + -enable-testing \ + -emit-module \ + -module-name TokenStepSwift \ + "$SWIFT_DIR/Sources/TokenStepSwift/Support/AppPaths.swift" \ + "$SWIFT_DIR/Sources/TokenStepSwift/Support/Localization.swift" \ + "$SWIFT_DIR/Sources/TokenStepSwift/Support/Theme.swift" \ + "$SWIFT_DIR/Sources/TokenStepSwift/Models/UsageModels.swift" \ + "$SWIFT_DIR/Sources/TokenStepSwift/Services/UsageCollector.swift" \ + -emit-module-path "$MODULE_DIR/TokenStepSwift.swiftmodule" + + swiftc \ + -target arm64-apple-macos14.0 \ + -vfsoverlay "$OVERLAY_FILE" \ + -Xcc -ivfsoverlay \ + -Xcc "$OVERLAY_FILE" \ + -typecheck \ + -I "$MODULE_DIR" \ + "$SWIFT_DIR/Tests/TokenStepSwiftTests/UsageCollectorCodexTests.swift" + + echo "UsageCollectorCodexTests XCTest source type-check passed" +else + echo "XCTest type-check skipped: active CommandLineTools has no XCTest module" +fi diff --git a/script/test_usage_recalibration_migration.sh b/script/test_usage_recalibration_migration.sh new file mode 100755 index 0000000..41c19c1 --- /dev/null +++ b/script/test_usage_recalibration_migration.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SWIFT_DIR="$ROOT_DIR/TokenStepSwift" +BUILD_DIR="$SWIFT_DIR/.build/usage-recalibration-fixture" +OVERLAY_DIR="$BUILD_DIR/vfs-overlay" +OVERLAY_FILE="$OVERLAY_DIR/overlay.yaml" +EMPTY_MODULEMAP="$OVERLAY_DIR/empty.modulemap" +EXECUTABLE="$BUILD_DIR/usage-recalibration-fixture-check" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/tokenstep-recalibration-test.XXXXXX")" +trap 'rm -rf "$TEST_ROOT"' EXIT + +mkdir -p "$BUILD_DIR" "$OVERLAY_DIR" +cat > "$EMPTY_MODULEMAP" <<'EOF' +// Intentionally empty. +EOF +cat > "$OVERLAY_FILE" <