Skip to content
Merged
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
8 changes: 8 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# ContextDaddy design contract

Platform: native-macos
Supported minimum width: 960

Codex credit evidence reviews the native Provider allowance panel in AppKit-hosted
SwiftUI windows at 960, 1180 and 1440 logical points, captured at the display's
2x backing scale. Credit balance uses the existing secondary-text treatment and
stays separate from reset grants, dollar amounts and local history.

## Selected direction

Owner-directed StorageDaddy family fork: keep the A+C information architecture (live operations plus a policy ledger), but express it through StorageDaddy's warm black-and-mint visual language, rounded typography, fine outlines, and editorial Daddy doodles.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The primary navigation is **Usage**, **Skills**, **Projects**, and **OpenTelemet
- Usage first scan shows side-by-side Codex/Claude allowance followed by one local history chart including Devin. The historical chart has shared agent filters, model/model-provider/project grouping where supported, range, day/week/month scale, generated/cache/cost metrics where available, selectable periods and exact breakdown. Model, project, and session drill-downs remain below. Cache reads, generated tokens, and estimated cost stay separate; unpriced models are flagged.
- Skills can run an explicit, read-only **Read local history** scan. Claude and Devin `skill` tool calls are counted separately from Cursor/Grok skill-file reads and Codex tool calls that reference a `SKILL.md` path. The library shows last-seen dates, agent and folder evidence, 30/90-day or all-history windows, and an evidence-only filter. Cursor dates use transcript modification time because its records lack event timestamps. Name-only tool calls cannot identify a particular same-name copy; no recorded event never means unused. This backfill is held in memory for the app session and does not automatically remove skills.
- Project grouping is a separately labelled session ledger, joining ccusage session IDs to Codex's read-only thread-index `cwd`, Grok's project paths, and Claude's encoded project slugs. It queries only Codex rollout path and working directory metadata, never prompt bodies. Buckets use session last activity, not an invented daily allocation; totals may not reconcile to daily accounting. Missing session identity remains **Unattributed**. Model-provider labels are inferred from reported model names, not billing endpoints; unknown aliases remain unknown. Devin's indexed daily tokens participate in the shared model and provider grouping; project attribution remains unavailable.
- Provider allowance for Codex and Claude is fetched on **Check both allowances**, or on opening Usage after the user enables the opt-in automatic switch (at most once per 15 minutes). It is never added to local token history. Codex reset-credit expiry is shown only when reported; detail rows may be capped. Claude's available usage-reset count is shown when its CLI emits a limit-reset notice; absent counts are explicitly not reported, never assumed to be zero. These grants are separate from paid usage credits and may cover only particular limits. ContextDaddy only reads the notices and never invokes `/limit-reset` or consumes a grant.
- Provider allowance for Codex and Claude is fetched on **Check both allowances**, or on opening Usage after the user enables the opt-in automatic switch (at most once per 15 minutes). It is never added to local token history. Codex's reported credit balance appears in credit units, separately from full-reset grants; unlimited, unavailable, and unreported balances remain explicit. Codex reset-credit expiry is shown only when reported; detail rows may be capped. Claude's available usage-reset count is shown when its CLI emits a limit-reset notice; absent counts are explicitly not reported, never assumed to be zero. These grants are separate from paid usage credits and may cover only particular limits. ContextDaddy only reads the notices and never invokes `/limit-reset` or consumes a grant.
- Skills and OTEL review panels can **Copy all issues** into an agent-ready brief with IDs, evidence, source scope, and verification limits. After skill edits, **Verify after changes** rescans and distinguishes detector-cleared, still-detected, and unverified findings. The library supports previewed local skill edits with recovery; OTEL signals need a new comparable observation window.
- Files & diagnostics configuration findings have the same copy-all and rescan handoff without copying configuration values or modifying files. This file audit detects the two misplaced `otel.*` keys but cannot detect launch-time `session-flags.token_budget`; that warning must be traced to the launcher supplying the flag.
- Cursor remains inventory-only for usage until a verified source exists.
Expand Down
16 changes: 16 additions & 0 deletions Sources/ContextCore/LocalUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -346,10 +346,26 @@ public struct ProviderCreditBalance: Decodable, Sendable {
public let remainingPercent: Double?
public let usedAmount: Double?
public let limitAmount: Double?
/// Codex reports a credit-unit balance, not a dollar amount or reset count.
public let balance: Decimal?
public let hasCredits: Bool?
public let unlimited: Bool?

public init(remainingPercent: Double? = nil, usedAmount: Double? = nil, limitAmount: Double? = nil,
balance: Decimal? = nil, hasCredits: Bool? = nil, unlimited: Bool? = nil) {
self.remainingPercent = remainingPercent
self.usedAmount = usedAmount
self.limitAmount = limitAmount
self.balance = balance
self.hasCredits = hasCredits
self.unlimited = unlimited
}

enum CodingKeys: String, CodingKey {
case remainingPercent = "remaining_percent"
case usedAmount = "used_amount", limitAmount = "limit_amount"
case balance, unlimited
case hasCredits = "has_credits"
}
}

Expand Down
19 changes: 17 additions & 2 deletions Sources/ContextCore/ProviderQuotaClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,8 @@ enum ProviderQuotaParser {
resetDescription: nil))
}
}
guard !windows.isEmpty else { throw ProviderQuotaError.requestFailed("Codex") }
let credits = selected.first(where: { $0.0 == "codex" }).flatMap { codexCredits($0.1["credits"]) }
guard !windows.isEmpty || credits != nil else { throw ProviderQuotaError.requestFailed("Codex") }
windows.sort { ($0.windowDurationMinutes ?? .max, $0.label) < ($1.windowDurationMinutes ?? .max, $1.label) }
let resetSummary = result["rateLimitResetCredits"] as? [String: Any]
let reset = resetSummary?["availableCount"] as? NSNumber
Expand All @@ -225,12 +226,26 @@ enum ProviderQuotaParser {
let noExpiryCount = availableDetails?.filter { $0["expiresAt"] is NSNull }.count
return ProviderQuotaStatus(provider: "codex", status: "ready", source: "codex app-server account/rateLimits/read",
checkedAt: timestamp(), plan: plan, windows: windows,
credits: nil, resetCredits: reset?.uint64Value,
credits: credits, resetCredits: reset?.uint64Value,
latestReportedResetCreditExpiryUnix: expiries?.max(),
resetCreditDetailsCount: availableDetails.map { UInt64($0.count) },
resetCreditsWithoutExpiryCount: noExpiryCount.map(UInt64.init), message: nil)
}

private static func codexCredits(_ value: Any?) -> ProviderCreditBalance? {
guard let snapshot = value as? [String: Any] else { return nil }
var balance: Decimal?
if let raw = snapshot["balance"] as? String,
raw.range(of: #"^[0-9]+(?:\.[0-9]+)?$"#, options: .regularExpression) != nil,
let amount = Decimal(string: raw, locale: Locale(identifier: "en_US_POSIX")), !amount.isNaN {
balance = amount
}
let hasCredits = snapshot["hasCredits"] as? Bool
let unlimited = snapshot["unlimited"] as? Bool
guard balance != nil || hasCredits != nil || unlimited != nil else { return nil }
return ProviderCreditBalance(balance: balance, hasCredits: hasCredits, unlimited: unlimited)
}

static func claude(_ output: String) throws -> ProviderQuotaStatus {
enum Section { case current, weekly, model(String), credits }
var section: Section?
Expand Down
24 changes: 19 additions & 5 deletions Sources/ContextDaddy/UsageSourcePanels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,12 @@ struct UsageAllowanceView: View {
.foregroundStyle(DaddyTheme.muted)
}
Spacer()
Text(status == nil ? "NOT CHECKED" : status?.status == "ready" && !windows.isEmpty
? health(worst).0.uppercased() : "UNAVAILABLE")
Text(status == nil ? "NOT CHECKED" : status?.status == "ready"
? (windows.isEmpty ? "READY" : health(worst).0.uppercased()) : "UNAVAILABLE")
.font(.caption2.weight(.bold))
.foregroundStyle(status?.status == "ready" && error == nil ? health(worst).1 : DaddyTheme.muted)
}
if status?.status == "ready", !windows.isEmpty {
if status?.status == "ready" {
ViewThatFits(in: .horizontal) {
HStack(alignment: .top, spacing: 14) {
ForEach(windows, id: \.id) { window in
Expand All @@ -80,9 +80,10 @@ struct UsageAllowanceView: View {
ForEach(windows, id: \.id) { window in windowValue(window) }
}
}
if let status, let creditText = creditText(status) {
if let status, let creditText = Self.creditText(status) {
Text(creditText).font(.caption2.weight(.semibold))
.foregroundStyle(DaddyTheme.muted)
.fixedSize(horizontal: false, vertical: true)
if let resets = status.resetCredits {
Text(resetCountText(resets, provider: provider))
.font(.caption2).foregroundStyle(DaddyTheme.muted)
Expand Down Expand Up @@ -169,7 +170,20 @@ struct UsageAllowanceView: View {
return "\(delta > 0 ? "+" : "")\(Int(delta.rounded())) pts \(delta > 0 ? "ahead of" : "behind") even pace"
}

private func creditText(_ status: ProviderQuotaStatus) -> String? {
static func creditText(_ status: ProviderQuotaStatus) -> String? {
if status.provider == "codex" {
guard let credits = status.credits else { return "Credit balance not reported" }
if credits.unlimited == true { return "Unlimited credits" }
if let balance = credits.balance {
if balance > 0, balance < Decimal(1) / 100 {
return "<\((Decimal(1) / 100).formatted(.number)) credits remaining"
}
return "\(balance.formatted(.number.precision(.fractionLength(0...2)))) credits remaining"
}
if credits.hasCredits == false { return "No credits available" }
if credits.hasCredits == true { return "Credits available · balance not reported" }
return "Credit balance not reported"
}
guard let credits = status.credits else { return nil }
if let limit = credits.limitAmount, let used = credits.usedAmount {
return "\(max(0, limit - used).formatted(.currency(code: "USD"))) credits"
Expand Down
52 changes: 52 additions & 0 deletions Tests/ContextCoreTests/ProviderQuotaClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,58 @@ import Testing
@testable import ContextCore

struct ProviderQuotaClientTests {
@Test func codexProjectsCreditUnitsWithoutMixingResetGrantsOrSpark() throws {
let response: [String: Any] = ["result": [
"rateLimitsByLimitId": [
"codex": ["primary": ["usedPercent": 30.0],
"credits": ["balance": "12345.6789000", "hasCredits": true, "unlimited": false]],
"spark": ["credits": ["balance": "999999", "hasCredits": true, "unlimited": true]],
],
"rateLimitResetCredits": ["availableCount": 2],
]]
let status = try ProviderQuotaParser.codex(response)
#expect(status.credits?.balance == Decimal(string: "12345.6789000"))
#expect(status.credits?.hasCredits == true)
#expect(status.credits?.unlimited == false)
#expect(status.credits?.limitAmount == nil)
#expect(status.credits?.remainingPercent == nil)
#expect(status.resetCredits == 2)
}

@Test func codexAcceptsCreditOnlyAndLegacySingleBucketReadings() throws {
let creditOnly = try ProviderQuotaParser.codex(["result": ["rateLimits": [
"credits": ["hasCredits": true, "unlimited": true, "balance": NSNull()],
]]])
#expect(creditOnly.windows.isEmpty)
#expect(creditOnly.credits?.unlimited == true)
#expect(creditOnly.credits?.balance == nil)

let depleted = try ProviderQuotaParser.codex(["result": ["rateLimits": [
"primary": ["usedPercent": 100.0],
"credits": ["hasCredits": false, "unlimited": false, "balance": "0"],
]]])
#expect(depleted.credits?.balance == 0)
#expect(depleted.credits?.hasCredits == false)
#expect(depleted.resetCredits == nil)
}

@Test func codexMissingAndMalformedBalancesStayUnknown() throws {
for value: Any in [NSNull(), [:] as [String: Any]] {
let status = try ProviderQuotaParser.codex(["result": ["rateLimits": [
"primary": ["usedPercent": 30.0], "credits": value,
]]])
#expect(status.credits == nil)
}
for value: Any in [NSNull(), "NaN", "Infinity", "-1", "12 credits", "", "1,234", 123] {
let status = try ProviderQuotaParser.codex(["result": ["rateLimits": [
"primary": ["usedPercent": 30.0],
"credits": ["balance": value, "hasCredits": true, "unlimited": false],
]]])
#expect(status.credits?.balance == nil)
#expect(status.credits?.hasCredits == true)
}
}

@Test func codexUsesAccountBucketAndExcludesSparkAndIdentity() throws {
let response: [String: Any] = [
"id": 1,
Expand Down
62 changes: 62 additions & 0 deletions Tests/ContextDaddyTests/UsageAllowanceViewTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import AppKit
import ContextCore
import SwiftUI
import Testing
@testable import ContextDaddy

@MainActor
struct UsageAllowanceViewTests {
@Test func creditLabelsDistinguishUnitsAndMissingStates() throws {
func status(_ credits: String, provider: String = "codex") throws -> ProviderQuotaStatus {
let json = "{\"provider\":\"\(provider)\",\"status\":\"ready\",\"source\":\"fixture\",\"checked_at\":\"2026-10-03T12:00:00Z\",\"windows\":[],\"credits\":\(credits)}"
return try JSONDecoder().decode(ProviderQuotaStatus.self, from: Data(json.utf8))
}
let balance = Decimal(string: "12345.6789")!
let formatted = balance.formatted(.number.precision(.fractionLength(0...2)))
#expect(try UsageAllowanceView.creditText(status(#"{"balance":12345.6789}"#)) == "\(formatted) credits remaining")
#expect(try UsageAllowanceView.creditText(status(#"{"unlimited":true,"balance":0}"#)) == "Unlimited credits")
#expect(try UsageAllowanceView.creditText(status(#"{"balance":0,"has_credits":false}"#)) == "0 credits remaining")
#expect(try UsageAllowanceView.creditText(status(#"{"has_credits":false}"#)) == "No credits available")
#expect(try UsageAllowanceView.creditText(status(#"{"has_credits":true}"#)) == "Credits available · balance not reported")
#expect(try UsageAllowanceView.creditText(status("null")) == "Credit balance not reported")
let tiny = (Decimal(1) / 100).formatted(.number)
#expect(try UsageAllowanceView.creditText(status(#"{"balance":0.001}"#)) == "<\(tiny) credits remaining")
let dollars = 125.formatted(.currency(code: "USD"))
#expect(try UsageAllowanceView.creditText(status(#"{"used_amount":25,"limit_amount":150}"#, provider: "claude")) == "\(dollars) credits")
}

/// Focused native evidence; no local-history scan or provider call is needed.
@Test func rendersCreditEvidence() throws {
let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
.deletingLastPathComponent().deletingLastPathComponent()
let directory = root.appendingPathComponent("artifacts/design/codex-credits/current", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let model = ContextDaddyModel()
let originalAutoCheck = model.autoCheckAllowance
defer { model.autoCheckAllowance = originalAutoCheck }
model.autoCheckAllowance = false
let fixture = #"{"schema_version":"contextdaddy.provider-quota/v1","generated_at":"2026-10-03T12:00:00Z","providers":[{"provider":"codex","status":"ready","source":"Codex fixture","checked_at":"2026-10-03T12:00:00Z","plan":"pro","windows":[{"id":"codex.primary","label":"5-hour window","remaining_percent":70}],"credits":{"balance":12345.6789,"has_credits":true,"unlimited":false},"reset_credits":2,"message":null},{"provider":"claude","status":"ready","source":"Claude fixture","checked_at":"2026-10-03T12:00:00Z","windows":[{"id":"current","label":"Current window","remaining_percent":80},{"id":"weekly","label":"Weekly window","remaining_percent":60}],"credits":{"used_amount":25,"limit_amount":150},"reset_credits":1,"message":null}]}"#
let receipt = try JSONDecoder().decode(ProviderQuotaReceipt.self, from: Data(fixture.utf8))
model.quotaReceipts["codex"] = receipt
model.quotaReceipts["claude"] = receipt
for width in [960, 1180, 1440] {
let view = FocusDeskView().environment(model)
.frame(width: CGFloat(width), height: 740, alignment: .topLeading)
.background(DaddyTheme.canvas).preferredColorScheme(.dark)
.tint(DaddyTheme.mint).buttonStyle(ContextDaddyButtonStyle())
let hosting = NSHostingView(rootView: view)
hosting.frame = NSRect(x: 0, y: 0, width: width, height: 740)
let window = NSWindow(contentRect: hosting.frame, styleMask: [], backing: .buffered, defer: false)
window.contentView = hosting
hosting.layoutSubtreeIfNeeded()
window.displayIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.25))
hosting.layoutSubtreeIfNeeded()
window.displayIfNeeded()
let bitmap = try #require(hosting.bitmapImageRepForCachingDisplay(in: hosting.bounds))
hosting.cacheDisplay(in: hosting.bounds, to: bitmap)
let png = try #require(bitmap.representation(using: .png, properties: [:]))
try png.write(to: directory.appendingPathComponent("allowance-\(width).png"), options: .atomic)
}
}
}
Loading