cctally 1.7.3 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/bin/_cctally_alerts.py +12 -5
- package/bin/_cctally_cache.py +12 -11
- package/bin/_cctally_config.py +34 -19
- package/bin/_cctally_core.py +890 -0
- package/bin/_cctally_dashboard.py +175 -233
- package/bin/_cctally_db.py +89 -20
- package/bin/_cctally_record.py +76 -75
- package/bin/_cctally_refresh.py +35 -20
- package/bin/_cctally_setup.py +26 -16
- package/bin/_cctally_sync_week.py +21 -6
- package/bin/_cctally_tui.py +151 -306
- package/bin/_cctally_update.py +11 -16
- package/bin/_lib_aggregators.py +7 -1
- package/bin/_lib_diff_kernel.py +19 -21
- package/bin/_lib_subscription_weeks.py +17 -9
- package/bin/_lib_view_models.py +993 -0
- package/bin/cctally +338 -1055
- package/dashboard/static/assets/{index-DhCnIFq9.js → index-CfXu9Fx_.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +10 -8
package/bin/_cctally_tui.py
CHANGED
|
@@ -202,43 +202,40 @@ def _cctally():
|
|
|
202
202
|
return sys.modules["cctally"]
|
|
203
203
|
|
|
204
204
|
|
|
205
|
+
# === Honest imports from extracted homes ===================================
|
|
206
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3.
|
|
207
|
+
from _cctally_core import (
|
|
208
|
+
eprint,
|
|
209
|
+
parse_iso_datetime,
|
|
210
|
+
_now_utc,
|
|
211
|
+
open_db,
|
|
212
|
+
get_latest_usage_for_week,
|
|
213
|
+
_canonicalize_optional_iso,
|
|
214
|
+
make_week_ref,
|
|
215
|
+
)
|
|
216
|
+
from _lib_display_tz import (
|
|
217
|
+
format_display_dt,
|
|
218
|
+
resolve_display_tz,
|
|
219
|
+
normalize_display_tz_value,
|
|
220
|
+
_compute_display_block,
|
|
221
|
+
)
|
|
222
|
+
from _lib_aggregators import _aggregate_monthly
|
|
223
|
+
|
|
224
|
+
|
|
205
225
|
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
206
226
|
# Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind
|
|
207
227
|
# time), so monkeypatches on cctally's namespace propagate into the moved
|
|
208
|
-
# code unchanged.
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
|
|
213
|
-
return sys.modules["cctally"].eprint(*args, **kwargs)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def parse_iso_datetime(*args, **kwargs):
|
|
217
|
-
return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def _now_utc(*args, **kwargs):
|
|
221
|
-
return sys.modules["cctally"]._now_utc(*args, **kwargs)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
def open_db(*args, **kwargs):
|
|
225
|
-
return sys.modules["cctally"].open_db(*args, **kwargs)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
+
# code unchanged. `load_config` and `get_claude_session_entries` STAY as
|
|
229
|
+
# shims even though their natural homes are decentralized (_cctally_config
|
|
230
|
+
# / _cctally_cache) — tests monkeypatch them via `ns["X"]` (21 sites total,
|
|
231
|
+
# audited 2026-05-17); direct imports would silently bypass the patches.
|
|
232
|
+
# See spec §3.5 (carve-out) and §3.7 (stays-on-shim allowlist).
|
|
228
233
|
def load_config(*args, **kwargs):
|
|
229
234
|
return sys.modules["cctally"].load_config(*args, **kwargs)
|
|
230
235
|
|
|
231
236
|
|
|
232
|
-
def
|
|
233
|
-
return sys.modules["cctally"].
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def resolve_display_tz(*args, **kwargs):
|
|
237
|
-
return sys.modules["cctally"].resolve_display_tz(*args, **kwargs)
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def normalize_display_tz_value(*args, **kwargs):
|
|
241
|
-
return sys.modules["cctally"].normalize_display_tz_value(*args, **kwargs)
|
|
237
|
+
def get_claude_session_entries(*args, **kwargs):
|
|
238
|
+
return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
|
|
242
239
|
|
|
243
240
|
|
|
244
241
|
def _resolve_display_tz_obj(*args, **kwargs):
|
|
@@ -253,10 +250,6 @@ def _apply_midweek_reset_override(*args, **kwargs):
|
|
|
253
250
|
return sys.modules["cctally"]._apply_midweek_reset_override(*args, **kwargs)
|
|
254
251
|
|
|
255
252
|
|
|
256
|
-
def _compute_display_block(*args, **kwargs):
|
|
257
|
-
return sys.modules["cctally"]._compute_display_block(*args, **kwargs)
|
|
258
|
-
|
|
259
|
-
|
|
260
253
|
def _compute_forecast(*args, **kwargs):
|
|
261
254
|
return sys.modules["cctally"]._compute_forecast(*args, **kwargs)
|
|
262
255
|
|
|
@@ -297,18 +290,6 @@ def _aggregate_claude_sessions(*args, **kwargs):
|
|
|
297
290
|
return sys.modules["cctally"]._aggregate_claude_sessions(*args, **kwargs)
|
|
298
291
|
|
|
299
292
|
|
|
300
|
-
def _aggregate_monthly(*args, **kwargs):
|
|
301
|
-
return sys.modules["cctally"]._aggregate_monthly(*args, **kwargs)
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def get_claude_session_entries(*args, **kwargs):
|
|
305
|
-
return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
def get_latest_usage_for_week(*args, **kwargs):
|
|
309
|
-
return sys.modules["cctally"].get_latest_usage_for_week(*args, **kwargs)
|
|
310
|
-
|
|
311
|
-
|
|
312
293
|
def get_latest_cost_for_week(*args, **kwargs):
|
|
313
294
|
return sys.modules["cctally"].get_latest_cost_for_week(*args, **kwargs)
|
|
314
295
|
|
|
@@ -317,10 +298,6 @@ def get_milestones_for_week(*args, **kwargs):
|
|
|
317
298
|
return sys.modules["cctally"].get_milestones_for_week(*args, **kwargs)
|
|
318
299
|
|
|
319
300
|
|
|
320
|
-
def _canonicalize_optional_iso(*args, **kwargs):
|
|
321
|
-
return sys.modules["cctally"]._canonicalize_optional_iso(*args, **kwargs)
|
|
322
|
-
|
|
323
|
-
|
|
324
301
|
def get_recent_weeks(*args, **kwargs):
|
|
325
302
|
return sys.modules["cctally"].get_recent_weeks(*args, **kwargs)
|
|
326
303
|
|
|
@@ -831,55 +808,18 @@ class TuiCurrentWeek:
|
|
|
831
808
|
five_hour_block: dict | None = None
|
|
832
809
|
|
|
833
810
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
@dataclass
|
|
847
|
-
class WeeklyPeriodRow:
|
|
848
|
-
"""One subscription-week row for the dashboard's Weekly panel/modal.
|
|
849
|
-
|
|
850
|
-
`models` is a list of `{model, display, chip, cost_usd, cost_pct}`
|
|
851
|
-
dicts sorted by `cost_usd` descending. Pre-bucketed in Python so
|
|
852
|
-
the React layer never re-derives per-model coloring.
|
|
853
|
-
"""
|
|
854
|
-
label: str # "04-23" — MM-DD of the week start
|
|
855
|
-
cost_usd: float
|
|
856
|
-
total_tokens: int
|
|
857
|
-
input_tokens: int
|
|
858
|
-
output_tokens: int
|
|
859
|
-
cache_creation_tokens: int
|
|
860
|
-
cache_read_tokens: int
|
|
861
|
-
used_pct: float | None # from weekly_usage_snapshots overlay
|
|
862
|
-
dollar_per_pct: float | None # cost / used_pct when used_pct > 0
|
|
863
|
-
delta_cost_pct: float | None # (cost - prev_cost) / prev_cost
|
|
864
|
-
is_current: bool
|
|
865
|
-
models: list[dict[str, Any]]
|
|
866
|
-
week_start_at: str # ISO-8601 with tz, from SubWeek.start_ts
|
|
867
|
-
week_end_at: str # ISO-8601 with tz, from SubWeek.end_ts
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
@dataclass
|
|
871
|
-
class MonthlyPeriodRow:
|
|
872
|
-
"""One calendar-month row for the dashboard's Monthly panel/modal."""
|
|
873
|
-
label: str # "YYYY-MM"
|
|
874
|
-
cost_usd: float
|
|
875
|
-
total_tokens: int
|
|
876
|
-
input_tokens: int
|
|
877
|
-
output_tokens: int
|
|
878
|
-
cache_creation_tokens: int
|
|
879
|
-
cache_read_tokens: int
|
|
880
|
-
delta_cost_pct: float | None
|
|
881
|
-
is_current: bool
|
|
882
|
-
models: list[dict[str, Any]]
|
|
811
|
+
# ---- View-model row dataclasses moved to bin/_lib_view_models.py ----
|
|
812
|
+
# Re-exported here so `from _cctally_tui import TuiTrendRow` and
|
|
813
|
+
# `ns["TuiTrendRow"]` direct-dict reads in tests keep resolving. The
|
|
814
|
+
# **extended** TuiTrendRow (spec §4.1: +10 nullable fields) is imported
|
|
815
|
+
# from the same module; the new fields default to None so existing TUI
|
|
816
|
+
# / dashboard fixtures that construct TuiTrendRow positionally stay
|
|
817
|
+
# byte-stable.
|
|
818
|
+
from _lib_view_models import ( # noqa: E402
|
|
819
|
+
TuiTrendRow,
|
|
820
|
+
WeeklyPeriodRow,
|
|
821
|
+
MonthlyPeriodRow,
|
|
822
|
+
)
|
|
883
823
|
|
|
884
824
|
|
|
885
825
|
@dataclass
|
|
@@ -901,43 +841,8 @@ class BlocksPanelRow:
|
|
|
901
841
|
label: str # "HH:MM MMM DD" in local tz, e.g. "14:00 Apr 26"
|
|
902
842
|
|
|
903
843
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
"""One row of the dashboard's Daily heatmap panel.
|
|
907
|
-
|
|
908
|
-
`intensity_bucket` is the server-computed quintile bucket (0..5) —
|
|
909
|
-
bucket 0 is reserved for zero-cost days; buckets 1..5 are quintiles
|
|
910
|
-
over non-zero days.
|
|
911
|
-
|
|
912
|
-
v2.3: Added per-day token rollup + `cache_hit_pct` so the Daily
|
|
913
|
-
detail modal can surface the same fields the CLI's `daily` command
|
|
914
|
-
shows. Defaults preserve compatibility with `_empty_dashboard_snapshot`
|
|
915
|
-
and any pre-v2.3 fixture that omits the new fields.
|
|
916
|
-
"""
|
|
917
|
-
date: str # local-tz YYYY-MM-DD
|
|
918
|
-
label: str # "MM-DD" — pre-formatted, mirrors Weekly/Monthly idiom
|
|
919
|
-
cost_usd: float
|
|
920
|
-
is_today: bool
|
|
921
|
-
intensity_bucket: int # 0..5
|
|
922
|
-
models: list[dict[str, Any]] # ModelCostRow shape, sorted desc by cost
|
|
923
|
-
# ---- v2.3 additions: Daily modal token + cache rollup ----
|
|
924
|
-
input_tokens: int = 0
|
|
925
|
-
output_tokens: int = 0
|
|
926
|
-
cache_creation_tokens: int = 0
|
|
927
|
-
cache_read_tokens: int = 0
|
|
928
|
-
total_tokens: int = 0
|
|
929
|
-
cache_hit_pct: float | None = None
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
@dataclass
|
|
933
|
-
class TuiSessionRow:
|
|
934
|
-
started_at: dt.datetime
|
|
935
|
-
duration_minutes: float
|
|
936
|
-
model_primary: str # first model used in the session
|
|
937
|
-
cost_usd: float
|
|
938
|
-
cache_hit_pct: float | None
|
|
939
|
-
project_label: str # basename of project_path
|
|
940
|
-
session_id: str # full session UUID (v2: needed for session-detail modal)
|
|
844
|
+
# DailyPanelRow + TuiSessionRow moved to bin/_lib_view_models.py — re-export.
|
|
845
|
+
from _lib_view_models import DailyPanelRow, TuiSessionRow # noqa: E402
|
|
941
846
|
|
|
942
847
|
|
|
943
848
|
@dataclass
|
|
@@ -1119,6 +1024,26 @@ class DataSnapshot:
|
|
|
1119
1024
|
# at sync-thread time so ``snapshot_to_envelope`` stays a pure
|
|
1120
1025
|
# renderer; empty list when no current 5h block is bound.
|
|
1121
1026
|
five_hour_milestones: list[dict] = field(default_factory=list)
|
|
1027
|
+
# ---- view-model unification (Bundle 1): pre-computed totals ----
|
|
1028
|
+
# Populated by the sync thread as sum-over-visible-rows over the
|
|
1029
|
+
# panel rows ``_dashboard_build_{daily,monthly,weekly}_periods``
|
|
1030
|
+
# returned (see ``_tui_build_snapshot``); the dashboard envelope
|
|
1031
|
+
# adapter emits these as ``<domain>.total_cost_usd`` /
|
|
1032
|
+
# ``total_tokens`` so the React panels stop running
|
|
1033
|
+
# ``rows.reduce(...)`` in JS. Sum-over-visible-rows is a structural
|
|
1034
|
+
# invariant: ``total === sum(rows[*].cost_usd)`` by construction —
|
|
1035
|
+
# see ``test_weekly_envelope_total_matches_sum_of_visible_rows``.
|
|
1036
|
+
# ``trend_avg_dollars_per_pct`` is sourced from ``build_trend_view``
|
|
1037
|
+
# (3-sample-rule mean per spec §4.3). Defaults preserve
|
|
1038
|
+
# compatibility with pre-Bundle-1 fixture modules that construct
|
|
1039
|
+
# ``DataSnapshot`` positionally. Spec §6.6.
|
|
1040
|
+
daily_total_cost_usd: float = 0.0
|
|
1041
|
+
daily_total_tokens: int = 0
|
|
1042
|
+
monthly_total_cost_usd: float = 0.0
|
|
1043
|
+
monthly_total_tokens: int = 0
|
|
1044
|
+
weekly_total_cost_usd: float = 0.0
|
|
1045
|
+
weekly_total_tokens: int = 0
|
|
1046
|
+
trend_avg_dollars_per_pct: float | None = None
|
|
1122
1047
|
|
|
1123
1048
|
@classmethod
|
|
1124
1049
|
def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
|
|
@@ -1518,158 +1443,16 @@ def _tui_build_trend(
|
|
|
1518
1443
|
) -> list[TuiTrendRow]:
|
|
1519
1444
|
"""Build the last `count` trend rows, chronological (oldest first).
|
|
1520
1445
|
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
`cmd_report` byte-for-byte — verified in the bundle regression diff.
|
|
1446
|
+
Bundle 1 / Task 10: wraps the unified ``build_trend_view`` kernel
|
|
1447
|
+
(spec §5.4) — the loop body that used to live here moved into
|
|
1448
|
+
``bin/_lib_view_models.build_trend_view``. The TUI snapshot module
|
|
1449
|
+
consumes the first 7 ``TuiTrendRow`` fields and ignores the 10
|
|
1450
|
+
extended fields (which exist for cmd_report's JSON contract).
|
|
1527
1451
|
"""
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
week_refs = get_recent_weeks(conn, max(1, count))
|
|
1533
|
-
|
|
1534
|
-
# Figure out which week_ref corresponds to the current subscription week.
|
|
1535
|
-
# Mirrors `cmd_report`'s Bug D pattern: build a current_ref from the
|
|
1536
|
-
# latest usage snapshot, route it through `_apply_reset_events_to_weekrefs`
|
|
1537
|
-
# so its `week_start_at` reflects the post-credit segment (or the
|
|
1538
|
-
# original start for non-credit weeks), then disambiguate the
|
|
1539
|
-
# synthesized pre-credit ref from the live post-credit ref via BOTH
|
|
1540
|
-
# `key` AND `week_start_at`. Key-only equality marks both segments
|
|
1541
|
-
# as current, which is why the dashboard's trend panel previously
|
|
1542
|
-
# showed two adjacent rows both highlighted as "current" with
|
|
1543
|
-
# identical 4.0% values on the user's live in-place credit data.
|
|
1544
|
-
latest_usage = conn.execute(
|
|
1545
|
-
"SELECT week_start_date, week_end_date "
|
|
1546
|
-
"FROM weekly_usage_snapshots "
|
|
1547
|
-
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
|
|
1548
|
-
).fetchone()
|
|
1549
|
-
current_key: str | None = None
|
|
1550
|
-
current_week_start_at: str | None = None
|
|
1551
|
-
if latest_usage is not None and latest_usage["week_start_date"] is not None:
|
|
1552
|
-
current_key = latest_usage["week_start_date"]
|
|
1553
|
-
try:
|
|
1554
|
-
c = _cctally()
|
|
1555
|
-
canon_start, canon_end = c._get_canonical_boundary_for_date(
|
|
1556
|
-
conn, latest_usage["week_start_date"]
|
|
1557
|
-
)
|
|
1558
|
-
current_ref = c.make_week_ref(
|
|
1559
|
-
week_start_date=latest_usage["week_start_date"],
|
|
1560
|
-
week_end_date=latest_usage["week_end_date"],
|
|
1561
|
-
week_start_at=canon_start,
|
|
1562
|
-
week_end_at=canon_end,
|
|
1563
|
-
)
|
|
1564
|
-
_adjusted = c._apply_reset_events_to_weekrefs(conn, [current_ref])
|
|
1565
|
-
if _adjusted:
|
|
1566
|
-
current_week_start_at = _adjusted[0].week_start_at
|
|
1567
|
-
except (ValueError, sqlite3.DatabaseError, AttributeError):
|
|
1568
|
-
current_week_start_at = None
|
|
1569
|
-
|
|
1570
|
-
# Build an intermediate list of (week_ref, used_pct, dpp) in oldest-first
|
|
1571
|
-
# chronological order.
|
|
1572
|
-
chrono = list(reversed(week_refs))
|
|
1573
|
-
# Split-key set (Bug D): credited weeks appear twice in `week_refs`
|
|
1574
|
-
# with identical `WeekRef.key`. For those keys ONLY, pin
|
|
1575
|
-
# `as_of_utc=week_ref.week_end_at` so each segment finds its own
|
|
1576
|
-
# latest snapshot — without this both segments collapse to the
|
|
1577
|
-
# post-credit snapshot's weekly_percent. Non-credit weeks (single
|
|
1578
|
-
# ref per key) keep the legacy unfiltered lookup.
|
|
1579
|
-
_split_keys = {
|
|
1580
|
-
r.key
|
|
1581
|
-
for r in week_refs
|
|
1582
|
-
if sum(1 for x in week_refs if x.key == r.key) > 1
|
|
1583
|
-
}
|
|
1584
|
-
intermediate: list[tuple[Any, float | None, float | None]] = []
|
|
1585
|
-
for week_ref in chrono:
|
|
1586
|
-
usage = get_latest_usage_for_week(
|
|
1587
|
-
conn,
|
|
1588
|
-
week_ref,
|
|
1589
|
-
as_of_utc=(
|
|
1590
|
-
week_ref.week_end_at if week_ref.key in _split_keys else None
|
|
1591
|
-
),
|
|
1592
|
-
)
|
|
1593
|
-
# See cmd_report for why reset-affected weeks skip the cost cache
|
|
1594
|
-
# and live-compute from session_entries over the effective range.
|
|
1595
|
-
if _week_ref_has_reset_event(conn, week_ref):
|
|
1596
|
-
cost_usd = _compute_cost_for_weekref(week_ref)
|
|
1597
|
-
else:
|
|
1598
|
-
cost = get_latest_cost_for_week(conn, week_ref)
|
|
1599
|
-
cost_usd = float(cost["cost_usd"]) if cost else None
|
|
1600
|
-
percent = float(usage["weekly_percent"]) if usage else None
|
|
1601
|
-
ratio = (cost_usd / percent) if (
|
|
1602
|
-
cost_usd is not None and percent and percent > 0
|
|
1603
|
-
) else None
|
|
1604
|
-
intermediate.append((week_ref, percent, ratio))
|
|
1605
|
-
|
|
1606
|
-
# Normalize dpp into spark heights 1..8 across the window.
|
|
1607
|
-
dpps = [d for _, _, d in intermediate if d is not None]
|
|
1608
|
-
if dpps:
|
|
1609
|
-
lo, hi = min(dpps), max(dpps)
|
|
1610
|
-
span = (hi - lo) or 1e-9
|
|
1611
|
-
else:
|
|
1612
|
-
lo, hi, span = 0.0, 1.0, 1e-9
|
|
1613
|
-
|
|
1614
|
-
out: list[TuiTrendRow] = []
|
|
1615
|
-
prev_dpp: float | None = None
|
|
1616
|
-
for week_ref, percent, dpp in intermediate:
|
|
1617
|
-
delta = (dpp - prev_dpp) if (dpp is not None and prev_dpp is not None) else None
|
|
1618
|
-
spark = 1
|
|
1619
|
-
if dpp is not None:
|
|
1620
|
-
spark = int(round((dpp - lo) / span * 7)) + 1
|
|
1621
|
-
spark = max(1, min(8, spark))
|
|
1622
|
-
# WeekRef.week_start is a date; synthesize a UTC datetime so
|
|
1623
|
-
# TuiTrendRow carries a timezone-aware instant (prefer the explicit
|
|
1624
|
-
# week_start_at if present).
|
|
1625
|
-
if week_ref.week_start_at:
|
|
1626
|
-
week_start_dt = parse_iso_datetime(
|
|
1627
|
-
week_ref.week_start_at, "week_start_at"
|
|
1628
|
-
)
|
|
1629
|
-
week_label = format_display_dt(
|
|
1630
|
-
week_start_dt, display_tz, fmt="%b %d", suffix=False,
|
|
1631
|
-
)
|
|
1632
|
-
else:
|
|
1633
|
-
week_start_dt = dt.datetime.combine(
|
|
1634
|
-
week_ref.week_start, dt.time(0, 0), dt.timezone.utc
|
|
1635
|
-
)
|
|
1636
|
-
# No real boundary instant — format the calendar date directly so
|
|
1637
|
-
# localizing midnight-UTC doesn't shift it to the prior day in
|
|
1638
|
-
# zones west of UTC (e.g. 2026-04-14 → "Apr 13" in America/New_York).
|
|
1639
|
-
week_label = week_ref.week_start.strftime("%b %d")
|
|
1640
|
-
# Bug G (v1.7.2 round-5): match on BOTH `key` AND `week_start_at`
|
|
1641
|
-
# for credited weeks so the pre-credit synthesized ref doesn't
|
|
1642
|
-
# also light up as "current" — both refs share `key`, only their
|
|
1643
|
-
# `week_start_at` differs (post-credit = effective reset moment,
|
|
1644
|
-
# pre-credit = original API-derived start). Non-credit weeks
|
|
1645
|
-
# have only one ref per key so `week_start_at` matching is
|
|
1646
|
-
# automatic. When `current_week_start_at` is None (no reset
|
|
1647
|
-
# event for the current week, or the resolution above failed),
|
|
1648
|
-
# falls back to legacy key-only matching.
|
|
1649
|
-
is_cur = (
|
|
1650
|
-
current_key is not None
|
|
1651
|
-
and week_ref.key == current_key
|
|
1652
|
-
and (
|
|
1653
|
-
current_week_start_at is None
|
|
1654
|
-
or week_ref.week_start_at == current_week_start_at
|
|
1655
|
-
)
|
|
1656
|
-
)
|
|
1657
|
-
out.append(TuiTrendRow(
|
|
1658
|
-
week_label=week_label,
|
|
1659
|
-
week_start_at=week_start_dt,
|
|
1660
|
-
# Preserve None when no usage snapshot exists for this week —
|
|
1661
|
-
# matches `cmd_report`'s "n/a" rendering (9980) and avoids
|
|
1662
|
-
# fabricating a 0.0% row for the phantom-week case (cost
|
|
1663
|
-
# snapshot present, usage snapshot absent).
|
|
1664
|
-
used_pct=float(percent) if percent is not None else None,
|
|
1665
|
-
dollars_per_percent=dpp,
|
|
1666
|
-
delta_dpp=delta,
|
|
1667
|
-
spark_height=spark,
|
|
1668
|
-
is_current=is_cur,
|
|
1669
|
-
))
|
|
1670
|
-
if dpp is not None:
|
|
1671
|
-
prev_dpp = dpp
|
|
1672
|
-
return out
|
|
1452
|
+
c = _cctally()
|
|
1453
|
+
view = c.build_trend_view(conn, now_utc=now_utc, n=max(1, count),
|
|
1454
|
+
display_tz=display_tz)
|
|
1455
|
+
return list(view.rows)
|
|
1673
1456
|
|
|
1674
1457
|
|
|
1675
1458
|
def _tui_build_weekly_history(
|
|
@@ -1708,6 +1491,14 @@ def _tui_build_sessions(
|
|
|
1708
1491
|
|
|
1709
1492
|
When `skip_sync=True`, honors the parent's `--no-sync` intent: no
|
|
1710
1493
|
ingest pass, just read whatever is already cached.
|
|
1494
|
+
|
|
1495
|
+
Bundle 2 / Task 15: wraps the unified ``build_sessions_view``
|
|
1496
|
+
kernel — the prior 40-line inline-derivation body now lives at
|
|
1497
|
+
``bin/_lib_view_models.build_sessions_view``. The TUI keeps
|
|
1498
|
+
ownership of the bounded 365-day scan window (rationale below) and
|
|
1499
|
+
consumes ``view.rows`` (the typed ``TuiSessionRow`` tuple). The
|
|
1500
|
+
view's parallel ``view.aggregated`` is reserved for the CLI / share
|
|
1501
|
+
surfaces; the TUI doesn't need ``ClaudeSessionUsage`` fields.
|
|
1711
1502
|
"""
|
|
1712
1503
|
# Bounded scan window — the sessions pane promises "last `limit`". A
|
|
1713
1504
|
# 365-day scan covers virtually all users (even one-session-every-few-days
|
|
@@ -1716,23 +1507,11 @@ def _tui_build_sessions(
|
|
|
1716
1507
|
# on every entry in the window before slicing.
|
|
1717
1508
|
range_start = now_utc - dt.timedelta(days=365)
|
|
1718
1509
|
entries = get_claude_session_entries(range_start, now_utc, skip_sync=skip_sync)
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
total_io = s.input_tokens + s.cache_creation_tokens + s.cache_read_tokens
|
|
1725
|
-
cache_pct = (total_read / total_io * 100) if total_io > 0 else None
|
|
1726
|
-
out.append(TuiSessionRow(
|
|
1727
|
-
started_at=s.first_activity,
|
|
1728
|
-
duration_minutes=duration_min,
|
|
1729
|
-
model_primary=(s.models[0] if s.models else "—"),
|
|
1730
|
-
cost_usd=s.cost_usd,
|
|
1731
|
-
cache_hit_pct=cache_pct,
|
|
1732
|
-
project_label=os.path.basename(s.project_path) or s.project_path,
|
|
1733
|
-
session_id=s.session_id,
|
|
1734
|
-
))
|
|
1735
|
-
return out
|
|
1510
|
+
c = _cctally()
|
|
1511
|
+
view = c.build_sessions_view(
|
|
1512
|
+
entries, now_utc=now_utc, limit=limit, display_tz=None,
|
|
1513
|
+
)
|
|
1514
|
+
return list(view.rows)
|
|
1736
1515
|
|
|
1737
1516
|
|
|
1738
1517
|
@dataclass
|
|
@@ -1876,10 +1655,19 @@ def _tui_build_snapshot(
|
|
|
1876
1655
|
fc = _tui_build_forecast(conn, now_utc, skip_sync=skip_sync)
|
|
1877
1656
|
except Exception as exc:
|
|
1878
1657
|
errors.append(f"forecast: {exc}")
|
|
1658
|
+
# Trend: source from build_trend_view so we capture the 3-sample
|
|
1659
|
+
# avg_dollars_per_pct alongside the rows. The TUI build path
|
|
1660
|
+
# historically called _tui_build_trend (which now wraps the
|
|
1661
|
+
# builder); calling the builder directly here saves one
|
|
1662
|
+
# `_aggregate_*` round-trip.
|
|
1663
|
+
trend_avg_dpp = None
|
|
1879
1664
|
try:
|
|
1880
|
-
|
|
1881
|
-
|
|
1665
|
+
c = _cctally()
|
|
1666
|
+
_trend_view = c.build_trend_view(
|
|
1667
|
+
conn, now_utc=now_utc, n=8, display_tz=_build_display_tz,
|
|
1882
1668
|
)
|
|
1669
|
+
trend = list(_trend_view.rows)
|
|
1670
|
+
trend_avg_dpp = _trend_view.avg_dollars_per_pct
|
|
1883
1671
|
except Exception as exc:
|
|
1884
1672
|
errors.append(f"trend: {exc}")
|
|
1885
1673
|
try:
|
|
@@ -1904,17 +1692,53 @@ def _tui_build_snapshot(
|
|
|
1904
1692
|
except Exception as exc:
|
|
1905
1693
|
errors.append(f"weekly-history: {exc}")
|
|
1906
1694
|
# ---- v2.1 additions: dashboard Weekly / Monthly panels ----
|
|
1695
|
+
# Sync-thread view-model totals (spec §6.6): sum directly over
|
|
1696
|
+
# the panel rows the dashboard ACTUALLY renders. The previous
|
|
1697
|
+
# implementation called ``build_weekly_view`` a second time to
|
|
1698
|
+
# capture totals, but that builder doesn't see the Bug-K
|
|
1699
|
+
# pre-credit synthesized rows that ``_dashboard_build_weekly_periods``
|
|
1700
|
+
# layers on top (``_apply_reset_events_to_subweeks`` shifts the
|
|
1701
|
+
# post-reset SubWeek's ``start_ts`` so the pre-credit interval
|
|
1702
|
+
# has no SubWeek for ``_aggregate_weekly`` to bucket). On credit
|
|
1703
|
+
# weeks the sync-thread total understated the rendered footer by
|
|
1704
|
+
# hundreds of dollars (~$372 in the v1.7.2 round-5 data).
|
|
1705
|
+
# Sum-over-visible-rows is a structural invariant — see
|
|
1706
|
+
# ``test_weekly_envelope_total_matches_sum_of_visible_rows``.
|
|
1707
|
+
weekly_total_cost_usd = 0.0
|
|
1708
|
+
weekly_total_tokens = 0
|
|
1907
1709
|
try:
|
|
1908
1710
|
weekly_periods = _dashboard_build_weekly_periods(
|
|
1909
1711
|
conn, now_utc, n=12, skip_sync=skip_sync
|
|
1910
1712
|
)
|
|
1713
|
+
# ``sum(..., 0.0)`` pins the type to ``float`` on empty rows so
|
|
1714
|
+
# the envelope stays byte-stable with the pre-fix ``0.0`` shape
|
|
1715
|
+
# (the dashboard fixture goldens assert exact JSON match).
|
|
1716
|
+
weekly_total_cost_usd = sum(
|
|
1717
|
+
(r.cost_usd for r in weekly_periods), 0.0,
|
|
1718
|
+
)
|
|
1719
|
+
weekly_total_tokens = sum(
|
|
1720
|
+
(r.total_tokens for r in weekly_periods), 0,
|
|
1721
|
+
)
|
|
1911
1722
|
except Exception as exc:
|
|
1912
1723
|
errors.append(f"weekly-periods: {exc}")
|
|
1724
|
+
# Sync-thread view-model totals (spec §6.6): sum-over-visible-rows
|
|
1725
|
+
# (same invariant as weekly above). Monthly has no Bug-K analogue,
|
|
1726
|
+
# but coupling the footer total to the panel-row source of truth
|
|
1727
|
+
# eliminates a parallel ``build_monthly_view`` pass that did the
|
|
1728
|
+
# same arithmetic with no behavioral upside.
|
|
1729
|
+
monthly_total_cost_usd = 0.0
|
|
1730
|
+
monthly_total_tokens = 0
|
|
1913
1731
|
try:
|
|
1914
1732
|
monthly_periods = _dashboard_build_monthly_periods(
|
|
1915
1733
|
conn, now_utc, n=12, skip_sync=skip_sync,
|
|
1916
1734
|
display_tz=_build_display_tz,
|
|
1917
1735
|
)
|
|
1736
|
+
monthly_total_cost_usd = sum(
|
|
1737
|
+
(r.cost_usd for r in monthly_periods), 0.0,
|
|
1738
|
+
)
|
|
1739
|
+
monthly_total_tokens = sum(
|
|
1740
|
+
(r.total_tokens for r in monthly_periods), 0,
|
|
1741
|
+
)
|
|
1918
1742
|
except Exception as exc:
|
|
1919
1743
|
errors.append(f"monthly-periods: {exc}")
|
|
1920
1744
|
# ---- v2.2 additions: dashboard Blocks / Daily panels ----
|
|
@@ -1929,11 +1753,25 @@ def _tui_build_snapshot(
|
|
|
1929
1753
|
)
|
|
1930
1754
|
except Exception as exc:
|
|
1931
1755
|
errors.append(f"blocks-panel: {exc}")
|
|
1756
|
+
# Sync-thread view-model totals (Bundle 1 / spec §6.6):
|
|
1757
|
+
# sum-over-visible-rows (same invariant as weekly/monthly above).
|
|
1758
|
+
# Gap days in the materialized panel carry ``cost_usd=0.0`` /
|
|
1759
|
+
# ``total_tokens=0``, so summing the panel rows preserves the
|
|
1760
|
+
# gap-free totals semantically — and removes a duplicate
|
|
1761
|
+
# ``build_daily_view`` pass that did the same arithmetic.
|
|
1762
|
+
daily_total_cost_usd = 0.0
|
|
1763
|
+
daily_total_tokens = 0
|
|
1932
1764
|
try:
|
|
1933
1765
|
daily_panel = _dashboard_build_daily_panel(
|
|
1934
1766
|
conn, now_utc, n=30, skip_sync=skip_sync,
|
|
1935
1767
|
display_tz=_build_display_tz,
|
|
1936
1768
|
)
|
|
1769
|
+
daily_total_cost_usd = sum(
|
|
1770
|
+
(r.cost_usd for r in daily_panel), 0.0,
|
|
1771
|
+
)
|
|
1772
|
+
daily_total_tokens = sum(
|
|
1773
|
+
(r.total_tokens for r in daily_panel), 0,
|
|
1774
|
+
)
|
|
1937
1775
|
except Exception as exc:
|
|
1938
1776
|
errors.append(f"daily-panel: {exc}")
|
|
1939
1777
|
# ---- threshold-actions T5: alerts envelope array ----
|
|
@@ -1974,6 +1812,13 @@ def _tui_build_snapshot(
|
|
|
1974
1812
|
daily_panel=daily_panel,
|
|
1975
1813
|
alerts=alerts,
|
|
1976
1814
|
five_hour_milestones=fh_milestones,
|
|
1815
|
+
daily_total_cost_usd=daily_total_cost_usd,
|
|
1816
|
+
daily_total_tokens=daily_total_tokens,
|
|
1817
|
+
monthly_total_cost_usd=monthly_total_cost_usd,
|
|
1818
|
+
monthly_total_tokens=monthly_total_tokens,
|
|
1819
|
+
weekly_total_cost_usd=weekly_total_cost_usd,
|
|
1820
|
+
weekly_total_tokens=weekly_total_tokens,
|
|
1821
|
+
trend_avg_dollars_per_pct=trend_avg_dpp,
|
|
1977
1822
|
)
|
|
1978
1823
|
finally:
|
|
1979
1824
|
conn.close()
|
package/bin/_cctally_update.py
CHANGED
|
@@ -192,28 +192,23 @@ def _cctally():
|
|
|
192
192
|
return sys.modules["cctally"]
|
|
193
193
|
|
|
194
194
|
|
|
195
|
+
# === Honest imports from extracted homes ===================================
|
|
196
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3.
|
|
197
|
+
from _cctally_core import eprint, _now_utc
|
|
198
|
+
from _cctally_config import save_config
|
|
199
|
+
|
|
200
|
+
|
|
195
201
|
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
196
202
|
# Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind
|
|
197
203
|
# time), so monkeypatches on cctally's namespace propagate into the moved
|
|
198
|
-
# code unchanged.
|
|
199
|
-
#
|
|
200
|
-
# (
|
|
201
|
-
|
|
202
|
-
return sys.modules["cctally"].eprint(*args, **kwargs)
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
def _now_utc(*args, **kwargs):
|
|
206
|
-
return sys.modules["cctally"]._now_utc(*args, **kwargs)
|
|
207
|
-
|
|
208
|
-
|
|
204
|
+
# code unchanged. `load_config` STAYS as a shim even though its natural
|
|
205
|
+
# home is _cctally_config — tests monkeypatch it via `ns["load_config"]`
|
|
206
|
+
# (16 sites, audited 2026-05-17); direct import would silently bypass.
|
|
207
|
+
# See spec §3.5 (carve-out) and §3.7 (stays-on-shim allowlist).
|
|
209
208
|
def load_config(*args, **kwargs):
|
|
210
209
|
return sys.modules["cctally"].load_config(*args, **kwargs)
|
|
211
210
|
|
|
212
211
|
|
|
213
|
-
def save_config(*args, **kwargs):
|
|
214
|
-
return sys.modules["cctally"].save_config(*args, **kwargs)
|
|
215
|
-
|
|
216
|
-
|
|
217
212
|
def _release_read_latest_release_version(*args, **kwargs):
|
|
218
213
|
return sys.modules["cctally"]._release_read_latest_release_version(
|
|
219
214
|
*args, **kwargs
|
|
@@ -2114,7 +2109,7 @@ def _should_show_update_banner(
|
|
|
2114
2109
|
return False
|
|
2115
2110
|
if not config.get("update", {}).get("check", {}).get("enabled", True):
|
|
2116
2111
|
return False
|
|
2117
|
-
available, _ = c._compute_effective_update_available(state, suppress,
|
|
2112
|
+
available, _ = c._compute_effective_update_available(state, suppress, _now_utc())
|
|
2118
2113
|
return available
|
|
2119
2114
|
|
|
2120
2115
|
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -79,6 +79,13 @@ _lib_subscription_weeks = _load_lib("_lib_subscription_weeks")
|
|
|
79
79
|
SubWeek = _lib_subscription_weeks.SubWeek
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
# === Honest imports from extracted homes ===================================
|
|
83
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
84
|
+
# import from _cctally_core. `CODEX_SESSIONS_DIR` (path constant) and
|
|
85
|
+
# `_decode_escaped_cwd` (out-of-scope) stay on the _cctally() accessor.
|
|
86
|
+
from _cctally_core import parse_iso_datetime
|
|
87
|
+
|
|
88
|
+
|
|
82
89
|
@dataclass
|
|
83
90
|
class BucketUsage:
|
|
84
91
|
"""Aggregated usage for one time bucket.
|
|
@@ -247,7 +254,6 @@ def _aggregate_weekly(
|
|
|
247
254
|
# candidate week in O(log W) per entry rather than the linear
|
|
248
255
|
# scan that previously ran ~130k x ~54 = 7M comparisons.
|
|
249
256
|
import bisect
|
|
250
|
-
parse_iso_datetime = _cctally().parse_iso_datetime
|
|
251
257
|
parsed_bounds: list[tuple[dt.datetime, dt.datetime, str]] = []
|
|
252
258
|
for w in weeks:
|
|
253
259
|
start_dt = parse_iso_datetime(w.start_ts, "week.start_ts")
|