@sriinnu/runic 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/LICENSE +1 -1
- package/README.md +47 -29
- package/RuniciOS/Core/RunicTypographyiOS.swift +24 -0
- package/RuniciOS/Views/ProviderDetailView.swift +9 -9
- package/RuniciOS/Views/ProviderListView.swift +10 -10
- package/RuniciOS/Widgets/LargeWidget.swift +14 -14
- package/RuniciOS/Widgets/LiveActivity.swift +19 -19
- package/RuniciOS/Widgets/LockScreenWidget.swift +15 -15
- package/RuniciOS/Widgets/MediumWidget.swift +10 -10
- package/RuniciOS/Widgets/RunicWidget.swift +5 -5
- package/RuniciOS/Widgets/SmallWidget.swift +9 -9
- package/Scripts/package_app.sh +2 -2
- package/Sources/Runic/About.swift +3 -3
- package/Sources/Runic/AppNotifications.swift +5 -3
- package/Sources/Runic/Controllers/StatusItemController+Actions.swift +17 -4
- package/Sources/Runic/Controllers/StatusItemController+Animation.swift +5 -4
- package/Sources/Runic/Controllers/StatusItemController+ChartSubmenus.swift +30 -16
- package/Sources/Runic/Controllers/StatusItemController+CustomProviders.swift +6 -3
- package/Sources/Runic/Controllers/StatusItemController+Menu.swift +47 -14
- package/Sources/Runic/Controllers/StatusItemController+MenuCards.swift +6 -4
- package/Sources/Runic/Controllers/StatusItemController+MenuHelpers.swift +40 -12
- package/Sources/Runic/Controllers/StatusItemController.swift +43 -43
- package/Sources/Runic/Core/BudgetNotificationManager.swift +2 -1
- package/Sources/Runic/Core/Login/ClaudeLoginRunner.swift +2 -2
- package/Sources/Runic/Core/Login/CodexLoginRunner.swift +1 -1
- package/Sources/Runic/Core/Login/CursorLoginRunner.swift +4 -4
- package/Sources/Runic/Core/Login/GeminiLoginRunner.swift +3 -6
- package/Sources/Runic/Core/Rendering/IconRenderer.swift +91 -85
- package/Sources/Runic/Core/Rendering/IconView.swift +3 -1
- package/Sources/Runic/Core/RunicColors.swift +6 -6
- package/Sources/Runic/Core/RunicSpacing.swift +7 -7
- package/Sources/Runic/Core/RunicTypography.swift +206 -0
- package/Sources/Runic/Core/Stores/AuggieTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/AzureOpenAITokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/CerebrasTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/CohereTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/CopilotTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/CustomProviderStore.swift +10 -10
- package/Sources/Runic/Core/Stores/DeepSeekTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/FireworksTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/GroqTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/KimiTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/MiniMaxCookieHeaderStore.swift +10 -36
- package/Sources/Runic/Core/Stores/MiniMaxGroupIDStore.swift +10 -36
- package/Sources/Runic/Core/Stores/MiniMaxTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/MistralTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/OpenRouterTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/PerplexityTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/ProviderToggleStore.swift +1 -1
- package/Sources/Runic/Core/Stores/QwenTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/SambaNovaTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/SettingsStore.swift +99 -52
- package/Sources/Runic/Core/Stores/TogetherTokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/UsageStore+Accessors.swift +19 -6
- package/Sources/Runic/Core/Stores/UsageStore+TokenCost.swift +1 -1
- package/Sources/Runic/Core/Stores/UsageStore+WidgetSnapshot.swift +1 -1
- package/Sources/Runic/Core/Stores/UsageStore.swift +234 -167
- package/Sources/Runic/Core/Stores/XAITokenStore.swift +10 -36
- package/Sources/Runic/Core/Stores/ZaiTokenStore.swift +10 -36
- package/Sources/Runic/Core/UsageExporter.swift +8 -4
- package/Sources/Runic/ProviderRegistry.swift +1 -1
- package/Sources/Runic/Providers/Antigravity/AntigravityProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Auggie/AuggieProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Azure/AzureProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Bedrock/BedrockProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Cerebras/CerebrasProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Codex/CodexProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Cohere/CohereProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Cursor/CursorProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/DeepSeek/DeepSeekProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Factory/FactoryProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Fireworks/FireworksProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Gemini/GeminiProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Groq/GroqProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Kimi/KimiProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/MiniMax/MiniMaxProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Mistral/MistralProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/OpenRouter/OpenRouterProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Perplexity/PerplexityProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/SambaNova/SambaNovaProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Shared/ProviderImplementation.swift +5 -2
- package/Sources/Runic/Providers/Shared/ProviderImplementationRegistry.swift +1 -1
- package/Sources/Runic/Providers/Shared/ProviderSettingsDescriptors.swift +1 -1
- package/Sources/Runic/Providers/Together/TogetherProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/XAI/XAIProviderImplementation.swift +1 -1
- package/Sources/Runic/Providers/Zai/ZaiProviderImplementation.swift +1 -1
- package/Sources/Runic/Resources/Fonts/Caveat-Bold.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/Caveat-Regular.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/FiraCode-Bold.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/FiraCode-Light.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/FiraCode-Medium.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/FiraCode-Regular.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/FiraCode-SemiBold.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/IBMPlexMono-Bold.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/IBMPlexMono-Medium.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/IBMPlexMono-Regular.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/IBMPlexMono-SemiBold.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/Inconsolata-Bold.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/Inconsolata-Medium.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/Inconsolata-Regular.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/Inconsolata-SemiBold.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/JetBrainsMono-Bold.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/JetBrainsMono-Medium.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/JetBrainsMono-Regular.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/JetBrainsMono-SemiBold.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/SpaceMono-Bold.ttf +0 -0
- package/Sources/Runic/Resources/Fonts/SpaceMono-Regular.ttf +0 -0
- package/Sources/Runic/Resources/provider-context-windows.json +20 -0
- package/Sources/Runic/RunicApp.swift +46 -36
- package/Sources/Runic/SessionQuotaNotifications.swift +2 -2
- package/Sources/Runic/UsagePaceText.swift +1 -1
- package/Sources/Runic/UsageProgressBar.swift +13 -9
- package/Sources/Runic/Utilities/LoadingPattern.swift +4 -2
- package/Sources/Runic/Utilities/PerformanceConstants.swift +28 -28
- package/Sources/Runic/Views/Menu/AlertsMenuView.swift +19 -21
- package/Sources/Runic/Views/Menu/CostComparativeChartMenuView.swift +19 -19
- package/Sources/Runic/Views/Menu/CostHistoryChartMenuView.swift +7 -7
- package/Sources/Runic/Views/Menu/CreditsHistoryChartMenuView.swift +5 -5
- package/Sources/Runic/Views/Menu/EfficiencyMetricsMenuView.swift +29 -30
- package/Sources/Runic/Views/Menu/HeroStatCardsMenuView.swift +20 -17
- package/Sources/Runic/Views/Menu/HourlyActivityChartMenuView.swift +14 -14
- package/Sources/Runic/Views/Menu/InlineUsageChartView.swift +15 -20
- package/Sources/Runic/Views/Menu/MenuCardEntranceModifier.swift +3 -4
- package/Sources/Runic/Views/Menu/MenuCardSidebarView.swift +5 -25
- package/Sources/Runic/Views/Menu/MenuCardSkeletonView.swift +1 -1
- package/Sources/Runic/Views/Menu/MenuCardView.swift +120 -131
- package/Sources/Runic/Views/Menu/MenuContent.swift +11 -8
- package/Sources/Runic/Views/Menu/MenuDescriptor.swift +4 -2
- package/Sources/Runic/Views/Menu/MenuHighlightStyle.swift +1 -8
- package/Sources/Runic/Views/Menu/ModelBreakdownMenuView.swift +20 -14
- package/Sources/Runic/Views/Menu/OverviewMenuView.swift +198 -58
- package/Sources/Runic/Views/Menu/ProjectBreakdownMenuView.swift +16 -11
- package/Sources/Runic/Views/Menu/ProjectBudgetMenuView.swift +27 -27
- package/Sources/Runic/Views/Menu/ProviderTabBarView.swift +4 -6
- package/Sources/Runic/Views/Menu/SubscriptionUtilizationChartMenuView.swift +24 -11
- package/Sources/Runic/Views/Menu/UsageBreakdownChartMenuView.swift +5 -5
- package/Sources/Runic/Views/Menu/UsageHeatmapMenuView.swift +7 -8
- package/Sources/Runic/Views/Menu/UsageTimelineChartMenuView.swift +28 -76
- package/Sources/Runic/Views/Menu/UsageWindowComparisonChartMenuView.swift +8 -7
- package/Sources/Runic/Views/Menu/WeeklyActivityChartMenuView.swift +7 -7
- package/Sources/Runic/Views/Performance/PerformanceDashboardView.swift +50 -47
- package/Sources/Runic/Views/Preferences/LiquidComponents.swift +15 -14
- package/Sources/Runic/Views/Preferences/PreferencesAboutPane.swift +7 -7
- package/Sources/Runic/Views/Preferences/PreferencesAdvancedPane.swift +27 -27
- package/Sources/Runic/Views/Preferences/PreferencesAlertsPane.swift +38 -42
- package/Sources/Runic/Views/Preferences/PreferencesAnalyticsPane.swift +39 -43
- package/Sources/Runic/Views/Preferences/PreferencesBudgetsPane.swift +21 -21
- package/Sources/Runic/Views/Preferences/PreferencesComponents.swift +7 -7
- package/Sources/Runic/Views/Preferences/PreferencesCustomProvidersPane.swift +49 -50
- package/Sources/Runic/Views/Preferences/PreferencesDebugPane.swift +10 -10
- package/Sources/Runic/Views/Preferences/PreferencesGeneralPane.swift +40 -21
- package/Sources/Runic/Views/Preferences/PreferencesHelpPane.swift +7 -7
- package/Sources/Runic/Views/Preferences/PreferencesIntegrationsPane.swift +108 -33
- package/Sources/Runic/Views/Preferences/PreferencesPerformancePane.swift +26 -23
- package/Sources/Runic/Views/Preferences/PreferencesProvidersPane.swift +271 -226
- package/Sources/Runic/Views/Preferences/PreferencesSelection.swift +6 -2
- package/Sources/Runic/Views/Preferences/PreferencesSyncPane.swift +7 -5
- package/Sources/Runic/Views/Preferences/PreferencesView.swift +38 -31
- package/Sources/Runic/Views/Rating/QualityRatingView.swift +11 -11
- package/Sources/Runic/Views/Team/TeamDashboardView.swift +29 -24
- package/Sources/Runic/Views/Team/TeamInviteSheet.swift +16 -16
- package/Sources/Runic/Views/Team/TeamManagementView.swift +50 -49
- package/Sources/Runic/Views/Team/TeamMemberRow.swift +8 -8
- package/Sources/RunicCLI/CLIEntry.swift +83 -42
- package/Sources/RunicCLI/CLIRenderer.swift +1 -1
- package/Sources/RunicCLI/Commands/EnhancedUsageCommand.swift +60 -60
- package/Sources/RunicCLI/Commands/ModelsCommand.swift +45 -44
- package/Sources/RunicCLI/Commands/ProjectsCommand.swift +83 -69
- package/Sources/RunicCLI/Commands/ResetCommand.swift +50 -49
- package/Sources/RunicCLI/Commands/UsageLedgerSourceFactory.swift +31 -32
- package/Sources/RunicClaudeWebProbe/main.swift +1 -1
- package/Sources/RunicCore/AlertRuleStore.swift +20 -20
- package/Sources/RunicCore/BrowserCookieImportOrder.swift +3 -3
- package/Sources/RunicCore/CostUsageModels.swift +1 -1
- package/Sources/RunicCore/CustomProviders/CustomProviderModels.swift +29 -30
- package/Sources/RunicCore/CustomProviders/GenericProviderFetcher.swift +80 -71
- package/Sources/RunicCore/CustomProviders/ProviderTemplates.swift +53 -109
- package/Sources/RunicCore/Host/PTY/TTYCommandRunner.swift +3 -3
- package/Sources/RunicCore/Integrations/GitHubIntegration.swift +3 -3
- package/Sources/RunicCore/Logging/OSLogLogHandler.swift +6 -6
- package/Sources/RunicCore/Models/EnhancedUsageModels.swift +47 -48
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +1 -1
- package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardFetcher.swift +1 -1
- package/Sources/RunicCore/Performance/PerformanceModels.swift +4 -4
- package/Sources/RunicCore/Performance/PerformanceStorageImpl.swift +81 -64
- package/Sources/RunicCore/Policy/UsagePolicyEngine.swift +2 -2
- package/Sources/RunicCore/ProjectBudgetStore.swift +15 -12
- package/Sources/RunicCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +4 -2
- package/Sources/RunicCore/Providers/Antigravity/AntigravityStatusProbe.swift +2 -2
- package/Sources/RunicCore/Providers/Auggie/AuggieProviderDescriptor.swift +2 -2
- package/Sources/RunicCore/Providers/Auggie/AuggieUsageFetcher.swift +3 -3
- package/Sources/RunicCore/Providers/Azure/AzureProviderDescriptor.swift +8 -8
- package/Sources/RunicCore/Providers/Azure/AzureUsageFetcher.swift +5 -3
- package/Sources/RunicCore/Providers/Bedrock/BedrockProviderDescriptor.swift +6 -6
- package/Sources/RunicCore/Providers/Bedrock/BedrockUsageFetcher.swift +3 -7
- package/Sources/RunicCore/Providers/Cerebras/CerebrasProviderDescriptor.swift +2 -2
- package/Sources/RunicCore/Providers/Cerebras/CerebrasUsageFetcher.swift +2 -2
- package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +3 -3
- package/Sources/RunicCore/Providers/Claude/ClaudeProviderDescriptor.swift +4 -2
- package/Sources/RunicCore/Providers/Claude/ClaudeStatusProbe.swift +4 -4
- package/Sources/RunicCore/Providers/Claude/ClaudeUsageDataSource.swift +3 -1
- package/Sources/RunicCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +1 -1
- package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +1 -0
- package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +12 -1
- package/Sources/RunicCore/Providers/Codex/CodexProviderDescriptor.swift +4 -2
- package/Sources/RunicCore/Providers/Codex/CodexUsageDataSource.swift +3 -1
- package/Sources/RunicCore/Providers/Codex/CodexWebDashboardStrategy.swift +5 -3
- package/Sources/RunicCore/Providers/Cohere/CohereProviderDescriptor.swift +2 -2
- package/Sources/RunicCore/Providers/Cohere/CohereUsageFetcher.swift +3 -3
- package/Sources/RunicCore/Providers/Copilot/CopilotGitHubCLITokenReader.swift +7 -4
- package/Sources/RunicCore/Providers/Copilot/CopilotProviderDescriptor.swift +4 -4
- package/Sources/RunicCore/Providers/Copilot/CopilotUsageFetcher.swift +5 -3
- package/Sources/RunicCore/Providers/Copilot/CopilotVSCodeTokenReader.swift +9 -10
- package/Sources/RunicCore/Providers/Cursor/CursorProviderDescriptor.swift +4 -2
- package/Sources/RunicCore/Providers/Cursor/CursorStatusProbe.swift +1 -0
- package/Sources/RunicCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +2 -2
- package/Sources/RunicCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +2 -2
- package/Sources/RunicCore/Providers/Factory/FactoryLocalStorageImporter.swift +4 -4
- package/Sources/RunicCore/Providers/Factory/FactoryProviderDescriptor.swift +4 -2
- package/Sources/RunicCore/Providers/Factory/FactoryStatusProbe.swift +3 -2
- package/Sources/RunicCore/Providers/Fireworks/FireworksProviderDescriptor.swift +2 -2
- package/Sources/RunicCore/Providers/Fireworks/FireworksUsageFetcher.swift +2 -2
- package/Sources/RunicCore/Providers/Gemini/GeminiProviderDescriptor.swift +4 -2
- package/Sources/RunicCore/Providers/Gemini/GeminiStatusProbe.swift +6 -2
- package/Sources/RunicCore/Providers/Groq/GroqProviderDescriptor.swift +2 -2
- package/Sources/RunicCore/Providers/Groq/GroqUsageFetcher.swift +2 -2
- package/Sources/RunicCore/Providers/Kimi/KimiProviderDescriptor.swift +2 -2
- package/Sources/RunicCore/Providers/Kimi/KimiUsageFetcher.swift +2 -2
- package/Sources/RunicCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +10 -5
- package/Sources/RunicCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +8 -11
- package/Sources/RunicCore/Providers/MiniMax/MiniMaxWebUsageFetcher.swift +10 -10
- package/Sources/RunicCore/Providers/Mistral/MistralProviderDescriptor.swift +2 -2
- package/Sources/RunicCore/Providers/Mistral/MistralUsageFetcher.swift +2 -2
- package/Sources/RunicCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift +2 -2
- package/Sources/RunicCore/Providers/OpenRouter/OpenRouterUsageFetcher.swift +25 -16
- package/Sources/RunicCore/Providers/Perplexity/PerplexityProviderDescriptor.swift +2 -2
- package/Sources/RunicCore/Providers/Perplexity/PerplexityUsageFetcher.swift +2 -2
- package/Sources/RunicCore/Providers/ProviderFetchPlan.swift +2 -2
- package/Sources/RunicCore/Providers/ProviderTokenResolver.swift +2 -2
- package/Sources/RunicCore/Providers/Qwen/QwenProviderDescriptor.swift +5 -3
- package/Sources/RunicCore/Providers/Qwen/QwenUsageStats.swift +27 -16
- package/Sources/RunicCore/Providers/SambaNova/SambaNovaProviderDescriptor.swift +2 -2
- package/Sources/RunicCore/Providers/SambaNova/SambaNovaUsageFetcher.swift +2 -2
- package/Sources/RunicCore/Providers/Together/TogetherProviderDescriptor.swift +2 -2
- package/Sources/RunicCore/Providers/Together/TogetherUsageFetcher.swift +2 -2
- package/Sources/RunicCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +5 -5
- package/Sources/RunicCore/Providers/VertexAI/VertexAISettingsReader.swift +1 -1
- package/Sources/RunicCore/Providers/VertexAI/VertexAIUsageFetcher.swift +3 -3
- package/Sources/RunicCore/Providers/XAI/XAIProviderDescriptor.swift +2 -2
- package/Sources/RunicCore/Providers/XAI/XAIUsageFetcher.swift +2 -2
- package/Sources/RunicCore/Providers/Zai/ZaiProviderDescriptor.swift +4 -2
- package/Sources/RunicCore/Providers/Zai/ZaiUsageStats.swift +51 -30
- package/Sources/RunicCore/Sync/BackgroundSyncManager.swift +76 -77
- package/Sources/RunicCore/Sync/SyncConflictResolver.swift +55 -58
- package/Sources/RunicCore/Sync/SyncProtocol.swift +21 -22
- package/Sources/RunicCore/Sync/SyncRecord.swift +46 -49
- package/Sources/RunicCore/Sync/SyncUsageExample.swift +44 -50
- package/Sources/RunicCore/Sync/TeamSyncRecords.swift +94 -101
- package/Sources/RunicCore/Sync/iCloudSyncEngine.swift +51 -46
- package/Sources/RunicCore/Team/TeamModels.swift +111 -110
- package/Sources/RunicCore/Team/TeamStore.swift +126 -109
- package/Sources/RunicCore/UsageFetcher.swift +2 -2
- package/Sources/RunicCore/UsageFormatter.swift +25 -25
- package/Sources/RunicCore/UsageLedger/ClaudeUsageLogSource.swift +141 -29
- package/Sources/RunicCore/UsageLedger/CodexUsageLogSource.swift +59 -5
- package/Sources/RunicCore/UsageLedger/LedgerCache.swift +161 -0
- package/Sources/RunicCore/UsageLedger/OTelGenAILedgerAdapter.swift +1 -1
- package/Sources/RunicCore/UsageLedger/UsageLedgerAggregator.swift +40 -25
- package/Sources/RunicCore/UsageLedger/UsageLedgerInsightsAdvisor.swift +1 -1
- package/Sources/RunicCore/UsageLedger/UsageLedgerProjectIdentity.swift +55 -16
- package/Sources/RunicCore/Vendored/CostUsage/CostUsageCache.swift +6 -6
- package/Sources/RunicCore/Vendored/CostUsage/CostUsageJsonl.swift +1 -1
- package/Sources/RunicCore/Vendored/CostUsage/CostUsagePricing.swift +2 -2
- package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner.swift +5 -5
- package/Tests/RunicTests/AppDelegateTests.swift +8 -3
- package/Tests/RunicTests/BrowserCookieOrderLabelTests.swift +2 -3
- package/Tests/RunicTests/CLIArgumentParsingTests.swift +3 -4
- package/Tests/RunicTests/CLICostTests.swift +6 -7
- package/Tests/RunicTests/CLIProviderSelectionTests.swift +2 -3
- package/Tests/RunicTests/CLISnapshotTests.swift +3 -4
- package/Tests/RunicTests/CLIWebFallbackTests.swift +3 -4
- package/Tests/RunicTests/ClaudeOAuthTests.swift +11 -12
- package/Tests/RunicTests/ClaudeResilienceTests.swift +3 -4
- package/Tests/RunicTests/ClaudeUsageTests.swift +20 -21
- package/Tests/RunicTests/CodexOAuthTests.swift +7 -8
- package/Tests/RunicTests/CodexUsageLogSourceTests.swift +1 -3
- package/Tests/RunicTests/CopilotTokenResolverTests.swift +5 -6
- package/Tests/RunicTests/CopilotVSCodeTokenReaderTests.swift +4 -5
- package/Tests/RunicTests/CostUsageDecodingTests.swift +15 -17
- package/Tests/RunicTests/CostUsagePricingTests.swift +5 -7
- package/Tests/RunicTests/CostUsageScannerTests.swift +10 -12
- package/Tests/RunicTests/CursorStatusProbeTests.swift +15 -16
- package/Tests/RunicTests/FactoryStatusProbeTests.swift +3 -5
- package/Tests/RunicTests/GeminiLoginAlertTests.swift +3 -4
- package/Tests/RunicTests/GeminiStatusProbeAPITests.swift +11 -11
- package/Tests/RunicTests/GeminiStatusProbePlanTests.swift +8 -8
- package/Tests/RunicTests/GeminiStatusProbeTests.swift +20 -21
- package/Tests/RunicTests/GoogleWorkspaceStatusTests.swift +2 -3
- package/Tests/RunicTests/InstallOriginTests.swift +1 -2
- package/Tests/RunicTests/KeyboardShortcutsBundleTests.swift +2 -2
- package/Tests/RunicTests/LiveAccountTests.swift +3 -3
- package/Tests/RunicTests/LoadingPatternTests.swift +3 -4
- package/Tests/RunicTests/MenuCardModelTests.swift +33 -34
- package/Tests/RunicTests/MiniMaxCookieHeaderStoreTestSupport.swift +4 -1
- package/Tests/RunicTests/MiniMaxGroupIDStoreTestSupport.swift +4 -1
- package/Tests/RunicTests/MiniMaxParsingTests.swift +4 -5
- package/Tests/RunicTests/MiniMaxTokenStoreTestSupport.swift +4 -1
- package/Tests/RunicTests/OTelGenAILedgerAdapterTests.swift +4 -6
- package/Tests/RunicTests/OpenAIDashboardBrowserCookieImporterTests.swift +1 -2
- package/Tests/RunicTests/OpenAIDashboardFetcherCreditsWaitTests.swift +5 -6
- package/Tests/RunicTests/OpenAIDashboardOffscreenHostTests.swift +2 -3
- package/Tests/RunicTests/OpenAIDashboardParserTests.swift +12 -13
- package/Tests/RunicTests/OpenAIWebAccountSwitchTests.swift +17 -6
- package/Tests/RunicTests/OpenRouterTokenStoreTestSupport.swift +52 -13
- package/Tests/RunicTests/PathBuilderTests.swift +14 -15
- package/Tests/RunicTests/ProviderFetchPipelineTests.swift +3 -4
- package/Tests/RunicTests/ProviderIconResourcesTests.swift +1 -2
- package/Tests/RunicTests/ProviderMetadataStatusLinkTests.swift +1 -2
- package/Tests/RunicTests/ProviderSettingsDescriptorTests.swift +53 -19
- package/Tests/RunicTests/ProviderToggleStoreTests.swift +10 -11
- package/Tests/RunicTests/RunicTests.swift +6 -7
- package/Tests/RunicTests/SessionQuotaNotificationLogicTests.swift +5 -6
- package/Tests/RunicTests/SettingsStoreTests.swift +149 -45
- package/Tests/RunicTests/StatusItemAnimationTests.swift +8 -3
- package/Tests/RunicTests/StatusMenuTests.swift +63 -22
- package/Tests/RunicTests/StatusProbeTests.swift +28 -29
- package/Tests/RunicTests/SubprocessRunnerTests.swift +1 -3
- package/Tests/RunicTests/SubscriptionDetectionTests.swift +8 -9
- package/Tests/RunicTests/TTYCommandRunnerTests.swift +8 -9
- package/Tests/RunicTests/TeamShowbackExporterTests.swift +2 -4
- package/Tests/RunicTests/TextParsingTests.swift +1 -2
- package/Tests/RunicTests/UsageFormatterTests.swift +15 -16
- package/Tests/RunicTests/UsageLedgerAnomalyDetectionTests.swift +12 -14
- package/Tests/RunicTests/UsageLedgerForecastTests.swift +4 -6
- package/Tests/RunicTests/UsageLedgerInsightsAdvisorTests.swift +7 -9
- package/Tests/RunicTests/UsageLedgerProjectNameTests.swift +6 -8
- package/Tests/RunicTests/UsagePaceTests.swift +5 -6
- package/Tests/RunicTests/UsagePaceTextTests.swift +8 -9
- package/Tests/RunicTests/UsagePolicyEngineTests.swift +3 -5
- package/Tests/RunicTests/WidgetSnapshotTests.swift +1 -3
- package/Tests/RunicTests/ZaiProviderTests.swift +3 -5
- package/Tests/RunicTests/ZaiTokenStoreTestSupport.swift +4 -1
- package/appcast.xml +36 -37
- package/package.json +1 -1
- package/runic.png +0 -0
- package/runic.svg +15 -0
- package/version.env +2 -2
|
@@ -97,7 +97,7 @@ public enum UsageFormatter {
|
|
|
97
97
|
guard tokenCount > 0 else { return nil }
|
|
98
98
|
let rate = costUSD / (Double(tokenCount) / 1000.0)
|
|
99
99
|
guard rate.isFinite, rate >= 0 else { return nil }
|
|
100
|
-
return "\(usdRateString(rate))/1K"
|
|
100
|
+
return "\(self.usdRateString(rate))/1K"
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
public static func usdPerRequestString(costUSD: Double?, requestCount: Int) -> String? {
|
|
@@ -105,7 +105,7 @@ public enum UsageFormatter {
|
|
|
105
105
|
guard requestCount > 0 else { return nil }
|
|
106
106
|
let rate = costUSD / Double(requestCount)
|
|
107
107
|
guard rate.isFinite, rate >= 0 else { return nil }
|
|
108
|
-
return "\(usdRateString(rate))/req"
|
|
108
|
+
return "\(self.usdRateString(rate))/req"
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
public static func usdPerHourFromTokensString(
|
|
@@ -120,7 +120,7 @@ public enum UsageFormatter {
|
|
|
120
120
|
guard per1KValue.isFinite, per1KValue >= 0 else { return nil }
|
|
121
121
|
let hourly = per1KValue * (tokensPerMinute * 60.0 / 1000.0)
|
|
122
122
|
guard hourly.isFinite, hourly >= 0 else { return nil }
|
|
123
|
-
return "\(usdRateString(hourly))/hr"
|
|
123
|
+
return "\(self.usdRateString(hourly))/hr"
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
public static func currencyString(_ value: Double, currencyCode: String) -> String {
|
|
@@ -164,21 +164,21 @@ public enum UsageFormatter {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
public static func tokenSummaryString(_ totals: UsageLedgerTotals, includeBreakdown: Bool = true) -> String {
|
|
167
|
-
let total = tokenCountString(totals.totalTokens)
|
|
167
|
+
let total = self.tokenCountString(totals.totalTokens)
|
|
168
168
|
guard includeBreakdown else {
|
|
169
169
|
return "\(total) tok"
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
var parts: [String] = []
|
|
173
173
|
if totals.inputTokens > 0 {
|
|
174
|
-
parts.append("in \(tokenCountString(totals.inputTokens))")
|
|
174
|
+
parts.append("in \(self.tokenCountString(totals.inputTokens))")
|
|
175
175
|
}
|
|
176
176
|
if totals.outputTokens > 0 {
|
|
177
|
-
parts.append("out \(tokenCountString(totals.outputTokens))")
|
|
177
|
+
parts.append("out \(self.tokenCountString(totals.outputTokens))")
|
|
178
178
|
}
|
|
179
179
|
let cached = totals.cacheCreationTokens + totals.cacheReadTokens
|
|
180
180
|
if cached > 0 {
|
|
181
|
-
parts.append("cache \(tokenCountString(cached))")
|
|
181
|
+
parts.append("cache \(self.tokenCountString(cached))")
|
|
182
182
|
}
|
|
183
183
|
guard !parts.isEmpty else { return "\(total) tok" }
|
|
184
184
|
return "\(total) tok (\(parts.joined(separator: ", ")))"
|
|
@@ -248,8 +248,8 @@ public enum UsageFormatter {
|
|
|
248
248
|
/// Returns a human-friendly context window label for a model when known.
|
|
249
249
|
/// Examples: "ctx 128k", "ctx 1M".
|
|
250
250
|
public static func modelContextLabel(for model: String) -> String? {
|
|
251
|
-
guard let context =
|
|
252
|
-
return "ctx \(tokenCountString(context))"
|
|
251
|
+
guard let context = modelContextWindow(for: model) else { return nil }
|
|
252
|
+
return "ctx \(self.tokenCountString(context))"
|
|
253
253
|
}
|
|
254
254
|
|
|
255
255
|
/// Resolves an approximate context window (in tokens) for common model families.
|
|
@@ -285,7 +285,7 @@ public enum UsageFormatter {
|
|
|
285
285
|
"gpt-4o-mini": 128_000,
|
|
286
286
|
"gpt-4-turbo": 128_000,
|
|
287
287
|
"gpt-4": 128_000,
|
|
288
|
-
"gpt-3.5-turbo":
|
|
288
|
+
"gpt-3.5-turbo": 16385,
|
|
289
289
|
"gpt-4.1": 1_000_000,
|
|
290
290
|
"gpt-4.1-mini": 1_000_000,
|
|
291
291
|
"gpt-4.1-nano": 1_000_000,
|
|
@@ -315,33 +315,33 @@ public enum UsageFormatter {
|
|
|
315
315
|
"llama-3-1-8b": 128_000,
|
|
316
316
|
"llama-3-3-70b": 128_000,
|
|
317
317
|
"mistral-large": 128_000,
|
|
318
|
-
"mistral-small":
|
|
318
|
+
"mistral-small": 32000,
|
|
319
319
|
"mistral-medium": 131_072,
|
|
320
|
-
"deepseek-chat":
|
|
321
|
-
"deepseek-coder":
|
|
320
|
+
"deepseek-chat": 64000,
|
|
321
|
+
"deepseek-coder": 32000,
|
|
322
322
|
"cohere-command": 128_000,
|
|
323
323
|
"cohere-command-r": 128_000,
|
|
324
324
|
"cohere-command-r-plus": 128_000,
|
|
325
325
|
"qwen-2-5-72b": 128_000,
|
|
326
326
|
"qwen-2-5-32b": 128_000,
|
|
327
|
-
"qwen1-5-32b":
|
|
328
|
-
"qwen1-5-14b":
|
|
327
|
+
"qwen1-5-32b": 8192,
|
|
328
|
+
"qwen1-5-14b": 8192,
|
|
329
329
|
"grok-2": 128_000,
|
|
330
330
|
"grok-2-mini": 131_072,
|
|
331
331
|
"grok-2-vision": 131_072,
|
|
332
|
-
"mixtral-8x7b":
|
|
333
|
-
"mixtral-8x22b":
|
|
334
|
-
"llama-3-8b":
|
|
335
|
-
"llama-3-70b":
|
|
336
|
-
"llama-2-70b":
|
|
337
|
-
"llama-2-13b":
|
|
332
|
+
"mixtral-8x7b": 32768,
|
|
333
|
+
"mixtral-8x22b": 64000,
|
|
334
|
+
"llama-3-8b": 8192,
|
|
335
|
+
"llama-3-70b": 8192,
|
|
336
|
+
"llama-2-70b": 4096,
|
|
337
|
+
"llama-2-13b": 4096,
|
|
338
338
|
]
|
|
339
339
|
|
|
340
340
|
private static let modelContextPrefixes: [(String, Int)] = [
|
|
341
341
|
("gpt-4.1", 1_000_000),
|
|
342
342
|
("gpt-4o", 128_000),
|
|
343
343
|
("gpt-4-", 128_000),
|
|
344
|
-
("gpt-3.5",
|
|
344
|
+
("gpt-3.5", 16385),
|
|
345
345
|
("gpt-5", 400_000),
|
|
346
346
|
("gpt-5-", 400_000),
|
|
347
347
|
("claude-3", 200_000),
|
|
@@ -351,9 +351,9 @@ public enum UsageFormatter {
|
|
|
351
351
|
("gemini-1-5", 1_000_000),
|
|
352
352
|
("gemini-2-0", 1_000_000),
|
|
353
353
|
("gemini-2-5", 1_000_000),
|
|
354
|
-
("llama-3",
|
|
354
|
+
("llama-3", 8192),
|
|
355
355
|
("qwen-2-5", 128_000),
|
|
356
|
-
("qwen1-5",
|
|
356
|
+
("qwen1-5", 8192),
|
|
357
357
|
]
|
|
358
358
|
|
|
359
359
|
private static func normalizedModelIdentifier(for model: String) -> String {
|
|
@@ -370,7 +370,7 @@ public enum UsageFormatter {
|
|
|
370
370
|
for token in tokens.reversed() {
|
|
371
371
|
let lower = token.lowercased()
|
|
372
372
|
if lower.hasSuffix("k"), let numeric = Int(lower.dropLast()), numeric > 0 {
|
|
373
|
-
return numeric *
|
|
373
|
+
return numeric * 1000
|
|
374
374
|
}
|
|
375
375
|
if lower.hasSuffix("m"), let numeric = Int(lower.dropLast()), numeric > 0 {
|
|
376
376
|
return numeric * 1_000_000
|
|
@@ -44,33 +44,142 @@ public struct ClaudeUsageLogSource: UsageLedgerSource, @unchecked Sendable {
|
|
|
44
44
|
throw ClaudeUsageLogError.noProjectsDirectory
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
// Only scan files modified since our last scan (incremental).
|
|
48
|
+
// On first run, scans the last `maxAgeDays` worth of files.
|
|
49
|
+
let cache = LedgerCache.shared
|
|
50
|
+
let lastScan = await cache.lastScanDate(provider: "claude")
|
|
51
|
+
let incrementalCutoff = lastScan ?? self.minDate() ?? self.now.addingTimeInterval(-86400)
|
|
47
52
|
let minDate = self.minDate()
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
|
|
54
|
+
let allFiles = self.findUsageFiles(in: projectsDirs, minDate: minDate)
|
|
55
|
+
|
|
56
|
+
// Filter to only files modified since last scan (plus today's files which may have grown).
|
|
57
|
+
let todayStart = Calendar.current.startOfDay(for: self.now)
|
|
58
|
+
let filesToScan = allFiles.filter { file in
|
|
59
|
+
guard let modDate = (try? self.fileManager
|
|
60
|
+
.attributesOfItem(atPath: file.url.path))?[.modificationDate] as? Date
|
|
61
|
+
else {
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
return modDate >= incrementalCutoff || modDate >= todayStart
|
|
51
65
|
}
|
|
52
66
|
|
|
53
|
-
|
|
54
|
-
|
|
67
|
+
if filesToScan.isEmpty {
|
|
68
|
+
return []
|
|
69
|
+
}
|
|
55
70
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
self.log
|
|
72
|
+
.info(
|
|
73
|
+
"Scanning \(filesToScan.count) of \(allFiles.count) JSONL files (incremental since \(incrementalCutoff))")
|
|
74
|
+
|
|
75
|
+
// Parse files concurrently (max 16 at a time).
|
|
76
|
+
let maxConcurrency = 16
|
|
77
|
+
let fileEntries = await withTaskGroup(
|
|
78
|
+
of: [UsageLedgerEntry].self,
|
|
79
|
+
returning: [UsageLedgerEntry].self)
|
|
80
|
+
{ group in
|
|
81
|
+
var all: [UsageLedgerEntry] = []
|
|
82
|
+
var inflight = 0
|
|
83
|
+
for file in filesToScan {
|
|
84
|
+
if inflight >= maxConcurrency {
|
|
85
|
+
if let batch = await group.next() {
|
|
86
|
+
all.append(contentsOf: batch)
|
|
87
|
+
inflight -= 1
|
|
64
88
|
}
|
|
65
89
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
90
|
+
group.addTask {
|
|
91
|
+
self.parseFile(file, minDate: minDate)
|
|
92
|
+
}
|
|
93
|
+
inflight += 1
|
|
94
|
+
}
|
|
95
|
+
for await batch in group {
|
|
96
|
+
all.append(contentsOf: batch)
|
|
97
|
+
}
|
|
98
|
+
return all
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Deduplicate across files using the same composite key as per-file dedup.
|
|
102
|
+
var seenKeys = Set<String>()
|
|
103
|
+
var entries: [UsageLedgerEntry] = []
|
|
104
|
+
entries.reserveCapacity(fileEntries.count)
|
|
105
|
+
for entry in fileEntries {
|
|
106
|
+
let key = self.dedupeKey(
|
|
107
|
+
requestID: entry.requestID,
|
|
108
|
+
messageID: entry.messageID,
|
|
109
|
+
sessionID: entry.sessionID,
|
|
110
|
+
timestamp: entry.timestamp,
|
|
111
|
+
inputTokens: entry.inputTokens,
|
|
112
|
+
outputTokens: entry.outputTokens,
|
|
113
|
+
cacheCreationTokens: entry.cacheCreationTokens,
|
|
114
|
+
cacheReadTokens: entry.cacheReadTokens)
|
|
115
|
+
if seenKeys.insert(key).inserted {
|
|
116
|
+
entries.append(entry)
|
|
71
117
|
}
|
|
72
118
|
}
|
|
73
119
|
|
|
120
|
+
// Aggregate into daily summaries and persist to cache.
|
|
121
|
+
let dailyFormatter = DateFormatter()
|
|
122
|
+
dailyFormatter.dateFormat = "yyyy-MM-dd"
|
|
123
|
+
dailyFormatter.timeZone = .current
|
|
124
|
+
|
|
125
|
+
var dailyBuckets: [String: (
|
|
126
|
+
input: Int,
|
|
127
|
+
output: Int,
|
|
128
|
+
cacheCreate: Int,
|
|
129
|
+
cacheRead: Int,
|
|
130
|
+
cost: Double,
|
|
131
|
+
requests: Int,
|
|
132
|
+
models: Set<String>)] = [:]
|
|
133
|
+
for entry in entries {
|
|
134
|
+
let key = dailyFormatter.string(from: entry.timestamp)
|
|
135
|
+
var bucket = dailyBuckets[key] ?? (0, 0, 0, 0, 0, 0, [])
|
|
136
|
+
bucket.input += entry.inputTokens
|
|
137
|
+
bucket.output += entry.outputTokens
|
|
138
|
+
bucket.cacheCreate += entry.cacheCreationTokens
|
|
139
|
+
bucket.cacheRead += entry.cacheReadTokens
|
|
140
|
+
bucket.cost += entry.costUSD ?? 0
|
|
141
|
+
bucket.requests += 1
|
|
142
|
+
if let model = entry.model { bucket.models.insert(model) }
|
|
143
|
+
dailyBuckets[key] = bucket
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let cachedDailies = dailyBuckets.map { key, bucket in
|
|
147
|
+
CachedDaily(
|
|
148
|
+
dayKey: key,
|
|
149
|
+
inputTokens: bucket.input,
|
|
150
|
+
outputTokens: bucket.output,
|
|
151
|
+
cacheCreationTokens: bucket.cacheCreate,
|
|
152
|
+
cacheReadTokens: bucket.cacheRead,
|
|
153
|
+
costUSD: bucket.cost > 0 ? bucket.cost : nil,
|
|
154
|
+
requestCount: bucket.requests,
|
|
155
|
+
modelsUsed: Array(bucket.models))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let todayKey = await LedgerCache.dayKey(for: self.now)
|
|
159
|
+
await cache.mergeDailies(provider: "claude", newDailies: cachedDailies, scanDate: self.now, todayKey: todayKey)
|
|
160
|
+
|
|
161
|
+
return entries
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// Parse a single JSONL file, returning usage entries.
|
|
165
|
+
private func parseFile(_ file: UsageFile, minDate: Date?) -> [UsageLedgerEntry] {
|
|
166
|
+
var entries: [UsageLedgerEntry] = []
|
|
167
|
+
do {
|
|
168
|
+
let data = try Data(contentsOf: file.url)
|
|
169
|
+
guard let content = String(data: data, encoding: .utf8) else { return [] }
|
|
170
|
+
var seenKeys = Set<String>()
|
|
171
|
+
let lines = content.split(whereSeparator: \.isNewline)
|
|
172
|
+
for line in lines {
|
|
173
|
+
if let entry = self.parseLine(line, file: file, minDate: minDate, seenKeys: &seenKeys) {
|
|
174
|
+
entries.append(entry)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
self.log.warning("Claude usage log read failed", metadata: [
|
|
179
|
+
"file": file.url.path,
|
|
180
|
+
"error": error.localizedDescription,
|
|
181
|
+
])
|
|
182
|
+
}
|
|
74
183
|
return entries
|
|
75
184
|
}
|
|
76
185
|
|
|
@@ -90,24 +199,22 @@ public struct ClaudeUsageLogSource: UsageLedgerSource, @unchecked Sendable {
|
|
|
90
199
|
|
|
91
200
|
var candidates: [URL] = []
|
|
92
201
|
if !envPaths.isEmpty {
|
|
93
|
-
candidates = envPaths.map { expandTilde(path: String($0)) }
|
|
202
|
+
candidates = envPaths.map { self.expandTilde(path: String($0)) }
|
|
94
203
|
} else {
|
|
95
|
-
let home = fileManager.homeDirectoryForCurrentUser
|
|
204
|
+
let home = self.fileManager.homeDirectoryForCurrentUser
|
|
96
205
|
candidates = [
|
|
97
206
|
home.appendingPathComponent(".config/claude", isDirectory: true),
|
|
98
207
|
home.appendingPathComponent(".claude", isDirectory: true),
|
|
99
208
|
]
|
|
100
209
|
}
|
|
101
210
|
|
|
102
|
-
|
|
211
|
+
return candidates
|
|
103
212
|
.map { $0.appendingPathComponent("projects", isDirectory: true) }
|
|
104
213
|
.filter { self.fileManager.directoryExists(at: $0) }
|
|
105
|
-
|
|
106
|
-
return projectsDirs
|
|
107
214
|
}
|
|
108
215
|
|
|
109
216
|
private func normalizePaths(_ urls: [URL]) -> [URL] {
|
|
110
|
-
urls.map
|
|
217
|
+
urls.map(\.standardizedFileURL)
|
|
111
218
|
.filter { self.fileManager.directoryExists(at: $0) }
|
|
112
219
|
.map { $0.appendingPathComponent("projects", isDirectory: true) }
|
|
113
220
|
.filter { self.fileManager.directoryExists(at: $0) }
|
|
@@ -123,7 +230,7 @@ public struct ClaudeUsageLogSource: UsageLedgerSource, @unchecked Sendable {
|
|
|
123
230
|
options: [.skipsHiddenFiles])
|
|
124
231
|
|
|
125
232
|
while let fileURL = enumerator?.nextObject() as? URL {
|
|
126
|
-
guard fileURL.
|
|
233
|
+
guard fileURL.pathExtension == "jsonl" else { continue }
|
|
127
234
|
if let minDate {
|
|
128
235
|
let values = try? fileURL.resourceValues(forKeys: [.contentModificationDateKey])
|
|
129
236
|
if let modifiedAt = values?.contentModificationDate, modifiedAt < minDate { continue }
|
|
@@ -138,7 +245,11 @@ public struct ClaudeUsageLogSource: UsageLedgerSource, @unchecked Sendable {
|
|
|
138
245
|
let projectID = pathComponents[projectsIndex + 1]
|
|
139
246
|
let projectName = self.derivedProjectName(from: projectID)
|
|
140
247
|
let sessionID = pathComponents[projectsIndex + 2]
|
|
141
|
-
results.append(UsageFile(
|
|
248
|
+
results.append(UsageFile(
|
|
249
|
+
url: fileURL,
|
|
250
|
+
projectID: projectID,
|
|
251
|
+
projectName: projectName,
|
|
252
|
+
sessionID: sessionID))
|
|
142
253
|
}
|
|
143
254
|
}
|
|
144
255
|
|
|
@@ -257,7 +368,7 @@ public struct ClaudeUsageLogSource: UsageLedgerSource, @unchecked Sendable {
|
|
|
257
368
|
|
|
258
369
|
private func expandTilde(path: String) -> URL {
|
|
259
370
|
if path.hasPrefix("~/") {
|
|
260
|
-
let home = fileManager.homeDirectoryForCurrentUser
|
|
371
|
+
let home = self.fileManager.homeDirectoryForCurrentUser
|
|
261
372
|
let trimmed = String(path.dropFirst(2))
|
|
262
373
|
return home.appendingPathComponent(trimmed, isDirectory: true)
|
|
263
374
|
}
|
|
@@ -312,6 +423,7 @@ private final class ClaudeISO8601FormatterBox: @unchecked Sendable {
|
|
|
312
423
|
formatter.formatOptions = [.withInternetDateTime]
|
|
313
424
|
return formatter
|
|
314
425
|
}()
|
|
426
|
+
|
|
315
427
|
let fractional: ISO8601DateFormatter = {
|
|
316
428
|
let formatter = ISO8601DateFormatter()
|
|
317
429
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
@@ -321,8 +433,8 @@ private final class ClaudeISO8601FormatterBox: @unchecked Sendable {
|
|
|
321
433
|
|
|
322
434
|
private let claudeISOFormatterBox = ClaudeISO8601FormatterBox()
|
|
323
435
|
|
|
324
|
-
|
|
325
|
-
func directoryExists(at url: URL) -> Bool {
|
|
436
|
+
extension FileManager {
|
|
437
|
+
fileprivate func directoryExists(at url: URL) -> Bool {
|
|
326
438
|
var isDir: ObjCBool = false
|
|
327
439
|
return self.fileExists(atPath: url.path, isDirectory: &isDir) && isDir.boolValue
|
|
328
440
|
}
|
|
@@ -87,15 +87,27 @@ public struct CodexUsageLogSource: UsageLedgerSource, @unchecked Sendable {
|
|
|
87
87
|
public func loadEntries() async throws -> [UsageLedgerEntry] {
|
|
88
88
|
let root = try self.resolveSessionsRoot()
|
|
89
89
|
let minDate = self.minDate()
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
|
|
91
|
+
// Incremental scan: only parse files modified since our last scan.
|
|
92
|
+
let cache = LedgerCache.shared
|
|
93
|
+
let lastScan = await cache.lastScanDate(provider: "codex")
|
|
94
|
+
let incrementalCutoff = lastScan ?? minDate ?? self.now.addingTimeInterval(-86400)
|
|
95
|
+
let todayStart = Calendar.current.startOfDay(for: self.now)
|
|
96
|
+
|
|
97
|
+
let allFiles = self.listSessionFiles(root: root, minDate: minDate)
|
|
98
|
+
let filesToScan = allFiles.filter { file in
|
|
99
|
+
guard let modDate = file.modifiedAt else { return true }
|
|
100
|
+
return modDate >= incrementalCutoff || modDate >= todayStart
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if filesToScan.isEmpty {
|
|
92
104
|
return []
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
var entries: [UsageLedgerEntry] = []
|
|
96
108
|
var seenKeys = Set<String>()
|
|
97
109
|
|
|
98
|
-
for file in
|
|
110
|
+
for file in filesToScan {
|
|
99
111
|
do {
|
|
100
112
|
try self.parseFile(file, minDate: minDate, seenKeys: &seenKeys, entries: &entries)
|
|
101
113
|
} catch {
|
|
@@ -106,6 +118,47 @@ public struct CodexUsageLogSource: UsageLedgerSource, @unchecked Sendable {
|
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
120
|
|
|
121
|
+
// Aggregate into daily summaries and persist to cache.
|
|
122
|
+
let dailyFormatter = DateFormatter()
|
|
123
|
+
dailyFormatter.dateFormat = "yyyy-MM-dd"
|
|
124
|
+
dailyFormatter.timeZone = .current
|
|
125
|
+
|
|
126
|
+
var dailyBuckets: [String: (
|
|
127
|
+
input: Int,
|
|
128
|
+
output: Int,
|
|
129
|
+
cacheCreate: Int,
|
|
130
|
+
cacheRead: Int,
|
|
131
|
+
cost: Double,
|
|
132
|
+
requests: Int,
|
|
133
|
+
models: Set<String>)] = [:]
|
|
134
|
+
for entry in entries {
|
|
135
|
+
let key = dailyFormatter.string(from: entry.timestamp)
|
|
136
|
+
var bucket = dailyBuckets[key] ?? (0, 0, 0, 0, 0, 0, [])
|
|
137
|
+
bucket.input += entry.inputTokens
|
|
138
|
+
bucket.output += entry.outputTokens
|
|
139
|
+
bucket.cacheCreate += entry.cacheCreationTokens
|
|
140
|
+
bucket.cacheRead += entry.cacheReadTokens
|
|
141
|
+
bucket.cost += entry.costUSD ?? 0
|
|
142
|
+
bucket.requests += 1
|
|
143
|
+
if let model = entry.model { bucket.models.insert(model) }
|
|
144
|
+
dailyBuckets[key] = bucket
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let cachedDailies = dailyBuckets.map { key, bucket in
|
|
148
|
+
CachedDaily(
|
|
149
|
+
dayKey: key,
|
|
150
|
+
inputTokens: bucket.input,
|
|
151
|
+
outputTokens: bucket.output,
|
|
152
|
+
cacheCreationTokens: bucket.cacheCreate,
|
|
153
|
+
cacheReadTokens: bucket.cacheRead,
|
|
154
|
+
costUSD: bucket.cost > 0 ? bucket.cost : nil,
|
|
155
|
+
requestCount: bucket.requests,
|
|
156
|
+
modelsUsed: Array(bucket.models))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let todayKey = await LedgerCache.dayKey(for: self.now)
|
|
160
|
+
await cache.mergeDailies(provider: "codex", newDailies: cachedDailies, scanDate: self.now, todayKey: todayKey)
|
|
161
|
+
|
|
109
162
|
return entries
|
|
110
163
|
}
|
|
111
164
|
|
|
@@ -431,6 +484,7 @@ private final class CodexISO8601FormatterBox: @unchecked Sendable {
|
|
|
431
484
|
formatter.formatOptions = [.withInternetDateTime]
|
|
432
485
|
return formatter
|
|
433
486
|
}()
|
|
487
|
+
|
|
434
488
|
let fractional: ISO8601DateFormatter = {
|
|
435
489
|
let formatter = ISO8601DateFormatter()
|
|
436
490
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
@@ -440,8 +494,8 @@ private final class CodexISO8601FormatterBox: @unchecked Sendable {
|
|
|
440
494
|
|
|
441
495
|
private let codexISOFormatterBox = CodexISO8601FormatterBox()
|
|
442
496
|
|
|
443
|
-
|
|
444
|
-
func directoryExists(at url: URL) -> Bool {
|
|
497
|
+
extension FileManager {
|
|
498
|
+
fileprivate func directoryExists(at url: URL) -> Bool {
|
|
445
499
|
var isDir: ObjCBool = false
|
|
446
500
|
return self.fileExists(atPath: url.path, isDirectory: &isDir) && isDir.boolValue
|
|
447
501
|
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import OSLog
|
|
3
|
+
|
|
4
|
+
/// Persistent cache for aggregated ledger data. Stores daily summaries per provider
|
|
5
|
+
/// so the app never re-scans old JSONL files. Works like a deque — new days are
|
|
6
|
+
/// appended, historical days are immutable.
|
|
7
|
+
///
|
|
8
|
+
/// Storage: `~/Library/Application Support/Runic/ledger-cache/`
|
|
9
|
+
/// - `claude-daily.json`, `codex-daily.json`, etc.
|
|
10
|
+
/// - Each file: `{ "lastScanDate": "...", "dailies": [...] }`
|
|
11
|
+
public actor LedgerCache {
|
|
12
|
+
public static let shared = LedgerCache()
|
|
13
|
+
|
|
14
|
+
private static let log = Logger(subsystem: "com.sriinnu.athena.Runic", category: "ledger-cache")
|
|
15
|
+
private let cacheDir: URL
|
|
16
|
+
|
|
17
|
+
private static let dayKeyFormatter: DateFormatter = {
|
|
18
|
+
let f = DateFormatter()
|
|
19
|
+
f.dateFormat = "yyyy-MM-dd"
|
|
20
|
+
f.timeZone = .current
|
|
21
|
+
return f
|
|
22
|
+
}()
|
|
23
|
+
|
|
24
|
+
public init() {
|
|
25
|
+
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
|
26
|
+
self.cacheDir = appSupport.appendingPathComponent("Runic/ledger-cache", isDirectory: true)
|
|
27
|
+
try? FileManager.default.createDirectory(at: self.cacheDir, withIntermediateDirectories: true)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// MARK: - Public API
|
|
31
|
+
|
|
32
|
+
/// Load cached daily summaries for a provider. Returns instantly.
|
|
33
|
+
public func loadCachedDailies(provider: String) -> CachedLedger? {
|
|
34
|
+
let url = self.fileURL(provider: provider)
|
|
35
|
+
guard let data = try? Data(contentsOf: url) else { return nil }
|
|
36
|
+
return try? JSONDecoder().decode(CachedLedger.self, from: data)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Save aggregated daily summaries for a provider.
|
|
40
|
+
public func saveDailies(provider: String, ledger: CachedLedger) {
|
|
41
|
+
let url = self.fileURL(provider: provider)
|
|
42
|
+
guard let data = try? JSONEncoder().encode(ledger) else { return }
|
|
43
|
+
try? data.write(to: url, options: .atomic)
|
|
44
|
+
Self.log.debug("Saved \(ledger.dailies.count) days for \(provider)")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Merge new daily entries into existing cache. Deque behavior:
|
|
48
|
+
/// - Existing days are NOT overwritten (immutable history)
|
|
49
|
+
/// - Today's entry is ACCUMULATED (adds to existing totals for partial scans)
|
|
50
|
+
/// - New days are appended
|
|
51
|
+
public func mergeDailies(provider: String, newDailies: [CachedDaily], scanDate: Date, todayKey: String? = nil) {
|
|
52
|
+
var ledger = self.loadCachedDailies(provider: provider) ?? CachedLedger(
|
|
53
|
+
lastScanDate: scanDate,
|
|
54
|
+
lastFullScanDate: nil,
|
|
55
|
+
dailies: [])
|
|
56
|
+
|
|
57
|
+
let resolvedTodayKey = todayKey ?? Self.dayKey(for: Date())
|
|
58
|
+
var existingByKey: [String: CachedDaily] = [:]
|
|
59
|
+
for daily in ledger.dailies {
|
|
60
|
+
existingByKey[daily.dayKey] = daily
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for newDaily in newDailies {
|
|
64
|
+
if newDaily.dayKey == resolvedTodayKey, let existing = existingByKey[newDaily.dayKey] {
|
|
65
|
+
// Accumulate today's partial scan results onto existing totals.
|
|
66
|
+
var mergedModels = Set(existing.modelsUsed)
|
|
67
|
+
mergedModels.formUnion(newDaily.modelsUsed)
|
|
68
|
+
existingByKey[newDaily.dayKey] = CachedDaily(
|
|
69
|
+
dayKey: newDaily.dayKey,
|
|
70
|
+
inputTokens: existing.inputTokens + newDaily.inputTokens,
|
|
71
|
+
outputTokens: existing.outputTokens + newDaily.outputTokens,
|
|
72
|
+
cacheCreationTokens: existing.cacheCreationTokens + newDaily.cacheCreationTokens,
|
|
73
|
+
cacheReadTokens: existing.cacheReadTokens + newDaily.cacheReadTokens,
|
|
74
|
+
costUSD: (existing.costUSD ?? 0) + (newDaily.costUSD ?? 0) > 0
|
|
75
|
+
? (existing.costUSD ?? 0) + (newDaily.costUSD ?? 0) : nil,
|
|
76
|
+
requestCount: existing.requestCount + newDaily.requestCount,
|
|
77
|
+
modelsUsed: Array(mergedModels))
|
|
78
|
+
} else if existingByKey[newDaily.dayKey] == nil {
|
|
79
|
+
existingByKey[newDaily.dayKey] = newDaily
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ledger.dailies = existingByKey.values.sorted { $0.dayKey < $1.dayKey }
|
|
84
|
+
ledger.lastScanDate = scanDate
|
|
85
|
+
self.saveDailies(provider: provider, ledger: ledger)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Mark that a full historical scan has been completed.
|
|
89
|
+
public func markFullScanComplete(provider: String) {
|
|
90
|
+
guard var ledger = self.loadCachedDailies(provider: provider) else { return }
|
|
91
|
+
ledger.lastFullScanDate = Date()
|
|
92
|
+
self.saveDailies(provider: provider, ledger: ledger)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// Whether a full historical scan has been done for this provider.
|
|
96
|
+
public func hasFullScan(provider: String) -> Bool {
|
|
97
|
+
self.loadCachedDailies(provider: provider)?.lastFullScanDate != nil
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// Date of last incremental scan (to know which files to scan next).
|
|
101
|
+
public func lastScanDate(provider: String) -> Date? {
|
|
102
|
+
self.loadCachedDailies(provider: provider)?.lastScanDate
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// MARK: - Internal
|
|
106
|
+
|
|
107
|
+
private func fileURL(provider: String) -> URL {
|
|
108
|
+
self.cacheDir.appendingPathComponent("\(provider)-daily.json")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
static func dayKey(for date: Date) -> String {
|
|
112
|
+
self.dayKeyFormatter.string(from: date)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// MARK: - Models
|
|
117
|
+
|
|
118
|
+
public struct CachedLedger: Codable, Sendable {
|
|
119
|
+
public var lastScanDate: Date
|
|
120
|
+
public var lastFullScanDate: Date?
|
|
121
|
+
public var dailies: [CachedDaily]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
public struct CachedDaily: Codable, Sendable {
|
|
125
|
+
public let dayKey: String // "2026-03-23"
|
|
126
|
+
public let inputTokens: Int
|
|
127
|
+
public let outputTokens: Int
|
|
128
|
+
public let cacheCreationTokens: Int
|
|
129
|
+
public let cacheReadTokens: Int
|
|
130
|
+
public let costUSD: Double?
|
|
131
|
+
public let requestCount: Int
|
|
132
|
+
public let modelsUsed: [String]
|
|
133
|
+
|
|
134
|
+
private static let dayKeyParser: DateFormatter = {
|
|
135
|
+
let f = DateFormatter()
|
|
136
|
+
f.dateFormat = "yyyy-MM-dd"
|
|
137
|
+
f.timeZone = .current
|
|
138
|
+
return f
|
|
139
|
+
}()
|
|
140
|
+
|
|
141
|
+
public var totalTokens: Int {
|
|
142
|
+
self.inputTokens + self.outputTokens + self.cacheCreationTokens + self.cacheReadTokens
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/// Convert to a UsageLedgerDailySummary for immediate display from cache.
|
|
146
|
+
public func toLedgerDailySummary(provider: UsageProvider) -> UsageLedgerDailySummary? {
|
|
147
|
+
guard let dayStart = Self.dayKeyParser.date(from: self.dayKey) else { return nil }
|
|
148
|
+
return UsageLedgerDailySummary(
|
|
149
|
+
provider: provider,
|
|
150
|
+
projectID: nil,
|
|
151
|
+
dayStart: dayStart,
|
|
152
|
+
dayKey: self.dayKey,
|
|
153
|
+
totals: UsageLedgerTotals(
|
|
154
|
+
inputTokens: self.inputTokens,
|
|
155
|
+
outputTokens: self.outputTokens,
|
|
156
|
+
cacheCreationTokens: self.cacheCreationTokens,
|
|
157
|
+
cacheReadTokens: self.cacheReadTokens,
|
|
158
|
+
costUSD: self.costUSD),
|
|
159
|
+
modelsUsed: self.modelsUsed)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -29,7 +29,7 @@ public enum OTelGenAILedgerAdapterError: LocalizedError, Sendable, Equatable {
|
|
|
29
29
|
switch self {
|
|
30
30
|
case .invalidUTF8:
|
|
31
31
|
"OTel payload is not valid UTF-8."
|
|
32
|
-
case .invalidJSON(
|
|
32
|
+
case let .invalidJSON(message):
|
|
33
33
|
"OTel JSON parse failed: \(message)"
|
|
34
34
|
}
|
|
35
35
|
}
|