Skip to content

feat: attribute Pi/OMP usage to the real model and break the day down per model - #225

Merged
chattymin merged 2 commits into
chattymin:mainfrom
yourconscience:feat/pi-model-breakdown
Aug 31, 2026
Merged

chattymin merged 2 commits into
chattymin:mainfrom
yourconscience:feat/pi-model-breakdown

Conversation

@yourconscience

Copy link
Copy Markdown
Contributor

Summary

Pi/OMP session JSONL carries the model id (vanilla Pi nests it in message.model; OMP-style forks write it at the envelope level, e.g. openrouter/stealth/ox-alpha), but every entry was hardcoded to model: "pi". This PR:

  • attributes each Pi entry to its real model (envelope model → message.model → "pi" fallback), which also makes ModelPricing lookups hit the actual model id
  • aggregates a per-model totalTokens breakdown into DailyUsage.models (optional field, backward-compatible decoding — old caches decode fine)
  • renders the breakdown as compact per-model rows under the provider section when a day used more than one model

Useful for Pi forks like OMP that route several models (e.g. OpenRouter) through the same session logs.

Test plan

  • swift test --filter PiUsageTests — 9 tests pass, including a new test covering envelope-level vs message-level model attribution and the daily per-model breakdown
  • one existing assertion updated from the hardcoded "pi" to the fixture's real model id (the old expectation contradicted the fixture itself)
  • swift build clean

@chattymin

Copy link
Copy Markdown
Owner

Thanks for this — attributing Pi usage to the real model instead of the literal "pi" is right, and handling both the envelope-level and message-level model fields is the correct read of the two writers.

There's a defect that needs fixing before this can go in, and it happens to hit the provider the PR is named for.

LocalUsageReader.daily now returns models, but two providers rebuild DailyUsage by hand instead of passing it through — LocalPiProvider and LocalCodexProvider, at Sources/PokeTokenBar/Core/LocalUsageProvider.swift:186 and :146. Neither forwards the new field, so models is always nil for them. Pi is one of the two, so the per-model breakdown never appears for Pi.

The inverse is also true and probably unintended: the other ten providers return daily(...) directly, so the new popover rows switch on for all of them — Claude, Gemini, Grok, OpenCode, Hermes, omp and the rest. That's a UI change across the whole popover, and it isn't mentioned in the description. Worth deciding explicitly whether you want it everywhere or gated.

On the tests: they pass because they call LocalUsageReader.daily directly, which is the one path where the field survives. The defect lives one layer up, in the provider that repackages the result. A test that goes through LocalPiProvider.fetchDaily() would have caught it.

One more thing to check while you're in there: Pi entries used to carry model: "pi", which matches nothing in ModelPricing, so cost computed to zero. Real model ids will now match the price table. LocalPiProvider overrides totalCost: 0 so nothing leaks today, but that invariant is now spread across two files.

Happy to look again once the field is forwarded.

@yourconscience

Copy link
Copy Markdown
Contributor Author

All four are valid. Fixed and pushed.

Defect: LocalPiProvider and LocalCodexProvider no longer rebuild DailyUsage field by field. They take the value from daily(...) and mutate totalCost = 0 on it, so anything the reader adds later (models here) carries through instead of getting silently dropped. Kills the class, not just this case.

Scope: gated to Pi rather than turned on everywhere. daily(...) now takes includeModels: false by default and only Pi opts in, so models stays nil for the other providers and the popover rows don't switch on for Claude, Gemini, Grok and the rest. The breakdown was meant for Pi forks that route several models through one session log. The cross-popover change wasn't intended.

Test: added testLocalPiProviderFetchDailyForwardsBreakdownAndZeroesCost. It runs a two-model fixture (model at the envelope level and at the message level) through LocalPiProvider.fetchDaily() and asserts the breakdown survives the repackaging and cost stays 0. That's the layer that was uncovered. Also pinned the gate: daily(...) without the flag returns models == nil.

Cost invariant: holds. Pi cost is blocked at three layers now. reportsCost = false hides it in the UI, and totalCost is zeroed in both fetchDaily and the period totals. The mutate form keeps that override as one visible line per provider instead of buried in a reconstruction.

@chattymin
chattymin merged commit e6d99a8 into chattymin:main Aug 31, 2026
2 checks passed
@chattymin

Copy link
Copy Markdown
Owner

Thank you for your contribution!

cte13 added a commit to cte13/PokeTokenBar-iOS that referenced this pull request Sep 2, 2026
* fix: prevent invisible text in floating pet hover tooltip (chattymin#193)

* fix: pick up in-place Claude account switch on auto-poll (chattymin#227) (chattymin#228)

The OAuth cache returned a still-unexpired token without re-reading
credentials.json, so /login to a second Team email left the 5h bars
on the previous account. Peek the file before the cache hit; Keychain
stays off the auto path. Same class in the Antigravity token cache.

* fix: resolve Claude account OAuth across multiple Keychain entries (chattymin#232)

* fix: show the Raising badge only on the current evolution stage (chattymin#211)

* fix: mark only current evolution stage as raising

* docs: clarify Pokédex raising badge semantics

* docs: correct Raising badge width note

* feat: let users pick animation quality for the menu bar sprite and floating pet (chattymin#212)

* fix: cap the sprite frame rate by decimating frames, not stretching them

The always-visible surfaces (menu bar sprite, floating pet) capped their frame
rate by raising each frame's delay -- max(floor, delay). That keeps the frame
count and stretches the whole loop, so a 55-frame Gen-V sprite (55 x 0.05s =
2.75s) played back over 22s at the 0.4s floor: 1/8 speed. The original rationale
("2.5fps and 5fps are indistinguishable at 22px") only held for the frame rate;
it missed that playback slowed 8x. Two-frame bob animations hid the defect,
because holding and decimating are the same thing there.

GIFDecoder.capFrameRate decimates instead: it emits one frame per accumulated
floor-length span, so the loop length matches the original and only the frame
rate drops. Same wakeup budget, correct speed, and fewer frames to composite
(55 -> 6 at a 0.4s floor).

Timer.tolerance drops from 0.5x to 0.1x for the same symptom. Tolerance only ever
fires late, so 0.5x declared that the loop may stretch by up to 1.5x -- next to
the popover, which runs with tolerance .zero, that made the menu bar look slower
on its own.

The menu bar cap also moves into a named constant. It was an inline
max(0.4, delay) while the pet's cap was a tested constant, so the cap could
vanish from the menu bar without any test noticing; both surfaces are now
asserted together in testAlwaysVisibleSurfacesAreCapped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: let users pick animation quality for the menu bar sprite and floating pet

The frame-rate cap on the always-visible surfaces was a single hardcoded value,
and no single value fits: the per-frame cost is a status-bar recomposite, so how
smooth it looks and how much battery it costs both vary by device and by sprite.
A 22px menu bar hides the low frame rate that is clearly visible on a 96px
floating pet.

This adds a three-preset setting -- Power saver (0.4s / ~2.5fps), Balanced (0.2s /
~5fps), Smooth (0.1s / ~10fps) -- shared by both surfaces, in all four UI
languages. No preset can disable the cap; 0 (native fps) is not reachable from
the UI, which keeps the idle-wakeup guarantee that the cap exists.

The default is Power saver: it is the same frame rate the app used before this
setting existed, so an existing user's battery profile does not change and the
smoother settings are opt-in. Measured idle CPU for reference: 1.8% at Balanced,
5.1% at Smooth.

The floor is part of the frame-cache identity (AppDelegate.menuSpriteKey,
SpriteView.frameTaskID) because cached frames are already decimated to a specific
rate -- without it, changing the setting would not take effect until the next
evolution. The menu bar also observes the setting explicitly: observeStore only
tracks menuTitle, so otherwise a change would not show until the next usage poll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cursor): read usage from dashboard API when local tokenCount is zero (chattymin#197)

* feat(cursor): fetch usage from dashboard API when local tokenCount is zero

Cursor's state.vscdb bubbles often store 0/0 token counts since early 2026,
so the SQLite scan under-reports. When the user is signed into Cursor, read
the same unofficial dashboard export the web UI uses (with sub::jwt cookie auth
from cursorAuth/accessToken), fall back to local bubbles or Downloads CSV
exports, and avoid merging dashboard rows with stale bubble estimates.

API and in-memory cursor reload cache TTL follow Settings → refresh interval
(1/2/5/15 min or manual = refetch on every refresh()).

Closes chattymin#196

* refactor(cursor): remove CSV export and local file fallbacks

Dashboard JSON API is the sole authoritative source when signed in; keep SQLite bubble scan only as offline fallback.

* fix(cursor): satisfy CI env guard and ISO8601 date parsing in tests

Route CURSOR_USAGE_API through UsageEnvironment and reuse CompanionStore state dir for the API cache path. Parse fractional-second ISO dates in CursorUsageTests.

* fix(cursor): extract AppStatePaths for nonisolated cache directory

CompanionStore.defaultURL() is MainActor-isolated; share PTB_STATE_DIR resolution via AppStatePaths for Cursor API disk cache.

* fix(cursor): harden dashboard usage fallback

Keep cached usage account-scoped, reject incomplete pagination, preserve authoritative empty responses, and make paginated event IDs unique.

* fix(cursor): harden API fetch, cache, logging, and pagination tests

Add explicit fetch failure reasons and HTTP status logging, ephemeral URLSession, request/page deadlines, account-scoped cache TTL and coveredSince checks, safer pagination/id handling, and SQLite fallback when stale cache is non-authoritative.

* test(cursor): fix transport test closures for CI

Avoid throwing closures in injected transport and compare empty fetch results by count.

* test(cursor): use Sendable page recorder in pagination test

Fix Swift 6 concurrency error when capturing mutable state in injected transport.

* fix(cursor): accept legacy disk cache without accountIdentifier

Migrate pre-account-scoped cache files instead of ignoring them on upgrade.

* fix(cursor): send epoch-ms range and read usageEventsDisplay

The dashboard endpoint was never exercised while the CSV export was the
primary source, so two request/response mismatches stayed hidden until
CSV was removed:

- startDate/endDate were sent as ISO8601, which the endpoint answers with
  HTTP 500. It expects epoch milliseconds as strings.
- Events are returned under usageEventsDisplay, not usageEvents/events,
  so parsing failed even once the request succeeded.

Pagination now also uses totalUsageEventsCount when the response carries
no pagination object. Verified live: 239 events fetched.

* docs(cursor): sync ko/ja READMEs with dashboard API data source

The English README gained the cursor.com data source row and the updated
Privacy host list, but the Korean and Japanese translations still listed
state.vscdb as Cursor's only source and claimed seven outbound hosts.
CLAUDE.md requires all three READMEs to stay in sync.

Also notes that the offline disk cache is account-scoped and capped at
6 h, so readers know how stale a fallback reading can be.

---------

Co-authored-by: Patrick.Park <patrick.park@buzzvil.com>

* fix: read Kiro CLI 2.20+ JSONL sessions (chattymin#236) (chattymin#238)

* fix: read Kiro CLI 2.20+ JSONL sessions instead of only data.sqlite3

Kiro stopped writing new turns to the SQLite store. Scan ~/.kiro/sessions
(cli/*.jsonl and v3 messages.jsonl) while keeping the legacy database, and
register KIRO_HOME / KIRO_CLI_HOME so the GUI app can see those overrides.

* fix: keep every v3 Kiro turn when timestamps are missing

Flat messages.jsonl lines share session.json createdAt, so millis ids
collapsed under dedupKeepMax. Key v3 entries by turn index and count
tool_call args as output, matching the writer-shaped parser.

---------

Co-authored-by: Patrick.Park <patrick.park@buzzvil.com>

* Add oh-my-pi (omp) usage provider (chattymin#214)

* Add oh-my-pi (omp) usage provider

Parse pi-format session JSONL under ~/.omp/agent/sessions recursively:
every assistant message's usage event is summed (rewound branches are
already billed; subagent sessions count since their tokens are not
folded into the parent), plus compaction/branch_summary envelope usage,
mirroring parsePiFile's semantics. Per-event cost.total is trusted over
the pricing table when reported; OMP_CODING_AGENT_DIR overrides the
agent home (verified against the omp binary's env vars). bridge/
conversion copies are excluded: they mirror sessions whose usage is
already counted at the original source (verified against a real bridge
file duplicating a -Projects/ session).

Also make testHermesAcceptsMillisecondStartedAt timezone-independent
(it hardcoded a local day that only holds at UTC+0/east) and cover the
previously uncovered omp reader branches.

* Use omp as the provider display name

The tool ships as `omp` (omp.sh, the `omp` binary, Homebrew `can1357/tap/omp`),
so the provider tab and Settings now show that name instead of `OMP`. The README
tables keep the repository name alongside it as `omp (oh-my-pi)` for discovery.

---------

Co-authored-by: Patrick.Park <patrick.park@buzzvil.com>

* Update readme (chattymin#239)

* Add Trendshift badges to README

* Update Korean README with Trendshift badges

* Update README.ja.md

* feat: fetch official limits with a claude.ai session key (no Keychain prompt) (chattymin#241)

* feat: fetch official limits with a claude.ai session key (no Keychain prompt)

Auto-polling never reads the Keychain by design, so once the cached token
expires and ~/.claude/.credentials.json is absent, official limits freeze until
the user presses "Refresh limit token cache" -- and that press raises the
password dialog on unnotarised builds, where an "Always Allow" grant does not
survive the next code-signature change.

This adds an opt-in second credential path. Paste a claude.ai sessionKey cookie
under Settings -> Advanced and limits come from
claude.ai/api/organizations/{org}/usage, which returns the same schema as
api.anthropic.com/api/oauth/usage -- so LimitStatus is reused unchanged and no
popover row changes shape. The path touches no Keychain, so auto-polling keeps
limits current and there is no ACL grant to lose on the next update. Without a
key nothing changes: ChainedLimitsProvider falls through to the existing OAuth
path and no file is created.

Organization selection is not "the first one". A claude.ai account also returns
orgs the user merely logged into, and API-only orgs that answer 403, so picking
first yields a permanent and plausible-looking 0%. Candidates are filtered to
chat-capable orgs, probed in parallel, and the one with usage is selected and
cached. A cached org that later returns 403 triggers rediscovery.

A dead key answers 403, not 401 -- verified by flipping one character of a
stored key in a running build, where the usage endpoint and /api/organizations
both returned 403. Since /api/organizations is not organization-scoped, a 403
there means the key itself can see nothing, so it maps to sessionKeyInvalid.
Per-organization 403s keep their existing meaning and still trigger rediscovery.

Expiry carries its source (limitsAuthExpiry: .oauth | .sessionKey) rather than a
single boolean, because the two remedies are opposites: a session key must be
re-copied from the browser, while OAuth only needs a Keychain re-read. Collapsed
into one flag, a session-key user is told to run Claude Code once -- which does
nothing for a dead cookie -- and the retry button reads the Keychain, raising
the very prompt the session key exists to avoid. The settings badge reads
"Expired" while the dead key is still stored, and the banner opens Settings with
the Advanced disclosure already expanded, since the key field lives inside it.

The key is stored as an owner-only (0600) plaintext file, not in the Keychain:
an app-owned Keychain item is the very class of defect this removes, because a
cdhash change invalidates its ACL and the prompts return. The settings copy
states the storage model and that a browser logout invalidates the key.

Note for anyone testing this: curl cannot verify these endpoints. Cloudflare
answers it with its own 403 challenge regardless of the cookie, so it will
confirm any theory brought to it. Only URLSession gives a real answer; the unit
tests seam out the HTTP layer instead.

Refs chattymin#240

* docs: list claude.ai in the outbound-host section (en/ko/ja)

The session-key path adds a twelfth outbound host, and the privacy section
enumerates every host the app talks to.

* fix: keep a configured session key from falling back into the Keychain

A stored-but-rejected key used to pass allowKeychainPrompt straight through to
the OAuth fallback, so pressing Refresh raised the very password dialog the key
exists to avoid. The remedy for a dead key is re-pasting it, not the Keychain,
and the expiry banner already routes there. Users with no key keep today's
behaviour, which the second test locks.

---------

Co-authored-by: Patrick.Park <patrick.park@buzzvil.com>

* fix: keep released Pokémon in the Pokédex (chattymin#242)

Buying an egg discarded the active Pokémon without touching `dex`, so a
species that was only backed by that companion vanished from the Pokédex.
That was the one path where the collection screen could shrink, which
contradicts the promise it makes.

`buyEgg` now records the released companion as a `DexEntry` carrying
`releasedAt`. The Pokédex folds `state.dex`, so species retention follows
automatically; only the capture log distinguishes the two, with a
`Released` badge next to the existing `Raising` one.

Three rules the record follows:

- Only reached forms are credited (`pathIDs.prefix(stageIndex + 1)`).
  Using `plannedPathIDs` would make buying an egg a shortcut to filling
  the Pokédex with evolutions never reached.
- Shiny follows `currentIsShiny`, so releasing a disguised Ditto does not
  reveal it early.
- `collectedFinals` is untouched — a released companion was not raised to
  its final form, so it must not count toward completion or branch
  weighting.

`releasedAt == nil` means "graduated", so existing saves need no
migration. A representative pinned to a released species now survives the
release, because the species is still owned.

* feat: add German UI language (chattymin#234)

Add native German copy, official Pokémon terminology, locale detection, and regression coverage so German users get a complete localized experience.

Co-authored-by: Patrick.Park <patrick.park@buzzvil.com>

* fix: split the Claude Keychain read so it stops failing with errSecParam (chattymin#243)

kSecMatchLimitAll cannot be combined with kSecReturnData; macOS rejects that
pair with errSecParam (-50) during parameter validation, before it looks for
any item. The query added in chattymin#232 therefore never succeeded for anyone whose
credentials live only in the Keychain — an approved ACL, a re-login, or a
single matching item made no difference. Measured on a real install: 24
consecutive -50 failures, zero successes.

Enumerate accounts with attributes only, then read each candidate's data with
kSecMatchLimitOne, preserving chattymin#232's intent of picking the entry that carries
claudeAiOauth. Falls back to an unscoped single read when no account attribute
comes back.

The old tests exercised extractDataItems with synthetic arrays, so
SecItemCopyMatching was never on the test path and the defect sat one layer
above what they covered. The new guard aims the production queries at a service
name that cannot exist -- nothing matches, so no ACL or prompt is involved --
and asserts the framework does not answer errSecParam. extractDataItems is
removed along with its tests; it has no production caller left.

* docs: record why Claude's refreshToken must not be used for renewal (chattymin#247)

The Keychain credential carries refreshToken and refreshTokenExpiresAt next to
a ~5h accessToken, which makes "renew with it and stop touching the Keychain
every few hours" an obvious idea. It is the wrong one: Claude Code's refresh
handler replaces its stored refreshToken with the value from the response, so
the token rotates. An external app renewing on its own would leave Claude Code
holding a dead token and force a re-login, and the only way around that is
writing into someone else's credential store, which the app-owned-Keychain rule
already forbids.

Antigravity can do this because Google does not rotate — its cache reuses the
same refreshToken across renewals. The asymmetry is the providers' token
semantics, not a gap on the Claude side.

Rotation is inferred from the replace-on-store behaviour rather than tested; the
cost of being wrong is breaking the user's primary tool, so the experiment was
not run. The decision rests on the asymmetry instead: at best it is a weaker
version of what the session key already provides, at worst Claude Code breaks.

Also records that adding a silent-first Keychain read on the user-action path
buys nothing, so the Antigravity pattern is not worth porting there either.

* feat: attribute Pi/OMP usage to the real model and break the day down per model (chattymin#225)

* feat: attribute Pi/OMP usage to the real model and break the day down per model

* fix: gate the Pi per-model breakdown to Pi and carry it through the provider

---------

Co-authored-by: Kirill Korikov <11762090+yourconscience@users.noreply.github.com>

* fix: align Antigravity limits stale UI and refresh indicator with Claude (chattymin#245)

* docs: add screenshots and README tour rows for session key, per-model breakdown, and animation quality

* release: bump version to 2.5.3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: keep the menu bar animating in Low Power Mode, capped at Power saver (chattymin#250)

Low Power Mode used to drop the menu bar sprite to a two-frame bob, and
the check only ran while building frames — toggling the mode mid-session
never rebuilt anything, so an already-running GIF kept its full frame
rate, and a bob built during Low Power survived after leaving it.

The Power saver preset is within +0.5 recomposites/s of the bob, so cap
the effective frame floor there instead of dropping the GIF: the stored
animationQuality is never mutated, the cap is derived at the consumption
site, and a power-state observer rebuilds frames the moment the mode
toggles (the effective floor is part of menuSpriteKey).

* perf: swap menu bar sprite frames through a layer instead of button.image (chattymin#255)

Assigning `button.image` redraws the entire NSStatusBarButton on every
animation frame — and that redraw includes the two-line attributed title, not
just the 22px sprite. At the default 5fps the text re-render is always on.

Route frame pixels through a dedicated sublayer's `contents` instead: an
already-uploaded bitmap is swapped without entering the view drawing path.
`button.image` keeps a transparent placeholder so AppKit still owns
`imagePosition`, title layout and status item width; it is only reassigned when
the sprite size changes.

Measured (A/B probe — same timer, 5fps, two-line title, 60s x 2 rounds):

  button.image + CATransaction   2.00 ms/frame
  button.image alone             3.51 ms/frame
  layer.contents                 0.27 ms/frame

Live app before/after (`sample`, main thread):

  timer path                235/17212 (1.37%)  ->  33/16581 (0.20%)
  CA::Transaction::commit   163                ->  14
  _NSViewDrawRect           105                ->  0

This is a different axis from the existing transition-suppression rule, which
still holds and is kept: assigning outside a CATransaction measured worse
(3.51 ms), not better.

If the conversion ever fails the code falls back to `button.image`, which would
make the saving disappear while the animation still looks correct. A test pins
the conversion for every sprite shape and bob phase, and asserts that nil is
actually reachable so the guard cannot silently stop being able to fail.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: drop stale ccusage references from runtime comments (chattymin#256)

ccusage was removed in b762be9 (shipped in v2.1.0) and the app has parsed local
logs directly ever since. Four comments still describe the refresh path as
spawning it, which no longer matches what the code does: a refresh parses local
logs and queries limits, and the only subprocess left is codex.

These are load-bearing comments — they explain why polling is suspended on
display sleep and why App Nap is disabled — so a reader takes them at face
value. During a battery investigation they sent me looking for a per-refresh
ccusage spawn that does not exist.

The other ccusage mentions are deliberate and kept: ModelPricing (the pricing
table was reverse-derived from it), Models (the JSON shapes originated there),
LocalUsageProvider / LocalUsageReader ("replaces ccusage" context) and
scripts/parity-check.sh (an intentional cross-check tool).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Seong SeungHyeon <73338585+ite476@users.noreply.github.com>
Co-authored-by: Boris <148207123+Borisserz@users.noreply.github.com>
Co-authored-by: Justin Jeong <justin.jeong@buzzvil.com>
Co-authored-by: 개발민수 <minsoo010@naver.com>
Co-authored-by: Sanghwan Park (Otis) <jae6118@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Florian Behrend <56365738+florianbehrend@users.noreply.github.com>
Co-authored-by: Patrick.Park <patrick.park@buzzvil.com>
Co-authored-by: Igor Borges <igor@borges.dev>
Co-authored-by: Patrick.Park <pdm001125@naver.com>
Co-authored-by: Sanghwan Park (Otis) <jae6120@naver.com>
Co-authored-by: Manuel <97159984+mledutke@users.noreply.github.com>
Co-authored-by: Kirill Korikov <korikov.kirill@gmail.com>
Co-authored-by: Kirill Korikov <11762090+yourconscience@users.noreply.github.com>
Co-authored-by: Cory Eurom <ce@Corys-MacBook-Pro.local>
yourconscience added a commit to yourconscience/PokeTokenBar that referenced this pull request Sep 7, 2026
omp has its own reader/provider (~/.omp/agent/sessions), separate from Pi, so
PR chattymin#225's per-model work never reached it. Bring omp to parity:

- read the model from the envelope first, then the message, falling back to
  "omp" (omp forks route several models through one session log and some write
  the id at the envelope level, e.g. openrouter/stealth/ox-alpha)
- turn on the per-model daily breakdown in LocalOmpProvider.fetchDaily via
  daily(includeModels:), which the popover already renders generically
- unlike Pi's flat rate, omp keeps its source-reported cost
  (usage.cost.total), so the cost passes through unchanged

Adds a cache seam to LocalOmpProvider so fetchDaily can be tested end to end.
LuceteYang added a commit to LuceteYang/PikaTokenBar that referenced this pull request Sep 10, 2026
Brings in the Aside usage provider (chattymin#283), the large catch-log UI stall fix
and sprite image cache (chattymin#282), MCP-placeholder account ordering that kills the
duplicate Keychain prompt (chattymin#280), refresh-on-network-reconnect (chattymin#252), the
errSecParam Keychain read split (chattymin#243), per-model Pi/OMP attribution (chattymin#225),
German UI (chattymin#234), and the rest of the 2.5.3 line. Nothing was rejected.

Conflict resolutions worth knowing:

- SpriteLoader: took upstream's injectable `directory` but kept
  `spriteCacheDirectory()` as the default. Upstream hardcodes
  "PokeTokenBar/sprites" there, which would make both apps share one cache.
  Renamed the fork's `AppIdentityTests` probe from `.dir` to `.directory`.
- build-app.sh: kept the fork's VERSION (1.5.2) and the `PRODUCT` / read_identity
  split; upstream's rename to a literal `APP_NAME` would bypass AppIdentity.
- Localization: the fork's session-key strings and upstream's chattymin#241 copy both
  landed. Dropped the fork's block — upstream's is the same text plus German.
  Filled German into the fork-only strings (daily trend, peak, GitHub issue
  reporting) and kept the issue wording over upstream's mail wording.
  `dailyTrend` is "Täglich im Monat", not "Täglich diesen Monat": the longer
  string wraps the caption row and breaks
  testCaptionStaysOnOneLineInEveryLanguageAtWorstCaseNumbers.
- UsageStoreTests: same duplication, 159 lines of a second copy removed.
- SessionKeyLimitsProvider: took upstream wholesale. Their copy is a superset —
  403 handling plus suppression of the Keychain fallback prompt when a key is
  configured, which the fork never had.
- LocalUsageProvider: took upstream's injectable cache seam, kept the fork's
  `.local(zeroCost:)` helper.
- README ×3: kept the fork's daily-trend row and added upstream's session-key
  and per-model rows below it.

swift test: 1001 passed, 0 failures. test-gate.sh: 91.53% >= 75%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
yourconscience added a commit to yourconscience/PokeTokenBar that referenced this pull request Sep 15, 2026
* fix: prevent invisible text in floating pet hover tooltip (chattymin#193)

* fix: pick up in-place Claude account switch on auto-poll (chattymin#227) (chattymin#228)

The OAuth cache returned a still-unexpired token without re-reading
credentials.json, so /login to a second Team email left the 5h bars
on the previous account. Peek the file before the cache hit; Keychain
stays off the auto path. Same class in the Antigravity token cache.

* fix: resolve Claude account OAuth across multiple Keychain entries (chattymin#232)

* fix: show the Raising badge only on the current evolution stage (chattymin#211)

* fix: mark only current evolution stage as raising

* docs: clarify Pokédex raising badge semantics

* docs: correct Raising badge width note

* feat: let users pick animation quality for the menu bar sprite and floating pet (chattymin#212)

* fix: cap the sprite frame rate by decimating frames, not stretching them

The always-visible surfaces (menu bar sprite, floating pet) capped their frame
rate by raising each frame's delay -- max(floor, delay). That keeps the frame
count and stretches the whole loop, so a 55-frame Gen-V sprite (55 x 0.05s =
2.75s) played back over 22s at the 0.4s floor: 1/8 speed. The original rationale
("2.5fps and 5fps are indistinguishable at 22px") only held for the frame rate;
it missed that playback slowed 8x. Two-frame bob animations hid the defect,
because holding and decimating are the same thing there.

GIFDecoder.capFrameRate decimates instead: it emits one frame per accumulated
floor-length span, so the loop length matches the original and only the frame
rate drops. Same wakeup budget, correct speed, and fewer frames to composite
(55 -> 6 at a 0.4s floor).

Timer.tolerance drops from 0.5x to 0.1x for the same symptom. Tolerance only ever
fires late, so 0.5x declared that the loop may stretch by up to 1.5x -- next to
the popover, which runs with tolerance .zero, that made the menu bar look slower
on its own.

The menu bar cap also moves into a named constant. It was an inline
max(0.4, delay) while the pet's cap was a tested constant, so the cap could
vanish from the menu bar without any test noticing; both surfaces are now
asserted together in testAlwaysVisibleSurfacesAreCapped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: let users pick animation quality for the menu bar sprite and floating pet

The frame-rate cap on the always-visible surfaces was a single hardcoded value,
and no single value fits: the per-frame cost is a status-bar recomposite, so how
smooth it looks and how much battery it costs both vary by device and by sprite.
A 22px menu bar hides the low frame rate that is clearly visible on a 96px
floating pet.

This adds a three-preset setting -- Power saver (0.4s / ~2.5fps), Balanced (0.2s /
~5fps), Smooth (0.1s / ~10fps) -- shared by both surfaces, in all four UI
languages. No preset can disable the cap; 0 (native fps) is not reachable from
the UI, which keeps the idle-wakeup guarantee that the cap exists.

The default is Power saver: it is the same frame rate the app used before this
setting existed, so an existing user's battery profile does not change and the
smoother settings are opt-in. Measured idle CPU for reference: 1.8% at Balanced,
5.1% at Smooth.

The floor is part of the frame-cache identity (AppDelegate.menuSpriteKey,
SpriteView.frameTaskID) because cached frames are already decimated to a specific
rate -- without it, changing the setting would not take effect until the next
evolution. The menu bar also observes the setting explicitly: observeStore only
tracks menuTitle, so otherwise a change would not show until the next usage poll.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(cursor): read usage from dashboard API when local tokenCount is zero (chattymin#197)

* feat(cursor): fetch usage from dashboard API when local tokenCount is zero

Cursor's state.vscdb bubbles often store 0/0 token counts since early 2026,
so the SQLite scan under-reports. When the user is signed into Cursor, read
the same unofficial dashboard export the web UI uses (with sub::jwt cookie auth
from cursorAuth/accessToken), fall back to local bubbles or Downloads CSV
exports, and avoid merging dashboard rows with stale bubble estimates.

API and in-memory cursor reload cache TTL follow Settings → refresh interval
(1/2/5/15 min or manual = refetch on every refresh()).

Closes chattymin#196

* refactor(cursor): remove CSV export and local file fallbacks

Dashboard JSON API is the sole authoritative source when signed in; keep SQLite bubble scan only as offline fallback.

* fix(cursor): satisfy CI env guard and ISO8601 date parsing in tests

Route CURSOR_USAGE_API through UsageEnvironment and reuse CompanionStore state dir for the API cache path. Parse fractional-second ISO dates in CursorUsageTests.

* fix(cursor): extract AppStatePaths for nonisolated cache directory

CompanionStore.defaultURL() is MainActor-isolated; share PTB_STATE_DIR resolution via AppStatePaths for Cursor API disk cache.

* fix(cursor): harden dashboard usage fallback

Keep cached usage account-scoped, reject incomplete pagination, preserve authoritative empty responses, and make paginated event IDs unique.

* fix(cursor): harden API fetch, cache, logging, and pagination tests

Add explicit fetch failure reasons and HTTP status logging, ephemeral URLSession, request/page deadlines, account-scoped cache TTL and coveredSince checks, safer pagination/id handling, and SQLite fallback when stale cache is non-authoritative.

* test(cursor): fix transport test closures for CI

Avoid throwing closures in injected transport and compare empty fetch results by count.

* test(cursor): use Sendable page recorder in pagination test

Fix Swift 6 concurrency error when capturing mutable state in injected transport.

* fix(cursor): accept legacy disk cache without accountIdentifier

Migrate pre-account-scoped cache files instead of ignoring them on upgrade.

* fix(cursor): send epoch-ms range and read usageEventsDisplay

The dashboard endpoint was never exercised while the CSV export was the
primary source, so two request/response mismatches stayed hidden until
CSV was removed:

- startDate/endDate were sent as ISO8601, which the endpoint answers with
  HTTP 500. It expects epoch milliseconds as strings.
- Events are returned under usageEventsDisplay, not usageEvents/events,
  so parsing failed even once the request succeeded.

Pagination now also uses totalUsageEventsCount when the response carries
no pagination object. Verified live: 239 events fetched.

* docs(cursor): sync ko/ja READMEs with dashboard API data source

The English README gained the cursor.com data source row and the updated
Privacy host list, but the Korean and Japanese translations still listed
state.vscdb as Cursor's only source and claimed seven outbound hosts.
CLAUDE.md requires all three READMEs to stay in sync.

Also notes that the offline disk cache is account-scoped and capped at
6 h, so readers know how stale a fallback reading can be.

---------

Co-authored-by: Patrick.Park <patrick.park@buzzvil.com>

* fix: read Kiro CLI 2.20+ JSONL sessions (chattymin#236) (chattymin#238)

* fix: read Kiro CLI 2.20+ JSONL sessions instead of only data.sqlite3

Kiro stopped writing new turns to the SQLite store. Scan ~/.kiro/sessions
(cli/*.jsonl and v3 messages.jsonl) while keeping the legacy database, and
register KIRO_HOME / KIRO_CLI_HOME so the GUI app can see those overrides.

* fix: keep every v3 Kiro turn when timestamps are missing

Flat messages.jsonl lines share session.json createdAt, so millis ids
collapsed under dedupKeepMax. Key v3 entries by turn index and count
tool_call args as output, matching the writer-shaped parser.

---------

Co-authored-by: Patrick.Park <patrick.park@buzzvil.com>

* Add oh-my-pi (omp) usage provider (chattymin#214)

* Add oh-my-pi (omp) usage provider

Parse pi-format session JSONL under ~/.omp/agent/sessions recursively:
every assistant message's usage event is summed (rewound branches are
already billed; subagent sessions count since their tokens are not
folded into the parent), plus compaction/branch_summary envelope usage,
mirroring parsePiFile's semantics. Per-event cost.total is trusted over
the pricing table when reported; OMP_CODING_AGENT_DIR overrides the
agent home (verified against the omp binary's env vars). bridge/
conversion copies are excluded: they mirror sessions whose usage is
already counted at the original source (verified against a real bridge
file duplicating a -Projects/ session).

Also make testHermesAcceptsMillisecondStartedAt timezone-independent
(it hardcoded a local day that only holds at UTC+0/east) and cover the
previously uncovered omp reader branches.

* Use omp as the provider display name

The tool ships as `omp` (omp.sh, the `omp` binary, Homebrew `can1357/tap/omp`),
so the provider tab and Settings now show that name instead of `OMP`. The README
tables keep the repository name alongside it as `omp (oh-my-pi)` for discovery.

---------

Co-authored-by: Patrick.Park <patrick.park@buzzvil.com>

* Update readme (chattymin#239)

* Add Trendshift badges to README

* Update Korean README with Trendshift badges

* Update README.ja.md

* feat: fetch official limits with a claude.ai session key (no Keychain prompt) (chattymin#241)

* feat: fetch official limits with a claude.ai session key (no Keychain prompt)

Auto-polling never reads the Keychain by design, so once the cached token
expires and ~/.claude/.credentials.json is absent, official limits freeze until
the user presses "Refresh limit token cache" -- and that press raises the
password dialog on unnotarised builds, where an "Always Allow" grant does not
survive the next code-signature change.

This adds an opt-in second credential path. Paste a claude.ai sessionKey cookie
under Settings -> Advanced and limits come from
claude.ai/api/organizations/{org}/usage, which returns the same schema as
api.anthropic.com/api/oauth/usage -- so LimitStatus is reused unchanged and no
popover row changes shape. The path touches no Keychain, so auto-polling keeps
limits current and there is no ACL grant to lose on the next update. Without a
key nothing changes: ChainedLimitsProvider falls through to the existing OAuth
path and no file is created.

Organization selection is not "the first one". A claude.ai account also returns
orgs the user merely logged into, and API-only orgs that answer 403, so picking
first yields a permanent and plausible-looking 0%. Candidates are filtered to
chat-capable orgs, probed in parallel, and the one with usage is selected and
cached. A cached org that later returns 403 triggers rediscovery.

A dead key answers 403, not 401 -- verified by flipping one character of a
stored key in a running build, where the usage endpoint and /api/organizations
both returned 403. Since /api/organizations is not organization-scoped, a 403
there means the key itself can see nothing, so it maps to sessionKeyInvalid.
Per-organization 403s keep their existing meaning and still trigger rediscovery.

Expiry carries its source (limitsAuthExpiry: .oauth | .sessionKey) rather than a
single boolean, because the two remedies are opposites: a session key must be
re-copied from the browser, while OAuth only needs a Keychain re-read. Collapsed
into one flag, a session-key user is told to run Claude Code once -- which does
nothing for a dead cookie -- and the retry button reads the Keychain, raising
the very prompt the session key exists to avoid. The settings badge reads
"Expired" while the dead key is still stored, and the banner opens Settings with
the Advanced disclosure already expanded, since the key field lives inside it.

The key is stored as an owner-only (0600) plaintext file, not in the Keychain:
an app-owned Keychain item is the very class of defect this removes, because a
cdhash change invalidates its ACL and the prompts return. The settings copy
states the storage model and that a browser logout invalidates the key.

Note for anyone testing this: curl cannot verify these endpoints. Cloudflare
answers it with its own 403 challenge regardless of the cookie, so it will
confirm any theory brought to it. Only URLSession gives a real answer; the unit
tests seam out the HTTP layer instead.

Refs chattymin#240

* docs: list claude.ai in the outbound-host section (en/ko/ja)

The session-key path adds a twelfth outbound host, and the privacy section
enumerates every host the app talks to.

* fix: keep a configured session key from falling back into the Keychain

A stored-but-rejected key used to pass allowKeychainPrompt straight through to
the OAuth fallback, so pressing Refresh raised the very password dialog the key
exists to avoid. The remedy for a dead key is re-pasting it, not the Keychain,
and the expiry banner already routes there. Users with no key keep today's
behaviour, which the second test locks.

---------

Co-authored-by: Patrick.Park <patrick.park@buzzvil.com>

* fix: keep released Pokémon in the Pokédex (chattymin#242)

Buying an egg discarded the active Pokémon without touching `dex`, so a
species that was only backed by that companion vanished from the Pokédex.
That was the one path where the collection screen could shrink, which
contradicts the promise it makes.

`buyEgg` now records the released companion as a `DexEntry` carrying
`releasedAt`. The Pokédex folds `state.dex`, so species retention follows
automatically; only the capture log distinguishes the two, with a
`Released` badge next to the existing `Raising` one.

Three rules the record follows:

- Only reached forms are credited (`pathIDs.prefix(stageIndex + 1)`).
  Using `plannedPathIDs` would make buying an egg a shortcut to filling
  the Pokédex with evolutions never reached.
- Shiny follows `currentIsShiny`, so releasing a disguised Ditto does not
  reveal it early.
- `collectedFinals` is untouched — a released companion was not raised to
  its final form, so it must not count toward completion or branch
  weighting.

`releasedAt == nil` means "graduated", so existing saves need no
migration. A representative pinned to a released species now survives the
release, because the species is still owned.

* feat: add German UI language (chattymin#234)

Add native German copy, official Pokémon terminology, locale detection, and regression coverage so German users get a complete localized experience.

Co-authored-by: Patrick.Park <patrick.park@buzzvil.com>

* fix: split the Claude Keychain read so it stops failing with errSecParam (chattymin#243)

kSecMatchLimitAll cannot be combined with kSecReturnData; macOS rejects that
pair with errSecParam (-50) during parameter validation, before it looks for
any item. The query added in chattymin#232 therefore never succeeded for anyone whose
credentials live only in the Keychain — an approved ACL, a re-login, or a
single matching item made no difference. Measured on a real install: 24
consecutive -50 failures, zero successes.

Enumerate accounts with attributes only, then read each candidate's data with
kSecMatchLimitOne, preserving chattymin#232's intent of picking the entry that carries
claudeAiOauth. Falls back to an unscoped single read when no account attribute
comes back.

The old tests exercised extractDataItems with synthetic arrays, so
SecItemCopyMatching was never on the test path and the defect sat one layer
above what they covered. The new guard aims the production queries at a service
name that cannot exist -- nothing matches, so no ACL or prompt is involved --
and asserts the framework does not answer errSecParam. extractDataItems is
removed along with its tests; it has no production caller left.

* docs: record why Claude's refreshToken must not be used for renewal (chattymin#247)

The Keychain credential carries refreshToken and refreshTokenExpiresAt next to
a ~5h accessToken, which makes "renew with it and stop touching the Keychain
every few hours" an obvious idea. It is the wrong one: Claude Code's refresh
handler replaces its stored refreshToken with the value from the response, so
the token rotates. An external app renewing on its own would leave Claude Code
holding a dead token and force a re-login, and the only way around that is
writing into someone else's credential store, which the app-owned-Keychain rule
already forbids.

Antigravity can do this because Google does not rotate — its cache reuses the
same refreshToken across renewals. The asymmetry is the providers' token
semantics, not a gap on the Claude side.

Rotation is inferred from the replace-on-store behaviour rather than tested; the
cost of being wrong is breaking the user's primary tool, so the experiment was
not run. The decision rests on the asymmetry instead: at best it is a weaker
version of what the session key already provides, at worst Claude Code breaks.

Also records that adding a silent-first Keychain read on the user-action path
buys nothing, so the Antigravity pattern is not worth porting there either.

* feat: attribute Pi/OMP usage to the real model and break the day down per model (chattymin#225)

* feat: attribute Pi/OMP usage to the real model and break the day down per model

* fix: gate the Pi per-model breakdown to Pi and carry it through the provider

---------

Co-authored-by: Kirill Korikov <11762090+yourconscience@users.noreply.github.com>

* fix: align Antigravity limits stale UI and refresh indicator with Claude (chattymin#245)

* docs: add screenshots and README tour rows for session key, per-model breakdown, and animation quality

* release: bump version to 2.5.3

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat: keep the menu bar animating in Low Power Mode, capped at Power saver (chattymin#250)

Low Power Mode used to drop the menu bar sprite to a two-frame bob, and
the check only ran while building frames — toggling the mode mid-session
never rebuilt anything, so an already-running GIF kept its full frame
rate, and a bob built during Low Power survived after leaving it.

The Power saver preset is within +0.5 recomposites/s of the bob, so cap
the effective frame floor there instead of dropping the GIF: the stored
animationQuality is never mutated, the cap is derived at the consumption
site, and a power-state observer rebuilds frames the moment the mode
toggles (the effective floor is part of menuSpriteKey).

* perf: swap menu bar sprite frames through a layer instead of button.image (chattymin#255)

Assigning `button.image` redraws the entire NSStatusBarButton on every
animation frame — and that redraw includes the two-line attributed title, not
just the 22px sprite. At the default 5fps the text re-render is always on.

Route frame pixels through a dedicated sublayer's `contents` instead: an
already-uploaded bitmap is swapped without entering the view drawing path.
`button.image` keeps a transparent placeholder so AppKit still owns
`imagePosition`, title layout and status item width; it is only reassigned when
the sprite size changes.

Measured (A/B probe — same timer, 5fps, two-line title, 60s x 2 rounds):

  button.image + CATransaction   2.00 ms/frame
  button.image alone             3.51 ms/frame
  layer.contents                 0.27 ms/frame

Live app before/after (`sample`, main thread):

  timer path                235/17212 (1.37%)  ->  33/16581 (0.20%)
  CA::Transaction::commit   163                ->  14
  _NSViewDrawRect           105                ->  0

This is a different axis from the existing transition-suppression rule, which
still holds and is kept: assigning outside a CATransaction measured worse
(3.51 ms), not better.

If the conversion ever fails the code falls back to `button.image`, which would
make the saving disappear while the animation still looks correct. A test pins
the conversion for every sprite shape and bob phase, and asserts that nil is
actually reachable so the guard cannot silently stop being able to fail.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: drop stale ccusage references from runtime comments (chattymin#256)

ccusage was removed in b762be9 (shipped in v2.1.0) and the app has parsed local
logs directly ever since. Four comments still describe the refresh path as
spawning it, which no longer matches what the code does: a refresh parses local
logs and queries limits, and the only subprocess left is codex.

These are load-bearing comments — they explain why polling is suspended on
display sleep and why App Nap is disabled — so a reader takes them at face
value. During a battery investigation they sent me looking for a per-refresh
ccusage spawn that does not exist.

The other ccusage mentions are deliberate and kept: ModelPricing (the pricing
table was reverse-derived from it), Models (the JSON shapes originated there),
LocalUsageProvider / LocalUsageReader ("replaces ccusage" context) and
scripts/parity-check.sh (an intentional cross-check tool).

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: keep shop egg cards visible during the egg stage with a disabled buy button (chattymin#261)

During the egg stage the shop dropped all three egg cards, which read as
"the shop doesn't sell eggs" rather than "you can't buy one right now".
The purchase gate itself is intended and unchanged: a shop egg always
means "release the current Pokemon and reroll", so there is nothing to
reroll while incubating (canBuyEgg still requires an active Pokemon).

- shopEntries no longer filters eggs by hasActive; price-ascending merge
  order is unchanged.
- EggCard shows a disabled Buy button during the egg stage with a
  one-line reason underneath, localized in all seven languages. The
  state reason takes priority over the insufficient-balance label.
- The "eggs omitted when no active" test is replaced by one pinning the
  new contract: eggs stay visible, canBuyEgg/buyEgg stay blocked for
  every tier, and a blocked attempt does not touch the wallet.

Co-authored-by: p <p@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix: resolve the codex binary bundled inside ChatGPT.app for limit display (chattymin#262)

Codex usage is parsed from ~/.codex/sessions logs, but limits are fetched
by spawning the codex CLI (app-server account/rateLimits/read). Users who
run Codex through the ChatGPT desktop app have no standalone codex install,
so limit fetch was skipped ("codex binary not found") while usage still
showed. Add the ChatGPT.app bundled CLI path as the last candidate so
dedicated installs keep precedence.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat: raise the floating pet size ceiling from 192px to 384px (chattymin#267)

The desktop pet slider topped out at 192px. Double the maximum so the
companion can be sized up to 384px. Sprites render with
`.interpolation(.none)`, so the extra scale stays crisp pixel art instead
of blurring, and `FloatingPetController` already observes
`floatingPetSize`, so the panel resizes with the sprite as the slider
moves.

Also cover the other branch of `max(petSize, bubbleMinWidth)`, where the
pet drives the panel width rather than the bubble column. Only 184 and
192 reached it before; now it is most of the range.


Claude-Session: https://claude.ai/code/session_01X91ErNfhphSw9gC1pqjjjN

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Show the absolute reset time next to limit countdowns (both 5-hour and weekly) (chattymin#263)

* feat: enhance 5-hour reset display with reset time aside countdown

* feat: enhance weekly reset display with reset date and time aside countdown (with weekday if not near)

* chore: update staled comment about weekly relative-only display

* feat: trigger immediate refresh upon network reconnection (chattymin#252)

* feat: trigger immediate refresh upon network reconnection

* fix: add lock synchronization and flapping debounce to NetworkReachabilityMonitor

* fix: avoid UI stalls when opening large catch logs (chattymin#282)

* fix: avoid UI stalls when opening large catch logs

Render catch log rows lazily and reuse NSImage instances across
synchronous and asynchronous sprite and item loading paths.

- Configure the shared image cache with countLimit set to 64
- Preserve cache-key isolation and existing fallback behavior
- Add regression tests for large-log rendering and image reuse
- Document the root cause, validation, and memory measurement limits

* test: keep image cache tests compatible with Xcode 16

Keep NSImage results on MainActor and use Task<Void, Never> to await
completion without requiring NSImage to conform to Sendable.

Preserve concurrent-load and object-identity checks, and document
the CI toolchain compatibility issue in the defect log.

* feat: add Aside local token usage provider (chattymin#283)

* feat: add Aside local token usage provider

* fix(aside): harden the local usage provider after review

- Read custom scan roots on every fetch instead of snapshotting at init
- Anchor turns on COALESCE(finished_at, last_message_timestamp) so
  long-running turns land in the day / 5-hour block they were generated in
- Skip an unreadable state.db (unmigrated profile, foreign DB) but throw
  when every database fails so a transient error keeps previous values
- Drop zero-token (aborted) turns so they never form an empty active block
- Document that sessions.model is the session's current model, not per-turn
- Tests: custom roots re-read per fetch, midnight-spanning turn, open /
  prepare / partial-scan failures, all-failed throws, zero-token filter

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NE6t8EQAG3aZxEZtQ7ZVfY

* docs: record why the Aside provider needed three review rounds

- provider-extension: new local sources must sit on LocalAdditionalUsageCache;
  trace UsageStore.refresh before changing a reader's throw/nil/[] semantics
- defect-log: chained regressions from fixing failure handling provider-side
  without reading the consumer; transient vs permanent SQLite codes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NE6t8EQAG3aZxEZtQ7ZVfY

* fix: move the Aside provider onto LocalAdditionalUsageCache

Resolves the three open findings from the Aside review (docs 96fc6ab):

- Deleting an Aside session cascades to its turns and subtracted already-counted
  usage from today/week/month. The `.aside` cache case now merges each scan with
  the previous entries (`dedupKeepMax(existing + loaded)`) like Kiro, which also
  shares one scan between fetchDaily and fetchEnrichment.
- The reader no longer throws. A permanent schema mismatch (`no such table`,
  NOTADB) used to throw on every refresh and freeze `lastUpdated` app-wide;
  unreadable databases are skipped and an all-failed rescan returns `[]`, which
  the keep-max merge treats as "nothing new" instead of zero usage.
- The `finished_at` arm of the COALESCE is now covered by a fixture in both
  directions.

Provider struct moves next to its siblings; the reader keeps its own file and
takes `roots:` for tests. defect-log records the final no-throw decision.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NE6t8EQAG3aZxEZtQ7ZVfY

* fix(aside): drop per-model rows, test the cache merge behaviorally, survive a recreated store

Follow-up to the Opus review of f232b96:

- `includeModels` off: `sessions.model` is the session's current model, not a
  per-turn record, so a per-model breakdown relabelled earlier turns on every
  cache refill. Only Pi opts in.
- The keep-max merge is now exercised through the real cache instead of a
  source-text assertion: `LocalAdditionalUsageCache(rootsOverride:clock:)` and
  `LocalAsideProvider(cache:)` let a test step past the 30 s entry. The first
  behavioral run caught a `MAX(id)` reset-detection attempt that misread a
  session deletion as a recreated store; that approach was dropped.
- Entry ids carry the state.db inode, so a recreated profile store (AUTOINCREMENT
  restarts at 1) no longer hides its new turns behind cached pre-reset ids.
- Reader doc and defect-log now say the deleted-session guarantee lasts until
  the cache is invalidated; fixture mirrors the real schema (AUTOINCREMENT,
  aborted_at) and an aborted turn with tokens is asserted counted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NE6t8EQAG3aZxEZtQ7ZVfY

* chore(aside): review nits — id prefix, model comment, seam naming, month-boundary skip

- Entry ids carry the `aside|` prefix like every sibling source.
- The `sessions.model` comment now names its only consumer (pricing fallback).
- `rootsOverride` was consulted by `.aside` only; renamed `asideRootsOverride`.
- Cache tests skip within a minute of a month boundary, where stepping the clock
  31 s would drop `existing` on the new month key.
- provider-extension: recreated-store guard is inode-in-id for mutable rows or
  the Copilot-style `didReset` probe for append-only ones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NE6t8EQAG3aZxEZtQ7ZVfY

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(limits): prioritize user account over MCP placeholders to prevent duplicate Keychain prompts (chattymin#280)

- Enumerate and prioritize candidate Keychain accounts (NSUserName() > email > general > unknown/none) so the primary user account with valid claudeAiOauth is queried first, eliminating redundant authentication dialogs caused by MCP dummy entries.
- Guard against prompt bombing by immediately breaking the enumeration loop when the user cancels or authentication fails (errSecUserCanceled, errSecAuthFailed).
- Avoid redundant unscoped queries when the service is completely absent from the Keychain (errSecItemNotFound).
- Add 7 comprehensive unit tests verifying ordering hierarchy, edge-case normalization, and case-insensitive user matching.
- Update defect log with multiple Keychain item prioritization rules.

* feat: add difficulty multipliers and a popover toggle for the floating pet (chattymin#244)

Two independent, opt-in controls. Both default to current behaviour, so an
existing install sees no change until the user touches them.

Difficulty multipliers (Settings)
- Two independent multipliers: growth (egg incubation, evolution and
  graduation thresholds) and shop prices.
- Applied at the consumption sites in CompanionStore rather than to the
  constant tables. The two must stay independent, and graded egg prices
  derive from a ratio of graduationTotal -- scaling that table in place
  would let the growth slider move shop pricing as a side effect.
- Thresholds are read through private eggHatchThreshold / stageThreshold
  helpers rather than PokemonBalance directly, so a call site that forgets
  the multiplier cannot silently fall back to default difficulty.
- Persisted in UserDefaults alongside the other Settings preferences, not
  in CompanionState. Difficulty is a preference, not save data, so it stays
  out of save export/import and SaveTransfer is untouched.
- Values from defaults are clamped on read. UserDefaults is writable from
  outside via `defaults write`, so 0, negative and NaN can genuinely
  arrive, and a multiplier of 0 would produce a zero threshold (division by
  zero in progress, degenerate evolution loop).
- Applied live: lowering growth re-evaluates evolution and hatching
  immediately instead of waiting for the next usage poll (default 120s).
- RareCandy.xp is deliberately not scaled. Scaling it would cancel against
  the thresholds and make candy the one thing immune to difficulty.
- The slider is log-mapped: the range spans five orders of magnitude, and
  on a linear track 1.0 sits at 5% of the track with everything below 0.1
  collapsed into a single tick. Values snap to two significant figures, and
  the 100% snap window is defined in position space -- on a log axis 1px
  near 1.0 is worth ~7% of multiplier, so a value-space window would be
  narrower than a pixel and drag would skip past the default with no way
  back.

Popover footer
- An eye button toggles floating pet visibility. It reads and writes the
  same store.floatingPetEnabled property as the Settings switch, so the two
  cannot diverge; @observable propagates each to the other with no explicit
  syncing.

Tests
- Difficulty is covered behaviourally (same token input, different
  outcome) rather than by asserting the arithmetic: evolution and hatch
  points move with growth, shop gates and charged amounts move with price,
  and neither multiplier moves the other's outcome.
- Clamping is covered for the values that can actually reach it from
  defaults (0, negative, NaN, out-of-range), including that a stored 0
  cannot produce a zero threshold.
- Slider mapping is covered by round-trip, endpoint and drag-reachability
  of the 100% default.
- The footer eye symbol is asserted in both states; inverted icons are the
  failure mode that reads as "hidden but looks on".

* feat: give repeat hatches 2x growth (chattymin#254)

Co-authored-by: Patrick.Park <pdm001125@naver.com>

* feat: give each Pokémon persistent individual values and a Pokédex detail page (chattymin#264)

* feat: give repeat hatches 2x growth

* feat: give each Pokémon persistent individual values and a Pokédex detail page

Every hatched Pokémon now rolls individual values that persist in the
companion save — IVs, gender, ability and a level-up moveset that follows its
growth — so the same creature stays the same creature across launches.
Tapping a Pokédex cell opens a detail page with nature-modified computed
stats, species data and the full supported learnset.

Immutable PokéAPI metadata is fetched at runtime and cached under Application
Support, never bundled. The parser keeps only the supported version group, so
neither memory nor disk holds PokéAPI's cross-generation move history
(82 KB -> 5.5 KB per species for Pikachu).

- Save schema 1 -> 2. v1 saves still import; a one-time
  companion-state.pre-profiles-v1.json backup is written before migrating,
  and imported profiles are clamped at the trust boundary.
- A revealed Ditto rebases its profile, so it reports Ditto's genderless
  identity, ability and moves instead of the disguise's.
- scripts/test-gate.sh now covers PokemonProfile.swift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyRfL85nwsaUytvcNA9qqm

* fix: align imported Pokémon levels with growth rules

---------

Co-authored-by: jun <jun@daangn.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Patrick.Park <pdm001125@naver.com>

* feat: show this month's day-by-day usage trend in the popover (chattymin#270)

Week and month usage each collapse to a single PeriodUsage scalar, so the popover can say
how much this month but not how the last few days looked.

The data is already in memory. enrichmentScanStart covers the whole current month for every
provider and Entry already carries localDay; period() filters on it, keeps the sum and drops
the rest. So this is a group-by over entries that were already loaded -- no Entry field, no
cache version bump, no re-parse, no new I/O, no change to the scan window.

- LocalUsageReader.monthDailySeries builds the date axis from the month range and folds
  totals onto it rather than grouping entries by localDay. The scan is an mtime filter, so a
  session that began last month and ran into this one is read in full and drags last month's
  entries along; emitting those would draw a partially filled previous month, since the other
  files from last month were never scanned. Days with no usage are explicit zeros -- bar
  position is the date, so dropping them would shift the axis.
- ProviderEnrichment.monthDaily / ProviderSnapshot.monthDaily are optional: a provider that
  cannot produce a series stays nil and drops out of the aggregate, and the rest still add up.
- The twelve copied fetchEnrichment bodies now share one assembly site,
  ProviderEnrichment.local(entries:now:zeroCost:). Copying the assembly per provider is the
  shape the append-only watermark loop had before chattymin#157 -- a field added later gets filled in
  some copies and not others. No behaviour change; zeroCost keeps Codex and Pi at no money.
- The popover header gains a bar row with a date axis (day 1, every seventh day, and always
  today), a short tick under weekends, and a caption naming the day you point at, falling
  back to today. Its visibility gate is peak > 0, not array emptiness: producers fill unused
  days with zeros, so an isEmpty gate would show an empty row to someone who has not used the
  tool this month.

Scope is the current month only, baked into the helper rather than parameterised.

Tests cover the cross-month truncation through a real mtime scan, dense zeros, the
sum(series) == monthTotal invariant, providers that report no series, axis label collisions
across all 31 possible "today" values, and the caption staying one line in all seven
languages. The day axis and weekend marking take an injectable time zone and calendar: a
fixed 86400-second stride passed the axis test on a machine without DST and only failed
under TZ=America/Los_Angeles, and which days are the weekend is a locale decision.

Co-authored-by: Patrick.Park <pdm001125@naver.com>

* feat(limits): add ambient Claude Keychain help popover with direct session key navigation (chattymin#275)

* feat(limits): add Claude Keychain limitation guidance banner and help popover

* feat(settings): auto-scroll and focus Claude session key on navigated open

* refactor(limits): remove noisy post-refresh banner in favor of ambient help popover

* docs(defect-log): update Claude Keychain guidance entry to reflect ambient help popover

* test: allow keyboard focus in settings render fixture

* test: separate desktop focus checks from hosted rendering

---------

Co-authored-by: Patrick.Park <pdm001125@naver.com>

* fix: count Codex total-only turns and price Fable 5.1 cache reads (chattymin#278, chattymin#277) (chattymin#279)

Codex sometimes emits last_token_usage with every component at 0 while
total_tokens is set; trust that total when it is the session signal, but
keep fork zero-context phantoms at 0. Add an exact Fable 5.1 rate row so
the fable family fallback no longer overstates cache reads 4×.

* fix: remove floating pet toggle from popover footer (chattymin#284)

* fix: keep dex sprite animation consistent when opening details (chattymin#285)

* fix: match quota graph fill to remaining display mode (chattymin#286)

* fix: save difficulty changes without advancing earned progress (chattymin#287)

* fix: distinguish estimated and unavailable usage costs (chattymin#289)

* fix: estimate costs only for known model prices

* fix: distinguish estimated and unavailable usage costs

* fix: complete selected-language localization across the app (chattymin#290)

* fix: localize Pokemon detail names with English fallback

* fix: refresh legacy dex names without losing saved translations

* fix: resolve error messages in the selected language

* fix: localize remaining interface labels and accessible controls

* fix: share dex name refreshes and preserve localized error diagnostics

* docs: require the repository pull request template

* fix: align quota percentages at the trailing edge (chattymin#292)

Co-authored-by: Codex <noreply@openai.com>

* fix: display usage costs as plain dollar amounts (chattymin#293)

Co-authored-by: Codex <noreply@openai.com>

* fix: avoid English flashes while loading Pokemon detail names (chattymin#295)

Co-authored-by: Codex <noreply@openai.com>

* fix(limits): support nested token file and automatic OAuth refresh for Antigravity (chattymin#274)

* fix(limits): support nested token object in Antigravity token file

- Parse nested token object (`{"token": {"access_token": ...}}`) in jetski-standalone-oauth-token file by reusing `parseCredential`
- Prevent unnecessary fallback to macOS Keychain and avoid repeated password prompts caused by CLI ACL reset
- Support background token refresh using refresh_token when available in token file
- Add regression tests in CredentialSwitchCacheTests and record entry in defect-log.md

* fix(limits): include client credentials in Antigravity token refresh

* fix(limits): refine OAuth form encoding and document public client credentials

* fix(limits): support top-level access_token in Antigravity token parser

* fix(limits): guard near-expiry account switch and support mock OAuth refresh flow

- Guard retained cache over expired token file with isSameSourceCredential to prevent retaining stale cached account on near-expiry switch
- Inject urlSession into AntigravityTokenCache with default .shared to enable clean test mocking
- Add mock HTTP integration tests with MockOAuthURLProtocol covering token refresh, subsequent poll cache retention, and failure fallback
- Refine defect-log.md to clarify Keychain access boundary between auto and manual paths, and note Antigravity client-specific requirement for client_secret

* fix: require release notes and verified contributor attribution (chattymin#296)

* docs: refresh release tour and screenshots for v2.5.4 (chattymin#297)

* release: bump version to 2.5.4

* feat: surface omp usage per model, matching the Pi breakdown (chattymin#276)

* feat: combine token usage from a remote Mac over SSH

---------

Co-authored-by: Seong SeungHyeon <73338585+ite476@users.noreply.github.com>
Co-authored-by: Boris <148207123+Borisserz@users.noreply.github.com>
Co-authored-by: Justin Jeong <justin.jeong@buzzvil.com>
Co-authored-by: 개발민수 <minsoo010@naver.com>
Co-authored-by: Sanghwan Park (Otis) <jae6118@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Florian Behrend <56365738+florianbehrend@users.noreply.github.com>
Co-authored-by: Patrick.Park <patrick.park@buzzvil.com>
Co-authored-by: Igor Borges <igor@borges.dev>
Co-authored-by: Patrick.Park <pdm001125@naver.com>
Co-authored-by: Sanghwan Park (Otis) <jae6120@naver.com>
Co-authored-by: Manuel <97159984+mledutke@users.noreply.github.com>
Co-authored-by: Kirill Korikov <11762090+yourconscience@users.noreply.github.com>
Co-authored-by: p <p@local>
Co-authored-by: Yongjae Kim <yojkim@lbox.kr>
Co-authored-by: Michael Mugnai <Maik93@users.noreply.github.com>
Co-authored-by: pinggoo <62925498+parkjh2568@users.noreply.github.com>
Co-authored-by: Youngmin Ryou <yym3055@gmail.com>
Co-authored-by: Issachar Vinajeras <issacharv@gmail.com>
Co-authored-by: parkjun5 <58926619+parkjun5@users.noreply.github.com>
Co-authored-by: Cedric Golla <50961672+CCedDev@users.noreply.github.com>
Co-authored-by: jun <jun@daangn.com>
Co-authored-by: Codex <noreply@openai.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants