-
Notifications
You must be signed in to change notification settings - Fork 1.9k
feat(provider): add Muse local token usage #3371
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import CodexBarCore | ||
| import Foundation | ||
|
|
||
| struct MuseProviderImplementation: ProviderImplementation { | ||
| let id: UsageProvider = .muse | ||
| } |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import Foundation | ||
|
|
||
| public enum MuseProviderDescriptor { | ||
| public static let descriptor: ProviderDescriptor = Self.makeDescriptor() | ||
|
|
||
| static func makeDescriptor() -> ProviderDescriptor { | ||
| ProviderDescriptor( | ||
| id: .muse, | ||
| metadata: ProviderMetadata( | ||
| id: .muse, | ||
| displayName: "Muse", | ||
| sessionLabel: "Today", | ||
| weeklyLabel: "30-day", | ||
| opusLabel: nil, | ||
| supportsOpus: false, | ||
| supportsCredits: false, | ||
| creditsHint: "", | ||
| toggleTitle: "Show Muse usage", | ||
| cliName: "muse", | ||
| defaultEnabled: false, | ||
| widgetSelectable: false, | ||
| dashboardURL: nil, | ||
| statusPageURL: nil), | ||
| branding: ProviderBranding( | ||
| iconStyle: .init(provider: .muse), | ||
| iconResourceName: "ProviderIcon-muse", | ||
| color: ProviderColor(red: 114 / 255, green: 96 / 255, blue: 255 / 255), | ||
| confettiPalette: [ | ||
| ProviderColor(hex: 0x7260FF), | ||
| ProviderColor(hex: 0x1A1A1A), | ||
| ProviderColor(hex: 0xEDE8FF), | ||
| ]), | ||
| tokenCost: ProviderTokenCostConfig( | ||
| supportsTokenCost: true, | ||
| noDataMessage: { | ||
| "No Muse sessions found in ~/.local/share/muse/sessions." | ||
| }, | ||
| supportsTokenSnapshot: true, | ||
| estimateDisclaimer: "From local Muse session logs; tokens only, no billing."), | ||
| pace: .unsupported, | ||
| history: .optIn, | ||
| fetchPlan: ProviderFetchPlan( | ||
| sourceModes: [.auto], | ||
| pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [MuseLocalFetchStrategy()] })), | ||
| cli: ProviderCLIConfig( | ||
| name: "muse", | ||
| versionDetector: { _ in ProviderVersionDetector.museVersion() })) | ||
| } | ||
| } | ||
|
|
||
| struct MuseLocalFetchStrategy: ProviderFetchStrategy { | ||
| let id: String = "muse.local" | ||
| let kind: ProviderFetchKind = .localProbe | ||
|
|
||
| func isAvailable(_: ProviderFetchContext) async -> Bool { true } | ||
|
|
||
| func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { | ||
| let summary = try await MuseLocalSessionScanner.summarizeOffMainThread( | ||
| env: context.env, | ||
| lookbackDays: context.costUsageHistoryDays, | ||
| now: Date()) | ||
| guard summary.toCostUsageTokenSnapshot(historyDays: context.costUsageHistoryDays) != nil else { | ||
| throw MuseLocalError.noUsage | ||
| } | ||
| let snapshot = MuseUsageSnapshot(summary: summary, updatedAt: summary.scannedAt) | ||
| return self.makeResult( | ||
| usage: snapshot.toUsageSnapshot(), | ||
| sourceLabel: "local") | ||
| } | ||
|
|
||
| func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { false } | ||
| } | ||
|
|
||
| private enum MuseLocalError: LocalizedError, Sendable { | ||
| case noUsage | ||
| var errorDescription: String? { | ||
| "No Muse sessions found in ~/.local/share/muse/sessions." | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| import Foundation | ||
|
|
||
| // MARK: - Cache structures (mirrors PiSessionCostCache pattern, simplified for Muse) | ||
|
|
||
| enum MuseSessionCostCacheIO { | ||
| private static let artifactVersion = 1 | ||
|
|
||
| private static func defaultCacheRoot() -> URL { | ||
| // Prefer the standard Caches directory, but fall back to a sandbox-allowed temp location | ||
| // when running under the `muse.bash` Managed sandbox (which denies writes to ~/Library/Caches). | ||
| if let root = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first { | ||
| let codexRoot = root.appendingPathComponent("CodexBar", isDirectory: true) | ||
| // Probe writability; `isWritableFile` is more reliable than trying to create and catching. | ||
| if FileManager.default.isWritableFile(atPath: root.path) || FileManager.default.isWritableFile(atPath: codexRoot.path) || FileManager.default.fileExists(atPath: codexRoot.path) { | ||
| return codexRoot | ||
| } | ||
| // Try to create the directory as a probe; if it succeeds, use it, otherwise fall back. | ||
| if (try? FileManager.default.createDirectory(at: codexRoot, withIntermediateDirectories: true)) != nil, | ||
| FileManager.default.isWritableFile(atPath: codexRoot.path) { | ||
| return codexRoot | ||
| } | ||
| } | ||
| // Fallback for sandboxed shells (e.g., `muse.bash`): use the process temp directory. | ||
| return FileManager.default.temporaryDirectory.appendingPathComponent("CodexBar", isDirectory: true) | ||
| } | ||
|
|
||
| static func cacheFileURL(cacheRoot: URL? = nil) -> URL { | ||
| let root = cacheRoot ?? self.defaultCacheRoot() | ||
| return root | ||
| .appendingPathComponent("cost-usage", isDirectory: true) | ||
| .appendingPathComponent("muse-sessions-v\(Self.artifactVersion).json", isDirectory: false) | ||
| } | ||
|
|
||
| static func load(cacheRoot: URL? = nil) -> MuseSessionCostCache { | ||
| let urls: [URL] = { | ||
| if let cacheRoot { | ||
| return [self.cacheFileURL(cacheRoot: cacheRoot)] | ||
| } | ||
| // Try default, then fallback temp for sandboxed shells | ||
| let defaultURL = self.cacheFileURL(cacheRoot: nil) | ||
| let fallbackURL = self.cacheFileURL(cacheRoot: FileManager.default.temporaryDirectory.appendingPathComponent("CodexBar", isDirectory: true)) | ||
| return [defaultURL, fallbackURL] | ||
| }() | ||
| for url in urls { | ||
| if let data = try? Data(contentsOf: url), | ||
| let decoded = try? JSONDecoder().decode(MuseSessionCostCache.self, from: data), | ||
| decoded.version == Self.artifactVersion { | ||
| return decoded | ||
| } | ||
| } | ||
| return MuseSessionCostCache(version: Self.artifactVersion) | ||
| } | ||
|
|
||
| static func save(cache: MuseSessionCostCache, cacheRoot: URL? = nil, calendar: Calendar = .current) { | ||
| var cache = cache | ||
| cache.timeZoneIdentifier = calendar.timeZone.identifier | ||
| let urls: [URL] = { | ||
| if let cacheRoot { | ||
| return [self.cacheFileURL(cacheRoot: cacheRoot)] | ||
| } | ||
| let defaultURL = self.cacheFileURL(cacheRoot: nil) | ||
| let fallbackURL = self.cacheFileURL(cacheRoot: FileManager.default.temporaryDirectory.appendingPathComponent("CodexBar", isDirectory: true)) | ||
| return [defaultURL, fallbackURL] | ||
| }() | ||
| let data = (try? JSONEncoder().encode(cache)) ?? Data() | ||
| var saved = false | ||
| for url in urls { | ||
| let dir = url.deletingLastPathComponent() | ||
| try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | ||
| let tmp = dir.appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) | ||
| do { | ||
| try data.write(to: tmp, options: [.atomic]) | ||
| if FileManager.default.fileExists(atPath: url.path) { | ||
| _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) | ||
| } else { | ||
| try FileManager.default.moveItem(at: tmp, to: url) | ||
| } | ||
| saved = true | ||
| break | ||
| } catch { | ||
| try? FileManager.default.removeItem(at: tmp) | ||
| continue | ||
| } | ||
| } | ||
| if !saved { | ||
| // Last resort: try workspace temp atomically | ||
| let fallback = FileManager.default.temporaryDirectory.appendingPathComponent("CodexBar/cost-usage/muse-sessions-v\(Self.artifactVersion).json") | ||
| try? FileManager.default.createDirectory(at: fallback.deletingLastPathComponent(), withIntermediateDirectories: true) | ||
| let tmpFallback = fallback.deletingLastPathComponent().appendingPathComponent(".tmp-\(UUID().uuidString).json", isDirectory: false) | ||
| if let _ = try? data.write(to: tmpFallback, options: [.atomic]) { | ||
| if FileManager.default.fileExists(atPath: fallback.path) { | ||
| _ = try? FileManager.default.replaceItemAt(fallback, withItemAt: tmpFallback) | ||
| } else { | ||
| try? FileManager.default.moveItem(at: tmpFallback, to: fallback) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| static func clear(cacheRoot: URL? = nil) { | ||
| let url = self.cacheFileURL(cacheRoot: cacheRoot) | ||
| try? FileManager.default.removeItem(at: url) | ||
| } | ||
| } | ||
|
|
||
| struct MuseSessionCostCache: Codable { | ||
| var version: Int | ||
| var lastScanUnixMs: Int64 = 0 | ||
| var timeZoneIdentifier: String? | ||
| // dayKey -> model -> packed usage | ||
| var days: [String: [String: MusePackedUsage]] = [:] | ||
| // file path -> per-file usage | ||
| var files: [String: MuseSessionFileUsage] = [:] | ||
|
|
||
| init(version: Int = 1) { | ||
| self.version = version | ||
| } | ||
| } | ||
|
|
||
| struct MuseSessionFileUsage: Codable, Equatable { | ||
| var mtimeUnixMs: Int64 | ||
| var size: Int64 | ||
| var parsedBytes: Int64 | ||
| var prefixFingerprint: String? // hash of first 4K for same-path replacement detection | ||
| var contributions: [String: [String: MusePackedUsage]] // day -> model -> usage | ||
| var entryCount: Int | ||
| } | ||
|
|
||
| struct MusePackedUsage: Codable, Equatable { | ||
| var inputTokens: Int = 0 | ||
| var cacheReadTokens: Int = 0 | ||
| var outputTokens: Int = 0 | ||
| var reasoningTokens: Int = 0 | ||
| var totalTokens: Int = 0 | ||
| var requestCount: Int = 0 | ||
|
|
||
| var isZero: Bool { | ||
| self.totalTokens == 0 && self.requestCount == 0 | ||
| } | ||
|
|
||
| static func +(lhs: MusePackedUsage, rhs: MusePackedUsage) -> MusePackedUsage { | ||
| MusePackedUsage( | ||
| inputTokens: lhs.inputTokens + rhs.inputTokens, | ||
| cacheReadTokens: lhs.cacheReadTokens + rhs.cacheReadTokens, | ||
| outputTokens: lhs.outputTokens + rhs.outputTokens, | ||
| reasoningTokens: lhs.reasoningTokens + rhs.reasoningTokens, | ||
| totalTokens: lhs.totalTokens + rhs.totalTokens, | ||
| requestCount: lhs.requestCount + rhs.requestCount) | ||
| } | ||
|
|
||
| static func -(lhs: MusePackedUsage, rhs: MusePackedUsage) -> MusePackedUsage { | ||
| MusePackedUsage( | ||
| inputTokens: lhs.inputTokens - rhs.inputTokens, | ||
| cacheReadTokens: lhs.cacheReadTokens - rhs.cacheReadTokens, | ||
| outputTokens: lhs.outputTokens - rhs.outputTokens, | ||
| reasoningTokens: lhs.reasoningTokens - rhs.reasoningTokens, | ||
| totalTokens: lhs.totalTokens - rhs.totalTokens, | ||
| requestCount: lhs.requestCount - rhs.requestCount) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import Foundation | ||
|
|
||
| public struct MuseUsageSnapshot: Sendable { | ||
| public let summary: MuseLocalSessionSummary? | ||
| public let updatedAt: Date | ||
|
|
||
| public init(summary: MuseLocalSessionSummary?, updatedAt: Date = Date()) { | ||
| self.summary = summary | ||
| self.updatedAt = updatedAt | ||
| } | ||
|
|
||
| public func toUsageSnapshot() -> UsageSnapshot { | ||
| let costUsage = summary?.toCostUsageTokenSnapshot( | ||
| historyDays: MuseLocalSessionScanner.defaultLookbackDays) | ||
|
Comment on lines
+13
to
+14
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For any non-default history setting, the fetch strategy scans Useful? React with 👍 / 👎. |
||
| let identity = ProviderIdentitySnapshot( | ||
| providerID: .muse, | ||
| accountEmail: nil, | ||
| accountOrganization: nil, | ||
| loginMethod: "Muse") | ||
| // Token-history only: no quota windows, no cost, no pace. | ||
| return UsageSnapshot( | ||
| primary: nil, | ||
| secondary: nil, | ||
| costUsage: costUsage, | ||
| updatedAt: summary?.scannedAt ?? self.updatedAt, | ||
| identity: costUsage != nil || summary?.requestCount ?? 0 > 0 ? identity : nil) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the Muse presentation snapshot is temporarily nil while a Grok token snapshot is cached, routing
.musethroughgrokLocalTokenSnapshotreturnsself.tokenSnapshots[.grok]; the Muse card can therefore display Grok token and request history. Pass the provider into the helper or use a Muse-specific projection so a missing Muse snapshot remains empty.AGENTS.md reference: AGENTS.md:L46-L46
Useful? React with 👍 / 👎.