diff --git a/README.md b/README.md index 6e4c432..4d2438b 100644 --- a/README.md +++ b/README.md @@ -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. +- 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. - 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. diff --git a/Sources/ContextCore/ProviderQuotaClient.swift b/Sources/ContextCore/ProviderQuotaClient.swift index dc771e4..97e2f26 100644 --- a/Sources/ContextCore/ProviderQuotaClient.swift +++ b/Sources/ContextCore/ProviderQuotaClient.swift @@ -126,7 +126,7 @@ public struct ProviderQuotaClient: Sendable { if readAvailable(master, into: &bytes) { lastOutput = Date() } let display = ProviderQuotaParser.cleanTerminal(String(decoding: bytes, as: UTF8.self)) if let status = try? ProviderQuotaParser.claude(display), - status.credits != nil || Date().timeIntervalSince(lastOutput) >= 0.9 { + Date().timeIntervalSince(lastOutput) >= 0.9 { return status } if bytes.count >= 256 * 1024 || !process.isRunning { break } @@ -237,9 +237,11 @@ enum ProviderQuotaParser { var pending: Double? var windows: [ProviderQuotaWindow] = [] var credits: ProviderCreditBalance? + var resetCredits: UInt64? var plan: String? for raw in output.components(separatedBy: CharacterSet.newlines) { let line = raw.trimmingCharacters(in: .whitespaces) + if let count = claudeResetCount(line) { resetCredits = count } if plan == nil, line.contains(" · Claude ") { plan = line.components(separatedBy: " · ").dropFirst().first } switch line { case "Current session": section = .current; pending = nil; continue @@ -282,11 +284,29 @@ enum ProviderQuotaParser { ($1.id == "current" ? 0 : $1.id == "weekly" ? 1 : 2, $1.id) } return ProviderQuotaStatus(provider: "claude", status: "ready", source: "Claude Code /usage", checkedAt: timestamp(), plan: plan, windows: windows, - credits: credits, resetCredits: nil, + credits: credits, resetCredits: resetCredits, latestReportedResetCreditExpiryUnix: nil, resetCreditDetailsCount: nil, resetCreditsWithoutExpiryCount: nil, message: nil) } + /// Claude's limit-reset notices report a count independently of paid usage + /// credits. Missing notices mean unknown, not zero. Do not invoke the reset + /// command to collect this reading: it can consume a grant. + private static func claudeResetCount(_ line: String) -> UInt64? { + guard line.hasPrefix("/limit-reset to refill your limits · ") || + line.hasPrefix("Reset used · ") || line.hasPrefix("Limits reset · ") else { return nil } + for component in line.components(separatedBy: " · ") { + if component == "no resets left" { return 0 } + let words = component.split(separator: " ") + guard words.count == 3, words[2] == "left", + words[1] == "reset" || words[1] == "resets", + words[0].allSatisfy({ $0.isASCII && $0.isNumber }), + let count = UInt64(words[0]) else { continue } + return count + } + return nil + } + static func cleanTerminal(_ input: String) -> String { let bytes = Array(input.utf8) var output: [UInt8] = [] diff --git a/Sources/ContextDaddy/UsageSourcePanels.swift b/Sources/ContextDaddy/UsageSourcePanels.swift index 5879549..6cfe227 100644 --- a/Sources/ContextDaddy/UsageSourcePanels.swift +++ b/Sources/ContextDaddy/UsageSourcePanels.swift @@ -84,14 +84,18 @@ struct UsageAllowanceView: View { Text(creditText).font(.caption2.weight(.semibold)) .foregroundStyle(DaddyTheme.muted) if let resets = status.resetCredits { - Text("\(resets) full \(resets == 1 ? "reset" : "resets") available") + Text(resetCountText(resets, provider: provider)) .font(.caption2).foregroundStyle(DaddyTheme.muted) } } else if let resets = status?.resetCredits { - Text("\(resets) full \(resets == 1 ? "reset" : "resets") available") + Text(resetCountText(resets, provider: provider)) .font(.caption2).foregroundStyle(DaddyTheme.muted) } - if let status, status.resetCredits != nil { + if provider == "claude", status?.resetCredits == nil { + Text("Usage reset count not reported") + .font(.caption2).foregroundStyle(DaddyTheme.muted) + } + if let status, provider == "codex", status.resetCredits != nil { resetExpiry(status) } } else { @@ -176,6 +180,10 @@ struct UsageAllowanceView: View { return nil } + private func resetCountText(_ count: UInt64, provider: String) -> String { + "\(count) \(provider == "claude" ? "usage" : "full") \(count == 1 ? "reset" : "resets") available" + } + @ViewBuilder private func resetExpiry(_ status: ProviderQuotaStatus) -> some View { if let unix = status.latestReportedResetCreditExpiryUnix { Text("Latest reported expiry · \(Date(timeIntervalSince1970: TimeInterval(unix)).formatted(date: .abbreviated, time: .shortened))") diff --git a/Tests/ContextCoreTests/ProviderQuotaClientTests.swift b/Tests/ContextCoreTests/ProviderQuotaClientTests.swift index 943662a..eb10209 100644 --- a/Tests/ContextCoreTests/ProviderQuotaClientTests.swift +++ b/Tests/ContextCoreTests/ProviderQuotaClientTests.swift @@ -69,11 +69,44 @@ struct ProviderQuotaClientTests { #expect(status.windows.map(\.remainingPercent) == [97, 68]) #expect(status.credits?.limitAmount == 150) #expect(status.plan == "Claude Team") + #expect(status.resetCredits == nil) #expect(throws: ProviderQuotaError.self) { try ProviderQuotaParser.claude("Current session\n3% used\nResets soon") } } + @Test func claudeProjectsReportedResetCountsSeparatelyFromPaidCredits() throws { + let usage = """ + Current session + 3% used + Resets 2:20am (Asia/Calcutta) + Current week (all models) + 32% used + Resets Oct 6 at 5:30pm (Asia/Calcutta) + Usage credits + 0% used + $0.00 / $150.00 spent · Resets Oct 1 (Asia/Calcutta) + """ + let offered = try ProviderQuotaParser.claude(usage + "\n/limit-reset to refill your limits · 2 resets left · use by Oct 7") + #expect(offered.resetCredits == 2) + #expect(offered.credits?.limitAmount == 150) + #expect(offered.latestReportedResetCreditExpiryUnix == nil) + #expect(offered.resetCreditDetailsCount == nil) + + let singular = try ProviderQuotaParser.claude(usage + "\n/limit-reset to refill your limits · 1 reset left · use by Oct 7") + #expect(singular.resetCredits == 1) + let depleted = try ProviderQuotaParser.claude(usage + "\nReset used · no resets left") + #expect(depleted.resetCredits == 0) + let latest = try ProviderQuotaParser.claude(usage + "\n/limit-reset to refill your limits · 2 resets left · use by Oct 7\nLimits reset · your weekly reset day stays Tuesday · 1 reset left") + #expect(latest.resetCredits == 1) + + for notice in ["Resets Oct 7", "3 resets left", "/limit-reset to reset your session limit now · uses weekly limit · 1/week", + "/limit-reset to refill your limits · -1 resets left · use by Oct 7", + "/limit-reset to refill your limits · 18446744073709551616 resets left · use by Oct 7"] { + #expect(try ProviderQuotaParser.claude(usage + "\n" + notice).resetCredits == nil) + } + } + @Test func missingProviderCLIIsUnavailable() async { let client = ProviderQuotaClient(codexURL: URL(fileURLWithPath: "/private/tmp/contextdaddy-missing-codex")) do {