cctally 1.28.0 → 1.30.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 (42) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/bin/_cctally_cache.py +147 -59
  3. package/bin/_cctally_core.py +22 -49
  4. package/bin/_cctally_dashboard.py +239 -152
  5. package/bin/_cctally_db.py +211 -31
  6. package/bin/_cctally_milestones.py +126 -166
  7. package/bin/_cctally_record.py +161 -192
  8. package/bin/_lib_alert_axes.py +7 -4
  9. package/bin/_lib_conversation.py +59 -8
  10. package/bin/_lib_conversation_query.py +306 -52
  11. package/bin/_lib_jsonl.py +69 -50
  12. package/bin/cctally +5 -5
  13. package/dashboard/static/assets/index-4OxMhN7N.js +53 -0
  14. package/dashboard/static/assets/index-DEDO-eqP.css +1 -0
  15. package/dashboard/static/assets/newsreader-latin-400-italic-CEihAR-f.woff2 +0 -0
  16. package/dashboard/static/assets/newsreader-latin-400-italic-CNZoH1hn.woff +0 -0
  17. package/dashboard/static/assets/newsreader-latin-400-normal-BFBkh4jY.woff2 +0 -0
  18. package/dashboard/static/assets/newsreader-latin-400-normal-gRTjlS2D.woff +0 -0
  19. package/dashboard/static/assets/newsreader-latin-500-normal-B66TYsaK.woff2 +0 -0
  20. package/dashboard/static/assets/newsreader-latin-500-normal-DFwuUcdu.woff +0 -0
  21. package/dashboard/static/assets/newsreader-latin-600-normal-30OJ_TG_.woff2 +0 -0
  22. package/dashboard/static/assets/newsreader-latin-600-normal-DUnT2r2g.woff +0 -0
  23. package/dashboard/static/assets/newsreader-latin-ext-400-italic-BMTE_bNQ.woff2 +0 -0
  24. package/dashboard/static/assets/newsreader-latin-ext-400-italic-qdgKLcPG.woff +0 -0
  25. package/dashboard/static/assets/newsreader-latin-ext-400-normal-DYA1XoQK.woff +0 -0
  26. package/dashboard/static/assets/newsreader-latin-ext-400-normal-svq1FPys.woff2 +0 -0
  27. package/dashboard/static/assets/newsreader-latin-ext-500-normal-BNHmvKvI.woff2 +0 -0
  28. package/dashboard/static/assets/newsreader-latin-ext-500-normal-CZruMFou.woff +0 -0
  29. package/dashboard/static/assets/newsreader-latin-ext-600-normal-BXv5iMHi.woff2 +0 -0
  30. package/dashboard/static/assets/newsreader-latin-ext-600-normal-BrbfzHZ5.woff +0 -0
  31. package/dashboard/static/assets/newsreader-vietnamese-400-italic-QbB8kb5s.woff +0 -0
  32. package/dashboard/static/assets/newsreader-vietnamese-400-italic-bZegYFuM.woff2 +0 -0
  33. package/dashboard/static/assets/newsreader-vietnamese-400-normal-BekUZro8.woff +0 -0
  34. package/dashboard/static/assets/newsreader-vietnamese-400-normal-DdKr49mV.woff2 +0 -0
  35. package/dashboard/static/assets/newsreader-vietnamese-500-normal-BEAbKU8A.woff +0 -0
  36. package/dashboard/static/assets/newsreader-vietnamese-500-normal-CL6a8tp2.woff2 +0 -0
  37. package/dashboard/static/assets/newsreader-vietnamese-600-normal-CVAR0otO.woff +0 -0
  38. package/dashboard/static/assets/newsreader-vietnamese-600-normal-CaH84vfx.woff2 +0 -0
  39. package/dashboard/static/dashboard.html +2 -2
  40. package/package.json +1 -1
  41. package/dashboard/static/assets/index-Bj5ckRUE.css +0 -1
  42. package/dashboard/static/assets/index-Dw4G5FD9.js +0 -18
@@ -273,20 +273,20 @@ def _build_alert_payload_codex_budget(*args, **kwargs):
273
273
  return sys.modules["cctally"]._build_alert_payload_codex_budget(*args, **kwargs)
274
274
 
275
275
 
276
- def insert_codex_budget_milestone(*args, **kwargs):
277
- return sys.modules["cctally"].insert_codex_budget_milestone(*args, **kwargs)
276
+ def _budget_crossings(*args, **kwargs):
277
+ return sys.modules["cctally"]._budget_crossings(*args, **kwargs)
278
278
 
279
279
 
280
- def _codex_budget_crossings(*args, **kwargs):
281
- return sys.modules["cctally"]._codex_budget_crossings(*args, **kwargs)
280
+ def _resolve_budget_window(*args, **kwargs):
281
+ return sys.modules["cctally"]._resolve_budget_window(*args, **kwargs)
282
282
 
283
283
 
284
- def _resolve_codex_budget_period_window(*args, **kwargs):
285
- return sys.modules["cctally"]._resolve_codex_budget_period_window(*args, **kwargs)
284
+ def _budget_spend_for_vendor(*args, **kwargs):
285
+ return sys.modules["cctally"]._budget_spend_for_vendor(*args, **kwargs)
286
286
 
287
287
 
288
- def _sum_codex_cost_for_range(*args, **kwargs):
289
- return sys.modules["cctally"]._sum_codex_cost_for_range(*args, **kwargs)
288
+ def _resolve_codex_budget_period_window(*args, **kwargs):
289
+ return sys.modules["cctally"]._resolve_codex_budget_period_window(*args, **kwargs)
290
290
 
291
291
 
292
292
  def resolve_display_tz(*args, **kwargs):
@@ -325,14 +325,6 @@ def _resolve_claude_budget_window(*args, **kwargs):
325
325
  return sys.modules["cctally"]._resolve_claude_budget_window(*args, **kwargs)
326
326
 
327
327
 
328
- def _sum_cost_for_range(*args, **kwargs):
329
- return sys.modules["cctally"]._sum_cost_for_range(*args, **kwargs)
330
-
331
-
332
- def insert_budget_milestone(*args, **kwargs):
333
- return sys.modules["cctally"].insert_budget_milestone(*args, **kwargs)
334
-
335
-
336
328
  def insert_projected_milestone(*args, **kwargs):
337
329
  return sys.modules["cctally"].insert_projected_milestone(*args, **kwargs)
338
330
 
@@ -756,10 +748,103 @@ def maybe_record_milestone(
756
748
  conn.close()
757
749
 
758
750
 
751
+ def _record_budget_milestone_for_vendor(
752
+ *, vendor, target, thresholds, period, config, tz, build_payload
753
+ ) -> None:
754
+ """Shared budget-milestone firing core for both vendors (#143).
755
+
756
+ Hot-path ordering is preserved verbatim (spec §4.2 / [Pre-probe before
757
+ sync_cache]): ``open_db`` → cheap ``_resolve_budget_window(vendor=…)`` →
758
+ unified pre-probe (which configured thresholds are STILL un-recorded for this
759
+ window/period) → **skip the cost SUM entirely when nothing is pending** →
760
+ ``_budget_spend_for_vendor(vendor=…)`` (the costly leg) →
761
+ ``_budget_crossings(vendor=…)`` (INSERT-and-arm, set-then-dispatch,
762
+ fire-once via rowcount) → single durable commit → post-commit dispatch.
763
+
764
+ The pre-probe's ``period = ? OR period IS NULL`` arm (#137) makes a pre-011
765
+ NULL-period row for this window count as already-recorded (no spurious
766
+ upgrade re-fire); a row under the SAME concrete ``period`` also counts
767
+ (fire-once). The cost SUM is skipped ONLY when every threshold already has a
768
+ row — a partial prior run still forces the SUM for the remaining thresholds
769
+ ([Dedup mustn't gate side effects]).
770
+
771
+ ``build_payload`` is the vendor's at-fire payload adapter (keeps the dispatch
772
+ ``id`` byte-stable per vendor); it is invoked with
773
+ ``threshold`` / ``crossed_at_utc`` / ``period_key`` / ``period`` /
774
+ ``budget_usd`` / ``spent_usd`` / ``consumption_pct`` keyword args.
775
+ """
776
+ now_utc = _command_as_of()
777
+ pending_alerts: list[dict[str, Any]] = []
778
+ conn = open_db()
779
+ try:
780
+ start_at = _resolve_budget_window(
781
+ conn, vendor=vendor, now_utc=now_utc, period=period,
782
+ config=config, tz=tz,
783
+ )
784
+ if start_at is None:
785
+ return # no resolvable window yet (claude subscription-week pre-snapshot)
786
+ period_key = start_at.isoformat(timespec="seconds")
787
+
788
+ present = {
789
+ int(r[0]) for r in conn.execute(
790
+ "SELECT threshold FROM budget_milestones "
791
+ "WHERE vendor = ? AND period_start_at = ? "
792
+ " AND (period = ? OR period IS NULL)",
793
+ (vendor, period_key, period),
794
+ )
795
+ }
796
+ pending = [t for t in sorted(thresholds) if t not in present]
797
+ if not pending:
798
+ return # nothing left this window → skip the cost SUM
799
+
800
+ spent = _budget_spend_for_vendor(
801
+ conn, vendor=vendor, start_at=start_at, now_utc=now_utc
802
+ )
803
+ # Shared INSERT-and-arm core (set-then-dispatch, fire-once via rowcount);
804
+ # commit=False inside, so this conn owns the single durable commit below.
805
+ for t, crossed_at, sp, tg, pct in _budget_crossings(
806
+ conn,
807
+ vendor=vendor,
808
+ period_key=period_key,
809
+ period=period,
810
+ thresholds=pending,
811
+ target=target,
812
+ spent=spent,
813
+ now_utc=now_utc,
814
+ ):
815
+ pending_alerts.append(build_payload(
816
+ threshold=t,
817
+ crossed_at_utc=crossed_at,
818
+ period_key=period_key,
819
+ period=period,
820
+ budget_usd=tg,
821
+ spent_usd=sp,
822
+ consumption_pct=pct,
823
+ ))
824
+ # Single commit: every INSERT + its alerted_at marker durable together.
825
+ conn.commit()
826
+ except Exception as exc:
827
+ eprint(f"[budget-milestone:{vendor}] error recording budget milestone: {exc}")
828
+ finally:
829
+ conn.close()
830
+
831
+ # Dispatch AFTER commit; a dispatch failure NEVER rolls back the milestone
832
+ # (set-then-dispatch invariant — one queue attempt per crossing, deduped on
833
+ # the alerted_at column).
834
+ for payload in pending_alerts:
835
+ try:
836
+ _dispatch_alert_notification(payload, mode="real")
837
+ except Exception as dispatch_exc:
838
+ eprint(f"[budget-alerts:{vendor}] dispatch failed: {dispatch_exc}")
839
+
840
+
759
841
  def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
760
- """Fire equiv-$ budget alerts on ACTUAL-spend threshold crossings
761
- (Approach A — called from ``cmd_record_usage`` alongside the weekly-% /
762
- 5h-% milestone helpers). Gated, hot-path-cheap, set-then-dispatch,
842
+ """Fire Claude equiv-$ budget alerts on ACTUAL-spend threshold crossings
843
+ (axis ``budget`` — called from ``cmd_record_usage`` alongside the weekly-% /
844
+ 5h-% milestone helpers). Thin vendor adapter over
845
+ :func:`_record_budget_milestone_for_vendor` (#143): reads the Claude budget
846
+ config block, gates, resolves ``target`` / ``thresholds`` / ``period``, and
847
+ passes the Claude payload builder. Gated, hot-path-cheap, set-then-dispatch,
763
848
  fire-once. Errors are logged, not raised (the caller also wraps).
764
849
 
765
850
  ``saved`` is accepted for call-site symmetry with
@@ -782,109 +867,35 @@ def maybe_record_budget_milestone(saved: dict[str, Any]) -> None:
782
867
  return
783
868
  if not _budget_alerts_active(budget_cfg):
784
869
  return
785
- target = budget_cfg["weekly_usd"]
786
870
  thresholds = budget_cfg["alert_thresholds"]
787
871
  if not thresholds:
788
872
  return
789
873
  # Period generalization (spec §6): subscription-week resolves the snapshot-
790
874
  # anchored window; a calendar period (calendar-week / calendar-month)
791
- # resolves the window purely from `now` + the period and stores the
792
- # period-start instant in the SAME `week_start_at` key column (back-compat
793
- # misnomer). config/tz are resolved once for the calendar branch.
875
+ # resolves the window purely from `now` + the period. config/tz are
876
+ # resolved once for the calendar branch.
794
877
  period = budget_cfg.get("period", "subscription-week")
795
-
796
878
  tz = resolve_display_tz(argparse.Namespace(tz=None), config)
797
- now_utc = _command_as_of()
798
- pending_alerts: list[dict[str, Any]] = []
799
- conn = open_db()
800
- try:
801
- window = _resolve_claude_budget_window(
802
- conn, now_utc, period=period, config=config, tz=tz
803
- )
804
- if window is None:
805
- return # no resolvable week window yet (spec §6 worst case)
806
- week_start_at, _week_end_at = window
807
- week_key = week_start_at.isoformat(timespec="seconds")
808
-
809
- # Pre-probe (hot-path discipline + [Dedup mustn't gate side effects]):
810
- # which configured thresholds are STILL un-recorded for this week?
811
- # The cost SUM is skipped ONLY when every threshold already has a row
812
- # — so a partial prior run that recorded some-but-not-all thresholds
813
- # still gets the remaining ones a SUM + crossing-check. The skip never
814
- # owes a crossing: an un-recorded threshold always forces the SUM.
815
- # The `period IS NULL` arm (#137) makes a pre-011 NULL-period row for
816
- # this window count as already-recorded, so an upgrading user never
817
- # re-fires a spurious alert against a historical crossing; a row stored
818
- # under the SAME concrete `period` also counts (fire-once).
819
- present = {
820
- int(r[0]) for r in conn.execute(
821
- "SELECT threshold FROM budget_milestones "
822
- "WHERE week_start_at = ? AND (period = ? OR period IS NULL)",
823
- (week_key, period),
824
- )
825
- }
826
- pending = [t for t in sorted(thresholds) if t not in present]
827
- if not pending:
828
- return # nothing left to cross this week → skip the cost SUM
829
-
830
- spent = _sum_cost_for_range(week_start_at, now_utc, mode="auto")
831
- # target > 0 is guaranteed by _get_budget_config (weekly_usd None is
832
- # excluded by _budget_alerts_active above); the else is belt-and-suspenders.
833
- consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
834
- for t in pending:
835
- # +1e-9 snap-up: spent/target*100 can land one ULP below an
836
- # integer threshold (CLAUDE.md float-floor gotcha).
837
- if consumption_pct + 1e-9 >= t:
838
- inserted = insert_budget_milestone(
839
- conn,
840
- week_start_at=week_key,
841
- period=period,
842
- threshold=t,
843
- budget_usd=target,
844
- spent_usd=spent,
845
- consumption_pct=consumption_pct,
846
- commit=False,
847
- )
848
- # Only the genuine-new-crossing winner (rowcount==1) dispatches;
849
- # a racing record-usage instance gets rowcount==0 and skips.
850
- if inserted == 1:
851
- crossed_at = now_utc_iso()
852
- # set-then-dispatch: alerted_at lands on the row BEFORE
853
- # the osascript Popen, sharing this transaction with the
854
- # INSERT (commit=False) so a crash between them is
855
- # impossible. The UPDATE keys on the CONCRETE `period` (#137)
856
- # — never a NULL-period sibling. `alerted_at IS NULL` guard
857
- # is write-once defense-in-depth.
858
- conn.execute(
859
- "UPDATE budget_milestones SET alerted_at = ? "
860
- "WHERE week_start_at = ? AND period = ? AND threshold = ? "
861
- " AND alerted_at IS NULL",
862
- (crossed_at, week_key, period, t),
863
- )
864
- pending_alerts.append(_build_alert_payload_budget(
865
- threshold=t,
866
- crossed_at_utc=crossed_at,
867
- week_start_at=week_key,
868
- budget_usd=target,
869
- spent_usd=spent,
870
- consumption_pct=consumption_pct,
871
- period=period,
872
- ))
873
- # Single commit: every INSERT + its alerted_at marker durable together.
874
- conn.commit()
875
- except Exception as exc:
876
- eprint(f"[budget-milestone] error recording budget milestone: {exc}")
877
- finally:
878
- conn.close()
879
-
880
- # Dispatch AFTER commit; a dispatch failure NEVER rolls back the milestone
881
- # (set-then-dispatch invariant — one queue attempt per crossing, deduped
882
- # on the alerted_at column).
883
- for payload in pending_alerts:
884
- try:
885
- _dispatch_alert_notification(payload, mode="real")
886
- except Exception as dispatch_exc:
887
- eprint(f"[budget-alerts] dispatch failed: {dispatch_exc}")
879
+ _record_budget_milestone_for_vendor(
880
+ vendor="claude",
881
+ target=budget_cfg["weekly_usd"],
882
+ thresholds=thresholds,
883
+ period=period,
884
+ config=config,
885
+ tz=tz,
886
+ # The Claude payload builder takes the legacy `week_start_at=` kwarg
887
+ # (its value is the resolved period-start instant, == period_key), so
888
+ # the at-fire dispatch id stays byte-stable `budget:<period_start_at>:<t>`.
889
+ build_payload=lambda **kw: _build_alert_payload_budget(
890
+ threshold=kw["threshold"],
891
+ crossed_at_utc=kw["crossed_at_utc"],
892
+ week_start_at=kw["period_key"],
893
+ budget_usd=kw["budget_usd"],
894
+ spent_usd=kw["spent_usd"],
895
+ consumption_pct=kw["consumption_pct"],
896
+ period=kw["period"],
897
+ ),
898
+ )
888
899
 
889
900
 
890
901
  def maybe_record_project_budget_milestone(saved: dict[str, Any]) -> None:
@@ -1056,24 +1067,26 @@ def maybe_record_codex_budget_milestone(saved: dict[str, Any]) -> None:
1056
1067
  """Fire Codex budget alerts on ACTUAL-Codex-spend threshold crossings (axis
1057
1068
  ``codex_budget``, calendar-period-codex-budgets spec §6 — the gap the Codex
1058
1069
  spec review flagged: Codex usage never flows through ``record-usage``, so the
1059
- Claude budget axes can't catch it). Called from ``cmd_record_usage``
1060
- alongside the weekly-% / 5h-% / budget / project-budget milestone helpers AND
1061
- opportunistically from ``cmd_budget`` (the Codex section already resolves
1062
- spend + crossings). Forward-only / fire-once, so the double-trigger never
1063
- double-fires. Gated, hot-path-cheap, set-then-dispatch. Errors are logged,
1064
- not raised (the caller also wraps).
1070
+ Claude budget axes can't catch it). Thin vendor adapter over
1071
+ :func:`_record_budget_milestone_for_vendor` (#143): reads the ``budget.codex``
1072
+ config block, gates, resolves ``target`` / ``thresholds`` / ``period``, and
1073
+ passes the Codex payload builder.
1074
+
1075
+ Called from ``cmd_record_usage`` alongside the weekly-% / 5h-% / budget /
1076
+ project-budget milestone helpers AND opportunistically from ``cmd_budget``
1077
+ (the public name is kept so that call site is unchanged). Forward-only /
1078
+ fire-once, so the double-trigger never double-fires. Gated, hot-path-cheap,
1079
+ set-then-dispatch. Errors are logged, not raised (the caller also wraps).
1065
1080
 
1066
1081
  Unlike the Claude budget axis, Codex has NO subscription week: the period
1067
1082
  window is resolved purely from ``now`` + the configured calendar period
1068
- (calendar-week / calendar-month) via the pure ``calendar_*_window``
1069
- functions it NEVER touches ``weekly_usage_snapshots``. The crossing +
1070
- set-then-dispatch arithmetic is factored into the shared
1071
- ``_codex_budget_crossings`` helper so this firing path and the
1072
- ``cctally budget`` opportunistic path stay byte-identical (plan §3.6).
1083
+ (calendar-week / calendar-month) it NEVER touches
1084
+ ``weekly_usage_snapshots`` (the shared core's ``_resolve_budget_window``
1085
+ dispatches to the pure calendar window for ``vendor='codex'``).
1073
1086
 
1074
1087
  ``saved`` is accepted for call-site symmetry with the sibling helpers but is
1075
- unused: Codex spend is resolved from the cache DB (``_sum_codex_cost_for_range``)
1076
- independent of the just-recorded 7d-% snapshot.
1088
+ unused: Codex spend is resolved from the cache DB independent of the
1089
+ just-recorded 7d-% snapshot.
1077
1090
  """
1078
1091
  # Gate FIRST (hot-path discipline): no Codex budget OR alerts off → zero
1079
1092
  # overhead for non-Codex-budget users. `load_config()` is safe outside any
@@ -1093,71 +1106,27 @@ def maybe_record_codex_budget_milestone(saved: dict[str, Any]) -> None:
1093
1106
  thresholds = codex_cfg.get("alert_thresholds") or []
1094
1107
  if target is None or not thresholds:
1095
1108
  return
1096
- period = codex_cfg["period"]
1097
-
1098
1109
  tz = resolve_display_tz(argparse.Namespace(tz=None), config)
1099
- now_utc = _command_as_of()
1100
- pending_alerts: list[dict[str, Any]] = []
1101
- conn = open_db()
1102
- try:
1103
- start_at, _end_at = _resolve_codex_budget_period_window(
1104
- period, now_utc, config, tz
1105
- )
1106
- period_key = start_at.isoformat(timespec="seconds")
1107
-
1108
- # Pre-probe (hot-path discipline + [Dedup mustn't gate side effects]):
1109
- # which configured thresholds are STILL un-recorded for this period? The
1110
- # Codex cost SUM is skipped ONLY when every threshold already has a row.
1111
- # The `period IS NULL` arm (#137) makes a pre-011 NULL-period row for
1112
- # this period count as already-recorded (no spurious upgrade re-fire); a
1113
- # row under the SAME concrete `period` also counts (fire-once).
1114
- present = {
1115
- int(r[0]) for r in conn.execute(
1116
- "SELECT threshold FROM codex_budget_milestones "
1117
- "WHERE period_start_at = ? AND (period = ? OR period IS NULL)",
1118
- (period_key, period),
1119
- )
1120
- }
1121
- pending = [t for t in sorted(thresholds) if t not in present]
1122
- if not pending:
1123
- return # nothing left to cross this period → skip the cost SUM
1124
-
1125
- spent = _sum_codex_cost_for_range(start_at, now_utc)
1126
- # Shared INSERT-and-arm core (set-then-dispatch, fire-once via rowcount);
1127
- # commit=False inside, so this conn owns the single durable commit below.
1128
- for t, crossed_at, sp, tg, pct in _codex_budget_crossings(
1129
- conn,
1130
- period_key=period_key,
1131
- period=period,
1132
- thresholds=pending,
1133
- target=target,
1134
- spent=spent,
1135
- now_utc=now_utc,
1136
- ):
1137
- pending_alerts.append(_build_alert_payload_codex_budget(
1138
- threshold=t,
1139
- crossed_at_utc=crossed_at,
1140
- period_start_at=period_key,
1141
- period=period,
1142
- budget_usd=tg,
1143
- spent_usd=sp,
1144
- consumption_pct=pct,
1145
- ))
1146
- # Single commit: every INSERT + its alerted_at marker durable together.
1147
- conn.commit()
1148
- except Exception as exc:
1149
- eprint(f"[codex-budget-milestone] error recording codex budget milestone: {exc}")
1150
- finally:
1151
- conn.close()
1152
-
1153
- # Dispatch AFTER commit; a dispatch failure NEVER rolls back the milestone
1154
- # (set-then-dispatch invariant — one queue attempt per crossing, deduped on
1155
- # the alerted_at column).
1156
- for payload in pending_alerts:
1157
- try:
1158
- _dispatch_alert_notification(payload, mode="real")
1159
- except Exception as dispatch_exc:
1160
- eprint(f"[codex-budget-alerts] dispatch failed: {dispatch_exc}")
1110
+ _record_budget_milestone_for_vendor(
1111
+ vendor="codex",
1112
+ target=target,
1113
+ thresholds=thresholds,
1114
+ period=codex_cfg["period"],
1115
+ config=config,
1116
+ tz=tz,
1117
+ # The Codex payload builder takes `period_start_at=` directly (== the
1118
+ # resolved period-start instant, == period_key), so the at-fire dispatch
1119
+ # id stays byte-stable `codex_budget:<period_start_at>:<threshold>`.
1120
+ build_payload=lambda **kw: _build_alert_payload_codex_budget(
1121
+ threshold=kw["threshold"],
1122
+ crossed_at_utc=kw["crossed_at_utc"],
1123
+ period_start_at=kw["period_key"],
1124
+ period=kw["period"],
1125
+ budget_usd=kw["budget_usd"],
1126
+ spent_usd=kw["spent_usd"],
1127
+ consumption_pct=kw["consumption_pct"],
1128
+ ),
1129
+ )
1161
1130
 
1162
1131
 
1163
1132
  def _weekly_pct_week_avg_projection(conn, now_utc):
@@ -41,12 +41,13 @@ class AlertAxisDescriptor:
41
41
  chip_label: str # SHOUT form, byte-identical with alertAxis.ts AXIS_CHIP_LABEL
42
42
  title_label: str # sentence-case form, byte-identical with AXIS_TITLE_LABEL
43
43
  milestone_table: str # SQLite table the dashboard envelope SELECTs from
44
+ vendor: "str | None" = None # 'claude'|'codex' for budget-family axes; None otherwise (#143)
44
45
 
45
46
 
46
47
  AXIS_REGISTRY: "tuple[AlertAxisDescriptor, ...]" = (
47
48
  AlertAxisDescriptor("weekly", "WEEKLY", "Weekly", "percent_milestones"),
48
49
  AlertAxisDescriptor("five_hour", "5H-BLOCK", "5h-block", "five_hour_milestones"),
49
- AlertAxisDescriptor("budget", "BUDGET", "Budget", "budget_milestones"),
50
+ AlertAxisDescriptor("budget", "BUDGET", "Budget", "budget_milestones", vendor="claude"),
50
51
  AlertAxisDescriptor("projected", "PROJECTED", "Projected", "projected_milestones"),
51
52
  # Per-project weekly budget alerts (issue #19 / #121). Distinct "PROJECT"
52
53
  # chip vs the global "BUDGET" chip; its own forward-only table.
@@ -55,10 +56,12 @@ AXIS_REGISTRY: "tuple[AlertAxisDescriptor, ...]" = (
55
56
  ),
56
57
  # Per-vendor Codex budget alerts (calendar-period; calendar-period-codex-budgets
57
58
  # feature). Distinct "CODEX" chip vs the global "BUDGET" / per-project
58
- # "PROJECT" chips; its own forward-only `codex_budget_milestones` table keyed
59
- # on the resolved period-window start instant (period_start_at, threshold).
59
+ # "PROJECT" chips. As of #143 it shares the unified vendor-tagged
60
+ # `budget_milestones` table with the Claude `budget` axis; the envelope
61
+ # mapper's `WHERE vendor=?` filter does the row-level split (keyed on the
62
+ # resolved period-window start instant period_start_at, threshold).
60
63
  AlertAxisDescriptor(
61
- "codex_budget", "CODEX", "Codex budget", "codex_budget_milestones"
64
+ "codex_budget", "CODEX", "Codex budget", "budget_milestones", vendor="codex"
62
65
  ),
63
66
  )
64
67
 
@@ -59,12 +59,27 @@ def iter_message_rows(fh, path_str):
59
59
  obj = json.loads(s)
60
60
  except json.JSONDecodeError:
61
61
  continue
62
- t = obj.get("type")
63
- if t not in ("user", "assistant"):
64
- continue
65
- if not obj.get("uuid"):
66
- continue
67
- yield _normalize(obj, t, offset)
62
+ row = parse_message_row(obj, offset)
63
+ if row is not None:
64
+ yield row
65
+
66
+
67
+ def parse_message_row(obj, offset):
68
+ """Pure per-line message parser: given a parsed JSONL object and its byte
69
+ offset, return a ``MessageRow`` when it is a user/assistant turn carrying a
70
+ ``uuid``, or ``None`` otherwise (summary / file-history-snapshot / uuid-less
71
+ lines). No I/O — the caller owns the readline()+tell() loop.
72
+
73
+ Extracted (#138) so ``iter_message_rows`` and the fused single-pass sync
74
+ walker (``_cctally_cache._iter_sync_entries``) share ONE classification —
75
+ each JSONL line is parsed once and the conversation index is no longer
76
+ populated by a separate second seek-and-walk over the same byte span."""
77
+ t = obj.get("type")
78
+ if t not in ("user", "assistant"):
79
+ return None
80
+ if not obj.get("uuid"):
81
+ return None
82
+ return _normalize(obj, t, offset)
68
83
 
69
84
 
70
85
  def _normalize(obj, t, offset):
@@ -121,12 +136,15 @@ def _blocks_and_text(content):
121
136
  blocks.append({"kind": "thinking", "text": b.get("thinking", "") or ""})
122
137
  elif bt == "tool_use":
123
138
  blocks.append({"kind": "tool_use", "name": b.get("name"),
124
- "input_summary": _summarize(b.get("input"))})
139
+ "input_summary": _summarize(b.get("input")),
140
+ "id": b.get("id"),
141
+ "preview": tool_preview(b.get("name"), b.get("input"))})
125
142
  elif bt == "tool_result":
126
143
  raw = _stringify(b.get("content"))
127
144
  blocks.append({"kind": "tool_result", "text": raw[:_TOOL_RESULT_CAP],
128
145
  "truncated": len(raw) > _TOOL_RESULT_CAP,
129
- "is_error": bool(b.get("is_error"))})
146
+ "is_error": bool(b.get("is_error")),
147
+ "tool_use_id": b.get("tool_use_id")})
130
148
  elif bt in ("image", "document"):
131
149
  blocks.append({"kind": bt, **_media(b.get("source"))})
132
150
  elif bt == "tool_reference":
@@ -155,6 +173,39 @@ def _summarize(inp):
155
173
  return s[:200]
156
174
 
157
175
 
176
+ _PREVIEW_FIELDS = {
177
+ "Read": "file_path", "Write": "file_path", "Edit": "file_path",
178
+ "MultiEdit": "file_path", "NotebookEdit": "file_path",
179
+ "Bash": "command", "Grep": "pattern", "Glob": "pattern",
180
+ "Task": "description", "WebFetch": "url", "WebSearch": "query",
181
+ }
182
+
183
+
184
+ def tool_preview(name, inp):
185
+ """One-line, full-fidelity preview for a tool call's collapsed chip (#164,
186
+ C5). Runs on the RAW input dict before _summarize truncates to 200 chars.
187
+ Known tools map to their primary arg; Bash takes the first command line;
188
+ Task falls back to subagent_type; unknown/mcp tools take the first
189
+ string-valued arg, else the tool name. Always returns a single-line str."""
190
+ if not isinstance(inp, dict):
191
+ return ""
192
+ field = _PREVIEW_FIELDS.get(name or "")
193
+ val = None
194
+ if field is not None:
195
+ val = inp.get(field)
196
+ if val is None and name == "Task":
197
+ val = inp.get("subagent_type")
198
+ if val is None:
199
+ # generic fallback: first string-valued arg, else the tool name
200
+ for v in inp.values():
201
+ if isinstance(v, str) and v:
202
+ val = v
203
+ break
204
+ if not isinstance(val, str) or not val:
205
+ return name or ""
206
+ return val.splitlines()[0]
207
+
208
+
158
209
  def _media(source):
159
210
  if not isinstance(source, dict):
160
211
  return {"media_type": None, "bytes": 0}