Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import Foundation
/// `ProviderImplementationRegistry.register(_:)`.
enum ProviderImplementationManifest {
static let makeImplementations: [@Sendable () -> any ProviderImplementation] = [
{ MuseProviderImplementation() },
{ CodexProviderImplementation() },
{ OpenAIAPIProviderImplementation() },
{ AzureOpenAIProviderImplementation() },
Expand Down
6 changes: 6 additions & 0 deletions Sources/CodexBar/Resources/ProviderIcon-muse.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions Sources/CodexBar/UsageStore+TokenCost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -518,7 +518,7 @@ extension UsageStore {
return snapshot?.costUsage
case .xai:
return snapshot.flatMap { XAICostUsageMapping.tokenSnapshot(from: $0, historyDays: windowDays) }
case .grok:
case .grok, .muse:
return self.grokLocalTokenSnapshot(from: snapshot, historyDays: windowDays)
Comment on lines +521 to 522

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep Muse from falling back to Grok token data

When the Muse presentation snapshot is temporarily nil while a Grok token snapshot is cached, routing .muse through grokLocalTokenSnapshot returns self.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 👍 / 👎.

default:
return nil
Expand All @@ -529,7 +529,7 @@ extension UsageStore {
// Provider-specific by design: these providers project live usage snapshots into the
// shared spend catalog instead of running the local CostUsageFetcher JSONL pipeline.
switch provider {
case .grok, .mistral, .openai, .opencodego, .openrouter, .xai:
case .grok, .muse, .mistral, .openai, .opencodego, .openrouter, .xai:
true
default:
false
Expand Down
556 changes: 556 additions & 0 deletions Sources/CodexBarCore/Providers/Muse/MuseLocalSessionScanner.swift

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions Sources/CodexBarCore/Providers/Muse/MuseProviderDescriptor.swift
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."
}
}
160 changes: 160 additions & 0 deletions Sources/CodexBarCore/Providers/Muse/MuseSessionCostCache.swift
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)
}
}
28 changes: 28 additions & 0 deletions Sources/CodexBarCore/Providers/Muse/MuseUsageSnapshot.swift
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the configured Muse history length

For any non-default history setting, the fetch strategy scans context.costUsageHistoryDays but this conversion always labels the result as 30 days. In particular, a 90-day scan is subsequently projected by grokLocalTokenSnapshot with historyCoverageIsEstablished == false because the published historyDays is only 30, preventing downstream dashboard coverage calculations from recognizing the complete history. Carry the requested history length into MuseUsageSnapshot instead of restoring the default.

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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// First-party spellings keep call sites concise while their values remain validated instance IDs.
// swiftformat:disable sortDeclarations
extension ProviderInstanceID {
public static let muse = UsageProvider.muse.instanceID
public static let codex = UsageProvider.codex.instanceID
public static let openai = UsageProvider.openai.instanceID
public static let azureopenai = UsageProvider.azureopenai.instanceID
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBarCore/Providers/ProviderManifest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Foundation
/// `ProviderDescriptorRegistry.register(_:)`.
public enum ProviderManifest {
public static let allDescriptors: [ProviderDescriptor] = [
MuseProviderDescriptor.descriptor,
CodexProviderDescriptor.descriptor,
OpenAIAPIProviderDescriptor.descriptor,
AzureOpenAIProviderDescriptor.descriptor,
Expand Down
5 changes: 5 additions & 0 deletions Sources/CodexBarCore/Providers/ProviderVersionDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ public enum ProviderVersionDetector {
return nil
}

public static func museVersion() -> String? {
guard let path = TTYCommandRunner.which(MuseProviderDescriptor.descriptor.cli.name) else { return nil }
return Self.run(path: path, args: ["--version"])
}

public static func geminiVersion() -> String? {
let env = ProcessInfo.processInfo.environment
guard let path = BinaryLocator.resolveGeminiBinary(env: env, loginPATH: nil)
Expand Down
1 change: 1 addition & 0 deletions Sources/CodexBarCore/Providers/Providers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public struct ProviderDebugPaneCapabilities: Sendable {

// swiftformat:disable sortDeclarations
public enum UsageProvider: String, CaseIterable, Sendable, Codable {
case muse
case codex
case openai
case azureopenai
Expand Down
Loading