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
@@ -25,13 +25,14 @@ host-local offset while boundaries are canonical UTC).
25
25
  from __future__ import annotations
26
26
 
27
27
  import datetime as dt
28
- import json
29
28
  import sqlite3
30
29
  import sys
31
30
  from dataclasses import replace
32
31
 
33
32
  from _cctally_core import make_week_ref, parse_iso_datetime
34
33
  from _cctally_quota import codex_quota_breakdown
34
+ from _lib_accounts import UNATTRIBUTED
35
+ from _lib_codex_pools import is_model_scoped_codex_quota
35
36
  from _lib_dashboard_sources import dashboard_resource_key
36
37
  from _lib_display_tz import _resolve_display_tz_obj, format_display_dt
37
38
  from _lib_json_envelope import _iso_z
@@ -479,22 +480,6 @@ def build_claude_week_detail(conn: sqlite3.Connection, key: str) -> "dict | None
479
480
  # per-identity — the spec-faithful realization for the disambiguated index.
480
481
 
481
482
 
482
- def _is_model_scoped_codex_quota(logical_limit_key) -> bool:
483
- """Whether an interpreted native identity is a per-model pool (outside the
484
- account-level standard quota). Mirrors the source builder's check."""
485
- if not isinstance(logical_limit_key, str):
486
- return False
487
- try:
488
- payload = json.loads(logical_limit_key)
489
- except (json.JSONDecodeError, TypeError, ValueError):
490
- return False
491
- return (
492
- isinstance(payload, dict)
493
- and isinstance(payload.get("modelPool"), str)
494
- and bool(payload["modelPool"].strip())
495
- )
496
-
497
-
498
483
  def _parse_utc(value) -> "dt.datetime | None":
499
484
  if not value:
500
485
  return None
@@ -520,7 +505,8 @@ class _CodexCycle:
520
505
 
521
506
  __slots__ = (
522
507
  "root", "limit", "slot", "window", "limit_id", "limit_name",
523
- "start", "reset", "end", "current_percent", "members",
508
+ "account_key", "start", "reset", "end", "current_percent", "members",
509
+ "is_current",
524
510
  )
525
511
 
526
512
  def __init__(self, row):
@@ -528,6 +514,11 @@ class _CodexCycle:
528
514
  self.limit = row["logical_limit_key"]
529
515
  self.slot = row["observed_slot"]
530
516
  self.window = int(row["window_minutes"])
517
+ # #373 root cause 3: `account_key` participates in QuotaWindowIdentity
518
+ # equality by #341 design, so every identity this class is used to
519
+ # reconstruct — and every query against the account-stamped projection
520
+ # tables reached from it — must carry it.
521
+ self.account_key = row["account_key"]
531
522
  self.limit_id = row["limit_id"]
532
523
  self.limit_name = row["limit_name"]
533
524
  self.start = _parse_utc(row["nominal_start_at_utc"])
@@ -538,6 +529,7 @@ class _CodexCycle:
538
529
  None if cp is None or isinstance(cp, bool) else float(cp)
539
530
  )
540
531
  self.members = None
532
+ self.is_current = False
541
533
 
542
534
  @property
543
535
  def cluster_members(self) -> list:
@@ -608,8 +600,40 @@ def _canonical_identity_cycles(cycles: list) -> list:
608
600
  ]
609
601
 
610
602
 
611
- def _select_physical_cycles(cycles: list, *, preferred_identity=None) -> list:
612
- """Select one full identity per physical reset boundary, then clip globally."""
603
+ def _boundary_has_live_reset(boundary) -> bool:
604
+ """Whether a boundary object actually names a live cycle.
605
+
606
+ The §7.4 guard needs a REAL live boundary. The former future-reset proxy was
607
+ true for EVERY future-ending cycle, so arming the guard on a stub boundary
608
+ suppressed every clip and made a historic cycle whose nominal reset had not
609
+ yet passed render its full seven-day length. That is how one cycle key came
610
+ back as ``Jul 21–Jul 28`` from the detail while the index said
611
+ ``Jul 21–Jul 25`` (#373). With no live boundary the correct behaviour is to
612
+ select no current cycle and clip exactly as before.
613
+ """
614
+ return getattr(boundary, "resets_at", None) is not None
615
+
616
+
617
+ def _select_physical_cycles(cycles: list, *, preferred_identity=None,
618
+ current_boundary=None, now_utc=None) -> list:
619
+ """Select one full identity per physical reset boundary, then clip globally.
620
+
621
+ #373 §7.4: the cycle the LIVE baseline marks current is never clipped by a
622
+ successor — it ends at its own reset. Every other cycle clips exactly as
623
+ before, and no identity axis enters the clip (§6 Q3: cross-identity
624
+ clipping is what collapses the concurrent secondary/primary weekly windows
625
+ into one non-overlapping timeline).
626
+
627
+ ``current_boundary`` is a ``CodexCycleBoundary``. Its reset can sit within
628
+ the jitter floor of more than one DISTINCT identity cluster, so proximity
629
+ is only the candidate filter. ``_select_live_physical_cycle`` resolves one
630
+ physical cycle (preferring the boundary's selected full identity), and that
631
+ object supplies both the no-clip guard and rendered ``is_current`` fact.
632
+
633
+ Suppressing a clip can restore an entire ROW, not merely lengthen one: the
634
+ ``end > start`` filter below runs after clipping, so a live cycle whose
635
+ successor starts at the same instant would otherwise be dropped outright.
636
+ """
613
637
  identity_cycles = _canonical_identity_cycles(cycles)
614
638
  selected = [
615
639
  _choose_physical_cycle(cluster, preferred_identity=preferred_identity)
@@ -618,14 +642,23 @@ def _select_physical_cycles(cycles: list, *, preferred_identity=None) -> list:
618
642
  )
619
643
  ]
620
644
  selected.sort(key=lambda c: (c.start, c.reset, c.identity_parts))
645
+ live_boundary = (
646
+ current_boundary if _boundary_has_live_reset(current_boundary) else None
647
+ )
648
+ live_cycle = _select_live_physical_cycle(selected, live_boundary)
621
649
  for index, cyc in enumerate(selected):
650
+ cyc.is_current = cyc is live_cycle
651
+ if cyc.is_current:
652
+ cyc.end = cyc.reset
653
+ continue
622
654
  next_start = selected[index + 1].start if index + 1 < len(selected) else None
623
655
  cyc.end = min(cyc.reset, next_start) if next_start is not None else cyc.reset
624
656
  return [c for c in selected if c.end > c.start]
625
657
 
626
658
 
627
659
  def _load_codex_cycles(stats_conn, root_keys, *, include_orphaned=False,
628
- preferred_identity=None) -> list:
660
+ preferred_identity=None, current_boundary=None,
661
+ now_utc=None) -> list:
629
662
  """Unbounded reset-defined 7-day cycles, one selected identity per boundary."""
630
663
  roots = tuple(sorted({r for r in root_keys if isinstance(r, str) and r}))
631
664
  if not roots:
@@ -634,8 +667,8 @@ def _load_codex_cycles(stats_conn, root_keys, *, include_orphaned=False,
634
667
  orphan_clause = "" if include_orphaned else "AND orphaned_at IS NULL "
635
668
  rows = stats_conn.execute(
636
669
  "SELECT source_root_key, logical_limit_key, observed_slot, "
637
- " window_minutes, limit_id, limit_name, resets_at_utc, "
638
- " nominal_start_at_utc, current_percent "
670
+ " window_minutes, limit_id, limit_name, account_key, "
671
+ " resets_at_utc, nominal_start_at_utc, current_percent "
639
672
  "FROM quota_window_blocks "
640
673
  "WHERE source='codex' AND window_minutes=10080 "
641
674
  f"{orphan_clause}"
@@ -646,14 +679,17 @@ def _load_codex_cycles(stats_conn, root_keys, *, include_orphaned=False,
646
679
 
647
680
  cycles: list = []
648
681
  for row in rows:
649
- if _is_model_scoped_codex_quota(row["logical_limit_key"]):
682
+ if is_model_scoped_codex_quota(row["logical_limit_key"], row["limit_name"]):
650
683
  continue
651
684
  cyc = _CodexCycle(row)
652
685
  if cyc.start is None or cyc.reset is None or cyc.reset <= cyc.start:
653
686
  continue
654
687
  cycles.append(cyc)
655
688
 
656
- selected = _select_physical_cycles(cycles, preferred_identity=preferred_identity)
689
+ selected = _select_physical_cycles(
690
+ cycles, preferred_identity=preferred_identity,
691
+ current_boundary=current_boundary, now_utc=now_utc,
692
+ )
657
693
  selected.sort(key=lambda c: c.start, reverse=True)
658
694
  return selected
659
695
 
@@ -675,24 +711,53 @@ def _canonicalize_codex_cluster(members: list) -> "_CodexCycle":
675
711
  return rep
676
712
 
677
713
 
678
- def _codex_is_current(cyc, identity, now_utc) -> bool:
679
- identity_reset = getattr(identity, "resets_at", None)
680
- if identity_reset is not None:
681
- try:
682
- target = identity_reset.astimezone(UTC)
683
- except (AttributeError, ValueError):
684
- target = None
685
- if target is not None:
686
- # The live boundary's reset (``select_baseline``) need not be the
687
- # cluster's max jittered reset, so match against ANY member within
688
- # the jitter floor — distinct clusters are > floor apart, so only
689
- # the live cluster can match.
690
- return any(
691
- abs((m.reset - target).total_seconds())
692
- <= _mh.CODEX_CYCLE_JITTER_FLOOR_SECONDS
693
- for m in cyc.cluster_members
694
- )
695
- return cyc.reset > now_utc
714
+ def _select_live_physical_cycle(cycles: list, boundary):
715
+ """Resolve at most one physical cycle for the live boundary.
716
+
717
+ Jitter clustering is identity-local before the cross-identity physical
718
+ selection. Two distinct clusters may therefore be more than one jitter
719
+ floor apart from each other while each remains within the floor of the
720
+ boundary reset. Prefer the boundary's already-selected full quota
721
+ identity, then the closest member reset, with stable scalar tie-breakers.
722
+ """
723
+ if boundary is None:
724
+ return None
725
+ try:
726
+ target = boundary.resets_at.astimezone(UTC)
727
+ except (AttributeError, ValueError):
728
+ return None
729
+ candidates = [
730
+ cyc for cyc in cycles
731
+ if any(
732
+ abs((member.reset - target).total_seconds())
733
+ <= _mh.CODEX_CYCLE_JITTER_FLOOR_SECONDS
734
+ for member in cyc.cluster_members
735
+ )
736
+ ]
737
+ if not candidates:
738
+ return None
739
+ preferred_identity = getattr(boundary, "quota_identity", None)
740
+ preferred = [
741
+ cyc for cyc in candidates if _identity_matches_cycle(preferred_identity, cyc)
742
+ ]
743
+ pool = preferred or candidates
744
+
745
+ def _rank(cyc):
746
+ distance = min(
747
+ abs((member.reset - target).total_seconds())
748
+ for member in cyc.cluster_members
749
+ )
750
+ current_rank = (
751
+ float("inf") if cyc.current_percent is None else -cyc.current_percent
752
+ )
753
+ return (
754
+ distance,
755
+ current_rank,
756
+ cyc.reset,
757
+ cyc.identity_parts,
758
+ )
759
+
760
+ return min(pool, key=_rank)
696
761
 
697
762
 
698
763
  def _codex_milestone_count(stats_conn, cyc) -> tuple[int, str | None]:
@@ -702,29 +767,47 @@ def _codex_milestone_count(stats_conn, cyc) -> tuple[int, str | None]:
702
767
  row = stats_conn.execute(
703
768
  "SELECT COUNT(DISTINCT percent_threshold), MAX(captured_at_utc) "
704
769
  "FROM quota_percent_milestones "
705
- "WHERE source='codex' AND source_root_key=? AND logical_limit_key=? "
770
+ "WHERE source='codex' AND source_root_key=? AND account_key=? "
771
+ " AND logical_limit_key=? "
706
772
  " AND observed_slot=? AND window_minutes=? "
707
773
  f" AND unixepoch(resets_at_utc) IN ({placeholders}) AND orphaned_at IS NULL",
708
- (cyc.root, cyc.limit, cyc.slot, cyc.window, *resets),
774
+ (cyc.root, cyc.account_key, cyc.limit, cyc.slot, cyc.window, *resets),
709
775
  ).fetchone()
710
776
  return int(row[0] or 0), row[1]
711
777
 
712
778
 
713
779
  def _codex_five_hour_rows(stats_conn, cyc, *, include_orphaned=False) -> list:
714
- """Every retained 5h block on the selected root intersecting [start, end)."""
780
+ """Every retained 5h block on the selected root intersecting [start, end).
781
+
782
+ The account filter admits the cycle's own key OR the ``unattributed``
783
+ sentinel, and deliberately NOT strict equality. Unlike
784
+ ``_codex_milestone_count`` — whose block and milestone parameters both come
785
+ from one ``block.identity`` and so cannot disagree — this query spans
786
+ identities, selecting by root plus time range. The 300-minute windows are a
787
+ separate physical-window group from the weekly one and
788
+ ``adopt_unidentified_observations`` resolves attribution PER GROUP, so a
789
+ decorated install can legitimately carry the weekly window under a real
790
+ account while its 5h windows are still unattributed. Strict equality would
791
+ then match nothing and the cycle would render 0 blocks (#373).
792
+
793
+ The widening is one-directional on purpose: an unattributed CYCLE never
794
+ picks up another account's identified 5h rows, which would violate #341's
795
+ never-combine rule.
796
+ """
715
797
  orphan_clause = "" if include_orphaned else "AND orphaned_at IS NULL "
716
798
  return stats_conn.execute(
717
799
  "SELECT source_root_key, logical_limit_key, observed_slot, "
718
- " window_minutes, limit_id, limit_name, resets_at_utc, "
719
- " nominal_start_at_utc, current_percent "
800
+ " window_minutes, limit_id, limit_name, account_key, "
801
+ " resets_at_utc, nominal_start_at_utc, current_percent "
720
802
  "FROM quota_window_blocks "
721
803
  "WHERE source='codex' AND window_minutes=300 "
722
804
  f"{orphan_clause}"
723
- "AND source_root_key=? "
805
+ "AND source_root_key=? AND account_key IN (?,?) "
724
806
  "AND unixepoch(nominal_start_at_utc) < unixepoch(?) "
725
807
  "AND unixepoch(resets_at_utc) > unixepoch(?) "
726
808
  "ORDER BY unixepoch(nominal_start_at_utc) ASC",
727
- (cyc.root, cyc.end.astimezone(UTC).isoformat(),
809
+ (cyc.root, cyc.account_key, UNATTRIBUTED,
810
+ cyc.end.astimezone(UTC).isoformat(),
728
811
  cyc.start.astimezone(UTC).isoformat()),
729
812
  ).fetchall()
730
813
 
@@ -755,7 +838,7 @@ def _codex_five_hour_clusters(stats_conn, cyc, *, include_orphaned=False) -> lis
755
838
  return clusters
756
839
 
757
840
 
758
- def _codex_cycle_entry(stats_conn, cyc, identity, now_utc, tz) -> dict:
841
+ def _codex_cycle_entry(stats_conn, cyc, tz) -> dict:
759
842
  milestone_count, max_captured = _codex_milestone_count(stats_conn, cyc)
760
843
  block_count = len(_codex_five_hour_clusters(stats_conn, cyc))
761
844
  key = cyc.key
@@ -765,7 +848,7 @@ def _codex_cycle_entry(stats_conn, cyc, identity, now_utc, tz) -> dict:
765
848
  "end_at_utc": _iso_z(cyc.end),
766
849
  "resets_at_utc": _iso_z(cyc.reset),
767
850
  "label": _codex_cycle_label(cyc, tz),
768
- "is_current": _codex_is_current(cyc, identity, now_utc),
851
+ "is_current": bool(cyc.is_current),
769
852
  "milestone_count": milestone_count,
770
853
  "block_count": block_count,
771
854
  "detail_stamp": _mh.compute_detail_stamp(
@@ -793,8 +876,9 @@ def build_codex_cycle_index(stats_conn, *, identity, now_utc) -> list:
793
876
  cycles = _load_codex_cycles(
794
877
  stats_conn, getattr(identity, "source_root_keys", ()),
795
878
  preferred_identity=getattr(identity, "quota_identity", None),
879
+ current_boundary=identity, now_utc=now,
796
880
  )
797
- return [_codex_cycle_entry(stats_conn, c, identity, now, tz) for c in cycles]
881
+ return [_codex_cycle_entry(stats_conn, c, tz) for c in cycles]
798
882
 
799
883
 
800
884
  def _shape_codex_milestone(row, *, key_parts, block_key) -> dict:
@@ -844,8 +928,8 @@ def _union_cluster_milestones(cluster, block_key, speed, cache_conn, stats_conn)
844
928
  ident = QuotaWindowIdentity(
845
929
  source="codex", source_root_key=member.root,
846
930
  logical_limit_key=member.limit, observed_slot=member.slot,
847
- window_minutes=member.window, limit_id=member.limit_id,
848
- limit_name=member.limit_name,
931
+ window_minutes=member.window, account_key=member.account_key,
932
+ limit_id=member.limit_id, limit_name=member.limit_name,
849
933
  )
850
934
  breakdown = _codex_breakdown_rows(
851
935
  ident, member.reset, speed, cache_conn, stats_conn
@@ -917,6 +1001,7 @@ def build_codex_cycle_detail(
917
1001
  cycles = _load_codex_cycles(
918
1002
  stats_conn, getattr(identity, "source_root_keys", ()),
919
1003
  preferred_identity=getattr(identity, "quota_identity", None),
1004
+ current_boundary=identity, now_utc=now,
920
1005
  )
921
1006
  except sqlite3.Error:
922
1007
  return (None, "projection_incoherent")
@@ -929,6 +1014,7 @@ def build_codex_cycle_detail(
929
1014
  stats_conn, getattr(identity, "source_root_keys", ()),
930
1015
  include_orphaned=True,
931
1016
  preferred_identity=getattr(identity, "quota_identity", None),
1017
+ current_boundary=identity, now_utc=now,
932
1018
  )
933
1019
  except sqlite3.Error:
934
1020
  return (None, "projection_incoherent")
@@ -945,7 +1031,7 @@ def build_codex_cycle_detail(
945
1031
  if blocks is None:
946
1032
  return (None, "projection_incoherent")
947
1033
 
948
- entry = _codex_cycle_entry(stats_conn, match, identity, now, tz)
1034
+ entry = _codex_cycle_entry(stats_conn, match, tz)
949
1035
  return {
950
1036
  "source": "codex",
951
1037
  "key": entry["key"],