cctally 1.90.1 → 1.91.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.91.0] - 2026-08-03
9
+
10
+ ### Added
11
+ - The Codex conversation reader now shows an indicator inside the transcript while a further page is loading, so a scroll-up or a jump that has to fetch is visibly in progress rather than silently pending. When a jump cannot reach its target the reader now says so instead of falling silent (#463).
12
+ - Internal (maintainer-only): the whole frontend test estate now runs inside `bin/cctally-test-all` through a new `bin/cctally-frontend-test` harness, with a two-sided golden pinning the Codex reader path on both the server envelope and the adapted client model. Every CI lane that runs the bundle now provisions it (#463).
13
+ - Internal (maintainer-only): the React client now runs ESLint's hook-order rule in both its normal build and the authoritative test bundle, so a conditional hook fails statically instead of surviving until a mounted component changes branches at runtime (#468).
14
+ - Codex reasoning now lists every heading the model actually wrote, one readable line each, instead of a single clipped line that hid the rest. Press `h` and `H` to step forward and back through them, including into a part of the conversation that has not loaded yet. Codex re-states earlier headings as a turn goes on, so a heading a turn has already shown is listed once rather than again each time (#463).
15
+ - Internal (maintainer-only): `bin/cctally-frontend-test` now typechecks the React client, so a TypeScript error fails the authoritative local and remote suite instead of only the CI build (#463).
16
+
17
+ ### Changed
18
+ - A long Codex conversation now opens without downloading the whole thing: the server serves a bounded page and the reader loads the rest as you scroll. The heaviest conversation in a real store went from 13.2 MB in a single response to 2.5 MB (#463).
19
+ - A very long Codex turn is now split into several bounded reading units instead of one enormous message, so the reader can scroll it smoothly. Deep links, bookmarks and saved reading positions issued before this change still resolve (#463).
20
+ - A Codex message that is not the one its turn's cost is attributed to now reports no cost at all rather than a zero, so a consumer of the JSON can tell "this message carries no cost of its own" apart from "this cost nothing". No displayed figure changes: every rendering site already showed a cost only when there was a positive one (#463).
21
+ - Consecutive Codex messages now read as the separate messages they were, instead of being run together into one block of prose (#463).
22
+ - The dashboard's Codex Cache Report wire now publishes only `cached_input_percent`, reports structurally inapplicable wasted-cost and efficiency values as `null`, and identifies the coordinated contract change as source schema version 4 (#465).
23
+
24
+ ### Fixed
25
+ - Internal (maintainer-only): generated cache fixtures now store UTC timestamps in the same `+00:00` form as production, so rows exactly at a reader's upper boundary are exercised instead of being silently excluded by lexical ordering (#467).
26
+ - Internal (maintainer-only): the remote-test harness's synthetic lock holders now acknowledge that their lock path is gone before a fixture continues, and wrapper cleanup no longer strands an owned lock when its secondary process-start probe is transiently unavailable under load (#464).
27
+ - The Codex hero's transient ingest-backlog note now stays to one compact line on phone-width screens instead of wrapping into two to four lines. The full `+N sessions still loading` wording remains on wider screens, and the complete explanation remains available to assistive technology at every width (#459).
28
+ - Multi-account Codex quota labels now stay attached to the account they came from when two accounts share one `$CODEX_HOME` root. The dashboard no longer borrows another account's label or window duration from an otherwise-identical active quota key, while single-account dashboards keep their existing undecorated join (#437).
29
+ - The dashboard's All tab now keeps its combined spend and token total when one provider's quota evidence is stale. The retained actuals carry a visible `Stale quota` marker in both the hero and current-usage modal, while forward-looking projections remain paused (#359).
30
+ - Multi-account Codex dashboard cards now mark stale quota evidence on the account it belongs to. A fresh sibling no longer hides another account's staleness, and the stale card keeps showing its retained percentage, reset, and spend; after reset its quota fields still clear while historical spend remains (#416, #360).
31
+ - The dashboard's Recent Alerts card and modal now identify which account each alert belongs to and honor the selected account filter, while retaining vendor-wide crossings as `All accounts`. Simultaneous same-threshold alerts from different accounts no longer collapse into one toast, and single-account dashboards keep their previous undecorated shape (#345).
32
+ - The dashboard now marks incomplete Codex totals everywhere they are shown: the Combined spend hero carries the same visible `+N sessions still loading` caveat as the Codex hero, and the Codex current-cycle modal explains that its totals will rise as bounded ingest finishes. The disclosure also reaches the Codex section embedded in the All-sources modal, while account-scoped stale-cycle notes keep their existing scope (#456).
33
+ - A deep link, bookmark or saved reading position pointing into a long Codex turn now loads the message it names. Because the reader's outline omits some turns from its navigation list, any position inside one of those turns resolved to nothing at all and the reader stayed on the last page it had, showing no error. On the heaviest conversation in a real store this affected every position inside a split turn. The same omission applies to Claude conversations for a position inside a system or lifecycle turn, and those now load as well (#463).
34
+ - Find in a Codex conversation now reports and reaches every match. Matches inside a long turn, past its first reading unit, were dropped from the result list — on the heaviest conversation in a real store one query reported 2 matches where there are 16 — and none of the dropped ones could be navigated to (#463).
35
+ - Paging up through a Codex conversation now returns the page immediately before your cursor. It previously returned the conversation's opening messages instead, which made scrolling up appear to jump to the beginning (#463).
36
+ - `cctally transcript export` and the dashboard export no longer repeat each Codex reasoning heading, so an exported Codex transcript reads once through rather than twice (#463).
37
+ - The transcript paging indicator no longer sits under the "↓ N new" pill on a narrow screen (#463).
38
+ - A long Codex reasoning heading is now readable in full: it wraps instead of being cut off mid-sentence with no way to see the rest (#463).
39
+ - A Codex message that opened a code block and never closed it no longer swallows the messages that follow it into that code block (#463).
40
+ - Internal (maintainer-only): the issue-triage tracker now keeps its reconciler ledger in a collapsed disclosure, matching the managed issue comments instead of rendering raw machine-state JSON below the human execution plan (#475).
41
+ - Concurrent first-time commands no longer misclassify a freshly initialized `stats.db` as a database written by a newer cctally. A waiting opener now rechecks the index epoch after acquiring the initialization lock, so it accepts the winner's completed index instead of sending epoch `1006` through the frozen legacy migration dispatcher.
42
+
8
43
  ## [1.90.1] - 2026-08-02
9
44
 
10
45
  ### Fixed
package/README.md CHANGED
@@ -30,9 +30,9 @@ Your Claude Code plan meters you with a percentage that creeps up all week. ccta
30
30
  </p>
31
31
 
32
32
  <!-- cctally:latest-stable:begin -->
33
- **Latest stable: v1.87.1** (2026-07-30)
33
+ **Latest stable: v1.90.1** (2026-08-02)
34
34
 
35
- - Codex session names now remain visible in Recent Sessions when an individual account is selected, whenever transcript visibility is enabled. Selecting an account no longer replaces every session name with an em dash; disabling transcript visibility still hides names in both the all-accounts and focused-account views.
35
+ - Internal (maintainer-only): the public release snapshot no longer ships the issue-triage pytest modules without the private workflow implementation they import. Those tests now stay behind the same mirror boundary as their `.agent-workflows` dependencies, preventing collection failures across the public Python matrix.
36
36
  <!-- cctally:latest-stable:end -->
37
37
 
38
38
  ## Quick start
@@ -1648,6 +1648,20 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
1648
1648
  # at `_uv == STATS_INDEX_EPOCH` above and never reaches here, so the hot
1649
1649
  # path takes no exclusive lock at all.
1650
1650
  with _cctally_store.stats_open_time_guard(live=_target_path is None):
1651
+ # Another opener can win the exclusive guard after this connection's
1652
+ # pre-lock epoch read, initialize/cut over the index, and stamp the
1653
+ # current epoch while this opener waits. Recheck under the guard before
1654
+ # acting on that stale legacy/fresh decision; otherwise the loser enters
1655
+ # the frozen migration dispatcher with user_version=1006 and reports a
1656
+ # false downgrade against legacy head 13.
1657
+ if _epoch_engaged and conn.execute(
1658
+ "PRAGMA user_version"
1659
+ ).fetchone()[0] == STATS_INDEX_EPOCH:
1660
+ if _target_path is None:
1661
+ _reconcile_durable_applied_migration_errors(
1662
+ conn, _STATS_MIGRATIONS, "stats.db"
1663
+ )
1664
+ return conn
1651
1665
  _fixups_current = _cctally_store.stats_open_fixups_current(conn)
1652
1666
  conn.execute(
1653
1667
  """
@@ -41,6 +41,7 @@ import bisect
41
41
  import datetime as dt
42
42
  import importlib.util as _ilu
43
43
  import os
44
+ import sqlite3
44
45
  import sys
45
46
  from collections.abc import Mapping
46
47
  from zoneinfo import ZoneInfo
@@ -329,7 +330,40 @@ def _select_current_block_for_envelope(
329
330
  # ``FROM`` clause so the table name lives in the registry, not inlined here.
330
331
 
331
332
 
332
- def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
333
+ def _alert_account_resolver(conn: sqlite3.Connection):
334
+ """Build one snapshot-scoped #345 resolver without per-row DB reads."""
335
+ import _cctally_account
336
+
337
+ decorated = {
338
+ provider: _cctally_account.provider_is_decorated(conn, provider)
339
+ for provider in ("claude", "codex")
340
+ }
341
+ labels = {
342
+ provider: _cctally_account.display_label_map(conn, provider)
343
+ for provider, enabled in decorated.items()
344
+ if enabled
345
+ }
346
+ for provider_labels in labels.values():
347
+ provider_labels.update({"*": "All accounts", "unattributed": "Unattributed"})
348
+
349
+ def fields(provider: str, account_key: object) -> dict[str, str]:
350
+ if not decorated.get(provider, False):
351
+ return {}
352
+ key = str(account_key or "*")
353
+ label = labels[provider].get(key)
354
+ if label is None:
355
+ label = _cctally_account.account_label(conn, key)
356
+ return {
357
+ "accountKey": key,
358
+ "accountLabel": label,
359
+ }
360
+
361
+ return fields
362
+
363
+
364
+ def _envelope_rows_weekly(
365
+ conn, descriptor, limit, severity_for, account_fields,
366
+ ) -> list[dict]:
333
367
  # ``reset_event_id`` (v1.7.2) segments the same (week, threshold)
334
368
  # across pre-credit (0) and post-credit (event.id) cohorts, both
335
369
  # of which can be alerted. The envelope id must include the
@@ -340,7 +374,7 @@ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
340
374
  rows = conn.execute(
341
375
  f"""
342
376
  SELECT week_start_date, percent_threshold, captured_at_utc,
343
- alerted_at, cumulative_cost_usd, reset_event_id
377
+ alerted_at, cumulative_cost_usd, reset_event_id, account_key
344
378
  FROM {descriptor.milestone_table}
345
379
  WHERE alerted_at IS NOT NULL
346
380
  ORDER BY alerted_at DESC
@@ -360,6 +394,7 @@ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
360
394
  "severity": severity_for(threshold),
361
395
  "crossed_at": r["captured_at_utc"],
362
396
  "alerted_at": r["alerted_at"],
397
+ **account_fields("claude", r["account_key"]),
363
398
  "context": {
364
399
  "week_start_date": r["week_start_date"],
365
400
  "cumulative_cost_usd": cumulative,
@@ -377,7 +412,9 @@ def _envelope_rows_weekly(conn, descriptor, limit, severity_for) -> list[dict]:
377
412
  return out
378
413
 
379
414
 
380
- def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict]:
415
+ def _envelope_rows_five_hour(
416
+ conn, descriptor, limit, severity_for, account_fields,
417
+ ) -> list[dict]:
381
418
  # Site F (spec §3.2 bucket C / §3.3): widen the row identity to
382
419
  # include ``reset_event_id`` so post-credit (seg=event.id) crossings
383
420
  # of the same (window_key, threshold) don't collide with pre-credit
@@ -387,10 +424,12 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
387
424
  rows = conn.execute(
388
425
  f"""
389
426
  SELECT m.five_hour_window_key, m.percent_threshold, m.captured_at_utc,
390
- m.alerted_at, m.block_cost_usd, m.reset_event_id,
427
+ m.alerted_at, m.block_cost_usd, m.reset_event_id, m.account_key,
391
428
  b.block_start_at
392
429
  FROM {descriptor.milestone_table} m
393
- LEFT JOIN five_hour_blocks b ON b.five_hour_window_key = m.five_hour_window_key
430
+ LEFT JOIN five_hour_blocks b
431
+ ON b.five_hour_window_key = m.five_hour_window_key
432
+ AND b.account_key = m.account_key
394
433
  WHERE m.alerted_at IS NOT NULL
395
434
  ORDER BY m.alerted_at DESC
396
435
  LIMIT ?
@@ -410,6 +449,7 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
410
449
  "severity": severity_for(threshold),
411
450
  "crossed_at": r["captured_at_utc"],
412
451
  "alerted_at": r["alerted_at"],
452
+ **account_fields("claude", r["account_key"]),
413
453
  "context": {
414
454
  "five_hour_window_key": int(r["five_hour_window_key"]),
415
455
  "block_start_at": r["block_start_at"] or "",
@@ -420,7 +460,9 @@ def _envelope_rows_five_hour(conn, descriptor, limit, severity_for) -> list[dict
420
460
  return out
421
461
 
422
462
 
423
- def _envelope_rows_budget_family(conn, descriptor, limit, severity_for) -> list[dict]:
463
+ def _envelope_rows_budget_family(
464
+ conn, descriptor, limit, severity_for, account_fields,
465
+ ) -> list[dict]:
424
466
  # Unified vendor-tagged budget axis (#143). ONE mapper backs BOTH the
425
467
  # ``budget`` (``vendor='claude'``, issue #19) and ``codex_budget``
426
468
  # (``vendor='codex'``, calendar-period-codex-budgets spec §6) axes —
@@ -461,7 +503,7 @@ def _envelope_rows_budget_family(conn, descriptor, limit, severity_for) -> list[
461
503
  SELECT period_start_at,
462
504
  COALESCE(period, ?) AS period,
463
505
  threshold, crossed_at_utc, alerted_at,
464
- budget_usd, spent_usd, consumption_pct
506
+ budget_usd, spent_usd, consumption_pct, account_key
465
507
  FROM {descriptor.milestone_table}
466
508
  WHERE vendor = ? AND alerted_at IS NOT NULL
467
509
  ORDER BY alerted_at DESC
@@ -498,12 +540,15 @@ def _envelope_rows_budget_family(conn, descriptor, limit, severity_for) -> list[
498
540
  "severity": severity_for(threshold),
499
541
  "crossed_at": r["crossed_at_utc"],
500
542
  "alerted_at": r["alerted_at"],
543
+ **account_fields(str(vendor), r["account_key"]),
501
544
  "context": ctx,
502
545
  })
503
546
  return out
504
547
 
505
548
 
506
- def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict]:
549
+ def _envelope_rows_projected(
550
+ conn, descriptor, limit, severity_for, account_fields,
551
+ ) -> list[dict]:
507
552
  # Fourth axis (issue #121): projected-pace threshold crossings. Like
508
553
  # budget, projected alerts re-anchor ``week_start_at`` on a mid-week
509
554
  # reset, so there is NO ``reset_event_id`` segment — the new window gets
@@ -524,7 +569,7 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
524
569
  SELECT week_start_at,
525
570
  COALESCE(period, 'subscription-week') AS period,
526
571
  metric, threshold, projected_value,
527
- denominator, crossed_at_utc, alerted_at
572
+ denominator, crossed_at_utc, alerted_at, account_key
528
573
  FROM {descriptor.milestone_table}
529
574
  WHERE alerted_at IS NOT NULL
530
575
  ORDER BY alerted_at DESC
@@ -547,6 +592,10 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
547
592
  "severity": severity_for(threshold),
548
593
  "crossed_at": r["crossed_at_utc"],
549
594
  "alerted_at": r["alerted_at"],
595
+ **account_fields(
596
+ "codex" if metric == "codex_budget_usd" else "claude",
597
+ r["account_key"],
598
+ ),
550
599
  "context": {
551
600
  "week_start_at": r["week_start_at"],
552
601
  "metric": metric,
@@ -557,7 +606,9 @@ def _envelope_rows_projected(conn, descriptor, limit, severity_for) -> list[dict
557
606
  return out
558
607
 
559
608
 
560
- def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list[dict]:
609
+ def _envelope_rows_project_budget(
610
+ conn, descriptor, limit, severity_for, account_fields,
611
+ ) -> list[dict]:
561
612
  # Fifth axis (issue #19 / #121): PER-PROJECT equiv-$ budget threshold
562
613
  # crossings. Like the global budget axis, project-budget alerts re-anchor
563
614
  # ``week_start_at`` on a mid-week reset, so there is NO ``reset_event_id``
@@ -575,7 +626,7 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
575
626
  rows = conn.execute(
576
627
  f"""
577
628
  SELECT week_start_at, project_key, threshold, budget_usd, spent_usd,
578
- consumption_pct, crossed_at_utc, alerted_at
629
+ consumption_pct, crossed_at_utc, alerted_at, account_key
579
630
  FROM {descriptor.milestone_table}
580
631
  WHERE alerted_at IS NOT NULL
581
632
  ORDER BY alerted_at DESC
@@ -605,6 +656,7 @@ def _envelope_rows_project_budget(conn, descriptor, limit, severity_for) -> list
605
656
  "severity": severity_for(threshold),
606
657
  "crossed_at": r["crossed_at_utc"],
607
658
  "alerted_at": r["alerted_at"],
659
+ **account_fields("claude", r["account_key"]),
608
660
  "context": {
609
661
  "week_start_at": r["week_start_at"],
610
662
  "project": label_by_key.get(project_key, project_key),
@@ -795,12 +847,15 @@ def _build_alerts_envelope_array(
795
847
  c = sys.modules["cctally"]
796
848
  registry = c.AXIS_REGISTRY
797
849
  severity_for = c.severity_for
850
+ account_fields = _alert_account_resolver(conn)
798
851
  out: list[dict] = []
799
852
  for descriptor in registry:
800
853
  mapper = _ENVELOPE_AXIS_MAPPERS.get(descriptor.id)
801
854
  if mapper is None: # pragma: no cover - registry/mapper drift guard
802
855
  continue
803
- out.extend(mapper(conn, descriptor, limit, severity_for))
856
+ out.extend(mapper(
857
+ conn, descriptor, limit, severity_for, account_fields,
858
+ ))
804
859
 
805
860
  # Python's list.sort is stable. When two alerts share the same
806
861
  # `alerted_at` ISO string (rare; multiple axes firing within the same
@@ -2422,9 +2422,29 @@ def _alerts_wire(
2422
2422
  so it stays visible under focus and the client labels it as vendor-wide.
2423
2423
  """
2424
2424
  rows: list[dict[str, object]] = []
2425
+ if decorated:
2426
+ import _cctally_account
2427
+
2428
+ account_labels = _cctally_account.display_label_map(stats_conn, "codex")
2429
+ account_labels.update({"*": "All accounts", "unattributed": "Unattributed"})
2430
+ else:
2431
+ account_labels = {}
2425
2432
 
2426
2433
  def _account(value: object) -> dict[str, object]:
2427
- return {"account_key": str(value or _CODEX_VENDOR_WIDE_ACCOUNT)} if decorated else {}
2434
+ if not decorated:
2435
+ return {}
2436
+
2437
+ key = str(value or _CODEX_VENDOR_WIDE_ACCOUNT)
2438
+ label = account_labels.get(key)
2439
+ if label is None:
2440
+ label = _cctally_account.account_label(stats_conn, key)
2441
+ return {
2442
+ # Retained as the internal account-scope selector used by the
2443
+ # source-state builder; the camel fields are the public #345 wire.
2444
+ "account_key": key,
2445
+ "accountKey": key,
2446
+ "accountLabel": label,
2447
+ }
2428
2448
 
2429
2449
  try:
2430
2450
  for period, threshold, consumption_pct, crossed_at, account_key in stats_conn.execute(
@@ -2880,6 +2900,12 @@ def _codex_accounts_wire(
2880
2900
  }
2881
2901
  if is_unattributed:
2882
2902
  card["unattributed"] = True
2903
+ elif cyc is not None and cyc.evidence_stale:
2904
+ # #360 / #416 closeout: freshness belongs to the account whose
2905
+ # resolved cycle supplied the card. The aggregate hero marker
2906
+ # cannot speak for a fresh sibling, and staleness is disclosure
2907
+ # only — the retained percentage, reset and spend remain useful.
2908
+ card["cycleFreshness"] = "stale"
2883
2909
  accounts_wire.append(card)
2884
2910
  if cyc is not None and not is_unattributed:
2885
2911
  hero_cycles_wire.append({
@@ -6,7 +6,7 @@ shared key set, which is how the Codex empty return and its populated
6
6
  return drifted apart (#443 F18).
7
7
 
8
8
  Provider parameterization resolves exactly four things and nothing else:
9
- the percent key(s), whether the not-applicable metadata is emitted, the
9
+ the percent key, whether the not-applicable metadata is emitted, the
10
10
  applicable predicate set, and the reason text. Everything else is
11
11
  provider-independent by construction.
12
12
 
@@ -31,15 +31,8 @@ ALL_PREDICATES = tuple(
31
31
 
32
32
  # Codex figures that are structurally absent rather than unmeasured.
33
33
  # OpenAI charges no cache-write premium, so there is nothing to waste and
34
- # therefore no ratio of saved to wasted. The VALUES stay numeric through
35
- # the transition release (a pre-S2 tab calls .toFixed on the first and
36
- # Math.round on the second); this map is the authoritative signal a
37
- # current client renders from.
38
- #
39
- # Switching these to None, removing the transitional `cache_hit_percent`
40
- # in _percent, and bumping SOURCE_SCHEMA_VERSION to 4 are ONE release's
41
- # work, tracked in cctally-dev#465. Doing any of them alone reintroduces
42
- # the stale-tab crash this retention exists to avoid.
34
+ # therefore no ratio of saved to wasted. Their values are None on the Codex
35
+ # wire; this map remains the authoritative user-facing reason.
43
36
  CODEX_NOT_APPLICABLE = {
44
37
  "wasted_usd": "OpenAI charges no cache-write premium, so Codex has no wasted-cache figure.",
45
38
  "fourteen_day_efficiency_ratio": "Efficiency compares saved against wasted, and Codex has no wasted-cache figure.",
@@ -82,16 +75,10 @@ def filter_inapplicable(provider, row):
82
75
 
83
76
 
84
77
  def _percent(provider, row):
85
- """Emit the percent key(s) for one row-shaped mapping.
86
-
87
- Codex dual-publishes: `cached_input_percent` is authoritative and
88
- `cache_hit_percent` carries the identical value for one release, so a
89
- tab that spans a `cctally update` keeps reading a true number instead
90
- of `Math.floor(NaN)`.
91
- """
78
+ """Emit the provider-authoritative percent key for one row mapping."""
92
79
  value = row["cache_hit_percent"]
93
80
  if provider == "codex":
94
- return {"cached_input_percent": value, "cache_hit_percent": value}
81
+ return {"cached_input_percent": value}
95
82
  return {"cache_hit_percent": value}
96
83
 
97
84
 
@@ -104,7 +91,7 @@ def _today_block(provider, today):
104
91
  "delta_pp": today["delta_pp"],
105
92
  "net_usd": today["net_usd"],
106
93
  "saved_usd": today["saved_usd"],
107
- "wasted_usd": today["wasted_usd"],
94
+ "wasted_usd": None if provider == "codex" else today["wasted_usd"],
108
95
  "anomaly_triggered": today["anomaly_triggered"],
109
96
  "anomaly_reasons": list(today["anomaly_reasons"]),
110
97
  "baseline_daily_row_count": today["baseline_daily_row_count"],
@@ -124,7 +111,7 @@ def _day_block(provider, d):
124
111
  "cache_creation_tokens": d["cache_creation_tokens"],
125
112
  "cache_read_tokens": d["cache_read_tokens"],
126
113
  "saved_usd": d["saved_usd"],
127
- "wasted_usd": d["wasted_usd"],
114
+ "wasted_usd": None if provider == "codex" else d["wasted_usd"],
128
115
  "net_usd": d["net_usd"],
129
116
  "anomaly_triggered": d["anomaly_triggered"],
130
117
  "anomaly_reasons": list(d["anomaly_reasons"]),
@@ -171,6 +158,7 @@ def build_cache_report_wire(
171
158
  "is_empty": is_empty,
172
159
  }
173
160
  if provider == "codex":
161
+ out["fourteen_day_efficiency_ratio"] = None
174
162
  out["not_applicable"] = dict(CODEX_NOT_APPLICABLE)
175
163
  out["anomaly_predicates"] = list(CODEX_PREDICATES)
176
164
  # The caller counted anomalies BEFORE inapplicable predicates were
@@ -1498,6 +1498,38 @@ def normalize_codex_events(
1498
1498
  # ── mirror pairing + canonical items (§5.3 / §5.2) ────────────────────────────
1499
1499
 
1500
1500
 
1501
+ _REASONING_WS_RE = re.compile(r"\s+")
1502
+
1503
+
1504
+ def _reasoning_projected_text(row: CodexNormalizedRow) -> str:
1505
+ """Normalized projected text of one reasoning row, or ``""``.
1506
+
1507
+ Read from the STORED projection in ``detail_json``, never from
1508
+ ``search_thinking``: that column is capped at 16,000 characters and a
1509
+ response item stores ``summary + "\\n" + body`` in it, so it cannot
1510
+ distinguish a title from a title-plus-body.
1511
+
1512
+ Normalization drops the bold wrapper and collapses whitespace, because the
1513
+ two extraction paths disagree about both. A ``response_item`` aggregate
1514
+ keeps its parts' ``**`` wrappers inside one ``summary`` string, while an
1515
+ ``event_msg`` part whose text matches the title pattern has already had its
1516
+ wrapper stripped into ``title``.
1517
+ """
1518
+ if not row.detail_json:
1519
+ return ""
1520
+ try:
1521
+ detail = json.loads(row.detail_json)
1522
+ except (json.JSONDecodeError, TypeError):
1523
+ return ""
1524
+ reasoning = detail.get("reasoning") if isinstance(detail, dict) else None
1525
+ if not isinstance(reasoning, dict):
1526
+ return ""
1527
+ head = reasoning.get("title") or reasoning.get("summary") or ""
1528
+ body = reasoning.get("body") or ""
1529
+ joined = "\n".join(part for part in (head, body) if part)
1530
+ return _REASONING_WS_RE.sub(" ", joined.replace("**", "")).strip()
1531
+
1532
+
1501
1533
  def _pair_mirrors_impl(
1502
1534
  rows: list[CodexNormalizedRow],
1503
1535
  ) -> tuple[set[int], dict[int, int]]:
@@ -1544,6 +1576,82 @@ def _pair_mirrors_impl(
1544
1576
  paired_response.add(prev_idx)
1545
1577
  last_prose_by_kind[row.kind] = j
1546
1578
 
1579
+ # Reasoning containment (#463 S1 / F5). Exact-digest pairing structurally
1580
+ # cannot collapse the general reasoning case: a response_item reasoning
1581
+ # row's identity is `"\n".join([summary, body])` while an event_msg
1582
+ # agent_reasoning row's is the raw single string, and the two coincide only
1583
+ # when the body is empty and the raw text equals the summary exactly. The
1584
+ # real relation is containment — one aggregate carries the parts that each
1585
+ # arrived earlier as their own event.
1586
+ #
1587
+ # Measured against the production store on 2026-08-02: of 17,149 turned
1588
+ # event_msg reasoning rows, 4,883 already pair by exact digest and ALL
1589
+ # 12,266 that survive are contained in exactly one same-turn aggregate, with
1590
+ # no orphans and nothing present-but-not-contained.
1591
+ #
1592
+ # This runs BESIDE the exact-digest passes rather than replacing them, so no
1593
+ # other mirror kind's behaviour changes. Three properties make it safe, and
1594
+ # each answers a concrete way a simpler rule would delete real content:
1595
+ #
1596
+ # * Per-aggregate, never concatenated. Concatenating a turn's aggregates
1597
+ # admits a match spanning the boundary between two unrelated ones, which
1598
+ # would suppress a part no aggregate actually contains.
1599
+ # * Multiset, not set. An aggregate covers each occurrence of a part once.
1600
+ # Two identical events must not both fold onto an aggregate that carries
1601
+ # that text once — and an aggregate already claimed by the exact-digest
1602
+ # pass above has that occurrence spent, which is why `used` is seeded
1603
+ # from `partners`.
1604
+ # * An explicit partner. `pair_mirror_partners` is the contract search and
1605
+ # find rely on to map a suppressed row to its canonical survivor, so a
1606
+ # containment match names WHICH aggregate owns it — something a
1607
+ # concatenated match could not do.
1608
+ #
1609
+ # A turn with no aggregate loses nothing, and a part genuinely contained in
1610
+ # none is retained.
1611
+ reasoning_turns: dict[str, dict[str, list[int]]] = {}
1612
+ for i, row in enumerate(rows):
1613
+ if row.kind != "reasoning" or row.turn_id is None:
1614
+ continue
1615
+ group = reasoning_turns.setdefault(row.turn_id, {"R": [], "E": []})
1616
+ group["E" if row.record_family == "event_msg" else "R"].append(i)
1617
+ for group in reasoning_turns.values():
1618
+ if not group["R"]:
1619
+ continue
1620
+ aggregates = [(index, _reasoning_projected_text(rows[index]), {})
1621
+ for index in group["R"]]
1622
+ # An aggregate the exact-digest pass already paired has spent one
1623
+ # occurrence of its own text; seed that so the two passes share one
1624
+ # budget instead of each spending it independently.
1625
+ spent = {index for index in partners.values()}
1626
+ for index, text, used in aggregates:
1627
+ if index in spent and text:
1628
+ used[text] = used.get(text, 0) + 1
1629
+ for event_index in group["E"]:
1630
+ if event_index in suppressed:
1631
+ continue
1632
+ part = _reasoning_projected_text(rows[event_index])
1633
+ if not part:
1634
+ continue
1635
+ # Physical order decides, so the earliest aggregate that still has
1636
+ # capacity for this part owns it. An aggregate whose whole text IS
1637
+ # the part is preferred over one that merely contains it, because it
1638
+ # is the more accurate survivor for a search hit to land on.
1639
+ owner = None
1640
+ for index, text, used in aggregates:
1641
+ if text.count(part) <= used.get(part, 0):
1642
+ continue
1643
+ if owner is None:
1644
+ owner = (index, text, used)
1645
+ if text == part:
1646
+ owner = (index, text, used)
1647
+ break
1648
+ if owner is None:
1649
+ continue
1650
+ index, _text, used = owner
1651
+ used[part] = used.get(part, 0) + 1
1652
+ suppressed.add(event_index)
1653
+ partners[event_index] = index
1654
+
1547
1655
  return suppressed, partners
1548
1656
 
1549
1657