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
|
@@ -251,121 +251,55 @@ def _cctally():
|
|
|
251
251
|
return sys.modules["cctally"]
|
|
252
252
|
|
|
253
253
|
|
|
254
|
+
# === Honest imports from extracted homes ===================================
|
|
255
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
256
|
+
# import from _cctally_core; already-decentralized buckets (X = _lib_*,
|
|
257
|
+
# Y = _cctally_*) import from their natural home. These bypass the
|
|
258
|
+
# legacy shim pattern entirely.
|
|
259
|
+
from _cctally_core import (
|
|
260
|
+
eprint,
|
|
261
|
+
now_utc_iso,
|
|
262
|
+
parse_iso_datetime,
|
|
263
|
+
_now_utc,
|
|
264
|
+
_command_as_of,
|
|
265
|
+
open_db,
|
|
266
|
+
get_latest_usage_for_week,
|
|
267
|
+
make_week_ref,
|
|
268
|
+
_get_alerts_config,
|
|
269
|
+
_AlertsConfigError,
|
|
270
|
+
)
|
|
271
|
+
from _lib_display_tz import (
|
|
272
|
+
format_display_dt,
|
|
273
|
+
resolve_display_tz,
|
|
274
|
+
normalize_display_tz_value,
|
|
275
|
+
_compute_display_block,
|
|
276
|
+
)
|
|
277
|
+
from _lib_aggregators import _aggregate_daily, _aggregate_monthly, _aggregate_weekly
|
|
278
|
+
from _lib_pricing import _calculate_entry_cost, _chip_for_model, _short_model_name
|
|
279
|
+
from _lib_five_hour import _canonical_5h_window_key
|
|
280
|
+
from _lib_subscription_weeks import _compute_subscription_weeks
|
|
281
|
+
from _lib_blocks import _group_entries_into_blocks
|
|
282
|
+
from _cctally_config import save_config, _load_config_unlocked
|
|
283
|
+
from _cctally_db import _render_migration_error_banner
|
|
284
|
+
from _cctally_cache import get_entries
|
|
285
|
+
|
|
286
|
+
|
|
254
287
|
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
255
288
|
# Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind
|
|
256
289
|
# time), so monkeypatches on cctally's namespace propagate into the moved
|
|
257
|
-
# code unchanged.
|
|
258
|
-
#
|
|
259
|
-
#
|
|
260
|
-
#
|
|
261
|
-
|
|
262
|
-
return sys.modules["cctally"].eprint(*args, **kwargs)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
def now_utc_iso(*args, **kwargs):
|
|
266
|
-
return sys.modules["cctally"].now_utc_iso(*args, **kwargs)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
def parse_iso_datetime(*args, **kwargs):
|
|
270
|
-
return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
def _now_utc(*args, **kwargs):
|
|
274
|
-
return sys.modules["cctally"]._now_utc(*args, **kwargs)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def _command_as_of(*args, **kwargs):
|
|
278
|
-
return sys.modules["cctally"]._command_as_of(*args, **kwargs)
|
|
279
|
-
|
|
280
|
-
|
|
290
|
+
# code unchanged. `load_config` and `get_claude_session_entries` STAY as
|
|
291
|
+
# shims even though their natural homes are decentralized (_cctally_config
|
|
292
|
+
# / _cctally_cache) — tests monkeypatch them via `ns["X"]` (21 sites total,
|
|
293
|
+
# audited 2026-05-17); direct imports would silently bypass the patches.
|
|
294
|
+
# See spec §3.5 (carve-out) and §3.7 (stays-on-shim allowlist).
|
|
281
295
|
def load_config(*args, **kwargs):
|
|
282
296
|
return sys.modules["cctally"].load_config(*args, **kwargs)
|
|
283
297
|
|
|
284
298
|
|
|
285
|
-
def save_config(*args, **kwargs):
|
|
286
|
-
return sys.modules["cctally"].save_config(*args, **kwargs)
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
def open_db(*args, **kwargs):
|
|
290
|
-
return sys.modules["cctally"].open_db(*args, **kwargs)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def get_entries(*args, **kwargs):
|
|
294
|
-
return sys.modules["cctally"].get_entries(*args, **kwargs)
|
|
295
|
-
|
|
296
|
-
|
|
297
299
|
def get_claude_session_entries(*args, **kwargs):
|
|
298
300
|
return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
|
|
299
301
|
|
|
300
302
|
|
|
301
|
-
def get_latest_usage_for_week(*args, **kwargs):
|
|
302
|
-
return sys.modules["cctally"].get_latest_usage_for_week(*args, **kwargs)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def make_week_ref(*args, **kwargs):
|
|
306
|
-
return sys.modules["cctally"].make_week_ref(*args, **kwargs)
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
def format_display_dt(*args, **kwargs):
|
|
310
|
-
return sys.modules["cctally"].format_display_dt(*args, **kwargs)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
def resolve_display_tz(*args, **kwargs):
|
|
314
|
-
return sys.modules["cctally"].resolve_display_tz(*args, **kwargs)
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def normalize_display_tz_value(*args, **kwargs):
|
|
318
|
-
return sys.modules["cctally"].normalize_display_tz_value(*args, **kwargs)
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def _compute_display_block(*args, **kwargs):
|
|
322
|
-
return sys.modules["cctally"]._compute_display_block(*args, **kwargs)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def _render_migration_error_banner(*args, **kwargs):
|
|
326
|
-
return sys.modules["cctally"]._render_migration_error_banner(*args, **kwargs)
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
def _aggregate_daily(*args, **kwargs):
|
|
330
|
-
return sys.modules["cctally"]._aggregate_daily(*args, **kwargs)
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
def _aggregate_monthly(*args, **kwargs):
|
|
334
|
-
return sys.modules["cctally"]._aggregate_monthly(*args, **kwargs)
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
def _aggregate_weekly(*args, **kwargs):
|
|
338
|
-
return sys.modules["cctally"]._aggregate_weekly(*args, **kwargs)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
def _calculate_entry_cost(*args, **kwargs):
|
|
342
|
-
return sys.modules["cctally"]._calculate_entry_cost(*args, **kwargs)
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
def _canonical_5h_window_key(*args, **kwargs):
|
|
346
|
-
return sys.modules["cctally"]._canonical_5h_window_key(*args, **kwargs)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
def _chip_for_model(*args, **kwargs):
|
|
350
|
-
return sys.modules["cctally"]._chip_for_model(*args, **kwargs)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
def _short_model_name(*args, **kwargs):
|
|
354
|
-
return sys.modules["cctally"]._short_model_name(*args, **kwargs)
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
def _compute_subscription_weeks(*args, **kwargs):
|
|
358
|
-
return sys.modules["cctally"]._compute_subscription_weeks(*args, **kwargs)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
def _group_entries_into_blocks(*args, **kwargs):
|
|
362
|
-
return sys.modules["cctally"]._group_entries_into_blocks(*args, **kwargs)
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
def _get_alerts_config(*args, **kwargs):
|
|
366
|
-
return sys.modules["cctally"]._get_alerts_config(*args, **kwargs)
|
|
367
|
-
|
|
368
|
-
|
|
369
303
|
def _warn_alerts_bad_config_once(*args, **kwargs):
|
|
370
304
|
return sys.modules["cctally"]._warn_alerts_bad_config_once(*args, **kwargs)
|
|
371
305
|
|
|
@@ -402,10 +336,6 @@ def doctor_gather_state(*args, **kwargs):
|
|
|
402
336
|
return sys.modules["cctally"].doctor_gather_state(*args, **kwargs)
|
|
403
337
|
|
|
404
338
|
|
|
405
|
-
def _load_config_unlocked(*args, **kwargs):
|
|
406
|
-
return sys.modules["cctally"]._load_config_unlocked(*args, **kwargs)
|
|
407
|
-
|
|
408
|
-
|
|
409
339
|
def _apply_display_tz_override(*args, **kwargs):
|
|
410
340
|
return sys.modules["cctally"]._apply_display_tz_override(*args, **kwargs)
|
|
411
341
|
|
|
@@ -1789,10 +1719,14 @@ def _dashboard_build_monthly_periods(conn: "sqlite3.Connection",
|
|
|
1789
1719
|
display_tz: "ZoneInfo | None" = None) -> "list[MonthlyPeriodRow]":
|
|
1790
1720
|
"""Latest n calendar months as MonthlyPeriodRow, newest-first.
|
|
1791
1721
|
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1722
|
+
Thin wrapper around ``build_monthly_view`` (spec §6.2). The view
|
|
1723
|
+
owns the aggregator call, boundary-spillover drop, delta_cost_pct
|
|
1724
|
+
derivation, and ``is_current`` flagging — this function just
|
|
1725
|
+
fetches the trailing window of entries and returns ``view.rows``.
|
|
1726
|
+
|
|
1727
|
+
The sync thread separately captures ``view.total_cost_usd`` /
|
|
1728
|
+
``view.total_tokens`` for the envelope; see
|
|
1729
|
+
``_tui_build_snapshot`` and ``DataSnapshot.monthly_total_*``.
|
|
1796
1730
|
"""
|
|
1797
1731
|
# Compute window start = first day of the month that is (n - 1) months
|
|
1798
1732
|
# before the current month. _aggregate_monthly walks all entries and
|
|
@@ -1811,44 +1745,10 @@ def _dashboard_build_monthly_periods(conn: "sqlite3.Connection",
|
|
|
1811
1745
|
range_end = now_utc
|
|
1812
1746
|
|
|
1813
1747
|
entries = get_entries(range_start, range_end, skip_sync=skip_sync)
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
# as a (n+1)th `'YYYY-MM'` row. Slicing here drops that partial bucket,
|
|
1819
|
-
# which (a) keeps the visible history at the requested length and
|
|
1820
|
-
# (b) makes the oldest visible row's delta `None` (prev = None) rather
|
|
1821
|
-
# than a wildly wrong delta vs. a few-hour spillover bucket.
|
|
1822
|
-
buckets = list(reversed(buckets))[:n]
|
|
1823
|
-
rows: list[MonthlyPeriodRow] = []
|
|
1824
|
-
# `_aggregate_monthly` keys buckets by `display_tz` (or local-tz when
|
|
1825
|
-
# unset) month. Mirror that here so `is_current` matches even when
|
|
1826
|
-
# now_utc straddles a tz month boundary (e.g. 23:30 UTC on the last
|
|
1827
|
-
# of the month in a UTC+1 zone).
|
|
1828
|
-
cur_label = (
|
|
1829
|
-
now_utc.astimezone(display_tz) if display_tz is not None
|
|
1830
|
-
# internal fallback: host-local intentional
|
|
1831
|
-
else now_utc.astimezone()
|
|
1832
|
-
).strftime("%Y-%m")
|
|
1833
|
-
for i, b in enumerate(buckets):
|
|
1834
|
-
# b.bucket is the YYYY-MM string for monthly aggregation.
|
|
1835
|
-
prev = buckets[i + 1] if i + 1 < len(buckets) else None
|
|
1836
|
-
delta = None
|
|
1837
|
-
if prev is not None and prev.cost_usd > 0:
|
|
1838
|
-
delta = (b.cost_usd - prev.cost_usd) / prev.cost_usd
|
|
1839
|
-
rows.append(MonthlyPeriodRow(
|
|
1840
|
-
label=b.bucket,
|
|
1841
|
-
cost_usd=b.cost_usd,
|
|
1842
|
-
total_tokens=b.total_tokens,
|
|
1843
|
-
input_tokens=b.input_tokens,
|
|
1844
|
-
output_tokens=b.output_tokens,
|
|
1845
|
-
cache_creation_tokens=b.cache_creation_tokens,
|
|
1846
|
-
cache_read_tokens=b.cache_read_tokens,
|
|
1847
|
-
delta_cost_pct=delta,
|
|
1848
|
-
is_current=(b.bucket == cur_label),
|
|
1849
|
-
models=_model_breakdowns_to_models(b.model_breakdowns, b.cost_usd),
|
|
1850
|
-
))
|
|
1851
|
-
return rows
|
|
1748
|
+
c = _cctally()
|
|
1749
|
+
view = c.build_monthly_view(entries, now_utc=now_utc, n=n,
|
|
1750
|
+
display_tz=display_tz)
|
|
1751
|
+
return list(view.rows)
|
|
1852
1752
|
|
|
1853
1753
|
|
|
1854
1754
|
def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
|
|
@@ -1857,10 +1757,15 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
|
|
|
1857
1757
|
skip_sync: bool = False) -> "list[WeeklyPeriodRow]":
|
|
1858
1758
|
"""Latest n subscription weeks as WeeklyPeriodRow, newest-first.
|
|
1859
1759
|
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1760
|
+
Thin builder-using prelude + Bug-K pre-credit synthesis on top of
|
|
1761
|
+
``view.rows``. ``build_weekly_view`` (in ``bin/_lib_view_models.py``)
|
|
1762
|
+
owns the bucket+overlay walk — this function calls it, swaps two
|
|
1763
|
+
presentation fields (``label`` ← ``display_start_date`` for post-
|
|
1764
|
+
early-reset weeks; ``is_current`` ← SubWeek-containing-now_utc with
|
|
1765
|
+
snapshot fallback so the "Now" pill tracks wall time), layers Bug-K
|
|
1766
|
+
pre-credit synthesized rows over the natural rows, then recomputes
|
|
1767
|
+
``delta_cost_pct`` newest-first so the synthesized rows participate
|
|
1768
|
+
in the deltas.
|
|
1864
1769
|
|
|
1865
1770
|
Note: weekly bucketing intentionally does NOT take ``display_tz`` —
|
|
1866
1771
|
SubWeek bucket keys come from server-anchored stored anchors and the
|
|
@@ -1878,10 +1783,6 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
|
|
|
1878
1783
|
parse_iso_datetime(weeks[0].start_ts, "week_start_at"),
|
|
1879
1784
|
)
|
|
1880
1785
|
entries = get_entries(fetch_start, range_end, skip_sync=skip_sync)
|
|
1881
|
-
buckets = _aggregate_weekly(entries, weeks)
|
|
1882
|
-
if not buckets:
|
|
1883
|
-
return []
|
|
1884
|
-
|
|
1885
1786
|
as_of_utc = (
|
|
1886
1787
|
range_end.astimezone(dt.timezone.utc)
|
|
1887
1788
|
.replace(microsecond=0)
|
|
@@ -1889,6 +1790,15 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
|
|
|
1889
1790
|
.replace("+00:00", "Z")
|
|
1890
1791
|
)
|
|
1891
1792
|
|
|
1793
|
+
# Builder prelude: produce the natural newest-first rows + overlay.
|
|
1794
|
+
c = _cctally()
|
|
1795
|
+
view = c.build_weekly_view(
|
|
1796
|
+
conn, entries, weeks=weeks, now_utc=now_utc,
|
|
1797
|
+
display_tz=None, as_of_utc=as_of_utc,
|
|
1798
|
+
)
|
|
1799
|
+
if not view.rows:
|
|
1800
|
+
return []
|
|
1801
|
+
|
|
1892
1802
|
# Prefer the SubWeek that actually contains `now_utc` so the "Now" pill
|
|
1893
1803
|
# tracks wall time even when `weekly_usage_snapshots` is stale (e.g.,
|
|
1894
1804
|
# status line hasn't fired yet this week but cost entries already exist).
|
|
@@ -1913,47 +1823,36 @@ def _dashboard_build_weekly_periods(conn: "sqlite3.Connection",
|
|
|
1913
1823
|
else:
|
|
1914
1824
|
cur_week_start = max(weeks, key=lambda w: w.start_date).start_date.isoformat()
|
|
1915
1825
|
|
|
1826
|
+
# SubWeek lookup by (start_ts, end_ts) — the builder identifies each row
|
|
1827
|
+
# by these ISO strings on ``WeeklyPeriodRow``, which match SubWeek 1:1
|
|
1828
|
+
# post _aggregate_weekly invariant.
|
|
1829
|
+
sw_by_window = {(w.start_ts, w.end_ts): w for w in weeks}
|
|
1830
|
+
|
|
1831
|
+
# Convert builder rows (newest-first) → oldest-first so the existing
|
|
1832
|
+
# Bug-K insertion logic (oldest-first indices) stays unchanged. Override
|
|
1833
|
+
# the two presentation fields that diverge between CLI/share (which use
|
|
1834
|
+
# ``start_date`` + "now in window" semantics) and the dashboard panel
|
|
1835
|
+
# (which uses ``display_start_date`` + "current SubWeek" semantics).
|
|
1916
1836
|
rows_oldest_first: list[WeeklyPeriodRow] = []
|
|
1917
|
-
for
|
|
1918
|
-
sw =
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
week_start_date=sw.start_date.isoformat(),
|
|
1927
|
-
week_end_date=sw.end_date.isoformat(),
|
|
1928
|
-
week_start_at=sw.start_ts,
|
|
1929
|
-
week_end_at=sw.end_ts,
|
|
1930
|
-
)
|
|
1931
|
-
usage_row = get_latest_usage_for_week(conn, ref, as_of_utc=as_of_utc)
|
|
1932
|
-
used_pct = None
|
|
1933
|
-
dpp = None
|
|
1934
|
-
if usage_row is not None and usage_row["weekly_percent"] is not None:
|
|
1935
|
-
used_pct = float(usage_row["weekly_percent"])
|
|
1936
|
-
dpp = (bucket.cost_usd / used_pct) if used_pct > 0 else None
|
|
1937
|
-
rows_oldest_first.append(WeeklyPeriodRow(
|
|
1938
|
-
label=label,
|
|
1939
|
-
cost_usd=bucket.cost_usd,
|
|
1940
|
-
total_tokens=bucket.total_tokens,
|
|
1941
|
-
input_tokens=bucket.input_tokens,
|
|
1942
|
-
output_tokens=bucket.output_tokens,
|
|
1943
|
-
cache_creation_tokens=bucket.cache_creation_tokens,
|
|
1944
|
-
cache_read_tokens=bucket.cache_read_tokens,
|
|
1945
|
-
used_pct=used_pct,
|
|
1946
|
-
dollar_per_pct=dpp,
|
|
1947
|
-
delta_cost_pct=None, # filled below in newest-first pass
|
|
1837
|
+
for r in reversed(view.rows):
|
|
1838
|
+
sw = sw_by_window.get((r.week_start_at, r.week_end_at))
|
|
1839
|
+
if sw is not None:
|
|
1840
|
+
# Label = MM-DD of the week's display_start_date — for non-reset
|
|
1841
|
+
# weeks this equals start_date; for post-early-reset weeks the
|
|
1842
|
+
# post-processor shifts it forward to the effective reset moment
|
|
1843
|
+
# so the user sees the date the week actually began (04-23 vs the
|
|
1844
|
+
# API-derived backdated 04-18).
|
|
1845
|
+
r.label = sw.display_start_date.strftime("%m-%d")
|
|
1948
1846
|
# is_current keys on start_date (the bucket / lookup key) on both
|
|
1949
1847
|
# sides of the comparison; display_start_date may diverge for
|
|
1950
1848
|
# reset-event weeks but that is intentional — display vs. lookup
|
|
1951
1849
|
# are kept separate.
|
|
1952
|
-
is_current=(sw.start_date.isoformat() == cur_week_start)
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1850
|
+
r.is_current = (sw.start_date.isoformat() == cur_week_start)
|
|
1851
|
+
# delta_cost_pct: builder computed it in asc order; reset and
|
|
1852
|
+
# recompute newest-first below AFTER Bug-K rows merge in, so the
|
|
1853
|
+
# synthesized rows participate in the deltas.
|
|
1854
|
+
r.delta_cost_pct = None
|
|
1855
|
+
rows_oldest_first.append(r)
|
|
1957
1856
|
|
|
1958
1857
|
# Bug K (v1.7.2 round-5): synthesize a pre-credit segment row for
|
|
1959
1858
|
# each in-place credit event. Without this the credited week shows
|
|
@@ -2320,27 +2219,51 @@ def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
|
|
|
2320
2219
|
display_tz: "ZoneInfo | None" = None) -> "list[DailyPanelRow]":
|
|
2321
2220
|
"""Latest n display-tz dates as DailyPanelRow, newest-first.
|
|
2322
2221
|
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2222
|
+
Two-layer composition (spec §4.4, §6.1):
|
|
2223
|
+
|
|
2224
|
+
1. ``build_daily_view`` (in ``bin/_lib_view_models.py``) is the
|
|
2225
|
+
data plane — gap-free rows + totals derived from the
|
|
2226
|
+
aggregator output.
|
|
2227
|
+
2. This function is the presentation adapter — materializes the
|
|
2228
|
+
contiguous N-day calendar window (gap days as zero-cost rows
|
|
2229
|
+
so the heatmap shows them as faded cells), fills the
|
|
2230
|
+
presentation-only ``label`` (``MM-DD``) and
|
|
2231
|
+
``intensity_bucket`` (quintile via
|
|
2232
|
+
``_compute_intensity_buckets``) fields.
|
|
2233
|
+
|
|
2234
|
+
Bucketing and the ``is_today`` reference both follow
|
|
2235
|
+
``display_tz`` so users on a non-host display zone see days
|
|
2236
|
+
grouped consistently with the rest of the UI.
|
|
2237
|
+
|
|
2238
|
+
Sync-thread totals: the caller sums over the materialized rows
|
|
2239
|
+
this function returns and stashes the result on
|
|
2240
|
+
``DataSnapshot.daily_total_cost_usd`` / ``daily_total_tokens`` for
|
|
2241
|
+
the envelope adapter. Sum-over-visible-rows preserves the
|
|
2242
|
+
structural-equality invariant the dashboard footer reads
|
|
2243
|
+
(`total === rows.reduce(...)`); gap days carry ``cost_usd=0.0`` /
|
|
2244
|
+
``total_tokens=0`` so the sum stays gap-free semantically. Doing
|
|
2245
|
+
it at the sync-thread site keeps this function's return type
|
|
2246
|
+
stable (preserving the dashboard / TUI / test fixture monkeypatch
|
|
2247
|
+
surface that consumes a plain ``list[DailyPanelRow]``).
|
|
2329
2248
|
"""
|
|
2330
2249
|
# Wide trailing window — n days of slack on either side keeps it
|
|
2331
2250
|
# forgiving of tz boundary issues.
|
|
2332
2251
|
range_start = now_utc - dt.timedelta(days=n + 1)
|
|
2333
2252
|
range_end = now_utc
|
|
2334
2253
|
entries = get_entries(range_start, range_end, skip_sync=skip_sync)
|
|
2335
|
-
|
|
2336
|
-
|
|
2254
|
+
|
|
2255
|
+
c = _cctally()
|
|
2256
|
+
view = c.build_daily_view(entries, now_utc=now_utc,
|
|
2257
|
+
display_tz=display_tz)
|
|
2258
|
+
if not view.rows:
|
|
2337
2259
|
return []
|
|
2338
2260
|
|
|
2339
|
-
# Materialize the
|
|
2340
|
-
#
|
|
2341
|
-
#
|
|
2342
|
-
#
|
|
2343
|
-
|
|
2261
|
+
# Materialize the contiguous N-day window. ``view.rows`` is gap-free
|
|
2262
|
+
# (newest-first) and carries the data-plane fields; the adapter
|
|
2263
|
+
# overlays it onto the calendar window and fills the presentation-
|
|
2264
|
+
# only ``label`` / ``intensity_bucket`` (which the builder left at
|
|
2265
|
+
# dataclass defaults per spec §4.4).
|
|
2266
|
+
rows_by_date = {r.date: r for r in view.rows}
|
|
2344
2267
|
today_local = (
|
|
2345
2268
|
now_utc.astimezone(display_tz) if display_tz is not None
|
|
2346
2269
|
# internal fallback: host-local intentional
|
|
@@ -2351,28 +2274,12 @@ def _dashboard_build_daily_panel(conn: "sqlite3.Connection",
|
|
|
2351
2274
|
for i in range(n):
|
|
2352
2275
|
d = today_local - dt.timedelta(days=i)
|
|
2353
2276
|
date_str = d.isoformat()
|
|
2354
|
-
|
|
2355
|
-
if
|
|
2356
|
-
#
|
|
2357
|
-
#
|
|
2358
|
-
#
|
|
2359
|
-
|
|
2360
|
-
denom = b.input_tokens + b.cache_creation_tokens + b.cache_read_tokens
|
|
2361
|
-
cache_hit = (b.cache_read_tokens / denom * 100.0) if denom > 0 else None
|
|
2362
|
-
rows.append(DailyPanelRow(
|
|
2363
|
-
date=date_str,
|
|
2364
|
-
label=date_str[5:], # YYYY-MM-DD → MM-DD
|
|
2365
|
-
cost_usd=b.cost_usd,
|
|
2366
|
-
is_today=(d == today_local),
|
|
2367
|
-
intensity_bucket=0, # set by _compute_intensity_buckets below
|
|
2368
|
-
models=_model_breakdowns_to_models(b.model_breakdowns, b.cost_usd),
|
|
2369
|
-
input_tokens=b.input_tokens,
|
|
2370
|
-
output_tokens=b.output_tokens,
|
|
2371
|
-
cache_creation_tokens=b.cache_creation_tokens,
|
|
2372
|
-
cache_read_tokens=b.cache_read_tokens,
|
|
2373
|
-
total_tokens=b.total_tokens,
|
|
2374
|
-
cache_hit_pct=cache_hit,
|
|
2375
|
-
))
|
|
2277
|
+
existing = rows_by_date.get(date_str)
|
|
2278
|
+
if existing is not None:
|
|
2279
|
+
# Use the view-model row but fill the presentation-only
|
|
2280
|
+
# ``label`` (intensity_bucket is set by
|
|
2281
|
+
# ``_compute_intensity_buckets`` below).
|
|
2282
|
+
rows.append(dataclasses.replace(existing, label=date_str[5:]))
|
|
2376
2283
|
else:
|
|
2377
2284
|
# Zero-cost gap day: tokens default to 0, cache_hit_pct to None
|
|
2378
2285
|
# (avoids /0 and signals 'no data' cleanly to the modal tile).
|
|
@@ -2947,8 +2854,28 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
2947
2854
|
# can distinguish "loading / not synced yet" (null parent) from
|
|
2948
2855
|
# "synced + no data" (empty rows). For Weekly/Monthly, sync always
|
|
2949
2856
|
# builds the rows list (even if empty), so we always emit the object.
|
|
2950
|
-
weekly_env = {
|
|
2951
|
-
|
|
2857
|
+
weekly_env = {
|
|
2858
|
+
"rows": [_weekly_row_to_dict(r) for r in snap.weekly_periods],
|
|
2859
|
+
# View-model unification (Bundle 1; spec §6.6): pre-computed
|
|
2860
|
+
# totals. Sourced from sum-over-visible-rows in the sync thread
|
|
2861
|
+
# (``_tui_build_snapshot``) so the footer total is structurally
|
|
2862
|
+
# equal to the React panel's ``rows.reduce(...)``. The earlier
|
|
2863
|
+
# ``build_weekly_view``-sourced totals undercounted Bug-K
|
|
2864
|
+
# pre-credit synthesized rows on credit weeks; the regression is
|
|
2865
|
+
# captured by
|
|
2866
|
+
# ``test_weekly_envelope_total_matches_sum_of_visible_rows``.
|
|
2867
|
+
"total_cost_usd": snap.weekly_total_cost_usd,
|
|
2868
|
+
"total_tokens": snap.weekly_total_tokens,
|
|
2869
|
+
}
|
|
2870
|
+
monthly_env = {
|
|
2871
|
+
"rows": [_monthly_row_to_dict(r) for r in snap.monthly_periods],
|
|
2872
|
+
# View-model unification (Bundle 1; spec §6.6): pre-computed
|
|
2873
|
+
# totals via sum-over-visible-rows so the React MonthlyPanel's
|
|
2874
|
+
# smoking-gun ``rows.reduce(...)`` collapses to a single envelope
|
|
2875
|
+
# read with the structural-equality invariant preserved.
|
|
2876
|
+
"total_cost_usd": snap.monthly_total_cost_usd,
|
|
2877
|
+
"total_tokens": snap.monthly_total_tokens,
|
|
2878
|
+
}
|
|
2952
2879
|
|
|
2953
2880
|
blocks_env = {"rows": [_blocks_row_to_dict(r) for r in snap.blocks_panel]}
|
|
2954
2881
|
|
|
@@ -2968,6 +2895,15 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
2968
2895
|
"rows": [_daily_row_to_dict(r) for r in daily_rows],
|
|
2969
2896
|
"quantile_thresholds": daily_thresholds,
|
|
2970
2897
|
"peak": daily_peak,
|
|
2898
|
+
# View-model unification (Bundle 1; spec §6.6): pre-computed
|
|
2899
|
+
# totals via sum-over-visible-rows in the sync thread. Gap days
|
|
2900
|
+
# carry ``cost_usd=0.0`` and ``total_tokens=0`` so summing the
|
|
2901
|
+
# materialized panel rows preserves the gap-free semantics. The
|
|
2902
|
+
# React panel's `rows.reduce(...)` collapses to a single
|
|
2903
|
+
# envelope read with the structural-equality invariant
|
|
2904
|
+
# preserved.
|
|
2905
|
+
"total_cost_usd": snap.daily_total_cost_usd,
|
|
2906
|
+
"total_tokens": snap.daily_total_tokens,
|
|
2971
2907
|
}
|
|
2972
2908
|
|
|
2973
2909
|
# ---- threshold-actions T5: alerts envelope + settings mirror ----
|
|
@@ -2987,7 +2923,7 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
2987
2923
|
alerts_array = list(getattr(snap, "alerts", []) or [])
|
|
2988
2924
|
try:
|
|
2989
2925
|
_alerts_cfg = _get_alerts_config(load_config())
|
|
2990
|
-
except
|
|
2926
|
+
except _AlertsConfigError as exc:
|
|
2991
2927
|
_warn_alerts_bad_config_once(exc)
|
|
2992
2928
|
_alerts_cfg = {
|
|
2993
2929
|
"enabled": False,
|
|
@@ -3169,6 +3105,12 @@ def snapshot_to_envelope(snap: "DataSnapshot", *,
|
|
|
3169
3105
|
}
|
|
3170
3106
|
for w in (snap.weekly_history or [])
|
|
3171
3107
|
],
|
|
3108
|
+
# View-model unification (Bundle 1; spec §6.6): the
|
|
3109
|
+
# pre-computed 3-sample $/% mean. TrendPanel can stop
|
|
3110
|
+
# re-deriving the panel-average; TrendModal's median
|
|
3111
|
+
# over trend.history is out of scope for this refactor
|
|
3112
|
+
# (separate dataset, separate follow-up).
|
|
3113
|
+
"avg_dollars_per_pct": snap.trend_avg_dollars_per_pct,
|
|
3172
3114
|
},
|
|
3173
3115
|
|
|
3174
3116
|
"weekly": weekly_env,
|
|
@@ -3782,7 +3724,7 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
3782
3724
|
# save_config has not yet been called).
|
|
3783
3725
|
try:
|
|
3784
3726
|
_get_alerts_config(merged)
|
|
3785
|
-
except
|
|
3727
|
+
except _AlertsConfigError as exc:
|
|
3786
3728
|
self._respond_json(400, {"error": str(exc)})
|
|
3787
3729
|
return
|
|
3788
3730
|
|
package/bin/_cctally_db.py
CHANGED
|
@@ -71,35 +71,32 @@ def _cctally():
|
|
|
71
71
|
return sys.modules["cctally"]
|
|
72
72
|
|
|
73
73
|
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
74
|
+
# === Honest imports from extracted homes ===================================
|
|
75
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
76
|
+
# import from _cctally_core. The legacy shim functions for these names
|
|
77
|
+
# are deleted.
|
|
78
|
+
from _cctally_core import (
|
|
79
|
+
eprint,
|
|
80
|
+
now_utc_iso,
|
|
81
|
+
parse_iso_datetime,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# Module-level back-ref shim for the one Z-high callable that STAYS in
|
|
86
|
+
# bin/cctally. Resolves `sys.modules['cctally'].X` at CALL TIME (not
|
|
87
|
+
# bind time), so monkeypatches on cctally's namespace propagate into the
|
|
88
|
+
# moved code unchanged. `_compute_block_totals` is Z-high (reaches into
|
|
89
|
+
# _cctally_cache via get_claude_session_entries) and is explicitly listed
|
|
90
|
+
# in spec §3.7's stays-on-shim allowlist.
|
|
82
91
|
#
|
|
83
92
|
# Path constants and rarer helpers (`MIGRATION_ERROR_LOG_PATH`,
|
|
84
93
|
# `LOG_DIR`, `DB_PATH`, `CACHE_DB_PATH`, `format_local_iso`) are
|
|
85
94
|
# accessed via the standard `c = _cctally()` + `c.X` pattern instead
|
|
86
95
|
# (call-time lookup so fixture-HOME redirects propagate).
|
|
87
|
-
def now_utc_iso(*args, **kwargs):
|
|
88
|
-
return sys.modules["cctally"].now_utc_iso(*args, **kwargs)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def parse_iso_datetime(*args, **kwargs):
|
|
92
|
-
return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
|
|
93
|
-
|
|
94
|
-
|
|
95
96
|
def _compute_block_totals(*args, **kwargs):
|
|
96
97
|
return sys.modules["cctally"]._compute_block_totals(*args, **kwargs)
|
|
97
98
|
|
|
98
99
|
|
|
99
|
-
def eprint(*args, **kwargs):
|
|
100
|
-
return sys.modules["cctally"].eprint(*args, **kwargs)
|
|
101
|
-
|
|
102
|
-
|
|
103
100
|
# === BEGIN MOVED REGIONS ===
|
|
104
101
|
# Regions below are inserted verbatim from bin/cctally. Bare-name
|
|
105
102
|
# references to `now_utc_iso(...)`, `parse_iso_datetime(...)`,
|
|
@@ -1598,6 +1595,78 @@ def _migration_five_hour_milestones_reset_event_id(conn: sqlite3.Connection) ->
|
|
|
1598
1595
|
raise
|
|
1599
1596
|
|
|
1600
1597
|
|
|
1598
|
+
@stats_migration("007_observed_pre_credit_pct")
|
|
1599
|
+
def _migration_observed_pre_credit_pct(conn: sqlite3.Connection) -> None:
|
|
1600
|
+
"""Add ``observed_pre_credit_pct`` to ``week_reset_events`` so the
|
|
1601
|
+
race-defensive cleanup DELETE in the in-place weekly credit branch
|
|
1602
|
+
has a durable record of the pre-credit baseline we observed at
|
|
1603
|
+
write time — independent of how the upstream claude-statusline
|
|
1604
|
+
tool rounds replays.
|
|
1605
|
+
|
|
1606
|
+
Today statusline replays cctally's ``hwm-7d`` value byte-identically,
|
|
1607
|
+
so the existing strict ``round(.,1)`` equality predicate is sound.
|
|
1608
|
+
Future-proofs against rounding drift: if Anthropic ever rounds the
|
|
1609
|
+
``--percent`` payload differently from the OAuth API used by
|
|
1610
|
+
record-usage, or if statusline grows its own coarser rounding, a
|
|
1611
|
+
replay at e.g. 67.5 against a stored prior_pct = 67.4 would slip
|
|
1612
|
+
past strict equality and then dominate the reset-aware clamp's
|
|
1613
|
+
MAX over the post-credit segment. With the value stamped on the
|
|
1614
|
+
event row, the cleanup predicate widens to a 1.0pp tolerance band
|
|
1615
|
+
(issue #45) — wide enough to absorb single-digit drift, narrow
|
|
1616
|
+
enough that legitimate post-credit observations (≥25pp away by
|
|
1617
|
+
the in-place credit detection threshold's hypothesis) stay.
|
|
1618
|
+
|
|
1619
|
+
Backfill: NULL on existing rows. NULL is legacy / never-stamped;
|
|
1620
|
+
the live cleanup's bind still uses the current tick's in-scope
|
|
1621
|
+
``prior_pct`` (the value we just observed and would have stamped),
|
|
1622
|
+
so the cleanup remains correct on the very tick that writes the
|
|
1623
|
+
row. The stored value matters for future tooling that may re-run
|
|
1624
|
+
cleanup against an already-written event row.
|
|
1625
|
+
|
|
1626
|
+
Companion live-path edits land in:
|
|
1627
|
+
- bin/cctally — CREATE TABLE adds the column for fresh installs.
|
|
1628
|
+
- bin/_cctally_record.py — in-place credit INSERT stamps
|
|
1629
|
+
``observed_pre_credit_pct = prior_pct``; race-defensive DELETE
|
|
1630
|
+
switches from ``round(weekly_percent,1) = round(?,1)`` to
|
|
1631
|
+
``ABS(weekly_percent - ?) < 1.0``.
|
|
1632
|
+
|
|
1633
|
+
Idempotent: a second invocation finds the column already present
|
|
1634
|
+
and returns. Empty-column fast path: when the live CREATE TABLE
|
|
1635
|
+
already carries the column (fresh install), stamp the marker and
|
|
1636
|
+
return without an ALTER. Simple ADD COLUMN — no UNIQUE constraint
|
|
1637
|
+
change, so no rename-recreate-copy needed (contrast migrations
|
|
1638
|
+
005 / 006).
|
|
1639
|
+
"""
|
|
1640
|
+
cols = {
|
|
1641
|
+
str(r[1])
|
|
1642
|
+
for r in conn.execute("PRAGMA table_info(week_reset_events)").fetchall()
|
|
1643
|
+
}
|
|
1644
|
+
if "observed_pre_credit_pct" in cols:
|
|
1645
|
+
conn.execute(
|
|
1646
|
+
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1647
|
+
"VALUES (?, ?)",
|
|
1648
|
+
("007_observed_pre_credit_pct", now_utc_iso()),
|
|
1649
|
+
)
|
|
1650
|
+
conn.commit()
|
|
1651
|
+
return
|
|
1652
|
+
|
|
1653
|
+
conn.execute("BEGIN")
|
|
1654
|
+
try:
|
|
1655
|
+
conn.execute(
|
|
1656
|
+
"ALTER TABLE week_reset_events "
|
|
1657
|
+
"ADD COLUMN observed_pre_credit_pct REAL"
|
|
1658
|
+
)
|
|
1659
|
+
conn.execute(
|
|
1660
|
+
"INSERT OR IGNORE INTO schema_migrations (name, applied_at_utc) "
|
|
1661
|
+
"VALUES (?, ?)",
|
|
1662
|
+
("007_observed_pre_credit_pct", now_utc_iso()),
|
|
1663
|
+
)
|
|
1664
|
+
conn.commit()
|
|
1665
|
+
except Exception:
|
|
1666
|
+
conn.rollback()
|
|
1667
|
+
raise
|
|
1668
|
+
|
|
1669
|
+
|
|
1601
1670
|
# === Region 8: Test-only migration registration (was bin/cctally:12086-12140) ===
|
|
1602
1671
|
|
|
1603
1672
|
# ──────────────────────────────────────────────────────────────────────
|