cctally 1.82.0 → 1.83.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 (52) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +52 -74
  3. package/bin/_cctally_alerts.py +8 -1
  4. package/bin/_cctally_cache.py +963 -149
  5. package/bin/_cctally_config.py +43 -4
  6. package/bin/_cctally_core.py +933 -759
  7. package/bin/_cctally_dashboard.py +157 -47
  8. package/bin/_cctally_dashboard_cache_report.py +13 -6
  9. package/bin/_cctally_dashboard_conversation.py +1 -0
  10. package/bin/_cctally_dashboard_envelope.py +186 -8
  11. package/bin/_cctally_dashboard_share.py +60 -20
  12. package/bin/_cctally_dashboard_sources.py +427 -128
  13. package/bin/_cctally_db.py +605 -128
  14. package/bin/_cctally_doctor.py +413 -28
  15. package/bin/_cctally_five_hour.py +12 -5
  16. package/bin/_cctally_journal.py +2050 -156
  17. package/bin/_cctally_journal_repair.py +519 -0
  18. package/bin/_cctally_milestone_history.py +142 -56
  19. package/bin/_cctally_milestones.py +179 -111
  20. package/bin/_cctally_parser.py +42 -0
  21. package/bin/_cctally_project.py +24 -18
  22. package/bin/_cctally_quota.py +139 -25
  23. package/bin/_cctally_record.py +279 -108
  24. package/bin/_cctally_rederive.py +1052 -0
  25. package/bin/_cctally_reporting.py +58 -53
  26. package/bin/_cctally_setup.py +1 -0
  27. package/bin/_cctally_source_analytics.py +4 -1
  28. package/bin/_cctally_statusline.py +11 -11
  29. package/bin/_cctally_store.py +1039 -31
  30. package/bin/_cctally_sync_week.py +17 -8
  31. package/bin/_cctally_tui.py +421 -54
  32. package/bin/_cctally_update.py +133 -8
  33. package/bin/_cctally_weekrefs.py +14 -0
  34. package/bin/_lib_aggregators.py +10 -6
  35. package/bin/_lib_cache_report.py +101 -9
  36. package/bin/_lib_codex_pools.py +82 -0
  37. package/bin/_lib_conversation_query.py +126 -33
  38. package/bin/_lib_dashboard_sources.py +126 -1
  39. package/bin/_lib_diff_kernel.py +28 -15
  40. package/bin/_lib_doctor.py +342 -4
  41. package/bin/_lib_journal.py +924 -2
  42. package/bin/_lib_jsonl.py +43 -14
  43. package/bin/_lib_pricing.py +140 -21
  44. package/bin/_lib_readme_refresh.py +401 -0
  45. package/bin/_lib_rederive.py +395 -0
  46. package/bin/_lib_share.py +58 -2
  47. package/bin/cctally +56 -8
  48. package/dashboard/static/assets/{index-DJP4gEB7.js → index-3bgCMVHb.js} +52 -52
  49. package/dashboard/static/assets/index-D27EIHEI.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +6 -1
  52. package/dashboard/static/assets/index-Dk1nplOz.css +0 -1
@@ -21,7 +21,7 @@ from datetime import datetime as _datetime, timezone as _timezone
21
21
  # Public surface (Plan 2): shipped in the npm tarball + brew formula + public
22
22
  # mirror — imported by the dashboard's conversation endpoints at runtime.
23
23
 
24
- from _lib_pricing import _calculate_entry_cost, _chip_for_model
24
+ from _lib_pricing import _calculate_entry_cost, _chip_for_model, claude_usage_dict
25
25
  # #178: the on-demand load-full re-read helper re-stringifies a raw tool_result
26
26
  # content block the same way the parser does at ingest — reuse the parser's
27
27
  # _stringify so the full (un-capped) result text matches the cached/capped one.
@@ -325,6 +325,51 @@ def _session_first_prompt_titles_map(conn, session_ids):
325
325
  return titles
326
326
 
327
327
 
328
+ def _session_rollup_titles_map(conn, session_ids):
329
+ """{sid: title} from the STORED ``conversation_sessions.title`` rollup — the
330
+ first-prompt title #302 materializes so the hot read paths never re-run
331
+ ``_session_first_prompt_titles_map``'s windowed ``conversation_messages``
332
+ scan. One PK lookup per id.
333
+
334
+ Falsey titles are DROPPED (a rollup row exists for every indexed session,
335
+ titled or not), so an untitled session is simply absent. Tolerates the table
336
+ being absent (pre-migration / rebuilding store) by returning {}."""
337
+ if not session_ids:
338
+ return {}
339
+ out = {}
340
+ try:
341
+ ph = ",".join("?" for _ in session_ids)
342
+ for sid, title in conn.execute(
343
+ f"SELECT session_id, title FROM conversation_sessions "
344
+ f"WHERE session_id IN ({ph})", tuple(session_ids)
345
+ ).fetchall():
346
+ if title:
347
+ out[sid] = title
348
+ except sqlite3.OperationalError:
349
+ pass # table absent -> {} (caller degrades to no title)
350
+ return out
351
+
352
+
353
+ def session_titles_indexed_map(conn, session_ids):
354
+ """{sid: title} composed from INDEXED reads only — the truthy AI title wins,
355
+ else the stored rollup title. Same precedence as ``_session_titles_map``,
356
+ and identical values whenever the rollup is current (#302: the stored title
357
+ IS the first-prompt title), but it NEVER falls back to the windowed
358
+ ``conversation_messages`` scan.
359
+
360
+ That bound is the contract: this is what a latency-sensitive, fail-soft
361
+ caller (the dashboard Sessions panel, which reads the transcript store over
362
+ a bounded read-only connection) may run per tick. A session missing from
363
+ both indexes is simply absent — the caller renders its em-dash fallback and
364
+ the next rollup recompute fills it in."""
365
+ if not session_ids:
366
+ return {}
367
+ titles = dict(_session_ai_titles_map(conn, session_ids))
368
+ for sid, t in _session_rollup_titles_map(conn, session_ids).items():
369
+ titles.setdefault(sid, t) # AI title (truthy) wins; else the rollup
370
+ return titles
371
+
372
+
328
373
  def _session_titles_map(conn, session_ids):
329
374
  """{sid: title} — the TRUTHY AI title wins, else the first-prompt title.
330
375
  Contract UNCHANGED (the live/degraded rail branch still calls this as-is); now
@@ -501,16 +546,22 @@ def _iso_ms(ts):
501
546
  return None
502
547
 
503
548
 
504
- def _entry_cost(model, inp, out, cc, cr, cost_usd_raw) -> float:
549
+ def _entry_cost(
550
+ model, inp, out, cc, cr, cost_usd_raw, *, cc_1h, speed
551
+ ) -> float:
505
552
  """Cost for one session_entries row via the shared pricing helper. Tokens →
506
553
  the helper's usage dict. cost_usd_raw is passed as the optional override the
507
- helper already understands (it is often NULL — never the primary source)."""
508
- usage = {
509
- "input_tokens": inp or 0,
510
- "output_tokens": out or 0,
511
- "cache_creation_input_tokens": cc or 0,
512
- "cache_read_input_tokens": cr or 0,
513
- }
554
+ helper already understands (it is often NULL — never the primary source).
555
+
556
+ `cc_1h` (#195) is a REQUIRED keyword, inherited verbatim from
557
+ `claude_usage_dict`: this is a thin shim over the builder, so a default here
558
+ would re-open the exact hazard the builder's required keyword closes — a
559
+ caller that forgets the split does not raise, it silently prices every
560
+ 1-hour cache write at the 5-minute rate. Pass an explicit None for a
561
+ genuinely unknown split; that reads as a declaration at the call site."""
562
+ usage = claude_usage_dict(
563
+ input_tokens=inp, output_tokens=out, cache_creation_tokens=cc,
564
+ cache_read_tokens=cr, cache_1h_tokens=cc_1h, speed=speed)
514
565
  return _calculate_entry_cost(model or "", usage, cost_usd=cost_usd_raw)
515
566
 
516
567
 
@@ -531,26 +582,49 @@ _CACHE_FAILURE_CACHE_FLOOR = 20_000 # prior cache must be meaningful to "lo
531
582
  _CACHE_FAILURE_CREATE_FLOOR = 20_000 # the re-creation must be substantial / real cost
532
583
 
533
584
 
534
- def _cache_failure_wasted_usd(model, lost):
585
+ def _cache_failure_wasted_usd(model, lost, *, speed):
535
586
  """Marginal extra paid by re-creating `lost` previously-cached tokens at the
536
587
  cache-WRITE rate instead of reading them at the cache-READ rate. Reuses the
537
588
  pricing chokepoint `_calculate_entry_cost` (zero on unknown models — the
538
589
  helper emits its own one-shot stderr warning, never raises). NEVER summed into
539
- any cost-snapshot / budget / reconciled figure — a display-only estimate."""
540
- write = _calculate_entry_cost(model or "", {"cache_creation_input_tokens": lost})
541
- read = _calculate_entry_cost(model or "", {"cache_read_input_tokens": lost})
590
+ any cost-snapshot / budget / reconciled figure — a display-only estimate.
591
+ The lost-prefix subset has no authoritative mapping to the source row's
592
+ 5m/1h write buckets, so this preserves the existing 5m estimate while
593
+ applying the retained effective speed tier."""
594
+ write = _calculate_entry_cost(
595
+ model or "",
596
+ claude_usage_dict(
597
+ cache_1h_tokens=None, speed=speed, cache_creation_tokens=lost
598
+ ),
599
+ )
600
+ read = _calculate_entry_cost(
601
+ model or "",
602
+ claude_usage_dict(
603
+ cache_1h_tokens=None, speed=speed, cache_read_tokens=lost
604
+ ),
605
+ )
542
606
  return write - read
543
607
 
544
608
 
545
- def _cache_read_saved_usd(model, cache_read):
609
+ def _cache_read_saved_usd(model, cache_read, *, speed):
546
610
  """Marginal USD the cache SAVED this turn: the `cache_read` prefix priced at
547
611
  the full input rate minus its actual cache-READ rate. Display-only (same
548
612
  caveat as `_cache_failure_wasted_usd`): NEVER summed into a cost-snapshot /
549
613
  budget / reconciled figure. `input_tokens` and `cache_read_input_tokens` are
550
614
  independent keys in the Claude `_calculate_entry_cost` (no subset
551
615
  subtraction), so passing each alone yields the two rates cleanly."""
552
- full = _calculate_entry_cost(model or "", {"input_tokens": cache_read})
553
- read = _calculate_entry_cost(model or "", {"cache_read_input_tokens": cache_read})
616
+ full = _calculate_entry_cost(
617
+ model or "",
618
+ claude_usage_dict(
619
+ cache_1h_tokens=None, speed=speed, input_tokens=cache_read
620
+ ),
621
+ )
622
+ read = _calculate_entry_cost(
623
+ model or "",
624
+ claude_usage_dict(
625
+ cache_1h_tokens=None, speed=speed, cache_read_tokens=cache_read
626
+ ),
627
+ )
554
628
  return full - read
555
629
 
556
630
 
@@ -561,21 +635,24 @@ def _cache_read_saved_usd(model, cache_read):
561
635
  # a document-ordered list of these and feed it to the ONE predicate below — the
562
636
  # rule is implemented exactly once (U1, #217 S1).
563
637
  class _CFEvent:
564
- __slots__ = ("compaction", "key", "cc", "cr", "model")
638
+ __slots__ = ("compaction", "key", "cc", "cr", "model", "speed")
565
639
 
566
- def __init__(self, *, compaction=False, key=None, cc=0, cr=0, model=None):
640
+ def __init__(
641
+ self, *, compaction=False, key=None, cc=0, cr=0, model=None, speed=None
642
+ ):
567
643
  self.compaction = compaction
568
644
  self.key = key
569
645
  self.cc = cc
570
646
  self.cr = cr
571
647
  self.model = model
648
+ self.speed = speed
572
649
 
573
650
 
574
651
  def _iter_cache_failures(events):
575
652
  """The single cache-failure rule (spec §1), as a generator over a
576
653
  document-ordered ``_CFEvent`` stream. Yields ``(index, prev_cached, lost,
577
- model)`` for each FLAGGED assistant event — ``index`` is the event's position
578
- in ``events`` so a caller can map a flag back to its source item.
654
+ model, speed)`` for each FLAGGED assistant event — ``index`` is the event's
655
+ position in ``events`` so a caller can map a flag back to its source item.
579
656
 
580
657
  Maintains a running-max of ``cache_read`` keyed by the event's ``key``
581
658
  (``(subagent_key, model)`` — ``None`` subagent_key = main session). The key
@@ -615,7 +692,7 @@ def _iter_cache_failures(events):
615
692
  and total > 0
616
693
  and cc / total >= _CACHE_FAILURE_RECREATE_FRACTION):
617
694
  lost = min(cc, max(0, rm - cr))
618
- yield (i, rm, lost, ev.model)
695
+ yield (i, rm, lost, ev.model, ev.speed)
619
696
  running_max[ev.key] = max(rm, cr)
620
697
 
621
698
 
@@ -650,7 +727,8 @@ def _cache_failure_events_from_items(items):
650
727
  key=(it.get("subagent_key"), it.get("model")),
651
728
  cc=tok.get("cache_creation", 0) or 0,
652
729
  cr=tok.get("cache_read", 0) or 0,
653
- model=it.get("model")))
730
+ model=it.get("model"),
731
+ speed=it.get("_speed")))
654
732
  sources.append(it)
655
733
  return events, sources
656
734
 
@@ -674,11 +752,13 @@ def _stamp_cache_failures(items):
674
752
  est_wasted_usd = write(lost) - read(lost)
675
753
  """
676
754
  events, sources = _cache_failure_events_from_items(items)
677
- for idx, prev_cached, lost, model in _iter_cache_failures(events):
755
+ for idx, prev_cached, lost, model, speed in _iter_cache_failures(events):
678
756
  sources[idx]["cache_failure"] = {
679
757
  "tokens_recreated": lost,
680
758
  "prev_cached": prev_cached,
681
- "est_wasted_usd": _cache_failure_wasted_usd(model, lost),
759
+ "est_wasted_usd": _cache_failure_wasted_usd(
760
+ model, lost, speed=speed
761
+ ),
682
762
  }
683
763
 
684
764
 
@@ -856,10 +936,15 @@ def _turn_costs_for_keys(conn, keys):
856
936
  cond = " OR ".join("(msg_id=? AND req_id=?)" for _ in chunk)
857
937
  params = [v for pair in chunk for v in pair]
858
938
  sql = ("SELECT msg_id, req_id, model, input_tokens, output_tokens, "
859
- "cache_create_tokens, cache_read_tokens, cost_usd_raw "
939
+ "cache_create_tokens, cache_read_tokens, cost_usd_raw, "
940
+ "cache_create_1h_tokens, speed "
860
941
  "FROM session_entries WHERE " + cond)
861
- for m, r, model, inp, out, cc, cr, raw in conn.execute(sql, params):
862
- costs[(m, r)] = _entry_cost(model, inp, out, cc, cr, raw)
942
+ for m, r, model, inp, out, cc, cr, raw, cc1h, speed in conn.execute(
943
+ sql, params
944
+ ):
945
+ costs[(m, r)] = _entry_cost(
946
+ model, inp, out, cc, cr, raw, cc_1h=cc1h, speed=speed
947
+ )
863
948
  return costs
864
949
 
865
950
 
@@ -1494,7 +1579,7 @@ def _turn_cost_map(conn, turn_keys):
1494
1579
 
1495
1580
 
1496
1581
  def _turn_usage_map(conn, turn_keys):
1497
- """{(msg_id, req_id): {"input","output","cache_creation","cache_read"}} for
1582
+ """{(msg_id, req_id): {tokens..., "speed"}} for
1498
1583
  the given non-null turn keys, read from the SAME deduped session_entries row
1499
1584
  cost is computed from (#177). This is a SEPARATE sibling of _turn_cost_map —
1500
1585
  that one returns a float and is also consumed by the search path
@@ -1512,11 +1597,12 @@ def _turn_usage_map(conn, turn_keys):
1512
1597
  cond = " OR ".join("(msg_id=? AND req_id=?)" for _ in chunk)
1513
1598
  params = [v for pair in chunk for v in pair]
1514
1599
  sql = ("SELECT msg_id, req_id, input_tokens, output_tokens, "
1515
- "cache_create_tokens, cache_read_tokens "
1600
+ "cache_create_tokens, cache_read_tokens, speed "
1516
1601
  "FROM session_entries WHERE " + cond)
1517
- for m, r, inp, out, cc, cr in conn.execute(sql, params):
1602
+ for m, r, inp, out, cc, cr, speed in conn.execute(sql, params):
1518
1603
  usage[(m, r)] = {"input": inp or 0, "output": out or 0,
1519
- "cache_creation": cc or 0, "cache_read": cr or 0}
1604
+ "cache_creation": cc or 0, "cache_read": cr or 0,
1605
+ "speed": speed}
1520
1606
  return usage
1521
1607
 
1522
1608
 
@@ -1936,7 +2022,11 @@ def _assemble_session(conn, session_id):
1936
2022
  # key has no session_entries row (omitted, not zero-filled).
1937
2023
  tok = usage.get((it["_msg_id"], it["_req_id"]))
1938
2024
  if tok is not None:
1939
- it["tokens"] = tok
2025
+ # `speed` is an internal pricing input, not part of the public
2026
+ # token-count object. Keep it alongside the assembled item for
2027
+ # cache financials and strip it from reader page copies.
2028
+ it["tokens"] = {k: v for k, v in tok.items() if k != "speed"}
2029
+ it["_speed"] = tok.get("speed")
1940
2030
  del it["_msg_id"]
1941
2031
  del it["_req_id"]
1942
2032
  it.pop("_has_prose", None)
@@ -2231,6 +2321,7 @@ def get_conversation(conn, session_id, *, after=None, before=None, tail=False,
2231
2321
  patched = []
2232
2322
  for it in page:
2233
2323
  nit = dict(it)
2324
+ nit.pop("_speed", None)
2234
2325
  nit["anchor"] = {**it["anchor"], "session_id": session_id}
2235
2326
  if nit.get("text"):
2236
2327
  nit["text"] = _strip_ansi(nit["text"])
@@ -2377,7 +2468,9 @@ def get_conversation_outline(conn, session_id):
2377
2468
  tokens[k] += tok.get(k, 0)
2378
2469
  cr_tokens = tok.get("cache_read", 0) or 0
2379
2470
  if cr_tokens > 0:
2380
- cache_saved += _cache_read_saved_usd(it.get("model"), cr_tokens)
2471
+ cache_saved += _cache_read_saved_usd(
2472
+ it.get("model"), cr_tokens, speed=it.get("_speed")
2473
+ )
2381
2474
  # Copy the cache-failure marker onto the OutlineTurn exactly where
2382
2475
  # tokens is copied (assistant-only, rides the same source row) and
2383
2476
  # accumulate the session-level aggregate (spec §2).
@@ -17,6 +17,7 @@ PhysicalSource = Literal["claude", "codex"]
17
17
  DashboardSelection = Literal["claude", "codex", "all"]
18
18
  Availability = Literal["ok", "empty", "partial", "unavailable"]
19
19
  Freshness = Literal["fresh", "stale"]
20
+ FreshnessDomain = Literal["hero", "quota", "sessions"]
20
21
  CapabilityStatus = Literal[
21
22
  "supported", "derived", "unavailable", "deferred", "not_applicable",
22
23
  ]
@@ -24,6 +25,7 @@ CapabilityStatus = Literal[
24
25
  SOURCE_SCHEMA_VERSION = 1
25
26
  DEFAULT_SOURCE = "claude"
26
27
  SOURCE_ORDER = ("claude", "codex", "all")
28
+ SOURCE_FRESHNESS_DOMAINS = ("hero", "quota", "sessions")
27
29
 
28
30
  _PHYSICAL_SOURCES = frozenset(("claude", "codex"))
29
31
  _SELECTIONS = frozenset(SOURCE_ORDER)
@@ -109,10 +111,19 @@ class SourceDashboardState:
109
111
  last_success_at: dt.datetime | None
110
112
  capabilities: Mapping[str, CapabilityRecord]
111
113
  data: Mapping[str, object] | None
114
+ # Domain freshness is orthogonal to provider-generation coherence. Legacy
115
+ # constructors may omit it; they deterministically inherit the provider
116
+ # value for every known domain.
117
+ domain_freshness: Mapping[str, Freshness] | None = None
112
118
  # Immutable, server-only facts used to advance an idle presentation clock.
113
119
  # They are deliberately separate from ``data`` so no internal accounting
114
120
  # evidence becomes part of the public source-envelope contract.
115
121
  clock_data: Mapping[str, object] | None = None
122
+ # Request-gated transcript content. This mapping is frozen with the source
123
+ # generation but is deliberately outside ``data``: source serialization
124
+ # publishes only ``data``, then the HTTP/SSE envelope layer injects a label
125
+ # into its request-local copies when that request's transcript gate is open.
126
+ private_session_labels: Mapping[str, str] | None = None
116
127
 
117
128
  def __post_init__(self) -> None:
118
129
  validate_dashboard_selection(self.source)
@@ -120,6 +131,16 @@ class SourceDashboardState:
120
131
  raise ValueError("unsupported availability")
121
132
  if self.freshness not in _FRESHNESS:
122
133
  raise ValueError("unsupported freshness")
134
+ domain_freshness = (
135
+ {domain: self.freshness for domain in SOURCE_FRESHNESS_DOMAINS}
136
+ if self.domain_freshness is None else dict(self.domain_freshness)
137
+ )
138
+ if set(domain_freshness) != set(SOURCE_FRESHNESS_DOMAINS):
139
+ raise ValueError(
140
+ "domain freshness must contain exactly hero, quota, and sessions"
141
+ )
142
+ if any(value not in _FRESHNESS for value in domain_freshness.values()):
143
+ raise ValueError("unsupported domain freshness")
123
144
  if not isinstance(self.data_version, str):
124
145
  raise ValueError("data_version must be a string")
125
146
  if self.availability != "unavailable":
@@ -141,10 +162,20 @@ class SourceDashboardState:
141
162
  raise ValueError("capabilities must contain CapabilityRecord values")
142
163
  object.__setattr__(self, "warnings", warnings)
143
164
  object.__setattr__(self, "capabilities", _freeze(capabilities))
165
+ object.__setattr__(self, "domain_freshness", _freeze(domain_freshness))
144
166
  if self.data is not None:
145
167
  object.__setattr__(self, "data", _freeze(self.data))
146
168
  if self.clock_data is not None:
147
169
  object.__setattr__(self, "clock_data", _freeze(self.clock_data))
170
+ if self.private_session_labels is not None:
171
+ private_session_labels = {
172
+ _nonempty_string(key, "private session label key"):
173
+ _nonempty_string(value, "private session label")
174
+ for key, value in self.private_session_labels.items()
175
+ }
176
+ object.__setattr__(
177
+ self, "private_session_labels", _freeze(private_session_labels),
178
+ )
148
179
 
149
180
 
150
181
  @dataclass(frozen=True)
@@ -246,7 +277,11 @@ def degrade_source_state(
246
277
  last_success_at=prior.last_success_at,
247
278
  capabilities=prior.capabilities,
248
279
  data=prior.data,
280
+ domain_freshness={
281
+ domain: "stale" for domain in SOURCE_FRESHNESS_DOMAINS
282
+ },
249
283
  clock_data=prior.clock_data,
284
+ private_session_labels=prior.private_session_labels,
250
285
  )
251
286
 
252
287
 
@@ -267,9 +302,28 @@ def unavailable_source_state(
267
302
  last_success_at=None,
268
303
  capabilities={},
269
304
  data=None,
305
+ domain_freshness={
306
+ domain: "stale" for domain in SOURCE_FRESHNESS_DOMAINS
307
+ },
270
308
  )
271
309
 
272
310
 
311
+ def source_domain_freshness(
312
+ state: SourceDashboardState,
313
+ domain: FreshnessDomain,
314
+ ) -> Freshness:
315
+ """Return one domain value with the frozen legacy-provider fallback."""
316
+ if domain not in SOURCE_FRESHNESS_DOMAINS:
317
+ raise ValueError("unsupported freshness domain")
318
+ mapping = getattr(state, "domain_freshness", None)
319
+ if isinstance(mapping, Mapping):
320
+ value = mapping.get(domain)
321
+ if value in _FRESHNESS:
322
+ return value
323
+ provider = getattr(state, "freshness", "stale")
324
+ return provider if provider in _FRESHNESS else "stale"
325
+
326
+
273
327
  def _coherent_provider(state: SourceDashboardState) -> bool:
274
328
  return (
275
329
  state.availability in ("ok", "empty", "partial")
@@ -297,12 +351,43 @@ def reuse_coherent_source_state(
297
351
  return prior if _coherent_provider(prior) and prior.data_version == data_version else None
298
352
 
299
353
 
354
+ def _hero_cycle_is_stale(state: SourceDashboardState) -> bool:
355
+ """Whether a provider's hero is bounded by STALE quota evidence (#350 §3.4).
356
+
357
+ ``hero.cycle_freshness`` is additive and OMITTED while the cycle is fresh, so
358
+ this is false for every provider and every generation that predates #350.
359
+ """
360
+ if source_domain_freshness(state, "hero") == "stale":
361
+ return True
362
+ if not isinstance(state.data, Mapping):
363
+ return False
364
+ hero = state.data.get("hero")
365
+ return isinstance(hero, Mapping) and hero.get("cycle_freshness") == "stale"
366
+
367
+
368
+ def _stale_cycle_providers(
369
+ claude: SourceDashboardState,
370
+ codex: SourceDashboardState,
371
+ ) -> tuple[str, ...]:
372
+ return tuple(
373
+ label for label, state in (("Claude", claude), ("Codex", codex))
374
+ if _hero_cycle_is_stale(state)
375
+ )
376
+
377
+
300
378
  def _combined_metrics(
301
379
  claude: SourceDashboardState,
302
380
  codex: SourceDashboardState,
303
381
  ) -> Mapping[str, object] | None:
304
382
  if not (_coherent_provider(claude) and _coherent_provider(codex)):
305
383
  return None
384
+ # #350 spec §3.5: a stale-cycle hero is NOT combinable. Retaining it would
385
+ # let a sum over stale evidence be published while `compose_all_state` marks
386
+ # the result fresh — and the All hero carries no Snapshot row or staleness
387
+ # marker at all, so the staleness would be silently undisclosed. The
388
+ # combined NUMBER therefore behaves exactly as it does today.
389
+ if _stale_cycle_providers(claude, codex):
390
+ return None
306
391
  for state in (claude, codex):
307
392
  hero_capability = state.capabilities.get("hero")
308
393
  if hero_capability is None or hero_capability.status not in {"supported", "derived"}:
@@ -366,7 +451,24 @@ def compose_all_state(
366
451
  raise ValueError("all composition requires Claude and Codex provider states")
367
452
  combined = _combined_metrics(claude, codex)
368
453
  providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
454
+ # #350 spec §3.5: "All behaves exactly as today" is true only of the combined
455
+ # NUMBER. Under §3.4 both providers stay coherent, so All now publishes
456
+ # partial/fresh with no provider warning to explain it — the status chip
457
+ # would fall through to a generic `degraded` and the All hero fallback would
458
+ # claim a provider is degraded while both provider envelopes say otherwise.
459
+ # This All-LOCAL warning states the real reason without touching either
460
+ # provider envelope. It is emitted only when the providers are otherwise
461
+ # coherent; an incoherent provider already publishes its own reason.
462
+ all_local_warnings: tuple[SourceDashboardWarning, ...] = ()
369
463
  if providers_coherent:
464
+ stale_cycle_providers = _stale_cycle_providers(claude, codex)
465
+ if stale_cycle_providers:
466
+ all_local_warnings = (SourceDashboardWarning(
467
+ "combined_totals_withheld",
468
+ f"{' and '.join(stale_cycle_providers)} quota evidence is stale, "
469
+ "so combined totals are withheld.",
470
+ "hero",
471
+ ),)
370
472
  availability: Availability = (
371
473
  "partial"
372
474
  if combined is None or "partial" in (claude.availability, codex.availability)
@@ -383,7 +485,15 @@ def compose_all_state(
383
485
  version_material = json.dumps(
384
486
  [
385
487
  claude.data_version, claude.availability, claude.freshness,
488
+ [
489
+ source_domain_freshness(claude, domain)
490
+ for domain in SOURCE_FRESHNESS_DOMAINS
491
+ ],
386
492
  codex.data_version, codex.availability, codex.freshness,
493
+ [
494
+ source_domain_freshness(codex, domain)
495
+ for domain in SOURCE_FRESHNESS_DOMAINS
496
+ ],
387
497
  combined is not None,
388
498
  ],
389
499
  separators=(",", ":"),
@@ -395,7 +505,11 @@ def compose_all_state(
395
505
  source="all",
396
506
  availability=availability,
397
507
  freshness=freshness,
398
- warnings=tuple((*claude.warnings, *codex.warnings)),
508
+ # All-LOCAL warnings lead: `warningForSource` on the client falls back to
509
+ # the FIRST warning, so a merely-partial provider warning (e.g.
510
+ # `codex_metadata_incomplete`) would otherwise pre-empt the chip label
511
+ # and hide the real reason combined totals are withheld.
512
+ warnings=tuple((*all_local_warnings, *claude.warnings, *codex.warnings)),
399
513
  data_version=data_version,
400
514
  last_success_at=last_success_at,
401
515
  capabilities={
@@ -412,6 +526,17 @@ def compose_all_state(
412
526
  "codex": codex.data,
413
527
  },
414
528
  },
529
+ domain_freshness={
530
+ domain: (
531
+ "fresh"
532
+ if all(
533
+ source_domain_freshness(state, domain) == "fresh"
534
+ for state in (claude, codex)
535
+ )
536
+ else "stale"
537
+ )
538
+ for domain in SOURCE_FRESHNESS_DOMAINS
539
+ },
415
540
  )
416
541
 
417
542
 
@@ -110,6 +110,8 @@ def _load_lib(name: str):
110
110
 
111
111
  _lib_pricing = _load_lib("_lib_pricing")
112
112
  _calculate_entry_cost = _lib_pricing._calculate_entry_cost
113
+ # #195: the single construction point for every cost-feeding usage dict.
114
+ claude_usage_dict = _lib_pricing.claude_usage_dict
113
115
 
114
116
  _lib_display_tz = _load_lib("_lib_display_tz")
115
117
  _resolve_tz = _lib_display_tz._resolve_tz
@@ -512,12 +514,14 @@ def _diff_aggregate_overall(
512
514
  continue
513
515
  cost += _calculate_entry_cost(
514
516
  e.model,
515
- {
516
- "input_tokens": e.input_tokens,
517
- "output_tokens": e.output_tokens,
518
- "cache_creation_input_tokens": e.cache_creation_tokens,
519
- "cache_read_input_tokens": e.cache_read_tokens,
520
- },
517
+ claude_usage_dict( # #195 chokepoint
518
+ input_tokens=e.input_tokens,
519
+ output_tokens=e.output_tokens,
520
+ cache_creation_tokens=e.cache_creation_tokens,
521
+ cache_read_tokens=e.cache_read_tokens,
522
+ cache_1h_tokens=getattr(e, "cache_1h_tokens", None),
523
+ speed=getattr(e, "speed", None),
524
+ ),
521
525
  mode="auto",
522
526
  cost_usd=e.cost_usd,
523
527
  )
@@ -552,9 +556,12 @@ def _diff_aggregate_models(
552
556
  })
553
557
  b["cost"] += _calculate_entry_cost(
554
558
  e.model,
555
- {"input_tokens": e.input_tokens, "output_tokens": e.output_tokens,
556
- "cache_creation_input_tokens": e.cache_creation_tokens,
557
- "cache_read_input_tokens": e.cache_read_tokens},
559
+ claude_usage_dict( # #195 chokepoint
560
+ input_tokens=e.input_tokens, output_tokens=e.output_tokens,
561
+ cache_creation_tokens=e.cache_creation_tokens,
562
+ cache_read_tokens=e.cache_read_tokens,
563
+ cache_1h_tokens=getattr(e, "cache_1h_tokens", None),
564
+ speed=getattr(e, "speed", None)),
558
565
  mode="auto", cost_usd=e.cost_usd,
559
566
  )
560
567
  b["ti"] += e.input_tokens
@@ -593,9 +600,12 @@ def _diff_aggregate_projects(
593
600
  })
594
601
  b["cost"] += _calculate_entry_cost(
595
602
  e.model,
596
- {"input_tokens": e.input_tokens, "output_tokens": e.output_tokens,
597
- "cache_creation_input_tokens": e.cache_creation_tokens,
598
- "cache_read_input_tokens": e.cache_read_tokens},
603
+ claude_usage_dict( # #195 chokepoint
604
+ input_tokens=e.input_tokens, output_tokens=e.output_tokens,
605
+ cache_creation_tokens=e.cache_creation_tokens,
606
+ cache_read_tokens=e.cache_read_tokens,
607
+ cache_1h_tokens=getattr(e, "cache_1h_tokens", None),
608
+ speed=getattr(e, "speed", None)),
599
609
  mode="auto", cost_usd=e.cost_usd,
600
610
  )
601
611
  b["ti"] += e.input_tokens
@@ -639,9 +649,12 @@ def _diff_aggregate_cache(
639
649
  continue
640
650
  cost += _calculate_entry_cost(
641
651
  e.model,
642
- {"input_tokens": e.input_tokens, "output_tokens": e.output_tokens,
643
- "cache_creation_input_tokens": e.cache_creation_tokens,
644
- "cache_read_input_tokens": e.cache_read_tokens},
652
+ claude_usage_dict( # #195 chokepoint
653
+ input_tokens=e.input_tokens, output_tokens=e.output_tokens,
654
+ cache_creation_tokens=e.cache_creation_tokens,
655
+ cache_read_tokens=e.cache_read_tokens,
656
+ cache_1h_tokens=getattr(e, "cache_1h_tokens", None),
657
+ speed=getattr(e, "speed", None)),
645
658
  mode="auto", cost_usd=e.cost_usd,
646
659
  )
647
660
  tcr += e.cache_read_tokens