cctally 1.116.0 → 1.117.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 (72) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +4 -6
  3. package/bin/_cctally_account.py +57 -3
  4. package/bin/_cctally_cache.py +5720 -885
  5. package/bin/_cctally_claude_attribution.py +997 -0
  6. package/bin/_cctally_completion.py +101 -28
  7. package/bin/_cctally_core.py +235 -12
  8. package/bin/_cctally_dashboard.py +224 -7
  9. package/bin/_cctally_dashboard_conversation.py +107 -21
  10. package/bin/_cctally_dashboard_envelope.py +34 -0
  11. package/bin/_cctally_db.py +928 -80
  12. package/bin/_cctally_diagnosis_sources.py +78 -13
  13. package/bin/_cctally_doctor.py +211 -86
  14. package/bin/_cctally_five_hour.py +389 -18
  15. package/bin/_cctally_forecast.py +30 -6
  16. package/bin/_cctally_journal.py +2418 -158
  17. package/bin/_cctally_oauth_samples.py +185 -7
  18. package/bin/_cctally_parser.py +38 -0
  19. package/bin/_cctally_project.py +34 -8
  20. package/bin/_cctally_quarantine.py +821 -343
  21. package/bin/_cctally_quota.py +69 -4
  22. package/bin/_cctally_quota_calibration.py +677 -3
  23. package/bin/_cctally_quota_model.py +927 -127
  24. package/bin/_cctally_record.py +922 -445
  25. package/bin/_cctally_rederive.py +119 -15
  26. package/bin/_cctally_statusline.py +1048 -176
  27. package/bin/_cctally_store.py +544 -110
  28. package/bin/_cctally_tui.py +93 -9
  29. package/bin/_cctally_update.py +77 -14
  30. package/bin/_lib_account_protocol.py +227 -19
  31. package/bin/_lib_claude_attribution.py +421 -0
  32. package/bin/_lib_clone_copy.py +30 -0
  33. package/bin/_lib_codex_conversation_query.py +45 -9
  34. package/bin/_lib_codex_conversation_watch.py +72 -13
  35. package/bin/_lib_codex_rollout_container.py +234 -19
  36. package/bin/_lib_codex_worker_lifecycle.py +374 -0
  37. package/bin/_lib_credit.py +22 -13
  38. package/bin/_lib_dashboard_sources.py +163 -10
  39. package/bin/_lib_detector_access_registry.py +1335 -166
  40. package/bin/_lib_doctor.py +276 -92
  41. package/bin/_lib_ingest_frontier.py +269 -36
  42. package/bin/_lib_journal.py +273 -22
  43. package/bin/_lib_jsonl.py +12 -0
  44. package/bin/_lib_meter_rate_change.py +72 -0
  45. package/bin/_lib_outcome_quarantine.py +917 -554
  46. package/bin/_lib_provider_activity.py +24 -6
  47. package/bin/_lib_quota_calibration.py +57 -3
  48. package/bin/_lib_quota_copy.py +7 -2
  49. package/bin/_lib_quota_lineage.py +613 -0
  50. package/bin/_lib_quota_model.py +435 -22
  51. package/bin/_lib_rate_change_delivery.py +16 -0
  52. package/bin/_lib_reclaim_planner.py +6 -4
  53. package/bin/_lib_rederive.py +2046 -21
  54. package/bin/_lib_selector_state.py +131 -3
  55. package/bin/_lib_stale_proof.py +113 -4
  56. package/bin/_lib_statusline.py +72 -3
  57. package/bin/_lib_statusline_candidates.py +246 -42
  58. package/bin/_lib_write_io.py +418 -5
  59. package/bin/cctally +24 -11
  60. package/dashboard/static/assets/{ConversationsView-Dflb_myA.js → ConversationsView-Dcfn8T6f.js} +1 -1
  61. package/dashboard/static/assets/{DoctorModal-DFX5CfIs.js → DoctorModal-BqmXbnhn.js} +1 -1
  62. package/dashboard/static/assets/{ModalRoot-HlxoagIa.js → ModalRoot-Ck3WJK2V.js} +1 -1
  63. package/dashboard/static/assets/{ProjectsDrillPanel-C3PJy1xx.js → ProjectsDrillPanel-B-HZkDnT.js} +1 -1
  64. package/dashboard/static/assets/{SourceDetailModal-BczsWKhD.js → SourceDetailModal-QhJkHB7i.js} +1 -1
  65. package/dashboard/static/assets/{UpdateModal-Os-Lo5cs.js → UpdateModal-CkXEIQcK.js} +1 -1
  66. package/dashboard/static/assets/index-D34eYhAc.css +1 -0
  67. package/dashboard/static/assets/{index-CYZ3NLqF.js → index-HKS1s56o.js} +3 -3
  68. package/dashboard/static/assets/{outlineNavigation-k-zIif6W.js → outlineNavigation-BNGaNPCp.js} +1 -1
  69. package/dashboard/static/assets/{useKeymap-CW7Up0Ay.js → useKeymap-CUgEnnua.js} +1 -1
  70. package/dashboard/static/dashboard.html +3 -3
  71. package/package.json +5 -1
  72. package/dashboard/static/assets/index-BKITk1CT.css +0 -1
@@ -26,9 +26,16 @@ nothing, and one that conflicts is detected from the retained whole-record
26
26
  digest, which taints the batch and forces a rebuild that re-derives from the
27
27
  journal.
28
28
 
29
- This module imports nothing outside the stdlib and `_lib_journal`, and in
30
- particular never imports `_cctally_journal`, so it is unit-testable without a
31
- journal on disk — the same rule `bin/_lib_journal_router.py` follows.
29
+ This module imports nothing outside the stdlib, `_lib_journal` and the pure
30
+ `_lib_quota_lineage` kernel, and in particular never imports
31
+ `_cctally_journal`, so it is unit-testable without a journal on disk — the same
32
+ rule `bin/_lib_journal_router.py` follows.
33
+
34
+ #1015 (spec 5.1): the batch-completion step also gives each newly completed
35
+ batch's lineage footprint (:func:`completed_batch_footprints`) while it still
36
+ holds every action core, before compaction drops them. The footprint is kept
37
+ off :class:`SelectorRows` — those rows are compared for validation and owned by
38
+ the selector — and handed to the caller through ``lineage`` out-dicts.
32
39
  """
33
40
  from __future__ import annotations
34
41
 
@@ -36,6 +43,7 @@ import json
36
43
  from dataclasses import dataclass, replace
37
44
 
38
45
  import _lib_journal as _jl
46
+ import _lib_quota_lineage
39
47
 
40
48
 
41
49
  #: Bumped whenever the durable row shapes or the merge semantics change. A
@@ -800,6 +808,107 @@ def advance_counter(
800
808
  )
801
809
 
802
810
 
811
+ def _revision_of(winner, owner_of):
812
+ """The `_lib_quota_lineage.Revision` of one winning candidate."""
813
+ if winner.status != "active" or winner.record is None:
814
+ return _lib_quota_lineage.Revision("tombstone")
815
+ return _lib_quota_lineage.Revision(
816
+ "active", owner_of((winner.record or {}).get("payload")))
817
+
818
+
819
+ def _takes_winner(prior, candidate) -> bool:
820
+ """The selector's winner rule: the highest revision, and at the same
821
+ revision the lowest sequence (a durable winner with no sequence keeps its
822
+ place)."""
823
+ if prior is None or candidate.rev > prior.rev:
824
+ return True
825
+ if candidate.rev < prior.rev:
826
+ return False
827
+ return (prior.sequence is not None and candidate.sequence is not None
828
+ and candidate.sequence < prior.sequence)
829
+
830
+
831
+ def completed_batch_footprints(fold, batch_ids, *, owner_of, seed=None):
832
+ """#1015 spec 5.1: the footprint of each completed batch in ``batch_ids``.
833
+
834
+ Returns ``{batch_id: frozenset((provider, account_key), ...)}`` through
835
+ `_lib_quota_lineage.batch_footprint`. Batches are walked in commit
836
+ order, and each action's predecessor is the revision effective
837
+ immediately before its batch commits: the winner over ``seed`` (the
838
+ durable winner of an event id before ``fold``'s stream, None on a full
839
+ pass) advanced by every base evt folded before that commit and by every
840
+ earlier completed batch — dominated and tombstoned revisions included —
841
+ then the revision the batch's own earlier action produced.
842
+
843
+ ``owner_of(payload)`` is the `(provider, account_key)` the evt fold stores
844
+ for a payload, or None when it cannot be resolved. ``batch_ids`` names
845
+ batches whose cores ``fold`` still holds (newly completed on this pass);
846
+ any other id is ignored.
847
+ """
848
+ targets = []
849
+ for batch_id in batch_ids:
850
+ if batch_id not in fold.completed:
851
+ continue
852
+ commit = fold.markers.get(batch_id, {}).get("commit")
853
+ actions = fold.actions.get(batch_id, {})
854
+ if commit is None or any(
855
+ actions[seq][0] is None for seq in actions):
856
+ continue
857
+ targets.append((commit[3], batch_id))
858
+ if not targets:
859
+ return {}
860
+ targets.sort()
861
+ base = sorted(
862
+ (candidate for candidate in fold.candidates
863
+ if candidate.batch_id is None),
864
+ key=lambda item: item.sequence)
865
+ winners: dict = {}
866
+ seeded: set = set()
867
+
868
+ def current(event_id):
869
+ if event_id not in seeded:
870
+ seeded.add(event_id)
871
+ if event_id not in winners and seed is not None:
872
+ prior = seed(event_id)
873
+ if prior is not None:
874
+ winners[event_id] = prior
875
+ return winners.get(event_id)
876
+
877
+ def offer(candidate):
878
+ prior = current(candidate.event_id)
879
+ if _takes_winner(prior, candidate):
880
+ winners[candidate.event_id] = candidate
881
+
882
+ footprints: dict = {}
883
+ position = 0
884
+ for commit_sequence, batch_id in targets:
885
+ while position < len(base) and base[position].sequence < commit_sequence:
886
+ offer(base[position])
887
+ position += 1
888
+ batch_actions = fold.actions.get(batch_id, {})
889
+ ordered = [batch_actions[seq] for seq in sorted(batch_actions)]
890
+ actions = []
891
+ for normalized, _digest, _sequence in ordered:
892
+ tombstone = normalized.get("action") == "tombstone"
893
+ actions.append(_lib_quota_lineage.Action(
894
+ event_id=normalized["id"],
895
+ rev=int(normalized["rev"]),
896
+ kind="tombstone" if tombstone else "replace",
897
+ payload_owner=(None if tombstone
898
+ else owner_of(normalized.get("payload"))),
899
+ ))
900
+
901
+ def predecessor_of(event_id, _rev):
902
+ winner = current(event_id)
903
+ return None if winner is None else _revision_of(winner, owner_of)
904
+
905
+ footprints[batch_id] = _lib_quota_lineage.batch_footprint(
906
+ actions, predecessor_of)
907
+ for normalized, _digest, sequence in ordered:
908
+ offer(_jl._candidate_from_correction(normalized, sequence))
909
+ return footprints
910
+
911
+
803
912
  def merge_delta(
804
913
  rows: SelectorRows,
805
914
  new_records,
@@ -807,6 +916,7 @@ def merge_delta(
807
916
  next_sequence: int,
808
917
  coordinates=None,
809
918
  covered=None,
919
+ lineage=None,
810
920
  ):
811
921
  """Continue the fold over ``new_records`` from durable state.
812
922
 
@@ -826,6 +936,13 @@ def merge_delta(
826
936
  `journal_protocol_resolution` op: acknowledging a violation authenticates an
827
937
  exact length-framed raw-prefix SHA-256, a claimed hash is never accepted
828
938
  without recomputation, and no durable summary can reconstruct it.
939
+
940
+ ``lineage`` (#1015) is an optional out-dict. When it carries an
941
+ ``"owner_of"`` callable, it receives under ``"footprints"`` the
942
+ footprint of every batch this delta completed, computed here, in the
943
+ batch-completion step, before the cores are compacted away (see
944
+ :func:`completed_batch_footprints`); the predecessor of an action is
945
+ seeded from the durable winner of its event id.
829
946
  """
830
947
  fold = _seed_fold(rows)
831
948
  seeded_completed = {
@@ -903,6 +1020,17 @@ def merge_delta(
903
1020
  # this one converges through the narrowest prefix.
904
1021
  transitions.sort(key=lambda item: (item.causal_sequence, item.batch_id))
905
1022
 
1023
+ if lineage is not None and lineage.get("owner_of") is not None:
1024
+ prior_rows = {row.event_id: row for row in rows.effective}
1025
+
1026
+ def seed(event_id):
1027
+ row = prior_rows.get(event_id)
1028
+ return None if row is None else _event_from_row(row)
1029
+
1030
+ lineage["footprints"] = completed_batch_footprints(
1031
+ fold, sorted(fold.completed - seeded_completed),
1032
+ owner_of=lineage["owner_of"], seed=seed)
1033
+
906
1034
  winners, conflicts = _merge_candidates(rows, fold.candidates)
907
1035
  batches, batch_records = _batch_rows(fold, coordinates)
908
1036
  merged_batches = _carry_coordinates(rows, batches)
@@ -20,6 +20,14 @@ Both axes are decided here. The weekly axis earns by (i) or (ii); the
20
20
  five-hour axis earns by (i) only, against the `statusline` source's detector
21
21
  baseline under the current five-hour credit generation (D11), and its
22
22
  validity follows the five-hour rows of the section 4.4 table.
23
+
24
+ #1019/#1014 (spec 4.1) adds the receipt-free DROP proof beside it: a
25
+ member's LOW is proven stale by an admitted provider sample in its exact
26
+ account and window, requested after the member's bound (its frozen supported
27
+ ``t_m``, else its ``received_at`` second), reading more than `TOLERANCE_PP`
28
+ ABOVE it and captured before the member's advertised reset
29
+ (`proven_stale_low`). Its facts are independent of the rise facts: a member
30
+ may be eligible for one direction and refused for the other.
23
31
  """
24
32
  from __future__ import annotations
25
33
 
@@ -124,14 +132,36 @@ class Member:
124
132
 
125
133
  @dataclasses.dataclass(frozen=True)
126
134
  class MemberFacts:
135
+ """One member's refusal facts. ``refused``, ``proof_observation_id`` and
136
+ ``receipt_valid`` decide its RISE (#797 spec 4.4); ``drop_refused`` and
137
+ ``drop_proof_observation_id`` decide its DROP (#1019 spec 4.1),
138
+ independently and without a receipt."""
139
+
127
140
  refused: bool
128
141
  proof_observation_id: str | None
129
142
  receipt_valid: bool
143
+ drop_refused: bool = False
144
+ drop_proof_observation_id: str | None = None
130
145
 
131
146
 
147
+ #: Eligible in both directions.
132
148
  ALL_ELIGIBLE = MemberFacts(False, None, False)
133
149
 
134
150
 
151
+ @dataclasses.dataclass(frozen=True)
152
+ class DropMember:
153
+ """A member's reading as the drop proof sees it (#1019 spec 4.1): its
154
+ contributor token, its bound (`drop_bound`), its axis value, its window
155
+ and the raw reset it advertised."""
156
+
157
+ token: str
158
+ bound_utc: str
159
+ axis: str
160
+ percent: float
161
+ window: int
162
+ advertised_reset_epoch: int | None = None
163
+
164
+
135
165
  def sample_identity(token: str, witness, *, axis: str, percent: float,
136
166
  window: int) -> SampleIdentity | None:
137
167
  """σ for a member, or None when its evidence is unknown: no witness, an
@@ -147,6 +177,45 @@ def sample_identity(token: str, witness, *, axis: str, percent: float,
147
177
  witness.evidence_revision, witness.parser_revision)
148
178
 
149
179
 
180
+ def drop_bound(token: str, witness, received_at_epoch: int | None, *, axis: str,
181
+ percent: float, window: int) -> str | None:
182
+ """The bound a member's LOW is proven stale after (#1019 spec 4.1): the
183
+ frozen, supported ``t_m`` when `sample_identity` accepts its witness;
184
+ otherwise its ``received_at`` as a whole-second UTC instant, an
185
+ assumption-free upper bound on when its already-composed reading
186
+ existed; otherwise None (always eligible). The fallback is used for this
187
+ proof alone: it is never a σ, a receipt or a provenance field."""
188
+ sigma = sample_identity(token, witness, axis=axis, percent=percent, window=window)
189
+ if sigma is not None:
190
+ return sigma.activity_at_utc
191
+ return _received_bound(received_at_epoch)
192
+
193
+
194
+ def _received_bound(received_at_epoch) -> str | None:
195
+ if (isinstance(received_at_epoch, bool) or not isinstance(received_at_epoch, int)
196
+ or received_at_epoch < 0):
197
+ return None
198
+ import datetime as _dt
199
+ return _dt.datetime.fromtimestamp(received_at_epoch, tz=_dt.timezone.utc).strftime(
200
+ "%Y-%m-%dT%H:%M:%SZ")
201
+
202
+
203
+ def witness_bound(witness, received_at_epoch: int | None) -> str | None:
204
+ """`drop_bound` of a retained support: whether `sample_identity` accepts
205
+ a witness depends on the witness alone, so the bound needs neither the
206
+ member's token nor its axis, value or window."""
207
+ if sample_identity("", witness, axis=WEEKLY, percent=0.0, window=0) is not None:
208
+ return witness.instant_utc
209
+ return _received_bound(received_at_epoch)
210
+
211
+
212
+ def bound_end(bound_utc: str | None) -> int | None:
213
+ """The end of a bound's precision interval in nanoseconds: a sample
214
+ follows the bound when its request starts at or after it (`after`)."""
215
+ interval = _interval(bound_utc)
216
+ return None if interval is None else interval[1]
217
+
218
+
150
219
  def _window_text(window: int) -> str:
151
220
  return _samples.normalize_weekly_reset(int(window))
152
221
 
@@ -217,6 +286,31 @@ def proven_stale(sigma: SampleIdentity, samples: Iterable, *, after_utc: str,
217
286
  return _latest(proving)
218
287
 
219
288
 
289
+ def proven_stale_low(bound_utc: str, samples: Iterable, *, axis: str, window: int,
290
+ percent: float, advertised_reset_epoch: int | None = None):
291
+ """The latest provider sample proving a member's LOW stale, or None
292
+ (#1019 spec 4.1, Q1).
293
+
294
+ A sample proves it when it is in the member's exact window (weekly: the
295
+ normalized reset; five-hour: the canonical key; the caller passes only
296
+ the member's own account's samples), its request instant ``q`` follows
297
+ ``bound_utc`` (`drop_bound`) under the precision intervals, its raw axis
298
+ value is strictly more than `TOLERANCE_PP` ABOVE ``percent``, and its
299
+ response was captured before the member's advertised reset (the
300
+ window's reset when it advertised none). No receipt, no ``e_σ``, and no
301
+ baseline or threshold cutoff."""
302
+ if axis not in AXES or _interval(bound_utc) is None:
303
+ return None
304
+ reset = int(window) if advertised_reset_epoch is None else int(advertised_reset_epoch)
305
+ proving = [
306
+ s for s in _axis_window(samples, axis, window)
307
+ if _axis_value(s, axis) > float(percent) + TOLERANCE_PP
308
+ and after(s.requested_at_utc, bound_utc)
309
+ and _before_epoch(s.captured_at_utc, reset)
310
+ ]
311
+ return _latest(proving)
312
+
313
+
220
314
  # --- accounting ------------------------------------------------------------------------
221
315
 
222
316
 
@@ -392,7 +486,8 @@ def receipt_valid(receipt: Receipt, transitions: Iterable[CreditTransition], *,
392
486
 
393
487
  def refusal_facts(axis: str, members: Sequence[Member], samples: Iterable,
394
488
  receipts: Iterable[Receipt], transitions: Iterable[CreditTransition], *,
395
- index_identity: str, supports: Collection[str] | None = None
489
+ index_identity: str, supports: Collection[str] | None = None,
490
+ drop_members: Sequence[DropMember] = ()
396
491
  ) -> dict[str, MemberFacts]:
397
492
  """Per-member facts for one axis, keyed by contributor token.
398
493
 
@@ -400,7 +495,12 @@ def refusal_facts(axis: str, members: Sequence[Member], samples: Iterable,
400
495
  and a sample requested after both ``t_m`` and ``e_σ`` proves it stale.
401
496
  A member without a receipt is eligible with no proof reported; one whose
402
497
  receipt is invalid is eligible with its proof reported. Each axis is
403
- decided on its own members, samples and credits."""
498
+ decided on its own members, samples and credits.
499
+
500
+ #1019 (spec 4.1): every ``drop_members`` entry gets its drop facts from
501
+ `proven_stale_low`, independently of ``receipts``, so one call per axis
502
+ yields complete facts; a token with drop facts only keeps the rise
503
+ fields of `ALL_ELIGIBLE`."""
404
504
  if axis not in AXES:
405
505
  return {}
406
506
  samples = list(samples)
@@ -433,6 +533,15 @@ def refusal_facts(axis: str, members: Sequence[Member], samples: Iterable,
433
533
  refused=bool(valid) and strict is not None,
434
534
  proof_observation_id=None if proof is None else proof.observation_id,
435
535
  receipt_valid=bool(valid))
536
+ for d in drop_members:
537
+ if d.axis != axis:
538
+ continue
539
+ low = proven_stale_low(d.bound_utc, samples, axis=axis, window=d.window,
540
+ percent=d.percent,
541
+ advertised_reset_epoch=d.advertised_reset_epoch)
542
+ facts[d.token] = dataclasses.replace(
543
+ facts.get(d.token, ALL_ELIGIBLE), drop_refused=low is not None,
544
+ drop_proof_observation_id=None if low is None else low.observation_id)
436
545
  return facts
437
546
 
438
547
 
@@ -535,8 +644,8 @@ def floor_microsecond(text: str) -> str | None:
535
644
 
536
645
  def compact_samples(samples: Iterable, *, window: int,
537
646
  floor_epoch: int | None, axis: str = WEEKLY) -> tuple:
538
- """A subset of the window's samples that yields the same `proven_stale`
539
- and `earn_receipts` answers for every member (both pick the latest
647
+ """A subset of the window's samples that yields the same `proven_stale`,
648
+ `proven_stale_low` and `earn_receipts` answers for every member (all pick the latest
540
649
  qualifying sample, and qualification is monotone in ``q`` within one
541
650
  value): per weekly value, the latest sample and the latest captured at
542
651
  or after the floor, plus every sample captured within an hour of the
@@ -10,6 +10,7 @@ for the full design.
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
+ import math
13
14
  import pathlib
14
15
  import re
15
16
  import sys
@@ -88,6 +89,12 @@ class StatuslineInput:
88
89
  rate_limits_7d_pct: Optional[float] = None
89
90
  rate_limits_7d_resets_at: Optional[int] = None # unix epoch
90
91
  raw: dict = field(default_factory=dict) # full parsed JSON for diagnostics
92
+ # #1002. Claude Code's own `context_window`, already validated (see
93
+ # `_to_count`): the session's window and the last request's input-side
94
+ # token sum (input + cache_read + cache_creation). `None` when absent or
95
+ # malformed; the `context_pct` port then falls back per value.
96
+ context_window_size: Optional[int] = None
97
+ context_tokens: Optional[int] = None
91
98
 
92
99
 
93
100
  # ---- CLI args (post-config-resolution) -------------------------------------
@@ -143,8 +150,13 @@ class StatuslineInjections:
143
150
  [],
144
151
  "Optional[tuple[Optional[float], Optional[int], Optional[float], Optional[int]]]",
145
152
  ]
146
- # transcript_path → context % (0.0..100.0) or None if unreadable/unknown.
147
- context_pct: Callable[[Optional[str], Optional[str]], Optional[float]]
153
+ # (transcript_path, model_id, window_size, context_tokens) → context %
154
+ # or None if unavailable. The two payload values win when not None; the
155
+ # port falls back to the model table / transcript tail independently.
156
+ context_pct: Callable[
157
+ [Optional[str], Optional[str], Optional[int], Optional[int]],
158
+ Optional[float],
159
+ ]
148
160
  # Emits one-shot stderr warnings (deduped by message — caller maintains set).
149
161
  warn_once: Callable[[str], None]
150
162
  # #661 S2 §9. The stored metering regimes for this install, oldest
@@ -230,6 +242,13 @@ def parse_statusline_stdin(raw: "bytes | str") -> "StatuslineInput | ParseError"
230
242
  def _to_str(v) -> Optional[str]:
231
243
  return v if isinstance(v, str) and v else None
232
244
 
245
+ context_window = parsed.get("context_window")
246
+ if not isinstance(context_window, dict):
247
+ context_window = {}
248
+ window_size = _to_count(context_window.get("context_window_size"))
249
+ if window_size is not None and window_size <= 0:
250
+ window_size = None
251
+
233
252
  return StatuslineInput(
234
253
  session_id=_to_str(parsed.get("session_id")),
235
254
  model_id=_to_str(_get(parsed, "model", "id")),
@@ -250,9 +269,56 @@ def parse_statusline_stdin(raw: "bytes | str") -> "StatuslineInput | ParseError"
250
269
  _get(parsed, "rate_limits", "seven_day", "resets_at")
251
270
  ),
252
271
  raw=parsed,
272
+ context_window_size=window_size,
273
+ context_tokens=_context_tokens(context_window.get("current_usage")),
253
274
  )
254
275
 
255
276
 
277
+ # The input-side `current_usage` fields that occupy the context window.
278
+ # `output_tokens` is deliberately excluded (#1002 spec §5.1).
279
+ _CONTEXT_USAGE_FIELDS = (
280
+ "input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens",
281
+ )
282
+
283
+
284
+ def _to_count(v) -> Optional[int]:
285
+ """A token count: a finite, integer-valued, non-negative JSON number.
286
+
287
+ Never a bool (an int subclass) or a string; an integral float such as
288
+ ``1000000.0`` normalizes to ``int``. Anything else → ``None``.
289
+ """
290
+ if isinstance(v, bool) or not isinstance(v, (int, float)):
291
+ return None
292
+ if isinstance(v, float):
293
+ if not math.isfinite(v) or not v.is_integer():
294
+ return None
295
+ v = int(v)
296
+ return v if v >= 0 else None
297
+
298
+
299
+ def _context_tokens(usage) -> Optional[int]:
300
+ """Sum of the input-side ``current_usage`` fields, or ``None``.
301
+
302
+ A missing or null field contributes zero, but at least one must be
303
+ present and non-null; an explicit zero is a valid observation. Any
304
+ invalid present field voids the whole sum rather than being skipped.
305
+ """
306
+ if not isinstance(usage, dict):
307
+ return None
308
+ total = 0
309
+ observed = False
310
+ for key in _CONTEXT_USAGE_FIELDS:
311
+ value = usage.get(key)
312
+ if value is None:
313
+ continue
314
+ count = _to_count(value)
315
+ if count is None:
316
+ return None
317
+ total += count
318
+ observed = True
319
+ return total if observed else None
320
+
321
+
256
322
  # ---- Segment 1: model -----------------------------------------------------
257
323
 
258
324
 
@@ -407,7 +473,10 @@ def resolve_context_pct(
407
473
  this function only returns the plain `🧠 X%` form. ``render_statusline``
408
474
  wraps the result in ANSI color codes per ``args.color``.
409
475
  """
410
- pct = inj.context_pct(inp.transcript_path, inp.model_id)
476
+ pct = inj.context_pct(
477
+ inp.transcript_path, inp.model_id,
478
+ inp.context_window_size, inp.context_tokens,
479
+ )
411
480
  if pct is None:
412
481
  return "🧠 N/A"
413
482
  return f"🧠 {int(round(pct))}%"