@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.
Files changed (348) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +1 -1
  3. package/README.md +47 -29
  4. package/RuniciOS/Core/RunicTypographyiOS.swift +24 -0
  5. package/RuniciOS/Views/ProviderDetailView.swift +9 -9
  6. package/RuniciOS/Views/ProviderListView.swift +10 -10
  7. package/RuniciOS/Widgets/LargeWidget.swift +14 -14
  8. package/RuniciOS/Widgets/LiveActivity.swift +19 -19
  9. package/RuniciOS/Widgets/LockScreenWidget.swift +15 -15
  10. package/RuniciOS/Widgets/MediumWidget.swift +10 -10
  11. package/RuniciOS/Widgets/RunicWidget.swift +5 -5
  12. package/RuniciOS/Widgets/SmallWidget.swift +9 -9
  13. package/Scripts/package_app.sh +2 -2
  14. package/Sources/Runic/About.swift +3 -3
  15. package/Sources/Runic/AppNotifications.swift +5 -3
  16. package/Sources/Runic/Controllers/StatusItemController+Actions.swift +17 -4
  17. package/Sources/Runic/Controllers/StatusItemController+Animation.swift +5 -4
  18. package/Sources/Runic/Controllers/StatusItemController+ChartSubmenus.swift +30 -16
  19. package/Sources/Runic/Controllers/StatusItemController+CustomProviders.swift +6 -3
  20. package/Sources/Runic/Controllers/StatusItemController+Menu.swift +47 -14
  21. package/Sources/Runic/Controllers/StatusItemController+MenuCards.swift +6 -4
  22. package/Sources/Runic/Controllers/StatusItemController+MenuHelpers.swift +40 -12
  23. package/Sources/Runic/Controllers/StatusItemController.swift +43 -43
  24. package/Sources/Runic/Core/BudgetNotificationManager.swift +2 -1
  25. package/Sources/Runic/Core/Login/ClaudeLoginRunner.swift +2 -2
  26. package/Sources/Runic/Core/Login/CodexLoginRunner.swift +1 -1
  27. package/Sources/Runic/Core/Login/CursorLoginRunner.swift +4 -4
  28. package/Sources/Runic/Core/Login/GeminiLoginRunner.swift +3 -6
  29. package/Sources/Runic/Core/Rendering/IconRenderer.swift +91 -85
  30. package/Sources/Runic/Core/Rendering/IconView.swift +3 -1
  31. package/Sources/Runic/Core/RunicColors.swift +6 -6
  32. package/Sources/Runic/Core/RunicSpacing.swift +7 -7
  33. package/Sources/Runic/Core/RunicTypography.swift +206 -0
  34. package/Sources/Runic/Core/Stores/AuggieTokenStore.swift +10 -36
  35. package/Sources/Runic/Core/Stores/AzureOpenAITokenStore.swift +10 -36
  36. package/Sources/Runic/Core/Stores/CerebrasTokenStore.swift +10 -36
  37. package/Sources/Runic/Core/Stores/CohereTokenStore.swift +10 -36
  38. package/Sources/Runic/Core/Stores/CopilotTokenStore.swift +10 -36
  39. package/Sources/Runic/Core/Stores/CustomProviderStore.swift +10 -10
  40. package/Sources/Runic/Core/Stores/DeepSeekTokenStore.swift +10 -36
  41. package/Sources/Runic/Core/Stores/FireworksTokenStore.swift +10 -36
  42. package/Sources/Runic/Core/Stores/GroqTokenStore.swift +10 -36
  43. package/Sources/Runic/Core/Stores/KimiTokenStore.swift +10 -36
  44. package/Sources/Runic/Core/Stores/MiniMaxCookieHeaderStore.swift +10 -36
  45. package/Sources/Runic/Core/Stores/MiniMaxGroupIDStore.swift +10 -36
  46. package/Sources/Runic/Core/Stores/MiniMaxTokenStore.swift +10 -36
  47. package/Sources/Runic/Core/Stores/MistralTokenStore.swift +10 -36
  48. package/Sources/Runic/Core/Stores/OpenRouterTokenStore.swift +10 -36
  49. package/Sources/Runic/Core/Stores/PerplexityTokenStore.swift +10 -36
  50. package/Sources/Runic/Core/Stores/ProviderToggleStore.swift +1 -1
  51. package/Sources/Runic/Core/Stores/QwenTokenStore.swift +10 -36
  52. package/Sources/Runic/Core/Stores/SambaNovaTokenStore.swift +10 -36
  53. package/Sources/Runic/Core/Stores/SettingsStore.swift +99 -52
  54. package/Sources/Runic/Core/Stores/TogetherTokenStore.swift +10 -36
  55. package/Sources/Runic/Core/Stores/UsageStore+Accessors.swift +19 -6
  56. package/Sources/Runic/Core/Stores/UsageStore+TokenCost.swift +1 -1
  57. package/Sources/Runic/Core/Stores/UsageStore+WidgetSnapshot.swift +1 -1
  58. package/Sources/Runic/Core/Stores/UsageStore.swift +234 -167
  59. package/Sources/Runic/Core/Stores/XAITokenStore.swift +10 -36
  60. package/Sources/Runic/Core/Stores/ZaiTokenStore.swift +10 -36
  61. package/Sources/Runic/Core/UsageExporter.swift +8 -4
  62. package/Sources/Runic/ProviderRegistry.swift +1 -1
  63. package/Sources/Runic/Providers/Antigravity/AntigravityProviderImplementation.swift +1 -1
  64. package/Sources/Runic/Providers/Auggie/AuggieProviderImplementation.swift +1 -1
  65. package/Sources/Runic/Providers/Azure/AzureProviderImplementation.swift +1 -1
  66. package/Sources/Runic/Providers/Bedrock/BedrockProviderImplementation.swift +1 -1
  67. package/Sources/Runic/Providers/Cerebras/CerebrasProviderImplementation.swift +1 -1
  68. package/Sources/Runic/Providers/Codex/CodexProviderImplementation.swift +1 -1
  69. package/Sources/Runic/Providers/Cohere/CohereProviderImplementation.swift +1 -1
  70. package/Sources/Runic/Providers/Cursor/CursorProviderImplementation.swift +1 -1
  71. package/Sources/Runic/Providers/DeepSeek/DeepSeekProviderImplementation.swift +1 -1
  72. package/Sources/Runic/Providers/Factory/FactoryProviderImplementation.swift +1 -1
  73. package/Sources/Runic/Providers/Fireworks/FireworksProviderImplementation.swift +1 -1
  74. package/Sources/Runic/Providers/Gemini/GeminiProviderImplementation.swift +1 -1
  75. package/Sources/Runic/Providers/Groq/GroqProviderImplementation.swift +1 -1
  76. package/Sources/Runic/Providers/Kimi/KimiProviderImplementation.swift +1 -1
  77. package/Sources/Runic/Providers/MiniMax/MiniMaxProviderImplementation.swift +1 -1
  78. package/Sources/Runic/Providers/Mistral/MistralProviderImplementation.swift +1 -1
  79. package/Sources/Runic/Providers/OpenRouter/OpenRouterProviderImplementation.swift +1 -1
  80. package/Sources/Runic/Providers/Perplexity/PerplexityProviderImplementation.swift +1 -1
  81. package/Sources/Runic/Providers/SambaNova/SambaNovaProviderImplementation.swift +1 -1
  82. package/Sources/Runic/Providers/Shared/ProviderImplementation.swift +5 -2
  83. package/Sources/Runic/Providers/Shared/ProviderImplementationRegistry.swift +1 -1
  84. package/Sources/Runic/Providers/Shared/ProviderSettingsDescriptors.swift +1 -1
  85. package/Sources/Runic/Providers/Together/TogetherProviderImplementation.swift +1 -1
  86. package/Sources/Runic/Providers/XAI/XAIProviderImplementation.swift +1 -1
  87. package/Sources/Runic/Providers/Zai/ZaiProviderImplementation.swift +1 -1
  88. package/Sources/Runic/Resources/Fonts/Caveat-Bold.ttf +0 -0
  89. package/Sources/Runic/Resources/Fonts/Caveat-Regular.ttf +0 -0
  90. package/Sources/Runic/Resources/Fonts/FiraCode-Bold.ttf +0 -0
  91. package/Sources/Runic/Resources/Fonts/FiraCode-Light.ttf +0 -0
  92. package/Sources/Runic/Resources/Fonts/FiraCode-Medium.ttf +0 -0
  93. package/Sources/Runic/Resources/Fonts/FiraCode-Regular.ttf +0 -0
  94. package/Sources/Runic/Resources/Fonts/FiraCode-SemiBold.ttf +0 -0
  95. package/Sources/Runic/Resources/Fonts/IBMPlexMono-Bold.ttf +0 -0
  96. package/Sources/Runic/Resources/Fonts/IBMPlexMono-Medium.ttf +0 -0
  97. package/Sources/Runic/Resources/Fonts/IBMPlexMono-Regular.ttf +0 -0
  98. package/Sources/Runic/Resources/Fonts/IBMPlexMono-SemiBold.ttf +0 -0
  99. package/Sources/Runic/Resources/Fonts/Inconsolata-Bold.ttf +0 -0
  100. package/Sources/Runic/Resources/Fonts/Inconsolata-Medium.ttf +0 -0
  101. package/Sources/Runic/Resources/Fonts/Inconsolata-Regular.ttf +0 -0
  102. package/Sources/Runic/Resources/Fonts/Inconsolata-SemiBold.ttf +0 -0
  103. package/Sources/Runic/Resources/Fonts/JetBrainsMono-Bold.ttf +0 -0
  104. package/Sources/Runic/Resources/Fonts/JetBrainsMono-Medium.ttf +0 -0
  105. package/Sources/Runic/Resources/Fonts/JetBrainsMono-Regular.ttf +0 -0
  106. package/Sources/Runic/Resources/Fonts/JetBrainsMono-SemiBold.ttf +0 -0
  107. package/Sources/Runic/Resources/Fonts/SpaceMono-Bold.ttf +0 -0
  108. package/Sources/Runic/Resources/Fonts/SpaceMono-Regular.ttf +0 -0
  109. package/Sources/Runic/Resources/provider-context-windows.json +20 -0
  110. package/Sources/Runic/RunicApp.swift +46 -36
  111. package/Sources/Runic/SessionQuotaNotifications.swift +2 -2
  112. package/Sources/Runic/UsagePaceText.swift +1 -1
  113. package/Sources/Runic/UsageProgressBar.swift +13 -9
  114. package/Sources/Runic/Utilities/LoadingPattern.swift +4 -2
  115. package/Sources/Runic/Utilities/PerformanceConstants.swift +28 -28
  116. package/Sources/Runic/Views/Menu/AlertsMenuView.swift +19 -21
  117. package/Sources/Runic/Views/Menu/CostComparativeChartMenuView.swift +19 -19
  118. package/Sources/Runic/Views/Menu/CostHistoryChartMenuView.swift +7 -7
  119. package/Sources/Runic/Views/Menu/CreditsHistoryChartMenuView.swift +5 -5
  120. package/Sources/Runic/Views/Menu/EfficiencyMetricsMenuView.swift +29 -30
  121. package/Sources/Runic/Views/Menu/HeroStatCardsMenuView.swift +20 -17
  122. package/Sources/Runic/Views/Menu/HourlyActivityChartMenuView.swift +14 -14
  123. package/Sources/Runic/Views/Menu/InlineUsageChartView.swift +15 -20
  124. package/Sources/Runic/Views/Menu/MenuCardEntranceModifier.swift +3 -4
  125. package/Sources/Runic/Views/Menu/MenuCardSidebarView.swift +5 -25
  126. package/Sources/Runic/Views/Menu/MenuCardSkeletonView.swift +1 -1
  127. package/Sources/Runic/Views/Menu/MenuCardView.swift +120 -131
  128. package/Sources/Runic/Views/Menu/MenuContent.swift +11 -8
  129. package/Sources/Runic/Views/Menu/MenuDescriptor.swift +4 -2
  130. package/Sources/Runic/Views/Menu/MenuHighlightStyle.swift +1 -8
  131. package/Sources/Runic/Views/Menu/ModelBreakdownMenuView.swift +20 -14
  132. package/Sources/Runic/Views/Menu/OverviewMenuView.swift +198 -58
  133. package/Sources/Runic/Views/Menu/ProjectBreakdownMenuView.swift +16 -11
  134. package/Sources/Runic/Views/Menu/ProjectBudgetMenuView.swift +27 -27
  135. package/Sources/Runic/Views/Menu/ProviderTabBarView.swift +4 -6
  136. package/Sources/Runic/Views/Menu/SubscriptionUtilizationChartMenuView.swift +24 -11
  137. package/Sources/Runic/Views/Menu/UsageBreakdownChartMenuView.swift +5 -5
  138. package/Sources/Runic/Views/Menu/UsageHeatmapMenuView.swift +7 -8
  139. package/Sources/Runic/Views/Menu/UsageTimelineChartMenuView.swift +28 -76
  140. package/Sources/Runic/Views/Menu/UsageWindowComparisonChartMenuView.swift +8 -7
  141. package/Sources/Runic/Views/Menu/WeeklyActivityChartMenuView.swift +7 -7
  142. package/Sources/Runic/Views/Performance/PerformanceDashboardView.swift +50 -47
  143. package/Sources/Runic/Views/Preferences/LiquidComponents.swift +15 -14
  144. package/Sources/Runic/Views/Preferences/PreferencesAboutPane.swift +7 -7
  145. package/Sources/Runic/Views/Preferences/PreferencesAdvancedPane.swift +27 -27
  146. package/Sources/Runic/Views/Preferences/PreferencesAlertsPane.swift +38 -42
  147. package/Sources/Runic/Views/Preferences/PreferencesAnalyticsPane.swift +39 -43
  148. package/Sources/Runic/Views/Preferences/PreferencesBudgetsPane.swift +21 -21
  149. package/Sources/Runic/Views/Preferences/PreferencesComponents.swift +7 -7
  150. package/Sources/Runic/Views/Preferences/PreferencesCustomProvidersPane.swift +49 -50
  151. package/Sources/Runic/Views/Preferences/PreferencesDebugPane.swift +10 -10
  152. package/Sources/Runic/Views/Preferences/PreferencesGeneralPane.swift +40 -21
  153. package/Sources/Runic/Views/Preferences/PreferencesHelpPane.swift +7 -7
  154. package/Sources/Runic/Views/Preferences/PreferencesIntegrationsPane.swift +108 -33
  155. package/Sources/Runic/Views/Preferences/PreferencesPerformancePane.swift +26 -23
  156. package/Sources/Runic/Views/Preferences/PreferencesProvidersPane.swift +271 -226
  157. package/Sources/Runic/Views/Preferences/PreferencesSelection.swift +6 -2
  158. package/Sources/Runic/Views/Preferences/PreferencesSyncPane.swift +7 -5
  159. package/Sources/Runic/Views/Preferences/PreferencesView.swift +38 -31
  160. package/Sources/Runic/Views/Rating/QualityRatingView.swift +11 -11
  161. package/Sources/Runic/Views/Team/TeamDashboardView.swift +29 -24
  162. package/Sources/Runic/Views/Team/TeamInviteSheet.swift +16 -16
  163. package/Sources/Runic/Views/Team/TeamManagementView.swift +50 -49
  164. package/Sources/Runic/Views/Team/TeamMemberRow.swift +8 -8
  165. package/Sources/RunicCLI/CLIEntry.swift +83 -42
  166. package/Sources/RunicCLI/CLIRenderer.swift +1 -1
  167. package/Sources/RunicCLI/Commands/EnhancedUsageCommand.swift +60 -60
  168. package/Sources/RunicCLI/Commands/ModelsCommand.swift +45 -44
  169. package/Sources/RunicCLI/Commands/ProjectsCommand.swift +83 -69
  170. package/Sources/RunicCLI/Commands/ResetCommand.swift +50 -49
  171. package/Sources/RunicCLI/Commands/UsageLedgerSourceFactory.swift +31 -32
  172. package/Sources/RunicClaudeWebProbe/main.swift +1 -1
  173. package/Sources/RunicCore/AlertRuleStore.swift +20 -20
  174. package/Sources/RunicCore/BrowserCookieImportOrder.swift +3 -3
  175. package/Sources/RunicCore/CostUsageModels.swift +1 -1
  176. package/Sources/RunicCore/CustomProviders/CustomProviderModels.swift +29 -30
  177. package/Sources/RunicCore/CustomProviders/GenericProviderFetcher.swift +80 -71
  178. package/Sources/RunicCore/CustomProviders/ProviderTemplates.swift +53 -109
  179. package/Sources/RunicCore/Host/PTY/TTYCommandRunner.swift +3 -3
  180. package/Sources/RunicCore/Integrations/GitHubIntegration.swift +3 -3
  181. package/Sources/RunicCore/Logging/OSLogLogHandler.swift +6 -6
  182. package/Sources/RunicCore/Models/EnhancedUsageModels.swift +47 -48
  183. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardBrowserCookieImporter.swift +1 -1
  184. package/Sources/RunicCore/OpenAIWeb/OpenAIDashboardFetcher.swift +1 -1
  185. package/Sources/RunicCore/Performance/PerformanceModels.swift +4 -4
  186. package/Sources/RunicCore/Performance/PerformanceStorageImpl.swift +81 -64
  187. package/Sources/RunicCore/Policy/UsagePolicyEngine.swift +2 -2
  188. package/Sources/RunicCore/ProjectBudgetStore.swift +15 -12
  189. package/Sources/RunicCore/Providers/Antigravity/AntigravityProviderDescriptor.swift +4 -2
  190. package/Sources/RunicCore/Providers/Antigravity/AntigravityStatusProbe.swift +2 -2
  191. package/Sources/RunicCore/Providers/Auggie/AuggieProviderDescriptor.swift +2 -2
  192. package/Sources/RunicCore/Providers/Auggie/AuggieUsageFetcher.swift +3 -3
  193. package/Sources/RunicCore/Providers/Azure/AzureProviderDescriptor.swift +8 -8
  194. package/Sources/RunicCore/Providers/Azure/AzureUsageFetcher.swift +5 -3
  195. package/Sources/RunicCore/Providers/Bedrock/BedrockProviderDescriptor.swift +6 -6
  196. package/Sources/RunicCore/Providers/Bedrock/BedrockUsageFetcher.swift +3 -7
  197. package/Sources/RunicCore/Providers/Cerebras/CerebrasProviderDescriptor.swift +2 -2
  198. package/Sources/RunicCore/Providers/Cerebras/CerebrasUsageFetcher.swift +2 -2
  199. package/Sources/RunicCore/Providers/Claude/ClaudeOAuth/ClaudeOAuthUsageFetcher.swift +3 -3
  200. package/Sources/RunicCore/Providers/Claude/ClaudeProviderDescriptor.swift +4 -2
  201. package/Sources/RunicCore/Providers/Claude/ClaudeStatusProbe.swift +4 -4
  202. package/Sources/RunicCore/Providers/Claude/ClaudeUsageDataSource.swift +3 -1
  203. package/Sources/RunicCore/Providers/Claude/ClaudeWeb/ClaudeWebAPIFetcher.swift +1 -1
  204. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +1 -0
  205. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +12 -1
  206. package/Sources/RunicCore/Providers/Codex/CodexProviderDescriptor.swift +4 -2
  207. package/Sources/RunicCore/Providers/Codex/CodexUsageDataSource.swift +3 -1
  208. package/Sources/RunicCore/Providers/Codex/CodexWebDashboardStrategy.swift +5 -3
  209. package/Sources/RunicCore/Providers/Cohere/CohereProviderDescriptor.swift +2 -2
  210. package/Sources/RunicCore/Providers/Cohere/CohereUsageFetcher.swift +3 -3
  211. package/Sources/RunicCore/Providers/Copilot/CopilotGitHubCLITokenReader.swift +7 -4
  212. package/Sources/RunicCore/Providers/Copilot/CopilotProviderDescriptor.swift +4 -4
  213. package/Sources/RunicCore/Providers/Copilot/CopilotUsageFetcher.swift +5 -3
  214. package/Sources/RunicCore/Providers/Copilot/CopilotVSCodeTokenReader.swift +9 -10
  215. package/Sources/RunicCore/Providers/Cursor/CursorProviderDescriptor.swift +4 -2
  216. package/Sources/RunicCore/Providers/Cursor/CursorStatusProbe.swift +1 -0
  217. package/Sources/RunicCore/Providers/DeepSeek/DeepSeekProviderDescriptor.swift +2 -2
  218. package/Sources/RunicCore/Providers/DeepSeek/DeepSeekUsageFetcher.swift +2 -2
  219. package/Sources/RunicCore/Providers/Factory/FactoryLocalStorageImporter.swift +4 -4
  220. package/Sources/RunicCore/Providers/Factory/FactoryProviderDescriptor.swift +4 -2
  221. package/Sources/RunicCore/Providers/Factory/FactoryStatusProbe.swift +3 -2
  222. package/Sources/RunicCore/Providers/Fireworks/FireworksProviderDescriptor.swift +2 -2
  223. package/Sources/RunicCore/Providers/Fireworks/FireworksUsageFetcher.swift +2 -2
  224. package/Sources/RunicCore/Providers/Gemini/GeminiProviderDescriptor.swift +4 -2
  225. package/Sources/RunicCore/Providers/Gemini/GeminiStatusProbe.swift +6 -2
  226. package/Sources/RunicCore/Providers/Groq/GroqProviderDescriptor.swift +2 -2
  227. package/Sources/RunicCore/Providers/Groq/GroqUsageFetcher.swift +2 -2
  228. package/Sources/RunicCore/Providers/Kimi/KimiProviderDescriptor.swift +2 -2
  229. package/Sources/RunicCore/Providers/Kimi/KimiUsageFetcher.swift +2 -2
  230. package/Sources/RunicCore/Providers/MiniMax/MiniMaxProviderDescriptor.swift +10 -5
  231. package/Sources/RunicCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +8 -11
  232. package/Sources/RunicCore/Providers/MiniMax/MiniMaxWebUsageFetcher.swift +10 -10
  233. package/Sources/RunicCore/Providers/Mistral/MistralProviderDescriptor.swift +2 -2
  234. package/Sources/RunicCore/Providers/Mistral/MistralUsageFetcher.swift +2 -2
  235. package/Sources/RunicCore/Providers/OpenRouter/OpenRouterProviderDescriptor.swift +2 -2
  236. package/Sources/RunicCore/Providers/OpenRouter/OpenRouterUsageFetcher.swift +25 -16
  237. package/Sources/RunicCore/Providers/Perplexity/PerplexityProviderDescriptor.swift +2 -2
  238. package/Sources/RunicCore/Providers/Perplexity/PerplexityUsageFetcher.swift +2 -2
  239. package/Sources/RunicCore/Providers/ProviderFetchPlan.swift +2 -2
  240. package/Sources/RunicCore/Providers/ProviderTokenResolver.swift +2 -2
  241. package/Sources/RunicCore/Providers/Qwen/QwenProviderDescriptor.swift +5 -3
  242. package/Sources/RunicCore/Providers/Qwen/QwenUsageStats.swift +27 -16
  243. package/Sources/RunicCore/Providers/SambaNova/SambaNovaProviderDescriptor.swift +2 -2
  244. package/Sources/RunicCore/Providers/SambaNova/SambaNovaUsageFetcher.swift +2 -2
  245. package/Sources/RunicCore/Providers/Together/TogetherProviderDescriptor.swift +2 -2
  246. package/Sources/RunicCore/Providers/Together/TogetherUsageFetcher.swift +2 -2
  247. package/Sources/RunicCore/Providers/VertexAI/VertexAIProviderDescriptor.swift +5 -5
  248. package/Sources/RunicCore/Providers/VertexAI/VertexAISettingsReader.swift +1 -1
  249. package/Sources/RunicCore/Providers/VertexAI/VertexAIUsageFetcher.swift +3 -3
  250. package/Sources/RunicCore/Providers/XAI/XAIProviderDescriptor.swift +2 -2
  251. package/Sources/RunicCore/Providers/XAI/XAIUsageFetcher.swift +2 -2
  252. package/Sources/RunicCore/Providers/Zai/ZaiProviderDescriptor.swift +4 -2
  253. package/Sources/RunicCore/Providers/Zai/ZaiUsageStats.swift +51 -30
  254. package/Sources/RunicCore/Sync/BackgroundSyncManager.swift +76 -77
  255. package/Sources/RunicCore/Sync/SyncConflictResolver.swift +55 -58
  256. package/Sources/RunicCore/Sync/SyncProtocol.swift +21 -22
  257. package/Sources/RunicCore/Sync/SyncRecord.swift +46 -49
  258. package/Sources/RunicCore/Sync/SyncUsageExample.swift +44 -50
  259. package/Sources/RunicCore/Sync/TeamSyncRecords.swift +94 -101
  260. package/Sources/RunicCore/Sync/iCloudSyncEngine.swift +51 -46
  261. package/Sources/RunicCore/Team/TeamModels.swift +111 -110
  262. package/Sources/RunicCore/Team/TeamStore.swift +126 -109
  263. package/Sources/RunicCore/UsageFetcher.swift +2 -2
  264. package/Sources/RunicCore/UsageFormatter.swift +25 -25
  265. package/Sources/RunicCore/UsageLedger/ClaudeUsageLogSource.swift +141 -29
  266. package/Sources/RunicCore/UsageLedger/CodexUsageLogSource.swift +59 -5
  267. package/Sources/RunicCore/UsageLedger/LedgerCache.swift +161 -0
  268. package/Sources/RunicCore/UsageLedger/OTelGenAILedgerAdapter.swift +1 -1
  269. package/Sources/RunicCore/UsageLedger/UsageLedgerAggregator.swift +40 -25
  270. package/Sources/RunicCore/UsageLedger/UsageLedgerInsightsAdvisor.swift +1 -1
  271. package/Sources/RunicCore/UsageLedger/UsageLedgerProjectIdentity.swift +55 -16
  272. package/Sources/RunicCore/Vendored/CostUsage/CostUsageCache.swift +6 -6
  273. package/Sources/RunicCore/Vendored/CostUsage/CostUsageJsonl.swift +1 -1
  274. package/Sources/RunicCore/Vendored/CostUsage/CostUsagePricing.swift +2 -2
  275. package/Sources/RunicCore/Vendored/CostUsage/CostUsageScanner.swift +5 -5
  276. package/Tests/RunicTests/AppDelegateTests.swift +8 -3
  277. package/Tests/RunicTests/BrowserCookieOrderLabelTests.swift +2 -3
  278. package/Tests/RunicTests/CLIArgumentParsingTests.swift +3 -4
  279. package/Tests/RunicTests/CLICostTests.swift +6 -7
  280. package/Tests/RunicTests/CLIProviderSelectionTests.swift +2 -3
  281. package/Tests/RunicTests/CLISnapshotTests.swift +3 -4
  282. package/Tests/RunicTests/CLIWebFallbackTests.swift +3 -4
  283. package/Tests/RunicTests/ClaudeOAuthTests.swift +11 -12
  284. package/Tests/RunicTests/ClaudeResilienceTests.swift +3 -4
  285. package/Tests/RunicTests/ClaudeUsageTests.swift +20 -21
  286. package/Tests/RunicTests/CodexOAuthTests.swift +7 -8
  287. package/Tests/RunicTests/CodexUsageLogSourceTests.swift +1 -3
  288. package/Tests/RunicTests/CopilotTokenResolverTests.swift +5 -6
  289. package/Tests/RunicTests/CopilotVSCodeTokenReaderTests.swift +4 -5
  290. package/Tests/RunicTests/CostUsageDecodingTests.swift +15 -17
  291. package/Tests/RunicTests/CostUsagePricingTests.swift +5 -7
  292. package/Tests/RunicTests/CostUsageScannerTests.swift +10 -12
  293. package/Tests/RunicTests/CursorStatusProbeTests.swift +15 -16
  294. package/Tests/RunicTests/FactoryStatusProbeTests.swift +3 -5
  295. package/Tests/RunicTests/GeminiLoginAlertTests.swift +3 -4
  296. package/Tests/RunicTests/GeminiStatusProbeAPITests.swift +11 -11
  297. package/Tests/RunicTests/GeminiStatusProbePlanTests.swift +8 -8
  298. package/Tests/RunicTests/GeminiStatusProbeTests.swift +20 -21
  299. package/Tests/RunicTests/GoogleWorkspaceStatusTests.swift +2 -3
  300. package/Tests/RunicTests/InstallOriginTests.swift +1 -2
  301. package/Tests/RunicTests/KeyboardShortcutsBundleTests.swift +2 -2
  302. package/Tests/RunicTests/LiveAccountTests.swift +3 -3
  303. package/Tests/RunicTests/LoadingPatternTests.swift +3 -4
  304. package/Tests/RunicTests/MenuCardModelTests.swift +33 -34
  305. package/Tests/RunicTests/MiniMaxCookieHeaderStoreTestSupport.swift +4 -1
  306. package/Tests/RunicTests/MiniMaxGroupIDStoreTestSupport.swift +4 -1
  307. package/Tests/RunicTests/MiniMaxParsingTests.swift +4 -5
  308. package/Tests/RunicTests/MiniMaxTokenStoreTestSupport.swift +4 -1
  309. package/Tests/RunicTests/OTelGenAILedgerAdapterTests.swift +4 -6
  310. package/Tests/RunicTests/OpenAIDashboardBrowserCookieImporterTests.swift +1 -2
  311. package/Tests/RunicTests/OpenAIDashboardFetcherCreditsWaitTests.swift +5 -6
  312. package/Tests/RunicTests/OpenAIDashboardOffscreenHostTests.swift +2 -3
  313. package/Tests/RunicTests/OpenAIDashboardParserTests.swift +12 -13
  314. package/Tests/RunicTests/OpenAIWebAccountSwitchTests.swift +17 -6
  315. package/Tests/RunicTests/OpenRouterTokenStoreTestSupport.swift +52 -13
  316. package/Tests/RunicTests/PathBuilderTests.swift +14 -15
  317. package/Tests/RunicTests/ProviderFetchPipelineTests.swift +3 -4
  318. package/Tests/RunicTests/ProviderIconResourcesTests.swift +1 -2
  319. package/Tests/RunicTests/ProviderMetadataStatusLinkTests.swift +1 -2
  320. package/Tests/RunicTests/ProviderSettingsDescriptorTests.swift +53 -19
  321. package/Tests/RunicTests/ProviderToggleStoreTests.swift +10 -11
  322. package/Tests/RunicTests/RunicTests.swift +6 -7
  323. package/Tests/RunicTests/SessionQuotaNotificationLogicTests.swift +5 -6
  324. package/Tests/RunicTests/SettingsStoreTests.swift +149 -45
  325. package/Tests/RunicTests/StatusItemAnimationTests.swift +8 -3
  326. package/Tests/RunicTests/StatusMenuTests.swift +63 -22
  327. package/Tests/RunicTests/StatusProbeTests.swift +28 -29
  328. package/Tests/RunicTests/SubprocessRunnerTests.swift +1 -3
  329. package/Tests/RunicTests/SubscriptionDetectionTests.swift +8 -9
  330. package/Tests/RunicTests/TTYCommandRunnerTests.swift +8 -9
  331. package/Tests/RunicTests/TeamShowbackExporterTests.swift +2 -4
  332. package/Tests/RunicTests/TextParsingTests.swift +1 -2
  333. package/Tests/RunicTests/UsageFormatterTests.swift +15 -16
  334. package/Tests/RunicTests/UsageLedgerAnomalyDetectionTests.swift +12 -14
  335. package/Tests/RunicTests/UsageLedgerForecastTests.swift +4 -6
  336. package/Tests/RunicTests/UsageLedgerInsightsAdvisorTests.swift +7 -9
  337. package/Tests/RunicTests/UsageLedgerProjectNameTests.swift +6 -8
  338. package/Tests/RunicTests/UsagePaceTests.swift +5 -6
  339. package/Tests/RunicTests/UsagePaceTextTests.swift +8 -9
  340. package/Tests/RunicTests/UsagePolicyEngineTests.swift +3 -5
  341. package/Tests/RunicTests/WidgetSnapshotTests.swift +1 -3
  342. package/Tests/RunicTests/ZaiProviderTests.swift +3 -5
  343. package/Tests/RunicTests/ZaiTokenStoreTestSupport.swift +4 -1
  344. package/appcast.xml +36 -37
  345. package/package.json +1 -1
  346. package/runic.png +0 -0
  347. package/runic.svg +15 -0
  348. 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 = Self.modelContextWindow(for: model) else { return nil }
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": 16_385,
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": 32_000,
318
+ "mistral-small": 32000,
319
319
  "mistral-medium": 131_072,
320
- "deepseek-chat": 64_000,
321
- "deepseek-coder": 32_000,
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": 8_192,
328
- "qwen1-5-14b": 8_192,
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": 32_768,
333
- "mixtral-8x22b": 64_000,
334
- "llama-3-8b": 8_192,
335
- "llama-3-70b": 8_192,
336
- "llama-2-70b": 4_096,
337
- "llama-2-13b": 4_096,
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", 16_385),
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", 8_192),
354
+ ("llama-3", 8192),
355
355
  ("qwen-2-5", 128_000),
356
- ("qwen1-5", 8_192),
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 * 1_000
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
- let files = self.findUsageFiles(in: projectsDirs, minDate: minDate)
49
- if files.isEmpty {
50
- return []
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
- var entries: [UsageLedgerEntry] = []
54
- var seenKeys = Set<String>()
67
+ if filesToScan.isEmpty {
68
+ return []
69
+ }
55
70
 
56
- for file in files {
57
- do {
58
- let data = try Data(contentsOf: file.url)
59
- guard let content = String(data: data, encoding: .utf8) else { continue }
60
- let lines = content.split(whereSeparator: \.isNewline)
61
- for line in lines {
62
- if let entry = self.parseLine(line, file: file, minDate: minDate, seenKeys: &seenKeys) {
63
- entries.append(entry)
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
- } catch {
67
- self.log.warning("Claude usage log read failed", metadata: [
68
- "file": file.url.path,
69
- "error": error.localizedDescription,
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
- let projectsDirs = candidates
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 { $0.standardizedFileURL }
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.lastPathComponent == "usage.jsonl" else { continue }
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(url: fileURL, projectID: projectID, projectName: projectName, sessionID: sessionID))
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
- private extension FileManager {
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
- let files = self.listSessionFiles(root: root, minDate: minDate)
91
- if files.isEmpty {
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 files {
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
- private extension FileManager {
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(let message):
32
+ case let .invalidJSON(message):
33
33
  "OTel JSON parse failed: \(message)"
34
34
  }
35
35
  }