Documentation
¶
Overview ¶
Persistent cache for the rolling 30-day usage dashboard — the aggregate report AND the per-file incremental index, both on disk. This is the safe redesign:
- Persisted (disk) = ~/.rmote/agent/usage_month.json. Holds the version, lastRefreshMs, and a per-file index: (path, mtime, size) → {that file's full-history (date,model) contribution}. A few hundred KB → low MB (paths + day×model cells dominate). Loaded on daemon start.
- In-memory = the same index + the running aggregate, rebuilt from the persisted index on load. GET serves the finalized (windowed) report instantly.
Cold/full scans happen ONLY when the cache file is missing, corrupt, the schema version mismatches, or (rare) the aggregate is nil. A normal `brm` loads the index, so the first Refresh after restart is already incremental (~ms) — no multi-second scan. Unchanged files are skipped; changed/new files are re-parsed and their contribution REPLACED (subtract old, merge new); deleted/aged-out files (>30d mtime, never seen by the walk) are dropped.
The window is ROLLING 30 days (not calendar month), so there is no month-rollover cold reset — the window slides and finalize() re-applies it each refresh. Per-file contributions are the file's FULL history (window-independent), so subtract/merge stay exact as the window moves.
Safety vs. the old saga: this stores per-file (date,model)-bucketed contributions (one tiny struct per cell), never the 175k per-line records (~80MB), and loads once at startup with none of the eviction/reload churn that spiked to 300MB. No polling, no background tick — refresh only on explicit POST.
On-demand rolling 30-day usage — types + accumulator. The wire shape (monthReport) matches the old UsageReport Codable so the restored dashboard decodes it unchanged. Scanning/caching lives in month_cache.go; this file holds the pure data structures shared by the cache and the per-file parsers.
The window is a rolling 30 days (not calendar month), so the dashboard's Daily/Weekly/Monthly tabs can all slice one scan: Daily=today, Weekly=last 7d, Monthly=last 30d. byDay + byDayModel are keyed by ABSOLUTE date ("2006-01-02") — day-of-month keying would collide across months.
Per-file contributions are parsed as the file's FULL history (no since filter) so a contribution is window-independent: subtract/merge stay exact as the 30-day window slides, and finalize() applies the window. The walk bounds the index by skipping files with mtime older than the window (they carry no in-window turns).
Memory-safety invariant (the lesson from the dashboard's idle-memory saga): the persisted index holds one tiny (date,model)-bucketed struct per transcript file — never the ~175k per-line records that cost ~80MB, and never the 20MB blob that spiked to 300MB. No polling, no background tick.
Per-agent transcript parsers for the on-demand month view (month_total.go). Each parser streams one transcript file and folds its in-month tokens into a shared *monthAccum. None of them retain per-record state — the accum's buckets are the only thing that survives past a line loop.
Field names + paths reused verbatim from the deleted report.go / codex_usage.go / grok_usage.go (verified against on-host transcripts):
Claude ~/.claude/projects/**/*.jsonl
type=="assistant", message.usage.{input,output,cache_read,cache_creation}_tokens
message.model, timestamp (RFC3339Nano)
Codex ~/.codex/sessions/**/*.jsonl (ONE cumulative record per session)
session_meta.payload.timestamp → session date
turn_context.payload.model → model (last wins)
event_msg payload.type=="token_count" → payload.info.total_token_usage
{input_tokens, output_tokens, total_tokens(=in+out)}
Grok ~/.grok/sessions/<cwd>/<uuid>/signals.json
contextTokensUsed (context SNAPSHOT, not throughput) + primaryModelId
dated by file mtime
Pi (earendil-works pi coding-agent) usage for the rolling 30-day dashboard. Each pi session is a JSONL tree at ~/.pi/agent/sessions/<dir>/<ISOts>_<uuid>.jsonl. Assistant messages carry a nested `message.usage` object:
{"type":"message","id":...,"timestamp":"2026-08-11T16:00:47.572Z",
"message":{"role":"assistant","model":"glm-5.2",
"usage":{"input":1612,"output":2269,"cacheRead":62336,
"cacheWrite":0,"reasoning":2047,"totalTokens":66217,
"cost":{...}}}}
totalTokens = input+output+cacheRead+cacheWrite (reasoning excluded — same convention codex uses for its reasoning tokens). The four buckets map onto the accum's input/output/cacheRead/cacheCreate (pi's cacheWrite = cache creation). cost is ignored: BYO-key providers report 0 and the daemon never prices. The line-level `timestamp` is ISO-8601 (the nested message.timestamp is epoch-ms; either resolves to the same day bucket — we use the line level for consistency with the Claude parser).
Index ¶
- func FetchZaiUsage() ([]byte, error)
- func GetCachedMonth() *monthReport
- func InvalidateClaudeQuotaCache()
- func InvalidateCodexQuotaCache()
- func InvalidateGrokQuotaCache()
- func InvalidateZaiCache()
- func RefreshMonth() *monthReport
- func RegisterPaymentDetector(agent string, d PaymentDetector)
- func RegisterQuotaReader(agent string, r QuotaReader)
- type PaymentDetector
- type PaymentModel
- type PaymentModelWire
- type Quota
- type QuotaReader
- type QuotaWindow
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func FetchZaiUsage ¶
FetchZaiUsage returns the raw z.ai quota JSON. Returns (nil, nil) when the host has no BYOK token configured — the handler maps that to 404. A non-nil error means the upstream call failed (timeout, non-2xx); the handler maps that to 502 so iOS keeps the last good value rather than retrying tightly.
func GetCachedMonth ¶
func GetCachedMonth() *monthReport
GetCachedMonth returns the cached report WITHOUT scanning. Before the first refresh it is an empty report with lastRefreshMs=0 (iOS bootstraps a refresh when it sees that sentinel). Takes the lock only briefly.
func InvalidateClaudeQuotaCache ¶
func InvalidateClaudeQuotaCache()
InvalidateClaudeQuotaCache clears the cached Claude quota (tests + future force-refresh entry point).
func InvalidateCodexQuotaCache ¶
func InvalidateCodexQuotaCache()
InvalidateCodexQuotaCache clears the cached codex quota. For tests + future "rescan" entry points.
func InvalidateGrokQuotaCache ¶
func InvalidateGrokQuotaCache()
InvalidateGrokQuotaCache clears the cached grok quota. For tests + future "rescan" entry points.
func InvalidateZaiCache ¶
func InvalidateZaiCache()
InvalidateZaiCache clears the cached z.ai response. For tests + a future "force refresh" entry point.
func RefreshMonth ¶
func RefreshMonth() *monthReport
RefreshMonth performs an incremental rescan and returns the fresh report. Cold (full) scan only when agg is nil — i.e. the cache file was missing, corrupt, or the schema version changed. Otherwise only changed/new files are re-parsed and files no longer seen (deleted/aged past the window) are dropped. The rolling window slides each call; finalize re-applies it. Holds the lock for the scan duration (acceptable: refresh is on-demand and rare; warm refreshes are ms).
func RegisterPaymentDetector ¶
func RegisterPaymentDetector(agent string, d PaymentDetector)
RegisterPaymentDetector adds an agent's detector to the registry. Called from init(); idempotent.
func RegisterQuotaReader ¶
func RegisterQuotaReader(agent string, r QuotaReader)
RegisterQuotaReader adds an agent's quota reader to the registry. Idempotent — late init() calls replace earlier registrations (useful in tests).
Types ¶
type PaymentDetector ¶
type PaymentDetector interface {
Detect() PaymentModel
}
PaymentDetector returns the auto-detected (pre-override) model for one agent. Agents with no registered detector default to subscription.
type PaymentModel ¶
type PaymentModel string
PaymentModel is an agent's billing classification. Values match iOS's PaymentModelWire.model / PaymentModel.rawValue decoders exactly.
const ( PaymentByokZai PaymentModel = "byokZai" // Claude Code BYOK via z.ai — quota endpoint + cost PaymentByokProxy PaymentModel = "byokProxy" // BYOK via a paid proxy — cost only, no quota PaymentSubscription PaymentModel = "subscription" // flat-fee sub (Claude Max / ChatGPT / Grok) PaymentUnknown PaymentModel = "unknown" )
type PaymentModelWire ¶
type PaymentModelWire struct {
Agent string `json:"agent"`
Model string `json:"model"`
DisplayName string `json:"displayName"`
Detected string `json:"detected"`
ShowsCost bool `json:"showsCost"`
ShowsQuota bool `json:"showsQuota"`
}
PaymentModelWire is the per-agent row iOS's PaymentModelWire Codable reads. JSON tags mirror the Swift field names exactly (agent/model/displayName/ detected/showsCost/showsQuota). showsCost + showsQuota are concrete bools (no omitempty) so iOS always receives the column gates, matching the Mac server's wire shape.
func ResolvedPaymentModels ¶
func ResolvedPaymentModels() []PaymentModelWire
ResolvedPaymentModels returns the wire list the iOS dashboard + chip read to decide which columns/chips to render. Order is stable (knownAgents order) so iOS's layout doesn't reshuffle between polls.
func SavePaymentOverride ¶
func SavePaymentOverride(agent, model string) ([]PaymentModelWire, error)
SavePaymentOverride writes a single agent override atomically into ~/.rmote/usage-models.json (read-modify-write; tmp + rename in the same dir). Returns the resolved wire list so the PUT handler can echo it directly.
type Quota ¶
type Quota struct {
Percent float64 `json:"percent"`
ResetEpochMs int64 `json:"resetEpochMs"`
Period string `json:"period,omitempty"`
Windows []QuotaWindow `json:"windows,omitempty"`
// Source identifies where this reading came from so a fallback (e.g. the
// OAuth→log-scrape path in codex/grok) is visible to the user rather than
// indistinguishable from authoritative data. Values: "oauth" | "log-scrape"
// | "cache". Omitted (empty) on legacy/z.ai paths that never fall back.
Source string `json:"source,omitempty"`
}
Quota is the wire shape iOS's GrokQuotaResponse Codable decodes. Both /api/grok/quota and /api/codex/quota return this shape so iOS reuses one decoder (GrokQuotaService + CodexQuotaService both decode GrokQuotaResponse). All fields are optional on the iOS side, so zero-values are acceptable.
type QuotaReader ¶
QuotaReader reads the subscription/quota state for one agent that exposes a quota endpoint. Returns (nil, nil) when no quota has been logged yet — the handler translates that into 404 ("none logged yet"), which iOS treats as an honest empty state rather than an error.
type QuotaWindow ¶
type QuotaWindow struct {
Label string `json:"label"`
Percent float64 `json:"percent"`
ResetEpochMs int64 `json:"resetEpochMs"`
}
QuotaWindow is a single rate window (e.g. "5h", "1w"). iOS renders each window as its own row with % used + reset countdown. Codex exposes two (primary 5h + secondary 1w); grok exposes one (weekly).