@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.
- package/LICENSE +1 -1
- package/README.md +40 -29
- package/Scripts/package_app.sh +2 -2
- package/Sources/Runic/Controllers/StatusItemController+Actions.swift +10 -1
- package/Sources/Runic/Controllers/StatusItemController+Menu.swift +30 -2
- package/Sources/Runic/Core/Login/GeminiLoginRunner.swift +2 -5
- package/Sources/Runic/Core/Stores/AuggieTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/AzureOpenAITokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/CerebrasTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/CohereTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/CopilotTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/DeepSeekTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/FireworksTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/GroqTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/KimiTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/MiniMaxCookieHeaderStore.swift +9 -33
- package/Sources/Runic/Core/Stores/MiniMaxGroupIDStore.swift +9 -33
- package/Sources/Runic/Core/Stores/MiniMaxTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/MistralTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/OpenRouterTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/PerplexityTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/QwenTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/SambaNovaTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/SettingsStore.swift +10 -1
- package/Sources/Runic/Core/Stores/TogetherTokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/UsageStore.swift +50 -9
- package/Sources/Runic/Core/Stores/XAITokenStore.swift +9 -33
- package/Sources/Runic/Core/Stores/ZaiTokenStore.swift +9 -34
- package/Sources/Runic/Resources/provider-context-windows.json +20 -0
- package/Sources/Runic/Views/Menu/InlineUsageChartView.swift +9 -16
- package/Sources/Runic/Views/Menu/OverviewMenuView.swift +192 -55
- package/Sources/Runic/Views/Menu/UsageTimelineChartMenuView.swift +12 -65
- package/Sources/Runic/Views/Preferences/PreferencesIntegrationsPane.swift +71 -1
- package/Sources/RunicCore/CustomProviders/GenericProviderFetcher.swift +11 -0
- package/Sources/RunicCore/Logging/OSLogLogHandler.swift +6 -6
- package/Sources/RunicCore/Providers/Azure/AzureUsageFetcher.swift +2 -0
- package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthCredentials.swift +1 -0
- package/Sources/RunicCore/Providers/Codex/CodexOAuth/CodexOAuthUsageFetcher.swift +12 -1
- package/Sources/RunicCore/Providers/Cursor/CursorStatusProbe.swift +1 -0
- package/Sources/RunicCore/Providers/Factory/FactoryStatusProbe.swift +1 -0
- package/Sources/RunicCore/Providers/Gemini/GeminiStatusProbe.swift +1 -0
- package/Sources/RunicCore/Providers/MiniMax/MiniMaxUsageFetcher.swift +1 -4
- package/Sources/RunicCore/Providers/OpenRouter/OpenRouterUsageFetcher.swift +2 -6
- package/Sources/RunicCore/Providers/Qwen/QwenUsageStats.swift +1 -3
- package/Sources/RunicCore/Providers/Zai/ZaiUsageStats.swift +3 -9
- package/Sources/RunicCore/UsageLedger/ClaudeUsageLogSource.swift +117 -19
- package/Sources/RunicCore/UsageLedger/CodexUsageLogSource.swift +49 -3
- package/Sources/RunicCore/UsageLedger/LedgerCache.swift +158 -0
- package/appcast.xml +39 -0
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.attributesOfItem(atPath: file.url.path))?[.modificationDate] as? Date else {
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
return modDate >= incrementalCutoff || modDate >= todayStart
|
|
51
63
|
}
|
|
52
64
|
|
|
53
|
-
|
|
54
|
-
|
|
65
|
+
if filesToScan.isEmpty {
|
|
66
|
+
return []
|
|
67
|
+
}
|
|
55
68
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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.
|
|
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
|
-
|
|
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,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.
|
|
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=
|
|
2
|
-
BUILD_NUMBER=
|
|
1
|
+
MARKETING_VERSION=1.1.0
|
|
2
|
+
BUILD_NUMBER=49
|