diff --git a/README.md b/README.md
index d10663f..86d6d26 100644
--- a/README.md
+++ b/README.md
@@ -796,17 +796,21 @@ estimate can still vary with reasoning effort, model mix, caching, prompt
shape, and backend quota weighting, so compare ranges and sample counts rather
than treating the midpoint as a fixed entitlement.
-Samples remain process-local and are never written to the preferences file, so
-evidence cannot leak from one login into another on a later run. During a run,
-Codexometer requests the current account email from the same local app-server,
-immediately reduces it to an in-memory one-way fingerprint, and uses that only
-to separate account observations. The email and fingerprint are not persisted.
+Estimator samples remain process-local and are never written to the preferences
+or usage-history files, so learned price evidence cannot leak from one login
+into another on a later run. Codexometer requests the current account email from
+the same local app-server and immediately reduces it to a one-way fingerprint.
+The email is never persisted; the fingerprint is stored only in the separate
+numeric usage ledger so histories remain isolated across restarts. Cached data
+is not selected until the current invocation verifies that same fingerprint.
If an older app-server cannot provide an account identity, the estimate fails
closed as `ACCOUNT ATTRIBUTION UNKNOWN` rather than mixing indistinguishable
accounts.
-The privacy trade-off is that quitting Codexometer discards every learned
-sample and quota anchor. On restart it can reconstruct cumulative priced usage
+The estimator trade-off is that quitting Codexometer discards every learned
+sample and its in-memory learning anchor. The separate ledger retains content-free
+quota observations for future history views, but does not silently treat them as
+complete price evidence. On restart it can reconstruct cumulative priced usage
from local rollout telemetry, but the current quota percentage and cost become
a new baseline: the display returns to `LEARNING` and needs another five clean
percentage points of movement before producing an estimate. Medium confidence
@@ -945,21 +949,30 @@ As in Codex's chart, the available display range is 52 Sunday-based weeks ending
in the current UTC week. Missing dates in a supplied history count as zero;
invalid dates, negative values, future dates, and dates outside that range are
ignored. Duplicate dates are summed. The compact summary shows the server's
-separately reported lifetime tokens, peak daily tokens, and current streak when
-available (`—` otherwise).
+separately reported lifetime tokens, peak daily tokens, current and longest
+streaks, and longest running turn when available (`—` otherwise).
History refreshes when you enter Usage, on the normal refresh interval while
-Usage is selected, or with `r` / the Refresh button. These are server-side account
-statistics, **not live Sessions telemetry**: updates may lag ongoing work.
-Older CLI versions or unsupported accounts can return an unavailable/error state;
-missing history is never silently presented as zero. A failed refresh labels
-previously fetched data **STALE**, and a detected account change discards it.
+Usage is selected, or with `r` / the Refresh button. OpenAI's account-wide daily
+totals are authoritative and can fill days when Codexometer was not running.
+Codexometer also rescans retained local Codex rollouts for content-free token
+counts, including input, cached input, output and reasoning-token detail. Local
+counts are shown as attribution against the OpenAI total and are never added to
+it. A compact provenance line reports **OPENAI**, **RECOVERED**, or **PARTIAL**
+and the percentage of the OpenAI total attributable to retained local history.
+
+Older CLI versions or unsupported accounts can return an unavailable/error
+state; unavailable history is never silently presented as an empty account. A
+failed refresh uses the last verified persisted result and labels it **STALE**.
+Account fingerprints keep histories isolated, and a newly verified account is
+never shown another account's cached Usage data.
The endpoint currently exposes daily **total tokens**, not historical per-model,
-input/output/cache splits, quota percentages, or dollar spend. Consequently this
-tab does not infer historical API-equivalent cost or combine these totals with
-the Sessions tab's local counters. History is held in memory only; restarting fetches it
-again from Codex. `--demo` includes sample history for previewing the charts.
+input/output/cache splits, quota percentages, or dollar spend. The finer token
+breakdown is therefore local recovery and can be partial; activity from another
+device, cloud task, or deleted rollout remains only in the OpenAI total. The tab
+does not infer historical API-equivalent cost. `--demo` includes sample history
+for previewing the charts.
### Other top-level views
@@ -1519,11 +1532,10 @@ any command; restart the demo to reset its approval.
### Saved presentation preferences
-Codexometer stores only the selected theme, main tab, Quota view, benchmark filter,
-benchmark ranking weight, and the Sessions context hide/show preference.
-No quota estimate or snapshot, raw session telemetry,
-benchmark result, message content, credential, session ID, email, account
-fingerprint, or account ID is written. The small JSON file uses the
+Codexometer's presentation preferences store only the selected theme, main tab,
+Quota view, benchmark filter, benchmark ranking weight, and the Sessions context
+hide/show preference. No benchmark result, message content, credential, session
+ID, email, or account ID is written to that file. The small JSON file uses the
platform-standard user configuration directory:
- Linux: `$XDG_CONFIG_HOME/codexometer/preferences.json`, normally
@@ -1537,6 +1549,32 @@ tab and remembers your Quota view separately. First launch defaults to Quota →
Bars; older preferences without a main tab reopen the saved Quota view.
Restoring a tab does not resume a benchmark run or reopen an approval dialog.
+### Persistent usage history
+
+Usage history is stored separately as `usage-history.json` in the same
+platform-standard `codexometer` directory. This versioned ledger contains only:
+
+- OpenAI daily account token totals and optional account summary metrics;
+- locally recovered daily numeric token aggregates;
+- current quota percentage observations, reset boundaries and window lengths;
+- a one-way account fingerprint used to prevent histories from being mixed.
+
+It never stores prompts, replies, commands, source content, working-directory
+paths, session IDs, email addresses, credentials, or authentication tokens.
+History is retained for approximately 400 days. Writes use a private temporary
+file, an atomic replacement, and a cross-process lock so terminal and web
+Codexometer instances cannot partially overwrite one another. A damaged or
+unwritable ledger does not prevent current OpenAI data from being displayed;
+it only disables persistence until the file is repaired or removed.
+
+On startup Codexometer reconciles three layers: persisted history, retained
+local rollout token events, and the newest OpenAI daily buckets. Repeated scans
+replace the bounded local aggregate rather than incrementing it, so restarts and
+concurrent invocations do not double-count usage. If Codexometer was closed,
+OpenAI can backfill account totals and retained rollouts can restore local detail.
+If neither source contains a missed period, Codexometer does not invent a
+per-session, per-model, quota-percentage, or cost breakdown for it.
+
### Benchmark authentication and usage boundary
> [!IMPORTANT]
diff --git a/internal/codex/account_usage.go b/internal/codex/account_usage.go
index d0a1f36..dcd7e78 100644
--- a/internal/codex/account_usage.go
+++ b/internal/codex/account_usage.go
@@ -9,10 +9,16 @@ import (
// local session telemetry. Nil buckets mean unavailable; an empty list means
// the server returned no activity. Summary fields are independently optional.
type AccountUsage struct {
- Summary AccountUsageSummary `json:"summary"`
- DailyUsageBuckets []AccountUsageDay `json:"dailyUsageBuckets"`
- AccountFingerprint string `json:"-"`
- FetchedAt time.Time `json:"-"`
+ Summary AccountUsageSummary `json:"summary"`
+ DailyUsageBuckets []AccountUsageDay `json:"dailyUsageBuckets"`
+ Coverage AccountUsageCoverage `json:"coverage"`
+ AccountFingerprint string `json:"-"`
+ FetchedAt time.Time `json:"-"`
+ // Persisted means the result has been reconciled with Codexometer's local
+ // history. Stale means the live refresh failed and this is the most recent
+ // verified account cache rather than a fresh OpenAI response.
+ Persisted bool `json:"persisted"`
+ Stale bool `json:"stale"`
}
type AccountUsageSummary struct {
@@ -24,12 +30,64 @@ type AccountUsageSummary struct {
}
type AccountUsageDay struct {
- StartDate string `json:"startDate"`
- Tokens int64 `json:"tokens"`
+ StartDate string `json:"startDate"`
+ Tokens int64 `json:"tokens"`
+ LocalTokens int64 `json:"localTokens,omitempty"`
+ InputTokens int64 `json:"inputTokens,omitempty"`
+ CachedInputTokens int64 `json:"cachedInputTokens,omitempty"`
+ OutputTokens int64 `json:"outputTokens,omitempty"`
+ ReasoningTokens int64 `json:"reasoningTokens,omitempty"`
+ Provenance string `json:"provenance,omitempty"`
+}
+
+// AccountUsageCoverage explains how much of the account-wide OpenAI total can
+// also be attributed to retained local Codex rollouts. LocalTokens is never
+// added to OpenAITokens: it is a subset/comparison, not another usage source.
+type AccountUsageCoverage struct {
+ Status string `json:"status,omitempty"`
+ OpenAITokens int64 `json:"openaiTokens,omitempty"`
+ LocalTokens int64 `json:"localTokens,omitempty"`
+ AttributedPct int `json:"attributedPercent,omitempty"`
+ OpenAIDays int `json:"openaiDays,omitempty"`
+ RecoveredDays int `json:"recoveredDays,omitempty"`
+}
+
+// RecoveredUsageDay is reconstructed from content-free token_count events in
+// retained local Codex rollouts. Cached input is part of input, not additional
+// usage, and TotalTokens remains the comparison value used against OpenAI.
+type RecoveredUsageDay struct {
+ StartDate string `json:"startDate"`
+ TotalTokens int64 `json:"totalTokens"`
+ InputTokens int64 `json:"inputTokens,omitempty"`
+ CachedInputTokens int64 `json:"cachedInputTokens,omitempty"`
+ OutputTokens int64 `json:"outputTokens,omitempty"`
+ ReasoningTokens int64 `json:"reasoningTokens,omitempty"`
}
func (c Client) FetchAccountUsage(ctx context.Context) (AccountUsage, error) {
var history AccountUsage
_, err := c.fetch(ctx, nil, &history)
- return history, err
+ if err != nil {
+ if c.History != nil {
+ if cached, cacheErr := c.History.Latest(); cacheErr == nil && cached.AccountFingerprint != "" {
+ cached.Stale = true
+ return cached, nil
+ }
+ }
+ return history, err
+ }
+ if c.History == nil {
+ return history, nil
+ }
+ var recovered []RecoveredUsageDay
+ if c.LiveUsage != nil {
+ recovered, _ = c.LiveUsage.RecoverDailyUsage(ctx, time.Now().UTC().AddDate(0, 0, -historyRetentionDays))
+ }
+ reconciled, reconcileErr := c.History.Reconcile(history, recovered)
+ if reconcileErr != nil {
+ // A local cache failure must not turn a valid OpenAI response into an
+ // unavailable Usage screen.
+ return history, nil
+ }
+ return reconciled, nil
}
diff --git a/internal/codex/account_usage_test.go b/internal/codex/account_usage_test.go
index 54cb588..61b4d4c 100644
--- a/internal/codex/account_usage_test.go
+++ b/internal/codex/account_usage_test.go
@@ -3,6 +3,7 @@ package codex
import (
"context"
"os"
+ "path/filepath"
"strings"
"testing"
)
@@ -45,6 +46,25 @@ func TestFetchAccountUsage(t *testing.T) {
}
}
+func TestFetchAccountUsageFallsBackToVerifiedPersistedHistory(t *testing.T) {
+ t.Setenv("CODEXOMETER_FAKE_APP_SERVER", "1")
+ exe, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := &HistoryStore{Path: filepath.Join(t.TempDir(), "usage.json")}
+ client := Client{Binary: exe, History: store}
+ fresh, err := client.FetchAccountUsage(context.Background())
+ if err != nil || !fresh.Persisted || fresh.Stale {
+ t.Fatalf("fresh history = %+v, %v", fresh, err)
+ }
+ t.Setenv("CODEXOMETER_FAKE_USAGE_ERROR", "1")
+ cached, err := client.FetchAccountUsage(context.Background())
+ if err != nil || !cached.Persisted || !cached.Stale || cached.AccountFingerprint != fresh.AccountFingerprint || len(cached.DailyUsageBuckets) != 1 {
+ t.Fatalf("cached history = %+v, %v", cached, err)
+ }
+}
+
func TestAccountUsageRequiresVerifiedAccount(t *testing.T) {
t.Setenv("CODEXOMETER_FAKE_APP_SERVER", "1")
t.Setenv("CODEXOMETER_FAKE_ACCOUNT_ERROR", "1")
diff --git a/internal/codex/client.go b/internal/codex/client.go
index 93de21f..0bbcd8f 100644
--- a/internal/codex/client.go
+++ b/internal/codex/client.go
@@ -21,6 +21,9 @@ const requestTimeout = 15 * time.Second
type Client struct {
Binary string
LiveUsage *LiveUsageReader
+ // History persists content-free account totals and quota observations so
+ // both terminal and web presentations survive process restarts.
+ History *HistoryStore
// QuotaSteps is an opt-in launch-time policy for lowering the model profile
// of loaded sessions as quota consumption crosses configured thresholds.
QuotaSteps []QuotaStep
@@ -66,7 +69,12 @@ type rpcResponse struct {
// Fetch starts a short-lived app-server, performs the initialization handshake,
// reads the authenticated account limits, and shuts the server down again.
func (c Client) Fetch(ctx context.Context) (Snapshot, error) {
- return c.fetch(ctx, nil, nil)
+ snapshot, err := c.fetch(ctx, nil, nil)
+ if err == nil && c.History != nil {
+ // Persistence failure must not hide current authoritative quota data.
+ _ = c.History.RecordQuota(snapshot)
+ }
+ return snapshot, err
}
type resetAttempt struct {
diff --git a/internal/codex/filelock_other.go b/internal/codex/filelock_other.go
index 1e57f20..81d712f 100644
--- a/internal/codex/filelock_other.go
+++ b/internal/codex/filelock_other.go
@@ -2,10 +2,19 @@
package codex
-import "errors"
+import (
+ "errors"
+ "os"
+)
const fileLockSupported = false
func fileLockHeld(string) (bool, error) {
return false, errors.ErrUnsupported
}
+
+func withHistoryFileLock(_ string, action func() error) error { return action() }
+
+func replaceHistoryFile(from, to string) error { return os.Rename(from, to) }
+
+func syncHistoryDirectory(string) error { return nil }
diff --git a/internal/codex/filelock_unix.go b/internal/codex/filelock_unix.go
index e67fef9..420d307 100644
--- a/internal/codex/filelock_unix.go
+++ b/internal/codex/filelock_unix.go
@@ -5,6 +5,7 @@ package codex
import (
"errors"
"os"
+ "path/filepath"
"golang.org/x/sys/unix"
)
@@ -28,3 +29,30 @@ func fileLockHeld(path string) (bool, error) {
}
return false, nil
}
+
+func withHistoryFileLock(path string, action func() error) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return err
+ }
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ if err := unix.Flock(int(file.Fd()), unix.LOCK_EX); err != nil {
+ return err
+ }
+ defer unix.Flock(int(file.Fd()), unix.LOCK_UN) //nolint:errcheck -- best-effort after action
+ return action()
+}
+
+func replaceHistoryFile(from, to string) error { return os.Rename(from, to) }
+
+func syncHistoryDirectory(path string) error {
+ directory, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer directory.Close()
+ return directory.Sync()
+}
diff --git a/internal/codex/filelock_windows.go b/internal/codex/filelock_windows.go
index 78be635..d513b74 100644
--- a/internal/codex/filelock_windows.go
+++ b/internal/codex/filelock_windows.go
@@ -5,6 +5,8 @@ package codex
import (
"errors"
"os"
+ "path/filepath"
+ "time"
"golang.org/x/sys/windows"
)
@@ -37,3 +39,43 @@ func fileLockHeld(path string) (bool, error) {
}
return false, nil
}
+
+func withHistoryFileLock(path string, action func() error) error {
+ if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ return err
+ }
+ file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ overlapped := new(windows.Overlapped)
+ deadline := time.Now().Add(5 * time.Second)
+ for {
+ err = windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, overlapped)
+ if err == nil {
+ break
+ }
+ if !errors.Is(err, windows.ERROR_LOCK_VIOLATION) || time.Now().After(deadline) {
+ return err
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ defer windows.UnlockFileEx(windows.Handle(file.Fd()), 0, 1, 0, overlapped) //nolint:errcheck -- best-effort after action
+ return action()
+}
+
+func replaceHistoryFile(from, to string) error {
+ fromPath, err := windows.UTF16PtrFromString(from)
+ if err != nil {
+ return err
+ }
+ toPath, err := windows.UTF16PtrFromString(to)
+ if err != nil {
+ return err
+ }
+ return windows.MoveFileEx(fromPath, toPath, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH)
+}
+
+// MOVEFILE_WRITE_THROUGH flushes the replacement before returning.
+func syncHistoryDirectory(string) error { return nil }
diff --git a/internal/codex/history_store.go b/internal/codex/history_store.go
new file mode 100644
index 0000000..c55fbb0
--- /dev/null
+++ b/internal/codex/history_store.go
@@ -0,0 +1,375 @@
+package codex
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+ "sort"
+ "sync"
+ "time"
+)
+
+const (
+ historySchemaVersion = 1
+ historyRetentionDays = 400
+ maxQuotaObservations = 12_000
+)
+
+// HistoryStore is a versioned, content-free local ledger. It stores numeric
+// account usage and quota observations, never prompts, replies, commands,
+// paths, email addresses, or authentication material.
+type HistoryStore struct {
+ Path string
+ mu sync.Mutex
+ activeAccount string // process-local: a persisted cache never selects identity
+}
+
+type historyFile struct {
+ Version int `json:"version"`
+ Accounts map[string]*historyAccount `json:"accounts"`
+}
+
+type historyAccount struct {
+ Summary AccountUsageSummary `json:"summary"`
+ FetchedAt time.Time `json:"fetchedAt"`
+ BucketsKnown bool `json:"bucketsKnown,omitempty"`
+ Days map[string]historyDay `json:"days"`
+ Quota []historyQuotaObservation `json:"quota,omitempty"`
+}
+
+type historyDay struct {
+ OpenAITokens *int64 `json:"openaiTokens,omitempty"`
+ Local RecoveredUsageDay `json:"local,omitempty"`
+}
+
+type historyQuotaObservation struct {
+ At time.Time `json:"at"`
+ LimitID string `json:"limitId"`
+ Window int `json:"window"`
+ Used int `json:"used"`
+ Duration *int64 `json:"duration,omitempty"`
+ Reset *int64 `json:"reset,omitempty"`
+}
+
+// NewDefaultHistoryStore follows the same cross-platform application-data
+// convention as presentation preferences while keeping history in its own
+// independently migratable file.
+func NewDefaultHistoryStore() (*HistoryStore, error) {
+ directory, err := os.UserConfigDir()
+ if err != nil {
+ return nil, err
+ }
+ return &HistoryStore{Path: filepath.Join(directory, "codexometer", "usage-history.json")}, nil
+}
+
+func (s *HistoryStore) Reconcile(remote AccountUsage, recovered []RecoveredUsageDay) (AccountUsage, error) {
+ if s == nil || remote.AccountFingerprint == "" {
+ return remote, nil
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var result AccountUsage
+ err := withHistoryFileLock(s.Path+".lock", func() error {
+ file, err := s.load()
+ if err != nil {
+ return err
+ }
+ account := file.account(remote.AccountFingerprint)
+ mergeSummary(&account.Summary, remote.Summary)
+ if !remote.FetchedAt.IsZero() {
+ account.FetchedAt = remote.FetchedAt
+ }
+ if remote.DailyUsageBuckets != nil {
+ account.BucketsKnown = true
+ }
+ for _, day := range remote.DailyUsageBuckets {
+ if !validUsageDate(day.StartDate) || day.Tokens < 0 {
+ continue
+ }
+ value := day.Tokens
+ stored := account.Days[day.StartDate]
+ stored.OpenAITokens = &value
+ account.Days[day.StartDate] = stored
+ }
+ // Recovery is a complete bounded rescan, so replace local aggregates in
+ // that range instead of incrementing them and risking duplicate imports.
+ if recovered != nil {
+ if len(recovered) > 0 {
+ account.BucketsKnown = true
+ }
+ for date, day := range account.Days {
+ day.Local = RecoveredUsageDay{}
+ account.Days[date] = day
+ }
+ for _, day := range recovered {
+ if !validUsageDate(day.StartDate) || day.TotalTokens < 0 {
+ continue
+ }
+ stored := account.Days[day.StartDate]
+ stored.Local = day
+ account.Days[day.StartDate] = stored
+ }
+ }
+ pruneHistory(account, time.Now().UTC())
+ if err := s.save(file); err != nil {
+ return err
+ }
+ result = renderAccountUsage(remote.AccountFingerprint, account, false)
+ s.activeAccount = remote.AccountFingerprint
+ return nil
+ })
+ if err != nil {
+ return remote, fmt.Errorf("persist usage history: %w", err)
+ }
+ return result, nil
+}
+
+// RecordQuota stores current snapshots for later quota-history visualisations.
+// Consecutive identical observations are collapsed.
+func (s *HistoryStore) RecordQuota(snapshot Snapshot) error {
+ if s == nil || snapshot.AccountFingerprint == "" {
+ return nil
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return withHistoryFileLock(s.Path+".lock", func() error {
+ file, err := s.load()
+ if err != nil {
+ return err
+ }
+ account := file.account(snapshot.AccountFingerprint)
+ at := snapshot.FetchedAt
+ if at.IsZero() {
+ at = time.Now()
+ }
+ windows := map[string]int{}
+ for _, meter := range snapshot.Meters() {
+ if meter.Kind != MeterQuotaWindow {
+ continue
+ }
+ window := windows[meter.LimitID]
+ bucket := snapshot.RateLimits
+ if len(snapshot.RateLimitsByLimitID) > 0 {
+ bucket = snapshot.RateLimitsByLimitID[meter.LimitID]
+ }
+ if bucket.Primary == nil {
+ window++
+ }
+ windows[meter.LimitID]++
+ observation := historyQuotaObservation{At: at.UTC(), LimitID: meter.LimitID, Window: window, Used: meter.Window.UsedPercent, Duration: meter.Window.WindowDurationMins, Reset: meter.Window.ResetsAt}
+ latest := -1
+ for i := len(account.Quota) - 1; i >= 0; i-- {
+ if account.Quota[i].LimitID == observation.LimitID && account.Quota[i].Window == observation.Window {
+ latest = i
+ break
+ }
+ }
+ if latest >= 0 && sameQuotaObservation(account.Quota[latest], observation) {
+ account.Quota[latest].At = observation.At
+ } else {
+ account.Quota = append(account.Quota, observation)
+ }
+ }
+ if len(account.Quota) > maxQuotaObservations {
+ account.Quota = append([]historyQuotaObservation(nil), account.Quota[len(account.Quota)-maxQuotaObservations:]...)
+ }
+ if err := s.save(file); err != nil {
+ return err
+ }
+ s.activeAccount = snapshot.AccountFingerprint
+ return nil
+ })
+}
+
+func (s *HistoryStore) Latest() (AccountUsage, error) {
+ if s == nil {
+ return AccountUsage{}, os.ErrNotExist
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ var result AccountUsage
+ err := withHistoryFileLock(s.Path+".lock", func() error {
+ if s.activeAccount == "" {
+ return os.ErrNotExist
+ }
+ file, err := s.load()
+ if err != nil {
+ return err
+ }
+ account := file.Accounts[s.activeAccount]
+ if account == nil {
+ return os.ErrNotExist
+ }
+ result = renderAccountUsage(s.activeAccount, account, true)
+ return nil
+ })
+ return result, err
+}
+
+func (f *historyFile) account(id string) *historyAccount {
+ if f.Accounts == nil {
+ f.Accounts = map[string]*historyAccount{}
+ }
+ account := f.Accounts[id]
+ if account == nil {
+ account = &historyAccount{Days: map[string]historyDay{}}
+ f.Accounts[id] = account
+ }
+ if account.Days == nil {
+ account.Days = map[string]historyDay{}
+ }
+ return account
+}
+
+func (s *HistoryStore) load() (*historyFile, error) {
+ data, err := os.ReadFile(s.Path)
+ if errors.Is(err, os.ErrNotExist) {
+ return &historyFile{Version: historySchemaVersion, Accounts: map[string]*historyAccount{}}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ var file historyFile
+ if err := json.Unmarshal(data, &file); err != nil {
+ return nil, err
+ }
+ if file.Version != historySchemaVersion {
+ return nil, fmt.Errorf("unsupported usage history schema %d", file.Version)
+ }
+ if file.Accounts == nil {
+ file.Accounts = map[string]*historyAccount{}
+ }
+ return &file, nil
+}
+
+func (s *HistoryStore) save(file *historyFile) error {
+ data, err := json.MarshalIndent(file, "", " ")
+ if err != nil {
+ return err
+ }
+ directory := filepath.Dir(s.Path)
+ if err := os.MkdirAll(directory, 0o700); err != nil {
+ return err
+ }
+ temporary, err := os.CreateTemp(directory, ".usage-history-*")
+ if err != nil {
+ return err
+ }
+ name := temporary.Name()
+ defer os.Remove(name)
+ if err := temporary.Chmod(0o600); err != nil {
+ temporary.Close()
+ return err
+ }
+ if _, err := temporary.Write(append(data, '\n')); err != nil {
+ temporary.Close()
+ return err
+ }
+ if err := temporary.Sync(); err != nil {
+ temporary.Close()
+ return err
+ }
+ if err := temporary.Close(); err != nil {
+ return err
+ }
+ if err := replaceHistoryFile(name, s.Path); err != nil {
+ return err
+ }
+ return syncHistoryDirectory(directory)
+}
+
+func renderAccountUsage(id string, account *historyAccount, stale bool) AccountUsage {
+ dates := make([]string, 0, len(account.Days))
+ for date, day := range account.Days {
+ if day.OpenAITokens != nil || day.Local.TotalTokens > 0 {
+ dates = append(dates, date)
+ }
+ }
+ sort.Strings(dates)
+ result := AccountUsage{Summary: account.Summary, AccountFingerprint: id, FetchedAt: account.FetchedAt, Persisted: true, Stale: stale, DailyUsageBuckets: []AccountUsageDay{}}
+ if !account.BucketsKnown {
+ result.DailyUsageBuckets = nil
+ }
+ var matchedLocal int64
+ for _, date := range dates {
+ stored := account.Days[date]
+ day := AccountUsageDay{StartDate: date, LocalTokens: stored.Local.TotalTokens, InputTokens: stored.Local.InputTokens, CachedInputTokens: stored.Local.CachedInputTokens, OutputTokens: stored.Local.OutputTokens, ReasoningTokens: stored.Local.ReasoningTokens}
+ if stored.OpenAITokens != nil {
+ day.Tokens = *stored.OpenAITokens
+ day.Provenance = "OPENAI"
+ result.Coverage.OpenAITokens = saturatingAdd(result.Coverage.OpenAITokens, day.Tokens)
+ result.Coverage.OpenAIDays++
+ matchedLocal = saturatingAdd(matchedLocal, day.LocalTokens)
+ } else {
+ day.Tokens = stored.Local.TotalTokens
+ day.Provenance = "RECOVERED"
+ result.Coverage.RecoveredDays++
+ }
+ result.Coverage.LocalTokens = saturatingAdd(result.Coverage.LocalTokens, day.LocalTokens)
+ result.DailyUsageBuckets = append(result.DailyUsageBuckets, day)
+ }
+ switch {
+ case result.Coverage.OpenAIDays > 0 && result.Coverage.RecoveredDays > 0:
+ result.Coverage.Status = "PARTIAL"
+ case result.Coverage.OpenAIDays > 0:
+ result.Coverage.Status = "OPENAI"
+ case result.Coverage.LocalTokens > 0:
+ result.Coverage.Status = "RECOVERED"
+ default:
+ result.Coverage.Status = "EMPTY"
+ }
+ if result.Coverage.OpenAITokens > 0 {
+ result.Coverage.AttributedPct = min(100, int(math.Round(100*float64(matchedLocal)/float64(result.Coverage.OpenAITokens))))
+ }
+ return result
+}
+
+func mergeSummary(current *AccountUsageSummary, incoming AccountUsageSummary) {
+ if incoming.LifetimeTokens != nil {
+ current.LifetimeTokens = incoming.LifetimeTokens
+ }
+ if incoming.PeakDailyTokens != nil {
+ current.PeakDailyTokens = incoming.PeakDailyTokens
+ }
+ if incoming.LongestRunningTurnSec != nil {
+ current.LongestRunningTurnSec = incoming.LongestRunningTurnSec
+ }
+ if incoming.CurrentStreakDays != nil {
+ current.CurrentStreakDays = incoming.CurrentStreakDays
+ }
+ if incoming.LongestStreakDays != nil {
+ current.LongestStreakDays = incoming.LongestStreakDays
+ }
+}
+
+func validUsageDate(value string) bool {
+ _, err := time.Parse("2006-01-02", value)
+ return err == nil
+}
+
+func pruneHistory(account *historyAccount, now time.Time) {
+ cutoff := now.AddDate(0, 0, -historyRetentionDays).Format("2006-01-02")
+ for date := range account.Days {
+ if date < cutoff {
+ delete(account.Days, date)
+ }
+ }
+}
+
+func sameQuotaObservation(a, b historyQuotaObservation) bool {
+ return a.LimitID == b.LimitID && a.Window == b.Window && a.Used == b.Used && equalInt64Ptr(a.Duration, b.Duration) && equalInt64Ptr(a.Reset, b.Reset)
+}
+
+func equalInt64Ptr(a, b *int64) bool {
+ return a == nil && b == nil || a != nil && b != nil && *a == *b
+}
+
+func saturatingAdd(a, b int64) int64 {
+ if b > 0 && a > math.MaxInt64-b {
+ return math.MaxInt64
+ }
+ return a + b
+}
diff --git a/internal/codex/history_store_test.go b/internal/codex/history_store_test.go
new file mode 100644
index 0000000..e557617
--- /dev/null
+++ b/internal/codex/history_store_test.go
@@ -0,0 +1,125 @@
+package codex
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestHistoryStoreReconcilesPersistsAndIsolatesAccounts(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "nested", "usage-history.json")
+ store := &HistoryStore{Path: path}
+ now := time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC)
+ lifetime, peak, turn, current, longest := int64(1_000), int64(100), int64(45), int64(2), int64(9)
+ remote := AccountUsage{
+ AccountFingerprint: "account-a", FetchedAt: now,
+ Summary: AccountUsageSummary{LifetimeTokens: &lifetime, PeakDailyTokens: &peak, LongestRunningTurnSec: &turn, CurrentStreakDays: ¤t, LongestStreakDays: &longest},
+ DailyUsageBuckets: []AccountUsageDay{{StartDate: "2026-09-17", Tokens: 100}},
+ }
+ recovered := []RecoveredUsageDay{
+ {StartDate: "2026-09-16", TotalTokens: 5, InputTokens: 4, OutputTokens: 1},
+ {StartDate: "2026-09-17", TotalTokens: 70, InputTokens: 60, CachedInputTokens: 30, OutputTokens: 10},
+ }
+ got, err := store.Reconcile(remote, recovered)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.Persisted || got.Stale || got.Coverage.Status != "PARTIAL" || got.Coverage.AttributedPct != 70 || got.Coverage.RecoveredDays != 1 || len(got.DailyUsageBuckets) != 2 {
+ t.Fatalf("reconciled usage = %+v", got)
+ }
+ if got.DailyUsageBuckets[0].Provenance != "RECOVERED" || got.DailyUsageBuckets[1].Tokens != 100 || got.DailyUsageBuckets[1].LocalTokens != 70 {
+ t.Fatalf("daily provenance = %+v", got.DailyUsageBuckets)
+ }
+
+ reopened := &HistoryStore{Path: path}
+ verified := DemoSnapshot()
+ verified.AccountFingerprint = "account-a"
+ verified.FetchedAt = now.Add(time.Minute)
+ if err := reopened.RecordQuota(verified); err != nil {
+ t.Fatal(err)
+ }
+ cached, err := reopened.Latest()
+ if err != nil || !cached.Stale || cached.AccountFingerprint != "account-a" || cached.Summary.LongestStreakDays == nil || *cached.Summary.LongestStreakDays != 9 {
+ t.Fatalf("reopened cache = %+v, %v", cached, err)
+ }
+ other := remote
+ other.AccountFingerprint = "account-b"
+ other.DailyUsageBuckets = []AccountUsageDay{{StartDate: "2026-09-18", Tokens: 3}}
+ if _, err := reopened.Reconcile(other, []RecoveredUsageDay{}); err != nil {
+ t.Fatal(err)
+ }
+ latest, _ := reopened.Latest()
+ if latest.AccountFingerprint != "account-b" || len(latest.DailyUsageBuckets) != 1 {
+ t.Fatalf("latest account was mixed: %+v", latest)
+ }
+ data, err := os.ReadFile(path)
+ if err != nil || os.FileMode(0o077)&fileMode(t, path) != 0 {
+ t.Fatalf("history permissions/read = %v, %v", fileMode(t, path), err)
+ }
+ if len(data) == 0 {
+ t.Fatal("empty history file")
+ }
+ if _, err := (&HistoryStore{Path: path}).Latest(); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("unverified restart selected a cached account: %v", err)
+ }
+}
+
+func TestHistoryStoreDistinguishesUnavailableAndEmptyBuckets(t *testing.T) {
+ store := &HistoryStore{Path: filepath.Join(t.TempDir(), "history.json")}
+ missing, err := store.Reconcile(AccountUsage{AccountFingerprint: "missing", FetchedAt: time.Now()}, nil)
+ if err != nil || missing.DailyUsageBuckets != nil {
+ t.Fatalf("missing buckets became available: %+v, %v", missing, err)
+ }
+ empty, err := store.Reconcile(AccountUsage{AccountFingerprint: "empty", FetchedAt: time.Now(), DailyUsageBuckets: []AccountUsageDay{}}, []RecoveredUsageDay{})
+ if err != nil || empty.DailyUsageBuckets == nil || len(empty.DailyUsageBuckets) != 0 {
+ t.Fatalf("empty buckets became unavailable: %+v, %v", empty, err)
+ }
+}
+
+func TestHistoryStoreQuotaObservationsCollapsePerWindow(t *testing.T) {
+ store := &HistoryStore{Path: filepath.Join(t.TempDir(), "history.json")}
+ snapshot := DemoSnapshot()
+ snapshot.AccountFingerprint = "account"
+ snapshot.FetchedAt = time.Now()
+ if err := store.RecordQuota(snapshot); err != nil {
+ t.Fatal(err)
+ }
+ snapshot.FetchedAt = snapshot.FetchedAt.Add(time.Minute)
+ if err := store.RecordQuota(snapshot); err != nil {
+ t.Fatal(err)
+ }
+ file, err := store.load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := len(file.Accounts["account"].Quota); got != 2 {
+ t.Fatalf("identical two-window snapshot produced %d observations", got)
+ }
+ snapshot.RateLimits.Primary.UsedPercent++
+ if err := store.RecordQuota(snapshot); err != nil {
+ t.Fatal(err)
+ }
+ file, _ = store.load()
+ if got := len(file.Accounts["account"].Quota); got != 3 {
+ t.Fatalf("changed window was not appended: %d", got)
+ }
+ snapshot.RateLimits.Primary = nil
+ if err := store.RecordQuota(snapshot); err != nil {
+ t.Fatal(err)
+ }
+ file, _ = store.load()
+ if got := len(file.Accounts["account"].Quota); got != 3 {
+ t.Fatalf("secondary window changed identity when primary disappeared: %d", got)
+ }
+}
+
+func fileMode(t *testing.T, path string) os.FileMode {
+ t.Helper()
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return info.Mode().Perm()
+}
diff --git a/internal/codex/live_usage.go b/internal/codex/live_usage.go
index e9202f1..9902259 100644
--- a/internal/codex/live_usage.go
+++ b/internal/codex/live_usage.go
@@ -114,6 +114,11 @@ type LiveUsageReader struct {
statusProvider sessionStatusProvider
mu sync.Mutex
+ recoveryMu sync.Mutex
+ recoveryAt time.Time
+ recoverySince time.Time
+ recoveredDays []RecoveredUsageDay
+ recoveryFiles map[string]recoveredRollout
initialized bool
startedAt time.Time
lastDiscovery time.Time
@@ -133,6 +138,153 @@ type LiveUsageReader struct {
daemonSubscribedThreads map[string]struct{}
}
+type recoveredRollout struct {
+ size int64
+ modified time.Time
+ days []RecoveredUsageDay
+}
+
+// RecoverDailyUsage performs a bounded, content-free rescan of retained
+// rollouts. It deliberately derives totals from token_count events only; no
+// prompts, replies, commands, or paths leave this reader. A short cache keeps
+// the web poller from repeatedly walking an unchanged year of history.
+func (r *LiveUsageReader) RecoverDailyUsage(ctx context.Context, since time.Time) ([]RecoveredUsageDay, error) {
+ r.recoveryMu.Lock()
+ defer r.recoveryMu.Unlock()
+ since = time.Date(since.UTC().Year(), since.UTC().Month(), since.UTC().Day(), 0, 0, 0, 0, time.UTC)
+ if !r.recoveryAt.IsZero() && time.Since(r.recoveryAt) < 4*time.Minute && r.recoverySince.Equal(since) {
+ return append([]RecoveredUsageDay(nil), r.recoveredDays...), nil
+ }
+ if !r.recoverySince.Equal(since) || r.recoveryFiles == nil {
+ r.recoveryFiles = map[string]recoveredRollout{}
+ }
+ seen := map[string]bool{}
+ err := filepath.WalkDir(r.SessionsRoot, func(path string, entry os.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ if errors.Is(walkErr, os.ErrNotExist) {
+ return nil
+ }
+ return walkErr
+ }
+ if err := contextErr(ctx); err != nil {
+ return err
+ }
+ if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !isRolloutFile(entry.Name()) {
+ return nil
+ }
+ info, err := entry.Info()
+ if err != nil {
+ return nil
+ }
+ seen[path] = true
+ if cached, ok := r.recoveryFiles[path]; ok && cached.size == info.Size() && cached.modified.Equal(info.ModTime()) {
+ return nil
+ }
+ metadata, err := readRolloutMetadata(path)
+ if err != nil {
+ return nil // one damaged rollout must not hide all recoverable history
+ }
+ fileDays := map[string]RecoveredUsageDay{}
+ if err := recoverRolloutDaily(path, metadata, since, fileDays); err != nil {
+ return nil
+ }
+ dates := make([]string, 0, len(fileDays))
+ for date := range fileDays {
+ dates = append(dates, date)
+ }
+ sort.Strings(dates)
+ rollout := recoveredRollout{size: info.Size(), modified: info.ModTime(), days: make([]RecoveredUsageDay, 0, len(dates))}
+ for _, date := range dates {
+ rollout.days = append(rollout.days, fileDays[date])
+ }
+ r.recoveryFiles[path] = rollout
+ return nil
+ })
+ if errors.Is(err, os.ErrNotExist) {
+ err = nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("recover local Codex usage: %w", err)
+ }
+ for path := range r.recoveryFiles {
+ if !seen[path] {
+ delete(r.recoveryFiles, path)
+ }
+ }
+ days := map[string]RecoveredUsageDay{}
+ for _, rollout := range r.recoveryFiles {
+ for _, recovered := range rollout.days {
+ day := days[recovered.StartDate]
+ day.StartDate = recovered.StartDate
+ day.TotalTokens = saturatingAdd(day.TotalTokens, recovered.TotalTokens)
+ day.InputTokens = saturatingAdd(day.InputTokens, recovered.InputTokens)
+ day.CachedInputTokens = saturatingAdd(day.CachedInputTokens, recovered.CachedInputTokens)
+ day.OutputTokens = saturatingAdd(day.OutputTokens, recovered.OutputTokens)
+ day.ReasoningTokens = saturatingAdd(day.ReasoningTokens, recovered.ReasoningTokens)
+ days[recovered.StartDate] = day
+ }
+ }
+ dates := make([]string, 0, len(days))
+ for date := range days {
+ dates = append(dates, date)
+ }
+ sort.Strings(dates)
+ result := make([]RecoveredUsageDay, 0, len(dates))
+ for _, date := range dates {
+ result = append(result, days[date])
+ }
+ r.recoveryAt, r.recoverySince = time.Now(), since
+ r.recoveredDays = append(r.recoveredDays[:0], result...)
+ return append([]RecoveredUsageDay(nil), result...), nil
+}
+
+func recoverRolloutDaily(path string, metadata rolloutMetadata, since time.Time, days map[string]RecoveredUsageDay) error {
+ file, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ reader := bufio.NewReader(file)
+ var previous int64
+ for {
+ line, readErr := reader.ReadBytes('\n')
+ if len(line) > 0 && line[len(line)-1] == '\n' {
+ record, ok := tokenUsageRecord(line)
+ if ok {
+ owned := tokenRecordIsOwned(record.ordinal, metadata.SubagentHistoryStartOrdinal, record.at, metadata.NonRoot, metadata.StartedAt)
+ if !owned {
+ previous = record.total
+ } else {
+ delta := record.total
+ if record.total >= previous {
+ delta = record.total - previous
+ }
+ previous = record.total
+ if delta > 0 && !record.at.IsZero() && !record.at.UTC().Before(since) {
+ date := record.at.UTC().Format("2006-01-02")
+ day := days[date]
+ day.StartDate = date
+ day.TotalTokens = saturatingAdd(day.TotalTokens, delta)
+ if record.usage.TotalTokens == delta {
+ day.InputTokens = saturatingAdd(day.InputTokens, record.usage.InputTokens)
+ day.CachedInputTokens = saturatingAdd(day.CachedInputTokens, record.usage.CachedInputTokens)
+ day.OutputTokens = saturatingAdd(day.OutputTokens, record.usage.OutputTokens)
+ day.ReasoningTokens = saturatingAdd(day.ReasoningTokens, record.usage.ReasoningOutputTokens)
+ }
+ days[date] = day
+ }
+ }
+ }
+ }
+ if readErr != nil {
+ if errors.Is(readErr, io.EOF) {
+ return nil
+ }
+ return readErr
+ }
+ }
+}
+
type rolloutCursor struct {
preview SessionContext
offset int64
diff --git a/internal/codex/live_usage_test.go b/internal/codex/live_usage_test.go
index b2f5cb6..0130a2b 100644
--- a/internal/codex/live_usage_test.go
+++ b/internal/codex/live_usage_test.go
@@ -60,6 +60,27 @@ func TestLiveUsageReaderBaselinesAndConsumesAppendedTelemetry(t *testing.T) {
}
}
+func TestRecoverDailyUsageRescansWithoutDoubleCounting(t *testing.T) {
+ home := t.TempDir()
+ now := time.Date(2026, time.September, 18, 12, 0, 0, 0, time.UTC)
+ path := testRolloutPath(t, home, now.Add(-24*time.Hour), "history")
+ writeRollout(t, path, sessionMetaLineAt("root", `"cli"`, "/private/path", nil, now.Add(-24*time.Hour))+"\n"+
+ richTokenCountLine(now.Add(-24*time.Hour), 100, 80, 40, 0, 20)+"\n"+
+ richTokenCountLine(now, 150, 40, 10, 0, 10)+"\n")
+ reader, err := NewLiveUsageReader(home)
+ if err != nil {
+ t.Fatal(err)
+ }
+ first, err := reader.RecoverDailyUsage(context.Background(), now.Add(-48*time.Hour))
+ if err != nil || len(first) != 2 || first[0].TotalTokens != 100 || first[0].CachedInputTokens != 40 || first[1].TotalTokens != 50 || first[1].InputTokens != 40 {
+ t.Fatalf("recovered days = %+v, %v", first, err)
+ }
+ second, err := reader.RecoverDailyUsage(context.Background(), now.Add(-48*time.Hour))
+ if err != nil || len(second) != 2 || second[1].TotalTokens != 50 {
+ t.Fatalf("cached recovery doubled usage: %+v, %v", second, err)
+ }
+}
+
func TestAppendBoundedRetainsNewestValuesWithoutCopyingOnOverflow(t *testing.T) {
history := make([]int, 3, 4)
copy(history, []int{1, 2, 3})
diff --git a/internal/i18n/locales/da.json b/internal/i18n/locales/da.json
index 47e2a2b..0009b2a 100644
--- a/internal/i18n/locales/da.json
+++ b/internal/i18n/locales/da.json
@@ -208,6 +208,9 @@
"LAST OUT ": "SENESTE OUTPUT ",
"LESS ": "MINDRE ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "SAMLET // %s TOKENS TOPDAG // %s STIME // %s DAGE",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "LÆNGSTE KØRSEL // %s LÆNGSTE STIME // %s DAGE",
+ "LOCAL %d%% ATTRIBUTED": "LOKALT %d%% TILSKREVET",
+ "%d RECOVERED DAYS": "%d GENDANNEDE DAGE",
"LIMIT NEAR": "TÆT PÅ GRÆNSEN",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "LINEÆR PROGNOSE // GRÆNSE OM ~%s // %s FOR TIDLIGT",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "LINEÆR PROGNOSE // SIKKER TIL NULSTILLING // ~%d%% TILBAGE",
diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json
index 5a33086..ede507c 100644
--- a/internal/i18n/locales/de.json
+++ b/internal/i18n/locales/de.json
@@ -208,6 +208,9 @@
"LAST OUT ": "LETZTE AUSGABE ",
"LESS ": "WENIGER ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "GESAMT // %s TOKEN TAGESREKORD // %s SERIE // %s TAGE",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "LÄNGSTER DURCHLAUF // %s LÄNGSTE SERIE // %s TAGE",
+ "LOCAL %d%% ATTRIBUTED": "LOKAL %d%% ZUGEORDNET",
+ "%d RECOVERED DAYS": "%d WIEDERHERGESTELLTE TAGE",
"LIMIT NEAR": "LIMIT NAHE",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "LINEARE PROGNOSE // LIMIT IN ~%s // %s ZU FRÜH",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "LINEARE PROGNOSE // REICHT BIS ZUM RESET // ~%d%% ÜBRIG",
diff --git a/internal/i18n/locales/en-GB.json b/internal/i18n/locales/en-GB.json
index 6f5be87..dec5337 100644
--- a/internal/i18n/locales/en-GB.json
+++ b/internal/i18n/locales/en-GB.json
@@ -208,6 +208,9 @@
"LAST OUT ": "LAST OUT ",
"LESS ": "LESS ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "LONGEST TURN // %s LONGEST STREAK // %s DAYS",
+ "LOCAL %d%% ATTRIBUTED": "LOCAL %d%% ATTRIBUTED",
+ "%d RECOVERED DAYS": "%d RECOVERED DAYS",
"LIMIT NEAR": "LIMIT NEAR",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT",
diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json
index 8bf9670..7a5b323 100644
--- a/internal/i18n/locales/es.json
+++ b/internal/i18n/locales/es.json
@@ -208,6 +208,9 @@
"LAST OUT ": "ÚLTIMA SALIDA ",
"LESS ": "MENOS ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "TOTAL HISTÓRICO // %s TOKENS PICO DIARIO // %s RACHA // %s DÍAS",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "TURNO MÁS LARGO // %s RACHA MÁS LARGA // %s DÍAS",
+ "LOCAL %d%% ATTRIBUTED": "LOCAL %d%% ATRIBUIDO",
+ "%d RECOVERED DAYS": "%d DÍAS RECUPERADOS",
"LIMIT NEAR": "LÍMITE CERCA",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "PROYECCIÓN LINEAL // LÍMITE EN ~%s // %s ANTES",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "PROYECCIÓN LINEAL // SUFICIENTE HASTA RESTABLECER // ~%d%% RESTANTE",
diff --git a/internal/i18n/locales/et.json b/internal/i18n/locales/et.json
index f7c8357..188edcc 100644
--- a/internal/i18n/locales/et.json
+++ b/internal/i18n/locales/et.json
@@ -208,6 +208,9 @@
"LAST OUT ": "VIIMANE VÄLJUND ",
"LESS ": "VÄHEM ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "KOGUAEG // %s TOKENIT TIPPPÄEV // %s JADA // %s PÄEVA",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "PIKIM TÖÖ // %s PIKIM JADA // %s PÄEVA",
+ "LOCAL %d%% ATTRIBUTED": "KOHALIKULT %d%% OMISTATUD",
+ "%d RECOVERED DAYS": "%d TAASTATUD PÄEVA",
"LIMIT NEAR": "PIIR LÄHEDAL",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "LINEAARNE PROGNOOS // PIIRINI ~%s // %s VAREM",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "LINEAARNE PROGNOOS // JÄTKUB LÄHTESTUSENI // ~%d%% ALLES",
diff --git a/internal/i18n/locales/fi.json b/internal/i18n/locales/fi.json
index a041a12..622804c 100644
--- a/internal/i18n/locales/fi.json
+++ b/internal/i18n/locales/fi.json
@@ -208,6 +208,9 @@
"LAST OUT ": "VIIMEISIN TULOSTE ",
"LESS ": "VÄHEMMÄN ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "KOKONAISAIKA // %s TOKENIA HUIPPUPÄIVÄ // %s PUTKI // %s PÄIVÄÄ",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "PISIN KIERROS // %s PISIN PUTKI // %s PÄIVÄÄ",
+ "LOCAL %d%% ATTRIBUTED": "PAIKALLISESTI %d%% KOHDENNETTU",
+ "%d RECOVERED DAYS": "%d PALAUTETTUA PÄIVÄÄ",
"LIMIT NEAR": "RAJA LÄHELLÄ",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "LINEAARINEN ENNUSTE // RAJA ~%s KULUTTUA // %s ETUAJASSA",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "LINEAARINEN ENNUSTE // RIITTÄÄ NOLLAUKSEEN // ~%d%% JÄLJELLÄ",
diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json
index da9e0f9..a3b3a50 100644
--- a/internal/i18n/locales/fr.json
+++ b/internal/i18n/locales/fr.json
@@ -208,6 +208,9 @@
"LAST OUT ": "DERNIÈRE SORTIE ",
"LESS ": "MOINS ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "TOTAL HISTORIQUE // %s TOKENS PIC JOURNALIER // %s SÉRIE // %s JOURS",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "TOUR LE PLUS LONG // %s SÉRIE LA PLUS LONGUE // %s JOURS",
+ "LOCAL %d%% ATTRIBUTED": "LOCAL ATTRIBUÉ À %d%%",
+ "%d RECOVERED DAYS": "%d JOURS RÉCUPÉRÉS",
"LIMIT NEAR": "LIMITE PROCHE",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "PROJECTION LINÉAIRE // LIMITE DANS ~%s // %s TROP TÔT",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "PROJECTION LINÉAIRE // SUFFISANT JUSQU’AU RESET // ~%d%% RESTANT",
diff --git a/internal/i18n/locales/it.json b/internal/i18n/locales/it.json
index 6b67433..aa3f0a3 100644
--- a/internal/i18n/locales/it.json
+++ b/internal/i18n/locales/it.json
@@ -208,6 +208,9 @@
"LAST OUT ": "ULTIMO OUTPUT ",
"LESS ": "MENO ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "TOTALE STORICO // %s TOKEN PICCO GIORNALIERO // %s SERIE // %s GIORNI",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "TURNO PIÙ LUNGO // %s SERIE PIÙ LUNGA // %s GIORNI",
+ "LOCAL %d%% ATTRIBUTED": "LOCALE %d%% ATTRIBUITO",
+ "%d RECOVERED DAYS": "%d GIORNI RECUPERATI",
"LIMIT NEAR": "LIMITE VICINO",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "PROIEZIONE LINEARE // LIMITE TRA ~%s // %s IN ANTICIPO",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "PROIEZIONE LINEARE // SUFFICIENTE FINO AL RESET // ~%d%% RESIDUO",
diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json
index 8bb811f..cd135ce 100644
--- a/internal/i18n/locales/ja.json
+++ b/internal/i18n/locales/ja.json
@@ -208,6 +208,9 @@
"LAST OUT ": "直近の出力 ",
"LESS ": "少 ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "全期間 // %s トークン 最多日 // %s 連続日数 // %s 日",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "最長ターン // %s 最長連続 // %s 日",
+ "LOCAL %d%% ATTRIBUTED": "ローカル帰属 %d%%",
+ "%d RECOVERED DAYS": "%d 日を復元",
"LIMIT NEAR": "上限間近",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "線形予測 // 上限まで ~%s // %s 早く到達",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "線形予測 // リセットまで余裕 // ~%d%% 残り",
diff --git a/internal/i18n/locales/nb.json b/internal/i18n/locales/nb.json
index 2f2330b..222cf0a 100644
--- a/internal/i18n/locales/nb.json
+++ b/internal/i18n/locales/nb.json
@@ -208,6 +208,9 @@
"LAST OUT ": "SISTE UTDATA ",
"LESS ": "MINDRE ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "TOTALT // %s TOKEN TOPPDAG // %s REKKE // %s DAGER",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "LENGSTE KJØRING // %s LENGSTE REKKE // %s DAGER",
+ "LOCAL %d%% ATTRIBUTED": "LOKALT %d%% TILSKREVET",
+ "%d RECOVERED DAYS": "%d GJENOPPRETTEDE DAGER",
"LIMIT NEAR": "NÆR GRENSEN",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "LINEÆR PROGNOSE // GRENSE OM ~%s // %s FOR TIDLIG",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "LINEÆR PROGNOSE // HOLDER TIL TILBAKESTILLING // ~%d%% IGJEN",
diff --git a/internal/i18n/locales/nl.json b/internal/i18n/locales/nl.json
index b989ddb..2699855 100644
--- a/internal/i18n/locales/nl.json
+++ b/internal/i18n/locales/nl.json
@@ -208,6 +208,9 @@
"LAST OUT ": "LAATSTE UITVOER ",
"LESS ": "MINDER ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "TOTAAL // %s TOKENS DAGPIEK // %s REEKS // %s DAGEN",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "LANGSTE BEURT // %s LANGSTE REEKS // %s DAGEN",
+ "LOCAL %d%% ATTRIBUTED": "LOKAAL %d%% TOEGEWEZEN",
+ "%d RECOVERED DAYS": "%d HERSTELDE DAGEN",
"LIMIT NEAR": "LIMIET NABIJ",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "LINEAIRE SCHATTING // LIMIET OVER ~%s // %s TE VROEG",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "LINEAIRE SCHATTING // VEILIG TOT RESET // ~%d%% OVER",
diff --git a/internal/i18n/locales/pt-BR.json b/internal/i18n/locales/pt-BR.json
index 0fd5a06..7bbdaed 100644
--- a/internal/i18n/locales/pt-BR.json
+++ b/internal/i18n/locales/pt-BR.json
@@ -208,6 +208,9 @@
"LAST OUT ": "ÚLTIMA SAÍDA ",
"LESS ": "MENOS ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "TOTAL HISTÓRICO // %s TOKENS DIA DE PICO // %s SEQUÊNCIA // %s DIAS",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "TURNO MAIS LONGO // %s MAIOR SEQUÊNCIA // %s DIAS",
+ "LOCAL %d%% ATTRIBUTED": "LOCAL %d%% ATRIBUÍDO",
+ "%d RECOVERED DAYS": "%d DIAS RECUPERADOS",
"LIMIT NEAR": "LIMITE PRÓXIMO",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "PROJEÇÃO LINEAR // LIMITE EM ~%s // %s ANTES",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "PROJEÇÃO LINEAR // SEGURO ATÉ REDEFINIÇÃO // ~%d%% RESTANTE",
diff --git a/internal/i18n/locales/pt-PT.json b/internal/i18n/locales/pt-PT.json
index 9c4bcf9..153241d 100644
--- a/internal/i18n/locales/pt-PT.json
+++ b/internal/i18n/locales/pt-PT.json
@@ -208,6 +208,9 @@
"LAST OUT ": "ÚLTIMA SAÍDA ",
"LESS ": "MENOS ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "TOTAL HISTÓRICO // %s TOKENS DIA DE PICO // %s SEQUÊNCIA // %s DIAS",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "TURNO MAIS LONGO // %s MAIOR SEQUÊNCIA // %s DIAS",
+ "LOCAL %d%% ATTRIBUTED": "LOCAL %d%% ATRIBUÍDO",
+ "%d RECOVERED DAYS": "%d DIAS RECUPERADOS",
"LIMIT NEAR": "LIMITE PRÓXIMO",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "PROJEÇÃO LINEAR // LIMITE EM ~%s // %s ANTES",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "PROJEÇÃO LINEAR // SEGURO ATÉ REPOSIÇÃO // ~%d%% RESTANTE",
diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json
index c6b196f..332169e 100644
--- a/internal/i18n/locales/ru.json
+++ b/internal/i18n/locales/ru.json
@@ -208,6 +208,9 @@
"LAST OUT ": "ПОСЛЕДНИЙ ВЫВОД ",
"LESS ": "МЕНЬШЕ ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "ЗА ВСЁ ВРЕМЯ // %s ТОКЕНЫ ПИК ЗА ДЕНЬ // %s СЕРИЯ // %s ДНЕЙ",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "САМЫЙ ДОЛГИЙ ХОД // %s САМАЯ ДОЛГАЯ СЕРИЯ // %s ДНЕЙ",
+ "LOCAL %d%% ATTRIBUTED": "ЛОКАЛЬНО ОПРЕДЕЛЕНО %d%%",
+ "%d RECOVERED DAYS": "%d ВОССТАНОВЛЕННЫХ ДНЕЙ",
"LIMIT NEAR": "ЛИМИТ БЛИЗКО",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "ЛИНЕЙНЫЙ ПРОГНОЗ // ЛИМИТ ЧЕРЕЗ ~%s // %s РАНЬШЕ",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "ЛИНЕЙНЫЙ ПРОГНОЗ // ХВАТИТ ДО СБРОСА // ~%d%% ОСТАЛОСЬ",
diff --git a/internal/i18n/locales/sv.json b/internal/i18n/locales/sv.json
index 6f1c498..ef7a4eb 100644
--- a/internal/i18n/locales/sv.json
+++ b/internal/i18n/locales/sv.json
@@ -208,6 +208,9 @@
"LAST OUT ": "SENASTE UTDATA ",
"LESS ": "MINDRE ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "TOTALT // %s TOKEN TOPPDAG // %s SVIT // %s DAGAR",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "LÄNGSTA KÖRNING // %s LÄNGSTA SVIT // %s DAGAR",
+ "LOCAL %d%% ATTRIBUTED": "LOKALT %d%% TILLSKRIVET",
+ "%d RECOVERED DAYS": "%d ÅTERSTÄLLDA DAGAR",
"LIMIT NEAR": "NÄRA GRÄNSEN",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "LINJÄR PROGNOS // GRÄNS OM ~%s // %s FÖR TIDIGT",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "LINJÄR PROGNOS // RÄCKER TILL ÅTERSTÄLLNING // ~%d%% KVAR",
diff --git a/internal/i18n/locales/tr.json b/internal/i18n/locales/tr.json
index 900b107..1aaf951 100644
--- a/internal/i18n/locales/tr.json
+++ b/internal/i18n/locales/tr.json
@@ -208,6 +208,9 @@
"LAST OUT ": "SON ÇIKTI ",
"LESS ": "DAHA AZ ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "TÜM ZAMANLAR // %s TOKEN ZİRVE GÜN // %s SERİ // %s GÜN",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "EN UZUN TUR // %s EN UZUN SERİ // %s GÜN",
+ "LOCAL %d%% ATTRIBUTED": "YEREL %d%% İLİŞKİLENDİRİLDİ",
+ "%d RECOVERED DAYS": "%d KURTARILAN GÜN",
"LIMIT NEAR": "SINIRA YAKIN",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "DOĞRUSAL TAHMİN // SINIRA ~%s // %s ERKEN",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "DOĞRUSAL TAHMİN // SIFIRLAMAYA KADAR YETERLİ // ~%d%% KALIR",
diff --git a/internal/i18n/locales/zh-Hans.json b/internal/i18n/locales/zh-Hans.json
index 13f866c..c9be139 100644
--- a/internal/i18n/locales/zh-Hans.json
+++ b/internal/i18n/locales/zh-Hans.json
@@ -208,6 +208,9 @@
"LAST OUT ": "最近输出 ",
"LESS ": "少 ",
"LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS": "历史总计 // %s 令牌 单日峰值 // %s 连续天数 // %s 天",
+ "LONGEST TURN // %s LONGEST STREAK // %s DAYS": "最长回合 // %s 最长连续 // %s 天",
+ "LOCAL %d%% ATTRIBUTED": "本地归因 %d%%",
+ "%d RECOVERED DAYS": "已恢复 %d 天",
"LIMIT NEAR": "接近上限",
"LINEAR PROJECTION // LIMIT IN ~%s // %s EARLY": "线性预测 // 距上限 ~%s // %s 提前",
"LINEAR PROJECTION // SAFE THROUGH RESET // ~%d%% LEFT": "线性预测 // 可用至重置 // ~%d%% 剩余",
diff --git a/internal/ui/english_snapshot_test.go b/internal/ui/english_snapshot_test.go
index 74c277a..baa99ed 100644
--- a/internal/ui/english_snapshot_test.go
+++ b/internal/ui/english_snapshot_test.go
@@ -31,9 +31,9 @@ func TestEnglishPresentationSnapshot(t *testing.T) {
}
}
got := fmt.Sprintf("%x", hash.Sum(nil))
- // Baseline intentionally updated for the compact [ (C)OPY ] label;
- // all themes, views and three terminal sizes are covered.
- const want = "49ac2efe33d6574b4f491d0c0165c72c98a2542670979a43f1bd659295936038"
+ // Baseline includes persistent Usage provenance and the two additional
+ // OpenAI account summary metrics; every theme, view and size is covered.
+ const want = "ec5498dccc07e2d2531d3ef645ccd819fcaea9f45b1f69aa206197f877178321"
if got != want {
t.Fatalf("English presentation changed: got %s, want %s", got, want)
}
diff --git a/internal/ui/usage.go b/internal/ui/usage.go
index 0eb8260..4a1b375 100644
--- a/internal/ui/usage.go
+++ b/internal/ui/usage.go
@@ -228,6 +228,13 @@ func optionalUsage(n *int64) string {
return usageNumber(*n)
}
+func optionalUsageDuration(seconds *int64) string {
+ if seconds == nil || *seconds < 0 {
+ return "—"
+ }
+ return (time.Duration(*seconds) * time.Second).Round(time.Second).String()
+}
+
func (m Model) renderHistory(width, height int, colors palette) string {
lines := []string{}
buttons := ""
@@ -245,9 +252,15 @@ func (m Model) renderHistory(width, height int, colors palette) string {
lines = append(lines, buttons)
data := m.history.data
lines = append(lines, colors.label().Render(i18n.Format("LIFETIME // %s TOKENS PEAK DAY // %s STREAK // %s DAYS", optionalUsage(data.Summary.LifetimeTokens), optionalUsage(data.Summary.PeakDailyTokens), optionalUsage(data.Summary.CurrentStreakDays))))
+ if height >= 8 {
+ lines = append(lines, colors.label().Render(i18n.Format("LONGEST TURN // %s LONGEST STREAK // %s DAYS", optionalUsageDuration(data.Summary.LongestRunningTurnSec), optionalUsage(data.Summary.LongestStreakDays))))
+ }
status := i18n.Format("ACCOUNT HISTORY // UTC // %d WEEKS // R REFRESH", m.history.weeks())
if !data.FetchedAt.IsZero() {
- status = i18n.Text("ACCOUNT HISTORY // UTC // UPDATED ") + data.FetchedAt.Local().Format("15:04:05") + i18n.Text(" // R REFRESH")
+ status = i18n.Text("ACCOUNT HISTORY // UTC // UPDATED ") + data.FetchedAt.Local().Format("15:04:05") + " // " + usageCoverageLabel(data) + i18n.Text(" // R REFRESH")
+ }
+ if data.Stale {
+ status = i18n.Text("STALE") + " // " + usageCoverageLabel(data) + " // " + data.FetchedAt.Local().Format("02 JAN 15:04") + i18n.Text(" // R RETRY")
}
if m.history.loading {
status = i18n.Text("FETCHING ACCOUNT HISTORY…")
@@ -316,6 +329,21 @@ func (m Model) renderHistory(width, height int, colors palette) string {
return strings.Join(lines, "\n")
}
+func usageCoverageLabel(data codex.AccountUsage) string {
+ status := strings.TrimSpace(data.Coverage.Status)
+ if status == "" {
+ status = "OPENAI"
+ }
+ parts := []string{status}
+ if data.Coverage.OpenAITokens > 0 && data.Coverage.LocalTokens > 0 {
+ parts = append(parts, i18n.Format("LOCAL %d%% ATTRIBUTED", data.Coverage.AttributedPct))
+ }
+ if data.Coverage.RecoveredDays > 0 {
+ parts = append(parts, i18n.Format("%d RECOVERED DAYS", data.Coverage.RecoveredDays))
+ }
+ return strings.Join(parts, " // ")
+}
+
type historyCalendarLayout struct {
cellWidth, cellHeight, gap, columns int
}
diff --git a/internal/web/dist/assets/index-CKsvYdC2.js b/internal/web/dist/assets/index-CKsvYdC2.js
new file mode 100644
index 0000000..49c1292
--- /dev/null
+++ b/internal/web/dist/assets/index-CKsvYdC2.js
@@ -0,0 +1,27 @@
+(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var e=Array.isArray,t=Array.prototype.indexOf,n=Array.prototype.includes,r=Array.from,i=Object.defineProperty,a=Object.getOwnPropertyDescriptor,o=Object.getOwnPropertyDescriptors,s=Object.prototype,c=Array.prototype,l=Object.getPrototypeOf,u=Object.isExtensible;function d(e){return typeof e==`function`}var f=()=>{};function p(e){for(var t=0;t Quota refresh failed. Values below are the last successful observation. Expiry details unavailable. No listed expiry does not mean no expiry. Read-only preview. Use the terminal to redeem a reset. The backend may return only some credits. This list does not establish
+ redemption order. Cycle duration or reset date unavailable — position cannot be
+ plotted. Cycle duration unavailable — pace cannot be calculated. No quota windows reported yet. All reported windows are shown. API-equivalent learning and quota status
+ scoring remain in the terminal for this first preview. MODEL / REASONING LEVEL / SPEED MODEL / REASONING LEVEL / SPEED Applied settings remain after Codexometer closes. Command unavailable from this observation. Open Codex to inspect the
+ request. Session controls temporarily unavailable. Check Codex for current state. Checking session controls… Controls require a supported live request from a connected shared
+ app-server session. Local observations alone cannot provide them. Type one of these choices exactly. Your answer stays masked. Command unavailable from this observation. Open Codex to inspect the
+ request. Session connection or refresh unavailable. Context and telemetry may be
+ stale. Some quota profile checks are unavailable. Only sessions with freshly
+ verified quota and settings can be updated; previous outcome notices remain
+ visible. Read only — reply or approve in Codex. This session is no longer in the current observation. Return to sessions. No locally observed sessions yet. Keep Codex running alongside
+ Codexometer. ↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK History refresh failed. Any displayed history is the last successful
+ observation. Showing the last persisted account history while the live OpenAI refresh is
+ unavailable. LESS ░ ▒ ▓ █ MORE // HOVER FOR DATE AND TOKENS DAYS SECONDS DAYS History unavailable or awaiting a matching account observation. Missing
+ history is not treated as zero usage. Account-wide history reported by Codex, not the local Sessions counter. Dates
+ use UTC. Historical resets are not provided by this data. Connecting to your local Codexometer… Your quota. Your sessions. Your command centre. Quota refresh failed. Values below are the last successful observation. Expiry details unavailable. No listed expiry does not mean no expiry. Read-only preview. Use the terminal to redeem a reset. The backend may return only some credits. This list does not establish
- redemption order. Cycle duration or reset date unavailable — position cannot be
- plotted. Cycle duration unavailable — pace cannot be calculated. No quota windows reported yet. All reported windows are shown. API-equivalent learning and quota status
- scoring remain in the terminal for this first preview. MODEL / REASONING LEVEL / SPEED MODEL / REASONING LEVEL / SPEED Applied settings remain after Codexometer closes. Command unavailable from this observation. Open Codex to inspect the
- request. Session controls temporarily unavailable. Check Codex for current state. Checking session controls… Controls require a supported live request from a connected shared
- app-server session. Local observations alone cannot provide them. Type one of these choices exactly. Your answer stays masked. Command unavailable from this observation. Open Codex to inspect the
- request. Session connection or refresh unavailable. Context and telemetry may be
- stale. Some quota profile checks are unavailable. Only sessions with freshly
- verified quota and settings can be updated; previous outcome notices remain
- visible. Read only — reply or approve in Codex. This session is no longer in the current observation. Return to sessions. No locally observed sessions yet. Keep Codex running alongside
- Codexometer. ↑ ↓ SELECT SESSION // ← LESS DETAIL // → MORE DETAIL // ESC BACK History refresh failed. Any displayed history is the last successful
- observation. LESS ░ ▒ ▓ █ MORE // HOVER FOR DATE AND TOKENS DAYS History unavailable or awaiting a matching account observation. Missing
- history is not treated as zero usage. Account-wide history reported by Codex, not the local Sessions counter. Dates
- use UTC. Historical resets are not provided by this data. Connecting to your local Codexometer… Your quota. Your sessions. Your command centre.0){var E=i&4&&s===0?n:null;if(o){for(v=0;v `),ca=G(` Observed at Period elapsed Consumed Trail segment
Observed quota
+ path, not individual session usage. Gaps are not interpolated. Expand the
+ observation table for times, positions and gaps.OBSERVATION TABLE
CURRENT PROFILE
PROPOSED PROFILE
`,1),Ba=G(`About browser controls
`),qa=G(`Grants permission beyond this one command. Check the scope
+ carefully.`),Ja=G(``),Ya=G(``),Xa=G(``),Za=G(``),Qa=G(` `,1),$a=G(`View fixed choices
`),fo=G(`
`),po=G(`
`,1),ho=G(`
`,1),_o=G(`
TOKEN ACTIVITY // 30 SECOND SAMPLES
SESSION TOTALS
`),Zo=G(` LIFETIME TOKENS
PEAK DAY
CURRENT STREAK
LONGEST TURN
LONGEST STREAK
Accessible data table
Date (UTC) Tokens Source Local USAGE // ACCOUNT HISTORY
Page not found
`,1);function ns(e){var t=ts();Te(2),K(e,t)}var rs=G(` `),is=G(`0){var E=i&4&&s===0?n:null;if(o){for(v=0;v `),sa=K(` Observed at Period elapsed Consumed Trail segment
Observed quota
- path, not individual session usage. Gaps are not interpolated. Expand the
- observation table for times, positions and gaps.OBSERVATION TABLE
CURRENT PROFILE
PROPOSED PROFILE
`,1),za=K(`About browser controls
`),Ka=K(`Grants permission beyond this one command. Check the scope
- carefully.`),qa=K(``),Ja=K(``),Ya=K(``),Xa=K(``),Za=K(` `,1),Qa=K(`View fixed choices
`),uo=K(`
`),fo=K(`
`,1),mo=K(`
`,1),go=K(`
TOKEN ACTIVITY // 30 SECOND SAMPLES
SESSION TOTALS
`),Yo=K(` LIFETIME TOKENS
PEAK DAY
CURRENT STREAK
Accessible data table
Date (UTC) Tokens USAGE // ACCOUNT HISTORY
Page not found
`,1);function es(e){var t=$o();Te(2),q(e,t)}var ts=K(` `),ns=K(`
+ Showing the last persisted account history while the live OpenAI refresh is + unavailable. +
{/if} {#if live.data?.usage && live.data.usage.dailyUsageBuckets !== null}+ {number(live.data.usage.summary.longestRunningTurnSec)} + SECONDS +
++ {number(live.data.usage.summary.longestStreakDays)} DAYS +
++ {coverage?.status || 'OPENAI'} // UTC{#if coverage?.openaiTokens && coverage?.localTokens} + // LOCAL {number(coverage.attributedPercent)}% ATTRIBUTED{/if}{#if coverage?.recoveredDays} + // {number(coverage.recoveredDays)} RECOVERED DAYS{/if} +
| Date (UTC) | Tokens | ||
|---|---|---|---|
| Date (UTC) | Tokens | Source | Local |
| {row.date} | {number(row.tokens)} | {row.date} | {number(row.tokens)} | {row.provenance} | {number(row.localTokens)} | {/each}