cctally 1.105.0 → 1.106.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/_cctally_core.py +151 -20
  3. package/bin/_cctally_dashboard.py +20 -227
  4. package/bin/_cctally_dashboard_conversation.py +38 -0
  5. package/bin/_cctally_dashboard_envelope.py +79 -10
  6. package/bin/_cctally_dashboard_share.py +7 -1
  7. package/bin/_cctally_db.py +267 -5
  8. package/bin/_cctally_doctor.py +40 -15
  9. package/bin/_cctally_five_hour.py +53 -6
  10. package/bin/_cctally_forecast.py +197 -42
  11. package/bin/_cctally_journal.py +217 -27
  12. package/bin/_cctally_milestone_history.py +141 -58
  13. package/bin/_cctally_milestones.py +72 -0
  14. package/bin/_cctally_parser.py +10 -6
  15. package/bin/_cctally_percent_breakdown.py +26 -20
  16. package/bin/_cctally_project.py +5 -3
  17. package/bin/_cctally_quota.py +12 -2
  18. package/bin/_cctally_quota_model.py +12 -4
  19. package/bin/_cctally_record.py +1254 -300
  20. package/bin/_cctally_reporting.py +19 -0
  21. package/bin/_cctally_share.py +21 -3
  22. package/bin/_cctally_statusline.py +15 -11
  23. package/bin/_cctally_tui.py +192 -42
  24. package/bin/_cctally_weekrefs.py +322 -161
  25. package/bin/_lib_credit.py +9 -2
  26. package/bin/_lib_credit_identity.py +159 -0
  27. package/bin/_lib_credit_selection.py +342 -0
  28. package/bin/_lib_dashboard_sources.py +16 -4
  29. package/bin/_lib_doctor.py +1 -1
  30. package/bin/_lib_ingest_frontier.py +46 -0
  31. package/bin/_lib_quota_model.py +34 -28
  32. package/bin/_lib_record.py +41 -9
  33. package/bin/_lib_rederive.py +8 -0
  34. package/bin/_lib_render.py +72 -4
  35. package/bin/_lib_snapshot_cache.py +13 -10
  36. package/bin/_lib_subscription_weeks.py +141 -54
  37. package/bin/_lib_view_models.py +128 -22
  38. package/bin/cctally +7 -0
  39. package/dashboard/static/assets/{ConversationsView-DA4j7Yso.js → ConversationsView-Dst33C0n.js} +13 -13
  40. package/dashboard/static/assets/{DoctorModal-D4PbVnbE.js → DoctorModal-5rvkyksy.js} +1 -1
  41. package/dashboard/static/assets/ModalRoot-BJH02VzE.js +1 -0
  42. package/dashboard/static/assets/{ProjectsDrillPanel-DkPlB9JH.js → ProjectsDrillPanel-CsR1bjJu.js} +1 -1
  43. package/dashboard/static/assets/{SourceDetailModal-BE7FONsS.js → SourceDetailModal-BnTIwt-R.js} +1 -1
  44. package/dashboard/static/assets/{UpdateModal-BJuI8glf.js → UpdateModal-D_RfssnW.js} +3 -3
  45. package/dashboard/static/assets/index-rzXw99Hy.js +13 -0
  46. package/dashboard/static/assets/index-u3wLfjt2.css +1 -0
  47. package/dashboard/static/assets/{outlineNavigation-zDVm6Hdd.js → outlineNavigation-C7J9lhGB.js} +1 -1
  48. package/dashboard/static/assets/useKeymap-B3KCc_c7.js +1 -0
  49. package/dashboard/static/dashboard.html +3 -3
  50. package/package.json +3 -1
  51. package/dashboard/static/assets/ModalRoot-D4oSPxPp.js +0 -1
  52. package/dashboard/static/assets/index-klO46NcU.css +0 -1
  53. package/dashboard/static/assets/index-w_bINkJ8.js +0 -13
  54. package/dashboard/static/assets/useKeymap-CJ-Pi17D.js +0 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.106.0] - 2026-09-03
9
+
10
+ ### Changed
11
+ - `cctally record-credit --force` replaces the single credit `--at` names instead of clearing the whole week, and refuses when `--at` names none. Recording a second credit in one week needs no `--force`: rerun with a different `--at`.
12
+ - A week Anthropic credited in place renders as one row on its own boundaries, instead of two rows split at the credit. A credit that also moved the week's declared end is marked, but still renders on the windows the API stated.
13
+ - The dashboard's Current Week header and modal name the week's own boundaries on a credited week, instead of the window that starts at the credit.
14
+ - A credited week's row is marked `+` in `cctally report` and `cctally weekly`, so a low `Used %` beside a week's worth of spend has a visible explanation.
15
+ - The dashboard's Weekly panel and its week detail mark a credited week, and the week detail, the Current Week card and its modal name why `$/1%` is withheld instead of the em-dash or `$0.000` you cannot tell from a week with no usage.
16
+ - The dashboard's `$/1%` tables — the Weekly modal's week list, the `$/1% Trend` panel and its modal — say `No climb` for a credited week whose ratio is withheld, instead of the em-dash you cannot tell from a week with no usage.
17
+ - `$/1%` for a credited week is measured from the credit forward, over the climb since it. With no climb yet the figure is withheld and names why. Uncredited weeks are unchanged.
18
+ - The dashboard's Current Week card, the TUI header and a shared Current Week recap report a credited week's whole spend, matching the week they name and the Weekly card beside them. Only `$/1%` is measured from the credit forward.
19
+ - `cctally milestone` history lists a credited week once instead of once per credit, and its detail separates the week's percent ladders with a row naming each credit and the level it dropped from.
20
+
21
+ ### Fixed
22
+ - A conversation open in the viewer updates its own cost as its turns stream, for both Claude and Codex. The live tail advanced the transcript but not the accounting the cost is read from.
23
+ - A Codex conversation's cost no longer reads `$0.00` long after its turns finish. The dashboard now re-scans each provider at least every two minutes, so accounting stays current even when that provider's activity hooks never fire.
24
+ - Upgrading an existing installation to v1.105.0 left every background sync failing and the dashboard stuck on `server sync error`. The two columns that release added now reach an existing database, not only a freshly created one.
25
+ - After Anthropic zeroes your weekly counter, every 7d surface lowers on the next status-line tick. A reading captured in the same hour no longer holds the week at its pre-credit percentage, and no manual command is needed.
26
+ - A goodwill credit to a non-zero level records that level's own percent milestone. Before, the ladder for the new period opened one percent late, and `cctally percent-breakdown` was missing its first row.
27
+ - `cctally percent-breakdown`, the TUI's milestone panel and the milestone writer agree on which credit a percent belongs to, including after a database rebuild and for a credit recorded with `cctally record-credit`.
28
+ - A percent milestone crossed after a credit reports the cost spent since that credit. After a credit recorded with `cctally record-credit` it reported the whole week's spend, which made the first post-credit `$/1%` figure far too high.
29
+ - `cctally record-credit` no longer deletes genuine usage recorded between the moment you assert with `--at` and the moment you run the command.
30
+ - On an install with more than one Claude account, a reset detected for one account no longer records a credit for another, and no longer discards the first account's pending detection.
31
+ - `cctally forecast` measures its rate from the latest credit rather than across it, including one you recorded yourself. `cctally budget` keeps summing the whole week, because the money was spent inside one unchanged window.
32
+ - `cctally doctor`'s post-credit milestone check now sees a week credited with `cctally record-credit`, which it silently skipped before.
33
+ - `cctally record-credit` accepts a second credit inside the same hour as the first. Only a credit at the identical instant is still refused, because that is indistinguishable from running the command twice.
34
+ - An install upgrading from a version that predates the append-only journal now gets the current weekly-credit table shape, instead of reporting the new database version while missing the columns a credit needs.
35
+ - `cctally record-credit --force` no longer leaves a replaced credit's percent milestones behind after a database rebuild, and `cctally record-usage` no longer leaves a removed stale reading's five-hour milestones pointing at it.
36
+ - `cctally` no longer records a second, automatically-shaped credit for a large credit you already recorded with `cctally record-credit`.
37
+ - A stale pre-credit reading replayed by the status line after a credit is removed on the next tick that contradicts it, instead of holding every 7d surface at the old percentage and provoking a second, phantom credit.
38
+ - On an install with more than one Claude account, `cctally record-credit` no longer names another account's credit when refusing, and no longer finishes another account's interrupted credit as though it were yours.
39
+
8
40
  ## [1.105.0] - 2026-09-02
9
41
 
10
42
  ### Added
@@ -410,7 +410,21 @@ _init_paths_from_env()
410
410
  # install and the table would simply never appear. `quota_alert_arming` is the
411
411
  # precedent the family follows — a journaled STATE record whose boundary
412
412
  # survives a rebuild, so history cannot re-fire.
413
- STATS_INDEX_EPOCH = 1011
413
+ # 1011 -> 1012 (#703 + #707): the unified Anthropic credit record. Adds the six
414
+ # `week_reset_events` fact columns (`week_start_date`, `observed_at_utc`,
415
+ # `confirming_capture_at_utc`, `observed_post_credit_pct`, `credit_key`,
416
+ # `credit_order`) and relaxes `old_week_end_at` / `new_week_end_at` to NULL. On
417
+ # a store ABOVE `LEGACY_STATS_HEAD` the bump alone delivers all of that, because
418
+ # an epoch mismatch resolves by rebuilding the disposable index from the journal
419
+ # against the new DDL. A PRE-CUTOVER store at `user_version <= 13` takes the
420
+ # in-place cutover path instead and reaches this epoch with its existing table
421
+ # untouched, so that path additionally runs the rename-recreate-copy in
422
+ # `begin_week_reset_events_reshape` / `finish_week_reset_events_reshape`.
423
+ # It is an epoch bump and NOT a stats migration for the two reasons every
424
+ # bump since 1005 carries: the 13-entry registry is frozen, AND an epoch-current
425
+ # open returns before any schema work, so a `@stats_migration` handler would
426
+ # never run on an upgraded install and the columns would simply never appear.
427
+ STATS_INDEX_EPOCH = 1012
414
428
  LEGACY_STATS_HEAD = 13
415
429
 
416
430
  #: #496 S1 F1. A NEW branch, for a state that cannot occur before the
@@ -1783,6 +1797,8 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1783
1797
  # Enforced by tests/test_kernel_extraction_invariants.py
1784
1798
  # test_core_accessor_use_is_bounded (lands in I2).
1785
1799
  add_column_if_missing = c.add_column_if_missing
1800
+ _begin_week_reset_events_reshape = c.begin_week_reset_events_reshape
1801
+ _finish_week_reset_events_reshape = c.finish_week_reset_events_reshape
1786
1802
  _canonical_5h_window_key = c._canonical_5h_window_key
1787
1803
  _backfill_week_reset_events = c._backfill_week_reset_events
1788
1804
  _backfill_five_hour_blocks = c._backfill_five_hour_blocks
@@ -2217,23 +2233,111 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
2217
2233
  # start — preventing the API's -7d-derived new week from overlapping
2218
2234
  # the old week. Inserted by cmd_record_usage on detection; read by
2219
2235
  # _apply_reset_events_to_weekrefs and the cost live-recompute path.
2236
+ # #703 + #707: this table is now the SINGLE durable representation of an
2237
+ # Anthropic weekly credit, and it defines an accounting epoch only —
2238
+ # never a display boundary. The six fact columns below are declared HERE
2239
+ # rather than added by `add_column_if_missing`, because this is an epoch
2240
+ # bump: an epoch-current open returns before any schema work, so a
2241
+ # conditional column addition would never run on an upgraded install.
2242
+ #
2243
+ # The epoch bump alone covers only ONE of the two upgrade paths. A store
2244
+ # ABOVE `LEGACY_STATS_HEAD` defers to a rebuild that materializes a fresh
2245
+ # scratch index through this DDL, so it arrives correct. A PRE-CUTOVER
2246
+ # store at `user_version <= 13` reaches this line with the table already
2247
+ # present, where `CREATE TABLE IF NOT EXISTS` is a no-op; the cutover
2248
+ # below then stamps the new epoch in place and every later open
2249
+ # fast-returns before any schema work. `begin_week_reset_events_reshape`
2250
+ # parks such a table aside so this CREATE builds the current shape, and
2251
+ # `finish_week_reset_events_reshape` (right after the `account_key`
2252
+ # guard) copies the rows back and drops the parked table. Without it the
2253
+ # store reports the current epoch while missing every fact column and
2254
+ # keeping both NOT NULL boundaries, which no later open can repair.
2255
+ #
2256
+ # week_start_date the week the credit belongs to, so the
2257
+ # unified table is queryable by week without
2258
+ # going through a boundary column.
2259
+ # observed_at_utc the exact instant the post-credit state was
2260
+ # first observed, in the SAME clock domain as
2261
+ # `weekly_usage_snapshots.captured_at_utc`
2262
+ # (the column it filters). NOT the detection
2263
+ # clock — the ingest path separates the two,
2264
+ # and comparing across the domains back-dates
2265
+ # the epoch (the #703 incident).
2266
+ # confirming_capture_at_utc the capture instant of the observation that
2267
+ # confirmed the credit; closes the upper end
2268
+ # of the automatic stale-replica bracket.
2269
+ # observed_post_credit_pct where the counter landed.
2270
+ # credit_key the row's durable identity, derived from the
2271
+ # SOURCE journal record and never from the
2272
+ # boundaries. Several credits per week are
2273
+ # representable because of it.
2274
+ # credit_order that same source record's own journal
2275
+ # instant, in Unix epoch seconds. NOT the
2276
+ # absolute journal sequence position: that
2277
+ # numbering exists but returns None on many
2278
+ # ordinary ticks, and a durable column cannot
2279
+ # be populated by something sometimes
2280
+ # unavailable. NOT the fold position of the
2281
+ # derived row either — a fold order expresses
2282
+ # dependency, not occurrence. The instant is
2283
+ # a fact OF THE SOURCE RECORD, so a rebuild
2284
+ # reproduces it byte-identically and it orders
2285
+ # by occurrence. Two known losses, both
2286
+ # deliberate: two source instants inside one
2287
+ # second tie and fall through to
2288
+ # `credit_key DESC`, which is content-hash
2289
+ # rather than chronological order; and under a
2290
+ # backward clock step instant order and append
2291
+ # order disagree.
2292
+ #
2293
+ # `old_week_end_at` / `new_week_end_at` are legacy provenance now and are
2294
+ # NULLABLE: a cutover-exported `weekly_credit_floor` op cannot supply them
2295
+ # (`weekly_credit_floors` retains no week-end timestamp), and NULL is the
2296
+ # honest value for a row that never had them. NULL on any new column means
2297
+ # the row predates this change or its source genuinely lacked the fact;
2298
+ # every accounting read falls back through
2299
+ # COALESCE(observed_at_utc, effective_reset_at_utc).
2300
+ _wre_reshaping = _begin_week_reset_events_reshape(conn)
2220
2301
  conn.execute(
2221
2302
  """
2222
2303
  CREATE TABLE IF NOT EXISTS week_reset_events (
2223
2304
  id INTEGER PRIMARY KEY AUTOINCREMENT,
2224
2305
  detected_at_utc TEXT NOT NULL,
2225
- old_week_end_at TEXT NOT NULL,
2226
- new_week_end_at TEXT NOT NULL,
2306
+ old_week_end_at TEXT,
2307
+ new_week_end_at TEXT,
2227
2308
  effective_reset_at_utc TEXT NOT NULL,
2228
2309
  observed_pre_credit_pct REAL,
2229
2310
  account_key TEXT NOT NULL DEFAULT 'unattributed',
2230
- UNIQUE(account_key, old_week_end_at, new_week_end_at)
2311
+ week_start_date TEXT,
2312
+ observed_at_utc TEXT,
2313
+ confirming_capture_at_utc TEXT,
2314
+ observed_post_credit_pct REAL,
2315
+ credit_key TEXT,
2316
+ credit_order INTEGER,
2317
+ -- Identity is the SOURCE record, not the boundaries. Keyed on
2318
+ -- the boundary pair, a second credit in one week had nowhere to
2319
+ -- live: two credits inside one hour collide outright, because
2320
+ -- `effective_reset_at_utc` is hour-floored. `credit_key` is NULL
2321
+ -- for a row that predates this change, and SQLite treats NULLs
2322
+ -- as distinct under UNIQUE, so legacy rows neither collide with
2323
+ -- each other nor with a keyed one. Replay idempotence does not
2324
+ -- rest on this constraint either way: every fold inserts with
2325
+ -- `journal_id` set, and that column carries its own partial
2326
+ -- UNIQUE index.
2327
+ UNIQUE(account_key, credit_key)
2231
2328
  )
2232
2329
  """
2233
2330
  )
2234
2331
  add_column_if_missing(
2235
2332
  conn, "week_reset_events", "account_key",
2236
2333
  "TEXT NOT NULL DEFAULT 'unattributed'")
2334
+ if _wre_reshaping:
2335
+ # `journal_id` is added here rather than by the loop further down so
2336
+ # the copy can carry it: a parked row's logical identity must reach
2337
+ # the new table, or harvest would re-emit every already-journaled
2338
+ # credit as a fresh `wr:` event. The loop below then no-ops on it.
2339
+ add_column_if_missing(conn, "week_reset_events", "journal_id", "TEXT")
2340
+ _finish_week_reset_events_reshape(conn)
2237
2341
  _backfill_week_reset_events(conn)
2238
2342
 
2239
2343
  # ── five_hour_reset_events (Anthropic-issued in-place 5h credits) ──
@@ -3064,9 +3168,8 @@ def _reset_aware_floor(
3064
3168
  *,
3065
3169
  account_key: str | None,
3066
3170
  ) -> str | None:
3067
- """Return the latest in-week clamp floor (an ISO timestamp) across BOTH
3068
- `week_reset_events` and `weekly_credit_floors`, or None when neither has a
3069
- row for this week.
3171
+ """Return the latest in-week accounting floor (an ISO timestamp), or None
3172
+ when this week has no credit.
3070
3173
 
3071
3174
  ``account_key`` (#341, review finding 11): MANDATORY account context — no
3072
3175
  silent global fallback. A real key scopes both legs to that account's
@@ -3075,40 +3178,68 @@ def _reset_aware_floor(
3075
3178
  analytics floor path, byte-identical to today on a single-account install.
3076
3179
 
3077
3180
  This is the single chokepoint the four MAX-clamp sites consult to floor the
3078
- current 7d % to the most-recent in-place credit / reset effective moment
3181
+ current 7d % to the most-recent in-place credit / reset moment
3079
3182
  (record-credit M2, issue #209, spec §4a):
3080
3183
  - statusline `_hwm_clamp` 7d (bin/_cctally_statusline.py)
3081
3184
  - the record-usage write-site monotonic clamp (bin/_cctally_record.py)
3082
3185
  - `_resolve_reset_aware_hwm` (the --from default helper)
3083
3186
  - `project`'s `_load_week_snapshots` per-week MAX (bin/_cctally_project.py)
3084
3187
 
3085
- A `week_reset_events` row counts iff its `effective_reset_at_utc` falls in
3086
- `[week_start_at, week_end_at)`; a `weekly_credit_floors` row counts iff its
3087
- `week_start_date` matches (record-credit always stamps `effective_at_utc`
3088
- inside the week, validated at plan-build time).
3188
+ #703 + #707 §5.3: the floor is ``COALESCE(observed_at_utc,
3189
+ effective_reset_at_utc)``, NOT the effective instant. The effective instant
3190
+ is hour-floored and display-only, and that rounding is what back-dated the
3191
+ 2026-09-01 incident's epoch: the credit was first observed at 17:59:41, the
3192
+ row recorded 17:00:00, and a genuine 13.0 snapshot captured at 17:33:38 fell
3193
+ inside the accounting window, held the maximum, and made every later genuine
3194
+ reading look like a regression. NULL on the exact instant means the row
3195
+ predates this change, and such a row keeps the only instant it has.
3196
+
3197
+ A row counts iff it belongs to this WEEK. That is `week_start_date` for a
3198
+ row that records it, and the `[week_start_at, week_end_at)` range only for a
3199
+ row that predates the column. The range alone is a narrowing this helper
3200
+ must not inherit from the reset-event leg it replaces: the display layer
3201
+ overrides `week_start_at` to a credit's own effective instant, so an EARLIER
3202
+ credit in the same week falls outside the range the caller passes. The
3203
+ `weekly_credit_floors` leg this collapses had no time bound at all and found
3204
+ it; keying on the week's identity keeps that.
3205
+
3206
+ The `weekly_credit_floors` leg is retained for exactly one state and is
3207
+ otherwise dead. The in-place cutover EXPORTS that table to a bootstrap
3208
+ segment and stamps `journal_id` on its rows, but it does not re-fold them,
3209
+ so between a legacy store's cutover and its first rebuild the credit exists
3210
+ only there. Nothing writes the table any more — the `weekly_credit_floor` op
3211
+ folds into `week_reset_events` — and a rebuilt index does not carry the rows
3212
+ at all, so on every other store this leg matches nothing. It cannot lower a
3213
+ floor either: for one credit present in both tables the unified row's exact
3214
+ instant is at or after the floored one, and the latest wins.
3089
3215
 
3090
3216
  The latest floor wins via `ORDER BY unixepoch(floor_at) DESC LIMIT 1` —
3091
- `unixepoch()`, NOT a textual `MAX(...)`: the two legs carry mixed offset
3217
+ `unixepoch()`, NOT a textual `MAX(...)`: the legs carry mixed offset
3092
3218
  spellings (`Z` / `+00:00`), and a lexical MAX would silently mis-order them
3093
3219
  on a non-UTC host (the same gotcha as the statusline clamp / 5h-block
3094
3220
  cross-reset flag; see the comment at bin/_cctally_statusline.py)."""
3095
3221
  acct_pred = "" if account_key is None else " AND account_key = ?"
3096
- reset_params: tuple = (week_start_at, week_end_at) + (
3097
- () if account_key is None else (account_key,))
3098
- floor_params: tuple = (week_start_date,) + (
3099
- () if account_key is None else (account_key,))
3222
+ acct_param: tuple = () if account_key is None else (account_key,)
3223
+ reset_params: tuple = (
3224
+ (week_start_date, week_start_at, week_end_at) + acct_param)
3225
+ floor_params: tuple = (week_start_date,) + acct_param
3100
3226
  row = conn.execute(
3101
3227
  f"""
3102
3228
  SELECT floor_at FROM (
3103
- SELECT effective_reset_at_utc AS floor_at
3229
+ SELECT COALESCE(observed_at_utc, effective_reset_at_utc) AS floor_at
3104
3230
  FROM week_reset_events
3105
- WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?)
3106
- AND unixepoch(effective_reset_at_utc) < unixepoch(?){acct_pred}
3231
+ WHERE (
3232
+ week_start_date = ?
3233
+ OR (week_start_date IS NULL
3234
+ AND unixepoch(effective_reset_at_utc) >= unixepoch(?)
3235
+ AND unixepoch(effective_reset_at_utc) < unixepoch(?))
3236
+ ){acct_pred}
3107
3237
  UNION ALL
3108
3238
  SELECT effective_at_utc AS floor_at
3109
3239
  FROM weekly_credit_floors
3110
3240
  WHERE week_start_date = ?{acct_pred}
3111
3241
  )
3242
+ WHERE floor_at IS NOT NULL
3112
3243
  ORDER BY unixepoch(floor_at) DESC
3113
3244
  LIMIT 1
3114
3245
  """,
@@ -510,15 +510,12 @@ from _cctally_cache import (
510
510
  from _lib_snapshot_cache import (
511
511
  build_cached_group_a,
512
512
  bump_generation,
513
- cached_bugk_segment,
514
513
  reset_bugk_segment_state,
515
514
  reset_cache_report_state,
516
515
  reset_group_a_state,
517
516
  reset_projects_env_state,
518
517
  reset_session_cache_state,
519
518
  reset_weekref_cost_state,
520
- BugKSegment,
521
- _bugk_key,
522
519
  _max_id as _snapshot_max_id,
523
520
  _reset_sig as _snapshot_reset_sig,
524
521
  )
@@ -3623,19 +3620,20 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
3623
3620
  now_utc: "dt.datetime",
3624
3621
  *, n: int = 12,
3625
3622
  skip_sync: bool = False,
3626
- use_group_a_cache: bool = False,
3627
- use_bugk_segment_cache: bool = False) -> "list[WeeklyPeriodRow]":
3623
+ use_group_a_cache: bool = False) -> "list[WeeklyPeriodRow]":
3628
3624
  """Latest n subscription weeks as WeeklyPeriodRow, newest-first.
3629
3625
 
3630
- Thin builder-using prelude + Bug-K pre-credit synthesis on top of
3631
- ``view.rows``. ``build_weekly_view`` (in ``bin/_lib_view_models.py``)
3632
- owns the bucket+overlay walk — this function calls it, swaps two
3633
- presentation fields (``label`` ← ``display_start_date`` for post-
3634
- early-reset weeks; ``is_current`` ← SubWeek-containing-now_utc with
3635
- snapshot fallback so the "Now" pill tracks wall time), layers Bug-K
3636
- pre-credit synthesized rows over the natural rows, then recomputes
3637
- ``delta_cost_pct`` newest-first so the synthesized rows participate
3638
- in the deltas.
3626
+ Thin builder-using prelude on top of ``view.rows``. ``build_weekly_view``
3627
+ (in ``bin/_lib_view_models.py``) owns the bucket+overlay walk — this
3628
+ function calls it, swaps two presentation fields (``label`` ←
3629
+ ``display_start_date`` for post-early-reset weeks; ``is_current`` ←
3630
+ SubWeek-containing-now_utc with snapshot fallback so the "Now" pill tracks
3631
+ wall time), then computes ``delta_cost_pct`` newest-first.
3632
+
3633
+ It used to layer a synthesized pre-credit row over the natural rows, because
3634
+ the credited week's own row covered only the post-credit interval. #703 +
3635
+ #707 §6.1 removed both halves of that: an Anthropic reset never changes the
3636
+ week's boundaries, so the natural row already covers the whole week.
3639
3637
 
3640
3638
  Note: weekly bucketing intentionally does NOT take ``display_tz`` —
3641
3639
  SubWeek bucket keys come from server-anchored stored anchors and the
@@ -3749,219 +3747,14 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
3749
3747
  r.delta_cost_pct = None
3750
3748
  rows_oldest_first.append(r)
3751
3749
 
3752
- # Bug K (v1.7.2 round-5): synthesize a pre-credit segment row for
3753
- # each in-place credit event. Without this the credited week shows
3754
- # ONLY the post-credit segment ($134 on live data) and the bulk of
3755
- # the week's cost (~$372 in entries before the credit moment) is
3756
- # invisible to the user.
3757
- #
3758
- # _apply_reset_events_to_subweeks shifts the credited SubWeek's
3759
- # start_ts to ``effective_reset_at_utc``, so _aggregate_weekly's
3760
- # bucket for that SubWeek already covers ONLY the post-credit
3761
- # interval. We rebuild the pre-credit bucket here by fetching the
3762
- # ``[original_start, effective)`` window directly (#268: no longer a
3763
- # wide ``entries`` list on the cached path) and re-aggregating cost /
3764
- # tokens / per-model.
3765
- #
3766
- # The pre-credit row's ``used_pct`` comes from the
3767
- # weekly_usage_snapshots row captured at-or-before the credit
3768
- # moment (the pre-credit peak the user reached); fall back to None
3769
- # if no snapshot was recorded before the credit fired.
3770
- in_place_credits = conn.execute(
3771
- "SELECT new_week_end_at, effective_reset_at_utc "
3772
- "FROM week_reset_events "
3773
- "WHERE old_week_end_at = effective_reset_at_utc"
3774
- ).fetchall()
3775
- if in_place_credits:
3776
- _lib_pricing = sys.modules.get("_lib_pricing")
3777
- if _lib_pricing is None:
3778
- import importlib.util as _ilu, pathlib as _pl
3779
- _p = _pl.Path(__file__).resolve().parent / "_lib_pricing.py"
3780
- _spec = _ilu.spec_from_file_location("_lib_pricing", _p)
3781
- _lib_pricing = _ilu.module_from_spec(_spec)
3782
- sys.modules["_lib_pricing"] = _lib_pricing
3783
- _spec.loader.exec_module(_lib_pricing)
3784
- _calc = _lib_pricing._calculate_entry_cost
3785
-
3786
- insertions: list[tuple[int, WeeklyPeriodRow]] = []
3787
- for ev in in_place_credits:
3788
- try:
3789
- eff_dt = parse_iso_datetime(
3790
- ev["effective_reset_at_utc"], "credit.eff"
3791
- )
3792
- new_end_dt = parse_iso_datetime(
3793
- ev["new_week_end_at"], "credit.new_end"
3794
- )
3795
- except ValueError:
3796
- continue
3797
- # Find the SubWeek whose end_ts equals new_week_end_at (the
3798
- # post-credit segment); its start_ts has already been
3799
- # shifted to ``effective`` by _apply_reset_events_to_subweeks.
3800
- post_sw = None
3801
- for w in weeks:
3802
- try:
3803
- w_end = parse_iso_datetime(w.end_ts, "sw.end")
3804
- except ValueError:
3805
- continue
3806
- if w_end == new_end_dt:
3807
- post_sw = w
3808
- break
3809
- if post_sw is None:
3810
- continue
3811
-
3812
- # Original start instant: take the EARLIEST recorded
3813
- # week_start_at for this week_start_date. The post-credit
3814
- # SubWeek's start_ts is the shifted value (= effective); the
3815
- # MIN over weekly_usage_snapshots gives us the original
3816
- # API-derived start before the override fired.
3817
- orig_row = conn.execute(
3818
- "SELECT MIN(week_start_at) AS ws "
3819
- "FROM weekly_usage_snapshots "
3820
- "WHERE week_start_date = ? AND week_start_at IS NOT NULL",
3821
- (post_sw.start_date.isoformat(),),
3822
- ).fetchone()
3823
- if orig_row is None or orig_row["ws"] is None:
3824
- continue
3825
- try:
3826
- original_start_iso = str(orig_row["ws"])
3827
- original_start_dt = parse_iso_datetime(
3828
- original_start_iso, "credit.original_start"
3829
- )
3830
- except ValueError:
3831
- continue
3832
- if original_start_dt >= eff_dt:
3833
- # No pre-credit interval to aggregate.
3834
- continue
3835
-
3836
- # Aggregate entries in [original_start, effective). Fetch this
3837
- # window directly (skip_sync=True — the rebuild already ingested,
3838
- # or the fallback's wide fetch above did) rather than filtering a
3839
- # wide `entries` list, so Bug-K works identically on the cached
3840
- # path (where no wide entry list exists) and the fallback path.
3841
- #
3842
- # #271 §18: the [original_start, effective) window is a CLOSED past
3843
- # interval (effective is a historical credit moment), so this folded
3844
- # aggregate is IMMUTABLE — cache it byte-identically (the same
3845
- # "re-aggregate immutable history every tick" pattern the #269
3846
- # weekref cost cache fixed). `_compute_pre_segment` is the exact
3847
- # from-scratch fetch+fold closure; on the sync thread
3848
- # (`use_bugk_segment_cache=True`) it routes through
3849
- # `cached_bugk_segment` (get-or-compute over the canonical window
3850
- # key), on EVERY other caller (CLI / share / tests / reconcile-
3851
- # failed) it computes directly — byte-unchanged. The `used_pct`
3852
- # snapshot query + WeeklyPeriodRow build BELOW always rerun fresh
3853
- # from the segment (do NOT cache the row — Codex-BK-5).
3854
- def _compute_pre_segment(_orig=original_start_dt, _eff=eff_dt):
3855
- pi = po = pcc = pcr = 0
3856
- pcost = 0.0
3857
- pmodels: dict[str, float] = {}
3858
- pcount = 0
3859
- for e in get_entries(_orig, _eff, skip_sync=True):
3860
- if _orig <= e.timestamp < _eff:
3861
- usage = e.usage
3862
- pi += usage.get("input_tokens", 0)
3863
- po += usage.get("output_tokens", 0)
3864
- pcc += usage.get("cache_creation_input_tokens", 0)
3865
- pcr += usage.get("cache_read_input_tokens", 0)
3866
- c = _calc(
3867
- e.model, usage, mode="auto", cost_usd=e.cost_usd,
3868
- )
3869
- pcost += c
3870
- pmodels[e.model] = pmodels.get(e.model, 0.0) + c
3871
- pcount += 1
3872
- # Codex-BK-4: freeze `models` as a tuple of (model, cost) in
3873
- # first-seen (dict-insertion) order so the row's stable cost-desc
3874
- # sort tie-order can't be mutated after caching.
3875
- return BugKSegment(
3876
- input=pi, output=po, cache_create=pcc, cache_read=pcr,
3877
- cost=pcost, models=tuple(pmodels.items()), entry_count=pcount,
3878
- )
3879
-
3880
- if use_bugk_segment_cache:
3881
- seg = cached_bugk_segment(
3882
- key=_bugk_key(original_start_dt, eff_dt),
3883
- compute=_compute_pre_segment,
3884
- )
3885
- else:
3886
- seg = _compute_pre_segment()
3887
- pre_input = seg.input
3888
- pre_output = seg.output
3889
- pre_cc = seg.cache_create
3890
- pre_cr = seg.cache_read
3891
- pre_cost = seg.cost
3892
- pre_models = seg.models # ((model, cost), ...) in first-seen order
3893
- pre_entry_count = seg.entry_count
3894
- if pre_entry_count == 0 and pre_cost <= 0:
3895
- # No measurable pre-credit activity — skip insertion.
3896
- continue
3897
-
3898
- # Pre-credit used_pct: latest snapshot at-or-before the
3899
- # credit moment for this week_start_date.
3900
- pre_usage = conn.execute(
3901
- "SELECT weekly_percent FROM weekly_usage_snapshots "
3902
- "WHERE week_start_date = ? "
3903
- " AND unixepoch(captured_at_utc) <= unixepoch(?) "
3904
- "ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
3905
- (post_sw.start_date.isoformat(), ev["effective_reset_at_utc"]),
3906
- ).fetchone()
3907
- pre_used_pct: float | None = None
3908
- if pre_usage is not None and pre_usage["weekly_percent"] is not None:
3909
- pre_used_pct = float(pre_usage["weekly_percent"])
3910
- pre_dpp = (
3911
- pre_cost / pre_used_pct
3912
- if pre_used_pct and pre_used_pct > 0 else None
3913
- )
3914
-
3915
- pre_total = pre_input + pre_output + pre_cc + pre_cr
3916
- # `pre_models` is a first-seen-order tuple of (model, cost); the
3917
- # stable cost-desc sort preserves that tie-order byte-for-byte,
3918
- # identical to the pre-#271 `sorted(dict.items(), ...)`.
3919
- pre_model_breakdowns = [
3920
- {"modelName": m, "cost": c}
3921
- for m, c in sorted(pre_models, key=lambda kv: -kv[1])
3922
- ]
3923
- pre_label = original_start_dt.strftime("%m-%d")
3924
- pre_row = WeeklyPeriodRow(
3925
- label=pre_label,
3926
- cost_usd=pre_cost,
3927
- total_tokens=pre_total,
3928
- input_tokens=pre_input,
3929
- output_tokens=pre_output,
3930
- cache_creation_tokens=pre_cc,
3931
- cache_read_tokens=pre_cr,
3932
- used_pct=pre_used_pct,
3933
- dollar_per_pct=pre_dpp,
3934
- delta_cost_pct=None,
3935
- # Pre-credit segment is historical even though it
3936
- # shares the bucket date with the live week.
3937
- is_current=False,
3938
- models=_model_breakdowns_to_models(
3939
- pre_model_breakdowns, pre_cost
3940
- ),
3941
- week_start_at=original_start_iso,
3942
- week_end_at=ev["effective_reset_at_utc"],
3943
- )
3944
-
3945
- # Find post-credit row's index and insert pre-credit BEFORE
3946
- # it (chronological order: pre then post in oldest-first).
3947
- post_idx = None
3948
- for i, r in enumerate(rows_oldest_first):
3949
- if r.week_start_at == post_sw.start_ts and r.week_end_at == post_sw.end_ts:
3950
- post_idx = i
3951
- break
3952
- if post_idx is None:
3953
- # The post-credit row may have been dropped by
3954
- # _aggregate_weekly (no entries in the post-credit
3955
- # interval) — append at the most-recent slot so the
3956
- # pre-credit segment still surfaces.
3957
- insertions.append((len(rows_oldest_first), pre_row))
3958
- else:
3959
- insertions.append((post_idx, pre_row))
3960
-
3961
- # Apply insertions in REVERSE index order so prior insertions
3962
- # don't shift the indices of later ones.
3963
- for idx, pre_row in sorted(insertions, key=lambda t: -t[0]):
3964
- rows_oldest_first.insert(idx, pre_row)
3750
+ # #703 + #707 §6.1: the pre-credit segment row is gone. It existed because
3751
+ # `_apply_reset_events_to_subweeks` shifted the credited week's `start_ts`
3752
+ # to the credit moment, so the panel's bucket covered only the post-credit
3753
+ # interval and the bulk of the week's spend was invisible. That shift is
3754
+ # gone an Anthropic reset never changes the week's boundaries — so the
3755
+ # single row already covers the whole week and a second one would double it.
3756
+ # The credit still shows: the row carries the marker §6.4 describes, and the
3757
+ # accounting segments remain in the milestone and drill-down data.
3965
3758
 
3966
3759
  # Reverse so caller gets newest-first; compute delta_cost_pct vs the
3967
3760
  # immediately older row in that orientation.
@@ -53,6 +53,7 @@ import time
53
53
  from _cctally_cache import (
54
54
  open_cache_db,
55
55
  scope_conversations_db_to_account,
56
+ sync_cache,
56
57
  sync_codex_cache,
57
58
  sync_claude_conversations,
58
59
  sync_codex_conversations,
@@ -64,6 +65,38 @@ from _lib_dashboard_json import (
64
65
  )
65
66
  from _lib_retained_size import retained_size_bytes
66
67
 
68
+
69
+ def _advance_live_tail_accounting(handler, changed, sync, label):
70
+ """Advance the accounting cache for paths a live tail just saw grow.
71
+
72
+ A conversation's rendered cost comes from the accounting store -- Claude
73
+ turns from ``session_entries``, Codex from ``codex_session_entries`` -- and
74
+ the periodic dashboard tick owns it. A tail that advanced only the
75
+ transcript therefore streamed new turns whose cost stayed frozen at the last
76
+ ordinary tick's value, which reads as ``$0.00`` for a whole session whenever
77
+ that tick was certified caught up. The ingest is targeted at exactly the
78
+ paths the tail resolved as changed, so this costs one file, never a walk.
79
+
80
+ Cost is an enhancement to the stream and never a precondition for it: a
81
+ contended or failed accounting write is logged and the transcript still
82
+ advances, because stalling the turns a reader is watching is strictly worse
83
+ than showing them beside a stale figure. Callers that genuinely depend on
84
+ the accounting result -- the account-scoped Codex tail, which needs the
85
+ physical account-range decision committed before transcript ingest -- must
86
+ do their own ordered sync instead of calling this.
87
+ """
88
+ try:
89
+ accounting = open_cache_db()
90
+ try:
91
+ sync(accounting, only_paths=set(changed))
92
+ finally:
93
+ accounting.close()
94
+ except Exception as exc: # noqa: BLE001
95
+ handler.log_error(
96
+ "api/conversation/events %s accounting sync failed: %r",
97
+ label, exc,
98
+ )
99
+
67
100
  # Live-tail watch-loop tuning — used ONLY by _handle_get_conversation_events_impl
68
101
  # below, so moved here with the events handler (spec §4.1 / §6).
69
102
  _LIVE_TAIL_POLL_INTERVAL = 1.0 # seconds between stat polls of the open file(s)
@@ -1104,6 +1137,7 @@ def _bare_conversation_events(
1104
1137
  return cq.session_source_paths(conn, session_id) if conn else []
1105
1138
 
1106
1139
  def _ingest(changed):
1140
+ _advance_live_tail_accounting(handler, changed, sync_cache, "claude")
1107
1141
  if account_key is None:
1108
1142
  return sync_claude_conversations(conn, only_paths=set(changed))
1109
1143
  before = conn.execute(
@@ -1274,6 +1308,8 @@ def _qualified_conversation_events(
1274
1308
 
1275
1309
  def _ingest(changed):
1276
1310
  if account_key is None:
1311
+ _advance_live_tail_accounting(
1312
+ handler, changed, sync_codex_cache, "codex")
1277
1313
  return sync_codex_conversations(conn, only_paths=set(changed))
1278
1314
  before = conn.execute(
1279
1315
  "SELECT COUNT(*),MAX(id) FROM codex_conversation_messages "
@@ -1317,6 +1353,8 @@ def _qualified_conversation_events(
1317
1353
  return cq.session_source_paths(conn, native)
1318
1354
 
1319
1355
  def _ingest(changed):
1356
+ _advance_live_tail_accounting(
1357
+ handler, changed, sync_cache, "claude")
1320
1358
  if account_key is None:
1321
1359
  return sync_claude_conversations(conn, only_paths=set(changed))
1322
1360
  before = conn.execute(