@sriinnu/runic 1.0.0 → 1.2.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 (51) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +40 -29
  3. package/Scripts/package_app.sh +2 -2
  4. package/Sources/Runic/Controllers/StatusItemController+Actions.swift +10 -1
  5. package/Sources/Runic/Controllers/StatusItemController+Menu.swift +30 -2
  6. package/Sources/Runic/Core/Login/GeminiLoginRunner.swift +2 -5
  7. package/Sources/Runic/Core/Stores/AuggieTokenStore.swift +9 -33
  8. package/Sources/Runic/Core/Stores/AzureOpenAITokenStore.swift +9 -33
  9. package/Sources/Runic/Core/Stores/CerebrasTokenStore.swift +9 -33
  10. package/Sources/Runic/Core/Stores/CohereTokenStore.swift +9 -33
  11. package/Sources/Runic/Core/Stores/CopilotTokenStore.swift +9 -33
  12. package/Sources/Runic/Core/Stores/DeepSeekTokenStore.swift +9 -33
  13. package/Sources/Runic/Core/Stores/FireworksTokenStore.swift +9 -33
  14. package/Sources/Runic/Core/Stores/GroqTokenStore.swift +9 -33
  15. package/Sources/Runic/Core/Stores/KimiTokenStore.swift +9 -33
  16. package/Sources/Runic/Core/Stores/MiniMaxCookieHeaderStore.swift +9 -33
  17. package/Sources/Runic/Core/Stores/MiniMaxGroupIDStore.swift +9 -33
  18. package/Sources/Runic/Core/Stores/MiniMaxTokenStore.swift +9 -33
  19. package/Sources/Runic/Core/Stores/MistralTokenStore.swift +9 -33
  20. package/Sources/Runic/Core/Stores/OpenRouterTokenStore.swift +9 -33
  21. package/Sources/Runic/Core/Stores/PerplexityTokenStore.swift +9 -33
  22. package/Sources/Runic/Core/Stores/QwenTokenStore.swift +9 -33
  23. package/Sources/Runic/Core/Stores/SambaNovaTokenStore.swift +9 -33
  24. package/Sources/Runic/Core/Stores/SettingsStore.swift +10 -1
  25. package/Sources/Runic/Core/Stores/TogetherTokenStore.swift +9 -33
  26. package/Sources/Runic/Core/Stores/UsageStore.swift +50 -9
  27. package/Sources/Runic/Core/Stores/XAITokenStore.swift +9 -33
  28. package/Sources/Runic/Core/Stores/ZaiTokenStore.swift +9 -34
  29. package/Sources/Runic/Resources/provider-context-windows.json +20 -0
  30. package/Sources/Runic/Views/Menu/InlineUsageChartView.swift +9 -16
  31. package/Sources/Runic/Views/Menu/OverviewMenuView.swift +192 -55
  32. package/Sources/Runic/Views/Menu/UsageTimelineChartMenuView.swift +12 -65
  33. package/Sources/Runic/Views/Preferences/PreferencesIntegrationsPane.swift +71 -1
  34. package/Sources/RunicCore/CustomProviders/GenericProviderFetcher.swift +11 -0
  35. package/Sources/RunicCore/Logging/OSLogLogHandler.swift +6 -6
  36. package/Sources/RunicCore/Providers/Azure/AzureUsageFetcher.swift +2 -0
  37. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +1 -0
  38. package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +12 -1
  39. package/Sources/RunicCore/Providers/Cursor/CursorStatusProbe.swift +1 -0
  40. package/Sources/RunicCore/Providers/Factory/FactoryStatusProbe.swift +1 -0
  41. package/Sources/RunicCore/Providers/Gemini/GeminiStatusProbe.swift +1 -0
  42. package/Sources/RunicCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +1 -4
  43. package/Sources/RunicCore/Providers/OpenRouter/OpenRouterUsageFetcher.swift +2 -6
  44. package/Sources/RunicCore/Providers/Qwen/QwenUsageStats.swift +1 -3
  45. package/Sources/RunicCore/Providers/Zai/ZaiUsageStats.swift +3 -9
  46. package/Sources/RunicCore/UsageLedger/ClaudeUsageLogSource.swift +117 -19
  47. package/Sources/RunicCore/UsageLedger/CodexUsageLogSource.swift +49 -3
  48. package/Sources/RunicCore/UsageLedger/LedgerCache.swift +158 -0
  49. package/appcast.xml +39 -0
  50. package/package.json +1 -1
  51. package/version.env +2 -2
@@ -619,6 +619,7 @@ public struct GeminiStatusProbe: Sendable {
619
619
 
620
620
  let updatedData = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted])
621
621
  try updatedData.write(to: credsURL, options: .atomic)
622
+ try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: credsURL.path)
622
623
  }
623
624
 
624
625
  private static func loadCredentials(homeDirectory: String) throws -> OAuthCredentials {
@@ -119,10 +119,7 @@ public struct MiniMaxUsageFetcher: Sendable {
119
119
  throw MiniMaxUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorMessage)")
120
120
  }
121
121
 
122
- // Log raw response for debugging
123
- if let jsonString = String(data: data, encoding: .utf8) {
124
- Self.log.debug("MiniMax API response: \(jsonString)")
125
- }
122
+ Self.log.debug("MiniMax API response: HTTP \(httpResponse.statusCode), \(data.count) bytes")
126
123
 
127
124
  let decoder = JSONDecoder()
128
125
  do {
@@ -91,9 +91,7 @@ struct OpenRouterUsageFetcher {
91
91
  throw OpenRouterAPIError.httpError(statusCode: httpResponse.statusCode)
92
92
  }
93
93
 
94
- if let json = String(data: data, encoding: .utf8) {
95
- Self.log.debug("OpenRouter credits response: \(json)")
96
- }
94
+ Self.log.debug("OpenRouter credits response: HTTP \(httpResponse.statusCode), \(data.count) bytes")
97
95
 
98
96
  return try JSONDecoder().decode(OpenRouterCreditsResponse.self, from: data)
99
97
  }
@@ -122,9 +120,7 @@ struct OpenRouterUsageFetcher {
122
120
  throw OpenRouterAPIError.httpError(statusCode: httpResponse.statusCode)
123
121
  }
124
122
 
125
- if let json = String(data: data, encoding: .utf8) {
126
- Self.log.debug("OpenRouter key info response: \(json)")
127
- }
123
+ Self.log.debug("OpenRouter key info response: HTTP \(httpResponse.statusCode), \(data.count) bytes")
128
124
 
129
125
  return try JSONDecoder().decode(OpenRouterKeyInfoResponse.self, from: data)
130
126
  }
@@ -218,9 +218,7 @@ public struct QwenUsageFetcher: Sendable {
218
218
  throw QwenUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorMessage)")
219
219
  }
220
220
 
221
- if let jsonString = String(data: data, encoding: .utf8) {
222
- Self.log.debug("DashScope usage response: \(jsonString)")
223
- }
221
+ Self.log.debug("DashScope usage response: HTTP \(httpResponse.statusCode), \(data.count) bytes")
224
222
 
225
223
  let apiResponse = try JSONDecoder().decode(QwenUsageResponse.self, from: data)
226
224
  guard apiResponse.isSuccess else {
@@ -520,9 +520,7 @@ public struct ZaiUsageFetcher: Sendable {
520
520
  throw ZaiUsageError.apiError("HTTP \(httpResponse.statusCode): \(errorMessage)")
521
521
  }
522
522
 
523
- if let jsonString = String(data: data, encoding: .utf8) {
524
- Self.log.debug("z.ai quota response: \(jsonString)")
525
- }
523
+ Self.log.debug("z.ai quota response: HTTP \(httpResponse.statusCode), \(data.count) bytes")
526
524
 
527
525
  let apiResponse: ZaiQuotaLimitResponse
528
526
  do {
@@ -573,9 +571,7 @@ public struct ZaiUsageFetcher: Sendable {
573
571
  throw ZaiUsageError.apiError("model-usage endpoint returned non-200")
574
572
  }
575
573
 
576
- if let jsonString = String(data: data, encoding: .utf8) {
577
- Self.log.debug("z.ai model-usage response: \(jsonString)")
578
- }
574
+ Self.log.debug("z.ai model-usage response: HTTP \(httpResponse.statusCode), \(data.count) bytes")
579
575
 
580
576
  let raw = try JSONDecoder().decode(ZaiModelUsageResponse.self, from: data)
581
577
  let models = raw.data?.models ?? []
@@ -635,9 +631,7 @@ public struct ZaiUsageFetcher: Sendable {
635
631
  throw ZaiUsageError.apiError("tool-usage endpoint returned non-200")
636
632
  }
637
633
 
638
- if let jsonString = String(data: data, encoding: .utf8) {
639
- Self.log.debug("z.ai tool-usage response: \(jsonString)")
640
- }
634
+ Self.log.debug("z.ai tool-usage response: HTTP \(httpResponse.statusCode), \(data.count) bytes")
641
635
 
642
636
  let raw = try JSONDecoder().decode(ZaiToolUsageResponse.self, from: data)
643
637
  let tools = raw.data?.tools ?? []
@@ -44,33 +44,131 @@ 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.attributesOfItem(atPath: file.url.path))?[.modificationDate] as? Date else {
60
+ return true
61
+ }
62
+ return modDate >= incrementalCutoff || modDate >= todayStart
51
63
  }
52
64
 
53
- var entries: [UsageLedgerEntry] = []
54
- var seenKeys = Set<String>()
65
+ if filesToScan.isEmpty {
66
+ return []
67
+ }
55
68
 
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)
69
+ self.log.info("Scanning \(filesToScan.count) of \(allFiles.count) JSONL files (incremental since \(incrementalCutoff))")
70
+
71
+ // Parse files concurrently (max 16 at a time).
72
+ let maxConcurrency = 16
73
+ let fileEntries = await withTaskGroup(
74
+ of: [UsageLedgerEntry].self,
75
+ returning: [UsageLedgerEntry].self)
76
+ { group in
77
+ var all: [UsageLedgerEntry] = []
78
+ var inflight = 0
79
+ for file in filesToScan {
80
+ if inflight >= maxConcurrency {
81
+ if let batch = await group.next() {
82
+ all.append(contentsOf: batch)
83
+ inflight -= 1
64
84
  }
65
85
  }
66
- } catch {
67
- self.log.warning("Claude usage log read failed", metadata: [
68
- "file": file.url.path,
69
- "error": error.localizedDescription,
70
- ])
86
+ group.addTask {
87
+ self.parseFile(file, minDate: minDate)
88
+ }
89
+ inflight += 1
90
+ }
91
+ for await batch in group {
92
+ all.append(contentsOf: batch)
93
+ }
94
+ return all
95
+ }
96
+
97
+ // Deduplicate across files using the same composite key as per-file dedup.
98
+ var seenKeys = Set<String>()
99
+ var entries: [UsageLedgerEntry] = []
100
+ entries.reserveCapacity(fileEntries.count)
101
+ for entry in fileEntries {
102
+ let key = self.dedupeKey(
103
+ requestID: entry.requestID,
104
+ messageID: entry.messageID,
105
+ sessionID: entry.sessionID,
106
+ timestamp: entry.timestamp,
107
+ inputTokens: entry.inputTokens,
108
+ outputTokens: entry.outputTokens,
109
+ cacheCreationTokens: entry.cacheCreationTokens,
110
+ cacheReadTokens: entry.cacheReadTokens)
111
+ if seenKeys.insert(key).inserted {
112
+ entries.append(entry)
71
113
  }
72
114
  }
73
115
 
116
+ // Aggregate into daily summaries and persist to cache.
117
+ let dailyFormatter = DateFormatter()
118
+ dailyFormatter.dateFormat = "yyyy-MM-dd"
119
+ dailyFormatter.timeZone = .current
120
+
121
+ var dailyBuckets: [String: (input: Int, output: Int, cacheCreate: Int, cacheRead: Int, cost: Double, requests: Int, models: Set<String>)] = [:]
122
+ for entry in entries {
123
+ let key = dailyFormatter.string(from: entry.timestamp)
124
+ var bucket = dailyBuckets[key] ?? (0, 0, 0, 0, 0, 0, [])
125
+ bucket.input += entry.inputTokens
126
+ bucket.output += entry.outputTokens
127
+ bucket.cacheCreate += entry.cacheCreationTokens
128
+ bucket.cacheRead += entry.cacheReadTokens
129
+ bucket.cost += entry.costUSD ?? 0
130
+ bucket.requests += 1
131
+ if let model = entry.model { bucket.models.insert(model) }
132
+ dailyBuckets[key] = bucket
133
+ }
134
+
135
+ let cachedDailies = dailyBuckets.map { key, bucket in
136
+ CachedDaily(
137
+ dayKey: key,
138
+ inputTokens: bucket.input,
139
+ outputTokens: bucket.output,
140
+ cacheCreationTokens: bucket.cacheCreate,
141
+ cacheReadTokens: bucket.cacheRead,
142
+ costUSD: bucket.cost > 0 ? bucket.cost : nil,
143
+ requestCount: bucket.requests,
144
+ modelsUsed: Array(bucket.models))
145
+ }
146
+
147
+ let todayKey = await LedgerCache.dayKey(for: self.now)
148
+ await cache.mergeDailies(provider: "claude", newDailies: cachedDailies, scanDate: self.now, todayKey: todayKey)
149
+
150
+ return entries
151
+ }
152
+
153
+ /// Parse a single JSONL file, returning usage entries.
154
+ private func parseFile(_ file: UsageFile, minDate: Date?) -> [UsageLedgerEntry] {
155
+ var entries: [UsageLedgerEntry] = []
156
+ do {
157
+ let data = try Data(contentsOf: file.url)
158
+ guard let content = String(data: data, encoding: .utf8) else { return [] }
159
+ var seenKeys = Set<String>()
160
+ let lines = content.split(whereSeparator: \.isNewline)
161
+ for line in lines {
162
+ if let entry = self.parseLine(line, file: file, minDate: minDate, seenKeys: &seenKeys) {
163
+ entries.append(entry)
164
+ }
165
+ }
166
+ } catch {
167
+ self.log.warning("Claude usage log read failed", metadata: [
168
+ "file": file.url.path,
169
+ "error": error.localizedDescription,
170
+ ])
171
+ }
74
172
  return entries
75
173
  }
76
174
 
@@ -123,7 +221,7 @@ public struct ClaudeUsageLogSource: UsageLedgerSource, @unchecked Sendable {
123
221
  options: [.skipsHiddenFiles])
124
222
 
125
223
  while let fileURL = enumerator?.nextObject() as? URL {
126
- guard fileURL.lastPathComponent == "usage.jsonl" else { continue }
224
+ guard fileURL.pathExtension == "jsonl" else { continue }
127
225
  if let minDate {
128
226
  let values = try? fileURL.resourceValues(forKeys: [.contentModificationDateKey])
129
227
  if let modifiedAt = values?.contentModificationDate, modifiedAt < minDate { continue }
@@ -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,40 @@ 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: (input: Int, output: Int, cacheCreate: Int, cacheRead: Int, cost: Double, requests: Int, models: Set<String>)] = [:]
127
+ for entry in entries {
128
+ let key = dailyFormatter.string(from: entry.timestamp)
129
+ var bucket = dailyBuckets[key] ?? (0, 0, 0, 0, 0, 0, [])
130
+ bucket.input += entry.inputTokens
131
+ bucket.output += entry.outputTokens
132
+ bucket.cacheCreate += entry.cacheCreationTokens
133
+ bucket.cacheRead += entry.cacheReadTokens
134
+ bucket.cost += entry.costUSD ?? 0
135
+ bucket.requests += 1
136
+ if let model = entry.model { bucket.models.insert(model) }
137
+ dailyBuckets[key] = bucket
138
+ }
139
+
140
+ let cachedDailies = dailyBuckets.map { key, bucket in
141
+ CachedDaily(
142
+ dayKey: key,
143
+ inputTokens: bucket.input,
144
+ outputTokens: bucket.output,
145
+ cacheCreationTokens: bucket.cacheCreate,
146
+ cacheReadTokens: bucket.cacheRead,
147
+ costUSD: bucket.cost > 0 ? bucket.cost : nil,
148
+ requestCount: bucket.requests,
149
+ modelsUsed: Array(bucket.models))
150
+ }
151
+
152
+ let todayKey = await LedgerCache.dayKey(for: self.now)
153
+ await cache.mergeDailies(provider: "codex", newDailies: cachedDailies, scanDate: self.now, todayKey: todayKey)
154
+
109
155
  return entries
110
156
  }
111
157
 
@@ -0,0 +1,158 @@
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(lastScanDate: scanDate, lastFullScanDate: nil, dailies: [])
53
+
54
+ let resolvedTodayKey = todayKey ?? Self.dayKey(for: Date())
55
+ var existingByKey: [String: CachedDaily] = [:]
56
+ for daily in ledger.dailies {
57
+ existingByKey[daily.dayKey] = daily
58
+ }
59
+
60
+ for newDaily in newDailies {
61
+ if newDaily.dayKey == resolvedTodayKey, let existing = existingByKey[newDaily.dayKey] {
62
+ // Accumulate today's partial scan results onto existing totals.
63
+ var mergedModels = Set(existing.modelsUsed)
64
+ mergedModels.formUnion(newDaily.modelsUsed)
65
+ existingByKey[newDaily.dayKey] = CachedDaily(
66
+ dayKey: newDaily.dayKey,
67
+ inputTokens: existing.inputTokens + newDaily.inputTokens,
68
+ outputTokens: existing.outputTokens + newDaily.outputTokens,
69
+ cacheCreationTokens: existing.cacheCreationTokens + newDaily.cacheCreationTokens,
70
+ cacheReadTokens: existing.cacheReadTokens + newDaily.cacheReadTokens,
71
+ costUSD: (existing.costUSD ?? 0) + (newDaily.costUSD ?? 0) > 0
72
+ ? (existing.costUSD ?? 0) + (newDaily.costUSD ?? 0) : nil,
73
+ requestCount: existing.requestCount + newDaily.requestCount,
74
+ modelsUsed: Array(mergedModels))
75
+ } else if existingByKey[newDaily.dayKey] == nil {
76
+ existingByKey[newDaily.dayKey] = newDaily
77
+ }
78
+ }
79
+
80
+ ledger.dailies = existingByKey.values.sorted { $0.dayKey < $1.dayKey }
81
+ ledger.lastScanDate = scanDate
82
+ self.saveDailies(provider: provider, ledger: ledger)
83
+ }
84
+
85
+ /// Mark that a full historical scan has been completed.
86
+ public func markFullScanComplete(provider: String) {
87
+ guard var ledger = self.loadCachedDailies(provider: provider) else { return }
88
+ ledger.lastFullScanDate = Date()
89
+ self.saveDailies(provider: provider, ledger: ledger)
90
+ }
91
+
92
+ /// Whether a full historical scan has been done for this provider.
93
+ public func hasFullScan(provider: String) -> Bool {
94
+ self.loadCachedDailies(provider: provider)?.lastFullScanDate != nil
95
+ }
96
+
97
+ /// Date of last incremental scan (to know which files to scan next).
98
+ public func lastScanDate(provider: String) -> Date? {
99
+ self.loadCachedDailies(provider: provider)?.lastScanDate
100
+ }
101
+
102
+ // MARK: - Internal
103
+
104
+ private func fileURL(provider: String) -> URL {
105
+ self.cacheDir.appendingPathComponent("\(provider)-daily.json")
106
+ }
107
+
108
+ static func dayKey(for date: Date) -> String {
109
+ self.dayKeyFormatter.string(from: date)
110
+ }
111
+ }
112
+
113
+ // MARK: - Models
114
+
115
+ public struct CachedLedger: Codable, Sendable {
116
+ public var lastScanDate: Date
117
+ public var lastFullScanDate: Date?
118
+ public var dailies: [CachedDaily]
119
+ }
120
+
121
+ public struct CachedDaily: Codable, Sendable {
122
+ public let dayKey: String // "2026-03-23"
123
+ public let inputTokens: Int
124
+ public let outputTokens: Int
125
+ public let cacheCreationTokens: Int
126
+ public let cacheReadTokens: Int
127
+ public let costUSD: Double?
128
+ public let requestCount: Int
129
+ public let modelsUsed: [String]
130
+
131
+ private static let dayKeyParser: DateFormatter = {
132
+ let f = DateFormatter()
133
+ f.dateFormat = "yyyy-MM-dd"
134
+ f.timeZone = .current
135
+ return f
136
+ }()
137
+
138
+ public var totalTokens: Int {
139
+ self.inputTokens + self.outputTokens + self.cacheCreationTokens + self.cacheReadTokens
140
+ }
141
+
142
+ /// Convert to a UsageLedgerDailySummary for immediate display from cache.
143
+ public func toLedgerDailySummary(provider: UsageProvider) -> UsageLedgerDailySummary? {
144
+ guard let dayStart = Self.dayKeyParser.date(from: self.dayKey) else { return nil }
145
+ return UsageLedgerDailySummary(
146
+ provider: provider,
147
+ projectID: nil,
148
+ dayStart: dayStart,
149
+ dayKey: self.dayKey,
150
+ totals: UsageLedgerTotals(
151
+ inputTokens: self.inputTokens,
152
+ outputTokens: self.outputTokens,
153
+ cacheCreationTokens: self.cacheCreationTokens,
154
+ cacheReadTokens: self.cacheReadTokens,
155
+ costUSD: self.costUSD),
156
+ modelsUsed: self.modelsUsed)
157
+ }
158
+ }
package/appcast.xml CHANGED
@@ -2,6 +2,45 @@
2
2
  <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
3
3
  <channel>
4
4
  <title>Runic</title>
5
+ <item>
6
+ <title>1.1.0</title>
7
+ <pubDate>Sun, 23 Mar 2026 00:00:00 +0100</pubDate>
8
+ <link>https://raw.githubusercontent.com/sriinnu/Runic/main/appcast.xml</link>
9
+ <sparkle:version>49</sparkle:version>
10
+ <sparkle:shortVersionString>1.1.0</sparkle:shortVersionString>
11
+ <sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
12
+ <description><![CDATA[<h2>Runic 1.1.0</h2>
13
+ <ul>
14
+ <li>Overview dashboard with activity ring, context windows, reset countdowns</li>
15
+ <li>Context windows from config file (no recompile needed)</li>
16
+ <li>Parallel provider refresh (concurrent instead of sequential)</li>
17
+ <li>Keychain ACL fix (no more password prompts on relaunch)</li>
18
+ </ul>
19
+ ]]></description>
20
+ <enclosure url="https://github.com/sriinnu/Runic/releases/download/v1.1.0/Runic-1.1.0.zip" length="30807384" type="application/octet-stream" sparkle:edSignature="hXMeE/CLrSDV+kbFythUA6w84WQ7u0YpvXK6uI58QHfBAn2FpH98xWERNfyL55gxBULdMASXzpv1UxmjtRFjCg=="/>
21
+ </item>
22
+ <item>
23
+ <title>1.0.0</title>
24
+ <pubDate>Sat, 22 Mar 2026 18:00:00 +0100</pubDate>
25
+ <link>https://raw.githubusercontent.com/sriinnu/Runic/main/appcast.xml</link>
26
+ <sparkle:version>48</sparkle:version>
27
+ <sparkle:shortVersionString>1.0.0</sparkle:shortVersionString>
28
+ <sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
29
+ <description><![CDATA[<h2>Runic 1.0.0</h2>
30
+ <ul>
31
+ <li>26 AI providers including Vertex AI and Qwen/DashScope</li>
32
+ <li>Inline line charts (1h/6h/1d/7d/30d), hourly/weekly bar charts</li>
33
+ <li>Provider tab bar with brand icons and Overview tab</li>
34
+ <li>Hero stat, glassmorphism cards with sparklines</li>
35
+ <li>Subscription utilization and usage window comparison charts</li>
36
+ <li>Export usage as CSV or JSON</li>
37
+ <li>Budget breach notifications</li>
38
+ <li>Dark/Light/System theme</li>
39
+ <li>Liquid UI animations</li>
40
+ </ul>
41
+ ]]></description>
42
+ <enclosure url="https://github.com/sriinnu/Runic/releases/download/v1.0.0/Runic-1.0.0.zip" length="30772430" type="application/octet-stream" sparkle:edSignature="a065lJa+Gwv2mH8DcniAxFW4tHB2JD4ZnT39+tXWs0k4p+Zq61WKi699aowEf8HakfgnSU7OQ3YdL5X0XYn7CQ=="/>
43
+ </item>
5
44
  <item>
6
45
  <title>0.16.0</title>
7
46
  <pubDate>Mon, 29 Dec 2025 19:03:57 +0100</pubDate>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sriinnu/runic",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "AI usage monitoring for your Mac menubar. Track usage, costs, and quotas across 26 AI providers.",
5
5
  "author": "Srinivas Pendela <sriinnu@users.noreply.github.com>",
6
6
  "license": "MIT",
package/version.env CHANGED
@@ -1,2 +1,2 @@
1
- MARKETING_VERSION=0.16.1
2
- BUILD_NUMBER=47
1
+ MARKETING_VERSION=1.1.0
2
+ BUILD_NUMBER=49