cctally 1.26.0 → 1.27.1
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 +13 -0
- package/README.md +1 -1
- package/bin/_cctally_cache_report.py +13 -12
- package/bin/_cctally_dashboard.py +11 -8
- package/bin/_cctally_db.py +1 -1
- package/bin/_cctally_project.py +3 -2
- package/bin/_cctally_reporting.py +2 -1
- package/bin/_cctally_setup.py +7 -3
- package/bin/_cctally_share.py +7 -6
- package/bin/_cctally_statusline.py +8 -0
- package/bin/_cctally_tui.py +10 -9
- package/bin/_cctally_update.py +3 -3
- package/bin/_lib_aggregators.py +3 -1
- package/bin/_lib_cache_report.py +37 -2
- package/bin/_lib_diff_kernel.py +3 -2
- package/bin/_lib_fmt.py +12 -0
- package/bin/_lib_render.py +7 -6
- package/bin/_lib_share_templates.py +44 -10
- package/bin/_lib_view_models.py +9 -2
- package/bin/cctally +14 -2
- package/bin/cctally-npm-shim.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.27.1] - 2026-06-04
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Three Linux-only bugs, surfaced by running the full test suite on Linux for the first time** (cctally is developed on macOS, whose case-insensitive filesystem and BSD tooling had masked them): (1) `cctally statusline` with `display.tz = utc` no longer prints a spurious `invalid timezone 'utc'; using 'UTC'` warning on Linux — the lowercase `utc` preference is now normalized to the portable IANA key `UTC` before resolution (Linux's case-sensitive zoneinfo rejects `"utc"` where macOS silently accepts it); (2) the local web dashboard's background update-check thread no longer crashes with `TypeError: 'Event' object is not callable` when the dashboard shuts down — its internal stop-event field was shadowing `threading.Thread._stop`, which `Thread.join()` invokes during teardown; (3) `cctally setup --uninstall` now reliably terminates a running legacy usage-poller on Linux even when it was launched from a long filesystem path — the process-identity check passes `ps -ww` (unlimited-width output) so Linux's default ~80-column truncation can't drop the identifying token and mis-read the live daemon as a dead PID. No action needed on upgrade.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- **Internal (no user-facing change): the full test suite (`bin/cctally-test-all`) is now Linux-portable, and a GitHub-hosted Linux matrix (Ubuntu × Python 3.11/3.12/3.13) runs it on every tag, weekly, and on demand** — closing the cross-version verification gap left when the floor was lowered to 3.11 in 1.27.0. The portability work fixed interpreter- and OS-divergences that only ever ran on macOS before: a `pytest-xdist` timezone leak (one test pinned a Pacific `tzset()` whose libc state outlived `monkeypatch`, flipping later tests' date boundaries — now reset suite-wide via an autouse `conftest` fixture), terminal-width-dependent block-gap rendering under `COLUMNS=80`, Python 3.13's `~~~^^^` traceback caret-anchors in a migration golden, the macOS-`osascript`-vs-Linux-`notify-send` notifier-dispatch assumptions, a BSD-vs-util-linux `script` PTY invocation in the update-banner harness (now a portable `pty.spawn`), a host-config leak in the dashboard-envelope golden, and a detached background update-check that raced fixture teardown. (#132)
|
|
15
|
+
|
|
16
|
+
## [1.27.0] - 2026-06-04
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- **cctally now supports Python 3.11 and 3.12, not just 3.13.** The supported floor is lowered from 3.13 to 3.11 (Debian 12, Ubuntu 24.04, and other distros whose system `python3` is 3.11/3.12 can now run cctally without a newer interpreter). This was previously blocked by a one-cent floating-point rounding difference between Python versions in a rendered cost Total; rendered cost/percent totals now route through a `math.fsum`-backed exact-summation chokepoint, so every figure is byte-identical on 3.11/3.12/3.13 (verified by running the reporting suite on all three interpreters). Homebrew installs are unaffected (the formula still bundles its own modern Python). No action needed on upgrade.
|
|
20
|
+
|
|
8
21
|
## [1.26.0] - 2026-06-03
|
|
9
22
|
|
|
10
23
|
### Added
|
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ If you're using `ccusage` to watch Claude Code spend, `cctally` covers the same
|
|
|
24
24
|
|
|
25
25
|
## Installation
|
|
26
26
|
|
|
27
|
-
**Requirements:** Python 3.
|
|
27
|
+
**Requirements:** Python 3.11+, macOS or Linux, Claude Code installed and run at least once.
|
|
28
28
|
|
|
29
29
|
### Homebrew (macOS / Linux)
|
|
30
30
|
|
|
@@ -19,6 +19,7 @@ from typing import Any, Literal, Optional
|
|
|
19
19
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
20
20
|
|
|
21
21
|
from _cctally_core import eprint, now_utc_iso, parse_iso_datetime, _command_as_of
|
|
22
|
+
from _lib_fmt import stable_sum
|
|
22
23
|
import _lib_cache_report as crk
|
|
23
24
|
|
|
24
25
|
|
|
@@ -467,10 +468,10 @@ def _render_cache_day_rows(
|
|
|
467
468
|
tot_cc = sum(row.cache_creation_tokens for row in rows)
|
|
468
469
|
tot_cr = sum(row.cache_read_tokens for row in rows)
|
|
469
470
|
tot_tokens = sum(row.total_tokens for row in rows)
|
|
470
|
-
tot_cost =
|
|
471
|
-
tot_saved =
|
|
472
|
-
tot_wasted =
|
|
473
|
-
tot_net =
|
|
471
|
+
tot_cost = stable_sum(row.cost for row in rows)
|
|
472
|
+
tot_saved = stable_sum(row.saved_usd for row in rows)
|
|
473
|
+
tot_wasted = stable_sum(row.wasted_usd for row in rows)
|
|
474
|
+
tot_net = stable_sum(row.net_usd for row in rows)
|
|
474
475
|
tot_hit = crk._compute_cache_hit_percent(tot_inp, tot_cc, tot_cr)
|
|
475
476
|
footer_cells = [
|
|
476
477
|
("Total", _yellow),
|
|
@@ -616,10 +617,10 @@ def _render_cache_session_rows(
|
|
|
616
617
|
tot_cc = sum(r.cache_creation_tokens for r in rows)
|
|
617
618
|
tot_cr = sum(r.cache_read_tokens for r in rows)
|
|
618
619
|
tot_tokens = sum(r.total_tokens for r in rows)
|
|
619
|
-
tot_cost =
|
|
620
|
-
tot_saved =
|
|
621
|
-
tot_wasted =
|
|
622
|
-
tot_net =
|
|
620
|
+
tot_cost = stable_sum(r.cost for r in rows)
|
|
621
|
+
tot_saved = stable_sum(r.saved_usd for r in rows)
|
|
622
|
+
tot_wasted = stable_sum(r.wasted_usd for r in rows)
|
|
623
|
+
tot_net = stable_sum(r.net_usd for r in rows)
|
|
623
624
|
tot_hit = crk._compute_cache_hit_percent(tot_inp, tot_cc, tot_cr)
|
|
624
625
|
|
|
625
626
|
footer_cells = [
|
|
@@ -991,13 +992,13 @@ def _emit_cache_report_json(
|
|
|
991
992
|
"cacheCreationTokens": tot_cc,
|
|
992
993
|
"cacheReadTokens": tot_cr,
|
|
993
994
|
"totalTokens": sum(r.total_tokens for r in rows),
|
|
994
|
-
"cost": round(
|
|
995
|
+
"cost": round(stable_sum(r.cost for r in rows), 6),
|
|
995
996
|
"cacheHitPercent": round(
|
|
996
997
|
crk._compute_cache_hit_percent(tot_inp, tot_cc, tot_cr), 2
|
|
997
998
|
),
|
|
998
|
-
"savedUsd": round(
|
|
999
|
-
"wastedUsd": round(
|
|
1000
|
-
"netUsd": round(
|
|
999
|
+
"savedUsd": round(stable_sum(r.saved_usd for r in rows), 6),
|
|
1000
|
+
"wastedUsd": round(stable_sum(r.wasted_usd for r in rows), 6),
|
|
1001
|
+
"netUsd": round(stable_sum(r.net_usd for r in rows), 6),
|
|
1001
1002
|
},
|
|
1002
1003
|
"generatedAt": now_utc_iso(now_utc=now_utc),
|
|
1003
1004
|
}
|
|
@@ -279,6 +279,7 @@ from _lib_display_tz import (
|
|
|
279
279
|
_compute_display_block,
|
|
280
280
|
)
|
|
281
281
|
from _lib_aggregators import _aggregate_daily, _aggregate_monthly, _aggregate_weekly
|
|
282
|
+
from _lib_fmt import stable_sum
|
|
282
283
|
from _lib_pricing import _calculate_entry_cost, _chip_for_model, _short_model_name
|
|
283
284
|
from _lib_five_hour import _canonical_5h_window_key
|
|
284
285
|
from _lib_subscription_weeks import _compute_subscription_weeks
|
|
@@ -1319,7 +1320,7 @@ def _build_daily_share_panel_data(options: dict,
|
|
|
1319
1320
|
# 7 and reverse to oldest→newest so the Recap template's days[-1]
|
|
1320
1321
|
# anchor lands on today.
|
|
1321
1322
|
last_7 = list(reversed(daily[:7]))
|
|
1322
|
-
total =
|
|
1323
|
+
total = stable_sum(float(getattr(r, "cost_usd", 0.0) or 0.0) for r in last_7) or 1.0
|
|
1323
1324
|
days: list[dict] = []
|
|
1324
1325
|
for r in last_7:
|
|
1325
1326
|
cost = float(getattr(r, "cost_usd", 0.0) or 0.0)
|
|
@@ -1721,10 +1722,10 @@ def _build_projects_share_panel_data(options: dict,
|
|
|
1721
1722
|
wc = (tp.get("weekly_cost") or [])[-take:]
|
|
1722
1723
|
wp = (tp.get("weekly_pct") or [])[-take:]
|
|
1723
1724
|
ws = (tp.get("sessions_per_week") or [])[-take:]
|
|
1724
|
-
cost = float(
|
|
1725
|
+
cost = float(stable_sum(wc))
|
|
1725
1726
|
running_total += cost
|
|
1726
1727
|
valid_pct = [float(p) for p in wp if p is not None]
|
|
1727
|
-
attributed =
|
|
1728
|
+
attributed = stable_sum(valid_pct) if valid_pct else None
|
|
1728
1729
|
# Sum per-week distinct session counts. Slight over-count when a
|
|
1729
1730
|
# single session spans a week boundary; the envelope's per-week
|
|
1730
1731
|
# bucketing has no session-id sets to union, so this is the
|
|
@@ -1735,6 +1736,8 @@ def _build_projects_share_panel_data(options: dict,
|
|
|
1735
1736
|
"bucket_path": tp["bucket_path"],
|
|
1736
1737
|
"cost_usd": cost,
|
|
1737
1738
|
"attributed_pct": attributed,
|
|
1739
|
+
# Integer session counts — bare sum() is exact (NOT a
|
|
1740
|
+
# stable_sum float-output site; see test_stable_sum_chokepoint).
|
|
1738
1741
|
"sessions_count": int(sum(ws)),
|
|
1739
1742
|
})
|
|
1740
1743
|
rows.sort(key=lambda r: (-r["cost_usd"], r["key"]))
|
|
@@ -2193,14 +2196,14 @@ def build_cache_report_snapshot(
|
|
|
2193
2196
|
# 7-day rollup: today + 6 prior. Walk by string date; ``days_newest_first``
|
|
2194
2197
|
# is already in the right order.
|
|
2195
2198
|
seven_day_rows = days[:7]
|
|
2196
|
-
seven_day_net_usd =
|
|
2199
|
+
seven_day_net_usd = stable_sum(r.net_usd for r in seven_day_rows)
|
|
2197
2200
|
seven_day_anomaly_count = sum(
|
|
2198
2201
|
1 for r in seven_day_rows if r.anomaly_triggered
|
|
2199
2202
|
)
|
|
2200
2203
|
|
|
2201
2204
|
# 14-day counterfactual: sum(saved_usd) across the window.
|
|
2202
|
-
fourteen_day_counterfactual_usd =
|
|
2203
|
-
fourteen_day_wasted_usd =
|
|
2205
|
+
fourteen_day_counterfactual_usd = stable_sum(r.saved_usd for r in days)
|
|
2206
|
+
fourteen_day_wasted_usd = stable_sum(r.wasted_usd for r in days)
|
|
2204
2207
|
denom = fourteen_day_counterfactual_usd + abs(fourteen_day_wasted_usd)
|
|
2205
2208
|
fourteen_day_efficiency_ratio = (
|
|
2206
2209
|
(fourteen_day_counterfactual_usd / denom) if denom > 1e-9 else 0.0
|
|
@@ -3431,7 +3434,7 @@ def _build_projects_envelope(
|
|
|
3431
3434
|
})
|
|
3432
3435
|
# Stable sort: desc by total window cost, ties broken by key.
|
|
3433
3436
|
trend_projects.sort(
|
|
3434
|
-
key=lambda p: (-
|
|
3437
|
+
key=lambda p: (-stable_sum(p["weekly_cost"]), p["key"]),
|
|
3435
3438
|
)
|
|
3436
3439
|
|
|
3437
3440
|
trend_block = {
|
|
@@ -3762,7 +3765,7 @@ def _project_detail_for_window(
|
|
|
3762
3765
|
sliced = weekly_pct_arr[-take:] if take > 0 else []
|
|
3763
3766
|
wp = [p for p in sliced if p is not None]
|
|
3764
3767
|
if wp:
|
|
3765
|
-
win_pct =
|
|
3768
|
+
win_pct = stable_sum(wp)
|
|
3766
3769
|
|
|
3767
3770
|
# Best-effort cleanup of the per-call TEMP TABLE so a reused conn
|
|
3768
3771
|
# doesn't carry path state into the next drill (tests share conns;
|
package/bin/_cctally_db.py
CHANGED
|
@@ -2066,7 +2066,7 @@ def _is_no_such_table_error(exc: sqlite3.OperationalError) -> bool:
|
|
|
2066
2066
|
|
|
2067
2067
|
* Substring match on the lowercased message (stable for ~20 years).
|
|
2068
2068
|
* ``exc.sqlite_errorcode == SQLITE_ERROR (1)`` (Python 3.11+;
|
|
2069
|
-
cctally's floor is 3.
|
|
2069
|
+
cctally's floor is 3.11 per ``__min_python_version__``). The
|
|
2070
2070
|
``getattr(..., None) in (None, 1)`` form degrades gracefully if
|
|
2071
2071
|
the attribute is ever missing — substring-only on legacy Python.
|
|
2072
2072
|
|
package/bin/_cctally_project.py
CHANGED
|
@@ -27,6 +27,7 @@ import os
|
|
|
27
27
|
import sys
|
|
28
28
|
|
|
29
29
|
from _cctally_core import _command_as_of, eprint, open_db, parse_iso_datetime
|
|
30
|
+
from _lib_fmt import stable_sum
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
def _cctally():
|
|
@@ -243,11 +244,11 @@ def _project_json_output(
|
|
|
243
244
|
denominator used by per-project attribution). `models[]` is included
|
|
244
245
|
per-project only when `--breakdown` is requested to avoid payload bloat.
|
|
245
246
|
"""
|
|
246
|
-
total_cost =
|
|
247
|
+
total_cost = stable_sum(r["cost_usd"] for r in rows)
|
|
247
248
|
# Aggregate used % across all weeks with snapshots in the range.
|
|
248
249
|
total_used_pct: float | None
|
|
249
250
|
if week_snapshots:
|
|
250
|
-
total_used_pct =
|
|
251
|
+
total_used_pct = stable_sum(week_snapshots.values())
|
|
251
252
|
else:
|
|
252
253
|
total_used_pct = None
|
|
253
254
|
|
|
@@ -34,6 +34,7 @@ import sys
|
|
|
34
34
|
from typing import Any
|
|
35
35
|
|
|
36
36
|
from _cctally_core import _command_as_of, eprint, open_db, parse_iso_datetime
|
|
37
|
+
from _lib_fmt import stable_sum
|
|
37
38
|
|
|
38
39
|
|
|
39
40
|
def _cctally():
|
|
@@ -134,7 +135,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
|
|
|
134
135
|
if getattr(args, "instances", False):
|
|
135
136
|
groups = c._aggregate_daily_by_project(keyed, tz=tz, mode=args.mode)
|
|
136
137
|
aug = c._project_disambiguate_labels(
|
|
137
|
-
[{"key": k, "cost_usd":
|
|
138
|
+
[{"key": k, "cost_usd": stable_sum(b.cost_usd for b in bl)}
|
|
138
139
|
for k, bl in groups]
|
|
139
140
|
)
|
|
140
141
|
json_groups: list = []
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -998,11 +998,15 @@ def _legacy_stop_active_poller() -> str:
|
|
|
998
998
|
# Ownership probe: the PID file is at a predictable /tmp path that
|
|
999
999
|
# outlives the daemon on uncleanly exit, and macOS PIDs cycle in a
|
|
1000
1000
|
# narrow space — verify the live process is actually our legacy
|
|
1001
|
-
# poller before signaling.
|
|
1002
|
-
#
|
|
1001
|
+
# poller before signaling. `-o command=` emits the cmdline with no
|
|
1002
|
+
# header on both macOS BSD ps and Linux util-linux ps; `-ww` forces
|
|
1003
|
+
# UNLIMITED width so the cmdline is never truncated. Without it,
|
|
1004
|
+
# Linux util-linux ps clamps the column to ~80 chars (macOS BSD ps
|
|
1005
|
+
# does not), so a poller launched from a long path drops the
|
|
1006
|
+
# "usage-poller.py" token off the end → a false "stale-pid".
|
|
1003
1007
|
try:
|
|
1004
1008
|
probe = subprocess.run(
|
|
1005
|
-
["ps", "-p", str(pid), "-o", "command="],
|
|
1009
|
+
["ps", "-ww", "-p", str(pid), "-o", "command="],
|
|
1006
1010
|
capture_output=True, text=True, timeout=2.0,
|
|
1007
1011
|
)
|
|
1008
1012
|
except (OSError, subprocess.TimeoutExpired):
|
package/bin/_cctally_share.py
CHANGED
|
@@ -19,6 +19,7 @@ import sys
|
|
|
19
19
|
|
|
20
20
|
import _lib_changelog # module-qualified: _lib_changelog._read_latest_changelog_version()
|
|
21
21
|
from _lib_display_tz import format_display_dt
|
|
22
|
+
from _lib_fmt import stable_sum
|
|
22
23
|
from _lib_render import _project_disambiguate_labels
|
|
23
24
|
|
|
24
25
|
|
|
@@ -406,7 +407,7 @@ def _build_report_snapshot(
|
|
|
406
407
|
avg_dpp = view.avg_dollars_per_pct
|
|
407
408
|
else:
|
|
408
409
|
avg_dpp = (
|
|
409
|
-
|
|
410
|
+
stable_sum(p.y_value for p in chart_pts) / len(chart_pts)
|
|
410
411
|
if chart_pts else 0.0
|
|
411
412
|
)
|
|
412
413
|
totals = (
|
|
@@ -627,7 +628,7 @@ def _build_monthly_snapshot(
|
|
|
627
628
|
_lib_share.BarChart(points=tuple(chart_pts), y_label="$")
|
|
628
629
|
if chart_pts else None
|
|
629
630
|
)
|
|
630
|
-
sum_cost =
|
|
631
|
+
sum_cost = stable_sum(p.y_value for p in chart_pts)
|
|
631
632
|
avg_cost = (sum_cost / len(chart_pts)) if chart_pts else 0.0
|
|
632
633
|
totals = (
|
|
633
634
|
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
@@ -811,12 +812,12 @@ def _build_weekly_snapshot(
|
|
|
811
812
|
)
|
|
812
813
|
if chart_pts else None
|
|
813
814
|
)
|
|
814
|
-
sum_cost =
|
|
815
|
+
sum_cost = stable_sum(p.y_value for p in chart_pts)
|
|
815
816
|
pct_values = [
|
|
816
817
|
float(o[0]) for o in overlay
|
|
817
818
|
if o is not None and o[0] is not None
|
|
818
819
|
]
|
|
819
|
-
avg_pct = (
|
|
820
|
+
avg_pct = (stable_sum(pct_values) / len(pct_values)) if pct_values else 0.0
|
|
820
821
|
peak_pct = max(pct_values, default=0.0)
|
|
821
822
|
totals = (
|
|
822
823
|
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
@@ -1144,7 +1145,7 @@ def _build_project_snapshot(
|
|
|
1144
1145
|
notes = (
|
|
1145
1146
|
f"Showing top 12 in chart; table includes all {len(chart_pts)}.",
|
|
1146
1147
|
)
|
|
1147
|
-
sum_cost =
|
|
1148
|
+
sum_cost = stable_sum(p.y_value for p in chart_pts)
|
|
1148
1149
|
totals = (
|
|
1149
1150
|
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
1150
1151
|
_lib_share.Totalled(label="Projects", value=str(len(chart_pts))),
|
|
@@ -1541,7 +1542,7 @@ def _build_session_snapshot(
|
|
|
1541
1542
|
notes = (
|
|
1542
1543
|
f"Showing top 15 in chart; table includes all {len(chart_pts)}.",
|
|
1543
1544
|
)
|
|
1544
|
-
sum_cost =
|
|
1545
|
+
sum_cost = stable_sum(p.y_value for p in chart_pts)
|
|
1545
1546
|
totals = (
|
|
1546
1547
|
_lib_share.Totalled(label="Sum", value=f"${sum_cost:,.2f}"),
|
|
1547
1548
|
_lib_share.Totalled(label="Sessions", value=str(len(chart_pts))),
|
|
@@ -91,6 +91,14 @@ def _resolve_statusline_tz(cli_tz, cfg, warn_once):
|
|
|
91
91
|
tz_name = c._local_tz_name() or "UTC"
|
|
92
92
|
except Exception:
|
|
93
93
|
tz_name = "UTC"
|
|
94
|
+
elif tz_name and tz_name.lower() == "utc":
|
|
95
|
+
# Canonical "utc" (the value get_display_tz_pref / normalize_display_tz_value
|
|
96
|
+
# emit) -> the portable IANA key "UTC". macOS's case-insensitive
|
|
97
|
+
# filesystem resolves ZoneInfo("utc") to UTC, but Linux's case-sensitive
|
|
98
|
+
# /usr/share/zoneinfo raises ZoneInfoNotFoundError, which would emit a
|
|
99
|
+
# spurious "invalid timezone 'utc'" warning below. Mirrors
|
|
100
|
+
# resolve_display_tz, which maps canonical "utc" -> ZoneInfo("Etc/UTC").
|
|
101
|
+
tz_name = "UTC"
|
|
94
102
|
try:
|
|
95
103
|
ZoneInfo(tz_name)
|
|
96
104
|
except (ZoneInfoNotFoundError, Exception):
|
package/bin/_cctally_tui.py
CHANGED
|
@@ -221,6 +221,7 @@ from _lib_display_tz import (
|
|
|
221
221
|
_compute_display_block,
|
|
222
222
|
)
|
|
223
223
|
from _lib_aggregators import _aggregate_monthly
|
|
224
|
+
from _lib_fmt import stable_sum
|
|
224
225
|
|
|
225
226
|
|
|
226
227
|
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
@@ -1926,11 +1927,11 @@ def _tui_build_snapshot(
|
|
|
1926
1927
|
weekly_periods = _dashboard_build_weekly_periods(
|
|
1927
1928
|
conn, now_utc, n=12, skip_sync=skip_sync
|
|
1928
1929
|
)
|
|
1929
|
-
# ``
|
|
1930
|
-
# the envelope stays byte-stable with the pre-fix ``0.0`` shape
|
|
1930
|
+
# ``stable_sum`` (math.fsum) returns float ``0.0`` on empty rows,
|
|
1931
|
+
# so the envelope stays byte-stable with the pre-fix ``0.0`` shape
|
|
1931
1932
|
# (the dashboard fixture goldens assert exact JSON match).
|
|
1932
|
-
weekly_total_cost_usd =
|
|
1933
|
-
|
|
1933
|
+
weekly_total_cost_usd = stable_sum(
|
|
1934
|
+
r.cost_usd for r in weekly_periods
|
|
1934
1935
|
)
|
|
1935
1936
|
weekly_total_tokens = sum(
|
|
1936
1937
|
(r.total_tokens for r in weekly_periods), 0,
|
|
@@ -1949,8 +1950,8 @@ def _tui_build_snapshot(
|
|
|
1949
1950
|
conn, now_utc, n=12, skip_sync=skip_sync,
|
|
1950
1951
|
display_tz=_build_display_tz,
|
|
1951
1952
|
)
|
|
1952
|
-
monthly_total_cost_usd =
|
|
1953
|
-
|
|
1953
|
+
monthly_total_cost_usd = stable_sum(
|
|
1954
|
+
r.cost_usd for r in monthly_periods
|
|
1954
1955
|
)
|
|
1955
1956
|
monthly_total_tokens = sum(
|
|
1956
1957
|
(r.total_tokens for r in monthly_periods), 0,
|
|
@@ -1992,8 +1993,8 @@ def _tui_build_snapshot(
|
|
|
1992
1993
|
conn, now_utc, n=30, skip_sync=skip_sync,
|
|
1993
1994
|
display_tz=_build_display_tz,
|
|
1994
1995
|
)
|
|
1995
|
-
daily_total_cost_usd =
|
|
1996
|
-
|
|
1996
|
+
daily_total_cost_usd = stable_sum(
|
|
1997
|
+
r.cost_usd for r in daily_panel
|
|
1997
1998
|
)
|
|
1998
1999
|
daily_total_tokens = sum(
|
|
1999
2000
|
(r.total_tokens for r in daily_panel), 0,
|
|
@@ -4183,7 +4184,7 @@ def _tui_modal_trend(snap, runtime, width):
|
|
|
4183
4184
|
show_age = bucket != "narrow"
|
|
4184
4185
|
# Header
|
|
4185
4186
|
valid = [h for h in history if h.dollars_per_percent is not None]
|
|
4186
|
-
avg_dpp =
|
|
4187
|
+
avg_dpp = stable_sum(h.dollars_per_percent for h in valid) / len(valid) if valid else 0.0
|
|
4187
4188
|
if len(valid) >= 2:
|
|
4188
4189
|
first_dpp = valid[0].dollars_per_percent
|
|
4189
4190
|
last_dpp = valid[-1].dollars_per_percent
|
package/bin/_cctally_update.py
CHANGED
|
@@ -1890,13 +1890,13 @@ class _DashboardUpdateCheckThread(threading.Thread):
|
|
|
1890
1890
|
snapshot_ref: "_SnapshotRef | None" = None,
|
|
1891
1891
|
) -> None:
|
|
1892
1892
|
super().__init__(name="cctally-update-check")
|
|
1893
|
-
self.
|
|
1893
|
+
self._stop_event = stop_event
|
|
1894
1894
|
self._hub = hub
|
|
1895
1895
|
self._ref = snapshot_ref
|
|
1896
1896
|
|
|
1897
1897
|
def run(self) -> None:
|
|
1898
1898
|
c = _cctally()
|
|
1899
|
-
while not self.
|
|
1899
|
+
while not self._stop_event.is_set():
|
|
1900
1900
|
try:
|
|
1901
1901
|
# Self-heal runs every tick (every 30 min by default),
|
|
1902
1902
|
# NOT gated by `_is_update_check_due`'s 24h TTL. Catches
|
|
@@ -1938,7 +1938,7 @@ class _DashboardUpdateCheckThread(threading.Thread):
|
|
|
1938
1938
|
)
|
|
1939
1939
|
except Exception:
|
|
1940
1940
|
pass
|
|
1941
|
-
self.
|
|
1941
|
+
self._stop_event.wait(c.UPDATE_DASHBOARD_CHECK_POLL_S)
|
|
1942
1942
|
|
|
1943
1943
|
|
|
1944
1944
|
def cmd_update(args) -> int:
|
package/bin/_lib_aggregators.py
CHANGED
|
@@ -77,6 +77,8 @@ _resolve_tz = _lib_display_tz._resolve_tz
|
|
|
77
77
|
|
|
78
78
|
_lib_subscription_weeks = _load_lib("_lib_subscription_weeks")
|
|
79
79
|
SubWeek = _lib_subscription_weeks.SubWeek
|
|
80
|
+
_lib_fmt = _load_lib("_lib_fmt")
|
|
81
|
+
stable_sum = _lib_fmt.stable_sum
|
|
80
82
|
|
|
81
83
|
|
|
82
84
|
# === Honest imports from extracted homes ===================================
|
|
@@ -260,7 +262,7 @@ def _aggregate_daily_by_project(
|
|
|
260
262
|
ranked: list[tuple[Any, list[BucketUsage], float]] = []
|
|
261
263
|
for key in order:
|
|
262
264
|
buckets = _aggregate_daily(grouped[key], mode=mode, tz=tz) # date-asc
|
|
263
|
-
total =
|
|
265
|
+
total = stable_sum(b.cost_usd for b in buckets)
|
|
264
266
|
ranked.append((key, buckets, total))
|
|
265
267
|
|
|
266
268
|
ranked.sort(key=lambda t: (-t[2], t[0].display_key))
|
package/bin/_lib_cache_report.py
CHANGED
|
@@ -21,6 +21,41 @@ from typing import Any, Callable, Iterable, Literal, Optional
|
|
|
21
21
|
from zoneinfo import ZoneInfo
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def _import_stable_sum():
|
|
25
|
+
"""Resolve ``_lib_fmt.stable_sum`` — the interpreter-stable float-summation
|
|
26
|
+
chokepoint (math.fsum) used for output-bound cost totals.
|
|
27
|
+
|
|
28
|
+
This kernel is loaded by file path from both ``bin/cctally`` and the test
|
|
29
|
+
harness, where ``_lib_fmt`` (a foundational kernel) is already imported, so
|
|
30
|
+
the ``sys.modules`` fast path is what runs in practice. The path-load
|
|
31
|
+
fallback differs from ``_import_share_lib``'s: ``_lib_share`` is dep-free,
|
|
32
|
+
but ``_lib_fmt`` imports the leaf kernels ``_cctally_core`` +
|
|
33
|
+
``_lib_display_tz`` *by name*, so the fallback must put ``bin/`` on
|
|
34
|
+
``sys.path`` before executing it or those imports raise ModuleNotFoundError.
|
|
35
|
+
The leaves never import back, so this stays acyclic.
|
|
36
|
+
"""
|
|
37
|
+
import sys
|
|
38
|
+
if "_lib_fmt" in sys.modules:
|
|
39
|
+
return sys.modules["_lib_fmt"].stable_sum
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
import importlib.util
|
|
42
|
+
bin_dir = Path(__file__).resolve().parent
|
|
43
|
+
if str(bin_dir) not in sys.path:
|
|
44
|
+
sys.path.insert(0, str(bin_dir))
|
|
45
|
+
spec = importlib.util.spec_from_file_location("_lib_fmt", bin_dir / "_lib_fmt.py")
|
|
46
|
+
m = importlib.util.module_from_spec(spec)
|
|
47
|
+
sys.modules["_lib_fmt"] = m
|
|
48
|
+
try:
|
|
49
|
+
spec.loader.exec_module(m)
|
|
50
|
+
except Exception:
|
|
51
|
+
sys.modules.pop("_lib_fmt", None)
|
|
52
|
+
raise
|
|
53
|
+
return m.stable_sum
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
stable_sum = _import_stable_sum()
|
|
57
|
+
|
|
58
|
+
|
|
24
59
|
# Anthropic's per-call >200K-tokens tier — kept in sync with bin/_lib_pricing.
|
|
25
60
|
# Callers may override via the ``tiered_threshold`` kwarg.
|
|
26
61
|
DEFAULT_TIERED_THRESHOLD = 200_000
|
|
@@ -732,7 +767,7 @@ def _aggregate_cache_breakdown(
|
|
|
732
767
|
return tuple(out)
|
|
733
768
|
head = out[:top_n]
|
|
734
769
|
tail = out[top_n:]
|
|
735
|
-
other_net =
|
|
770
|
+
other_net = stable_sum(r.net_usd for r in tail)
|
|
736
771
|
# True aggregate hit % over the tail buckets — sum directly from the
|
|
737
772
|
# CacheBreakdownRow token fields (EFF-4 — avoids the previous triple
|
|
738
773
|
# walk over ``buckets.items()``).
|
|
@@ -800,7 +835,7 @@ def _aggregate_cache_breakdown_from_rows(
|
|
|
800
835
|
return tuple(out)
|
|
801
836
|
head = out[:top_n]
|
|
802
837
|
tail = out[top_n:]
|
|
803
|
-
other_net =
|
|
838
|
+
other_net = stable_sum(r.net_usd for r in tail)
|
|
804
839
|
tail_input = sum(r.input_tokens for r in tail)
|
|
805
840
|
tail_creation = sum(r.cache_creation_tokens for r in tail)
|
|
806
841
|
tail_read = sum(r.cache_read_tokens for r in tail)
|
package/bin/_lib_diff_kernel.py
CHANGED
|
@@ -119,6 +119,7 @@ format_display_dt = _lib_display_tz.format_display_dt
|
|
|
119
119
|
_lib_fmt = _load_lib("_lib_fmt")
|
|
120
120
|
_style_ansi = _lib_fmt._style_ansi
|
|
121
121
|
_supports_unicode_stdout = _lib_fmt._supports_unicode_stdout
|
|
122
|
+
stable_sum = _lib_fmt.stable_sum
|
|
122
123
|
|
|
123
124
|
|
|
124
125
|
# === Honest imports from extracted homes ===================================
|
|
@@ -706,7 +707,7 @@ def _diff_resolve_used_pct(window: ParsedWindow) -> tuple:
|
|
|
706
707
|
return None, "n/a"
|
|
707
708
|
if not vals:
|
|
708
709
|
return None, "n/a"
|
|
709
|
-
return
|
|
710
|
+
return stable_sum(vals) / len(vals), "avg"
|
|
710
711
|
finally:
|
|
711
712
|
conn.close()
|
|
712
713
|
return None, "n/a"
|
|
@@ -855,7 +856,7 @@ def _sum_metric_bundles(bundles) -> "MetricBundle | None":
|
|
|
855
856
|
bundles = list(bundles)
|
|
856
857
|
if not bundles:
|
|
857
858
|
return None
|
|
858
|
-
cost =
|
|
859
|
+
cost = stable_sum(b.cost_usd for b in bundles)
|
|
859
860
|
ti = sum(b.tokens_input or 0 for b in bundles)
|
|
860
861
|
to = sum(b.tokens_output or 0 for b in bundles)
|
|
861
862
|
tcr = sum(b.tokens_cache_read or 0 for b in bundles)
|
package/bin/_lib_fmt.py
CHANGED
|
@@ -17,6 +17,7 @@ Spec: docs/superpowers/specs/2026-06-01-extract-fmt-color-table-primitives-desig
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import datetime as dt
|
|
20
|
+
import math
|
|
20
21
|
import os
|
|
21
22
|
import pathlib
|
|
22
23
|
import re
|
|
@@ -275,6 +276,17 @@ def _boxed_table(
|
|
|
275
276
|
return "\n".join(out_lines)
|
|
276
277
|
|
|
277
278
|
|
|
279
|
+
def stable_sum(values):
|
|
280
|
+
"""Exactly-rounded, interpreter-stable sum of float addends.
|
|
281
|
+
|
|
282
|
+
math.fsum (Shewchuk) returns a single correctly-rounded result that is
|
|
283
|
+
byte-identical across all CPython versions, unlike the built-in sum(),
|
|
284
|
+
which switched to Neumaier compensated summation for floats in 3.12. Use at
|
|
285
|
+
every float sum() whose result reaches byte-compared output.
|
|
286
|
+
"""
|
|
287
|
+
return math.fsum(values)
|
|
288
|
+
|
|
289
|
+
|
|
278
290
|
def _fmt_num(n: int) -> str:
|
|
279
291
|
"""Format integer with comma separators: 1234567 -> '1,234,567'."""
|
|
280
292
|
return f"{n:,}"
|
package/bin/_lib_render.py
CHANGED
|
@@ -108,6 +108,7 @@ _style_ansi = _lib_fmt._style_ansi
|
|
|
108
108
|
_fmt_num = _lib_fmt._fmt_num
|
|
109
109
|
_truncate_num = _lib_fmt._truncate_num
|
|
110
110
|
_boxed_table = _lib_fmt._boxed_table
|
|
111
|
+
stable_sum = _lib_fmt.stable_sum
|
|
111
112
|
|
|
112
113
|
|
|
113
114
|
# Module-level back-ref shims. Each shim resolves
|
|
@@ -788,7 +789,7 @@ def _bucket_totals_dict(buckets) -> dict[str, Any]:
|
|
|
788
789
|
"outputTokens": sum(b.output_tokens for b in buckets),
|
|
789
790
|
"cacheCreationTokens": sum(b.cache_creation_tokens for b in buckets),
|
|
790
791
|
"cacheReadTokens": sum(b.cache_read_tokens for b in buckets),
|
|
791
|
-
"totalCost":
|
|
792
|
+
"totalCost": stable_sum(b.cost_usd for b in buckets),
|
|
792
793
|
"totalTokens": sum(b.total_tokens for b in buckets),
|
|
793
794
|
}
|
|
794
795
|
|
|
@@ -1354,7 +1355,7 @@ def _render_bucket_table(
|
|
|
1354
1355
|
tot_cc = sum(d.cache_creation_tokens for d in footer_buckets)
|
|
1355
1356
|
tot_cr = sum(d.cache_read_tokens for d in footer_buckets)
|
|
1356
1357
|
tot_tokens = sum(d.total_tokens for d in footer_buckets)
|
|
1357
|
-
tot_cost =
|
|
1358
|
+
tot_cost = stable_sum(d.cost_usd for d in footer_buckets)
|
|
1358
1359
|
footer_cells = [
|
|
1359
1360
|
("Total", _yellow),
|
|
1360
1361
|
("", None),
|
|
@@ -1695,7 +1696,7 @@ def _render_weekly_table(
|
|
|
1695
1696
|
tot_cc = sum(d.cache_creation_tokens for d in buckets)
|
|
1696
1697
|
tot_cr = sum(d.cache_read_tokens for d in buckets)
|
|
1697
1698
|
tot_tokens = sum(d.total_tokens for d in buckets)
|
|
1698
|
-
tot_cost =
|
|
1699
|
+
tot_cost = stable_sum(d.cost_usd for d in buckets)
|
|
1699
1700
|
footer_cells = [
|
|
1700
1701
|
("Total", _yellow),
|
|
1701
1702
|
("", None),
|
|
@@ -1984,7 +1985,7 @@ def _render_codex_bucket_table(
|
|
|
1984
1985
|
tot_reasoning = sum(b.reasoning_output_tokens for b in buckets)
|
|
1985
1986
|
tot_non_cached = max(0, tot_input_inclusive - tot_cached)
|
|
1986
1987
|
tot_tokens = tot_input_inclusive + tot_output
|
|
1987
|
-
tot_cost =
|
|
1988
|
+
tot_cost = stable_sum(b.cost_usd for b in buckets)
|
|
1988
1989
|
footer_cells = [
|
|
1989
1990
|
("Total", _yellow),
|
|
1990
1991
|
("", None),
|
|
@@ -2264,7 +2265,7 @@ def _render_codex_session_table(
|
|
|
2264
2265
|
tot_reasoning = sum(s.reasoning_output_tokens for s in sessions)
|
|
2265
2266
|
tot_non_cached = max(0, tot_input_inclusive - tot_cached)
|
|
2266
2267
|
tot_tokens = tot_input_inclusive + tot_output
|
|
2267
|
-
tot_cost =
|
|
2268
|
+
tot_cost = stable_sum(s.cost_usd for s in sessions)
|
|
2268
2269
|
footer_cells = [
|
|
2269
2270
|
("Total", _yellow),
|
|
2270
2271
|
("", None), ("", None), ("", None),
|
|
@@ -2540,7 +2541,7 @@ def _render_claude_session_table(
|
|
|
2540
2541
|
tot_output = sum(s.output_tokens for s in sessions)
|
|
2541
2542
|
# Issue #104: Total Tokens footer sums all four components.
|
|
2542
2543
|
tot_tokens = sum(s.total_tokens for s in sessions)
|
|
2543
|
-
tot_cost =
|
|
2544
|
+
tot_cost = stable_sum(s.cost_usd for s in sessions)
|
|
2544
2545
|
footer_cells = [
|
|
2545
2546
|
("Total", _yellow),
|
|
2546
2547
|
("", None), ("", None), ("", None),
|
|
@@ -155,6 +155,40 @@ def _import_share_lib():
|
|
|
155
155
|
_LS = _import_share_lib()
|
|
156
156
|
|
|
157
157
|
|
|
158
|
+
def _import_stable_sum():
|
|
159
|
+
"""Resolve ``_lib_fmt.stable_sum`` — the interpreter-stable float-summation
|
|
160
|
+
chokepoint (math.fsum) used for output-bound cost totals.
|
|
161
|
+
|
|
162
|
+
The ``sys.modules`` fast path is what runs in practice (``_lib_fmt`` is a
|
|
163
|
+
foundational kernel already imported by ``bin/cctally`` and the test
|
|
164
|
+
harness). The path-load fallback differs from ``_import_share_lib``'s:
|
|
165
|
+
``_lib_share`` is dep-free, but ``_lib_fmt`` imports the leaf kernels
|
|
166
|
+
``_cctally_core`` + ``_lib_display_tz`` *by name*, so the fallback must put
|
|
167
|
+
``bin/`` on ``sys.path`` before executing it or those imports raise
|
|
168
|
+
ModuleNotFoundError. The leaves never import back, so this stays acyclic.
|
|
169
|
+
"""
|
|
170
|
+
import sys
|
|
171
|
+
if "_lib_fmt" in sys.modules:
|
|
172
|
+
return sys.modules["_lib_fmt"].stable_sum
|
|
173
|
+
from pathlib import Path
|
|
174
|
+
import importlib.util
|
|
175
|
+
bin_dir = Path(__file__).resolve().parent
|
|
176
|
+
if str(bin_dir) not in sys.path:
|
|
177
|
+
sys.path.insert(0, str(bin_dir))
|
|
178
|
+
spec = importlib.util.spec_from_file_location("_lib_fmt", bin_dir / "_lib_fmt.py")
|
|
179
|
+
m = importlib.util.module_from_spec(spec)
|
|
180
|
+
sys.modules["_lib_fmt"] = m
|
|
181
|
+
try:
|
|
182
|
+
spec.loader.exec_module(m)
|
|
183
|
+
except Exception:
|
|
184
|
+
sys.modules.pop("_lib_fmt", None)
|
|
185
|
+
raise
|
|
186
|
+
return m.stable_sum
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
stable_sum = _import_stable_sum()
|
|
190
|
+
|
|
191
|
+
|
|
158
192
|
def _kpi_strip(*items: tuple[str, str]) -> tuple:
|
|
159
193
|
"""Generic KPI strip → tuple of `Totalled`."""
|
|
160
194
|
return tuple(_LS.Totalled(label=lbl, value=val) for lbl, val in items)
|
|
@@ -280,7 +314,7 @@ def _detect_residual(
|
|
|
280
314
|
`has_other_residual` in `_cross_tab_columns`.
|
|
281
315
|
"""
|
|
282
316
|
for row_total, breakdown in rows_and_breakdowns:
|
|
283
|
-
top_k_sum =
|
|
317
|
+
top_k_sum = stable_sum(float(breakdown.get(lbl, 0.0)) for lbl in top_k_labels)
|
|
284
318
|
if abs(row_total - top_k_sum) > epsilon:
|
|
285
319
|
return True
|
|
286
320
|
return False
|
|
@@ -537,7 +571,7 @@ def _build_daily_recap(*, panel_data, options):
|
|
|
537
571
|
start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
|
|
538
572
|
end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
|
|
539
573
|
end = end_anchor + _dt.timedelta(days=1)
|
|
540
|
-
sum_cost =
|
|
574
|
+
sum_cost = stable_sum(float(d["cost_usd"]) for d in days)
|
|
541
575
|
return _LS.ShareSnapshot(
|
|
542
576
|
cmd="daily",
|
|
543
577
|
title=f"Daily — last {len(days)} day{'s' if len(days) != 1 else ''}",
|
|
@@ -591,7 +625,7 @@ def _build_monthly_recap(*, panel_data, options):
|
|
|
591
625
|
end = end_anchor.replace(day=1) - _dt.timedelta(days=1)
|
|
592
626
|
else:
|
|
593
627
|
end = start
|
|
594
|
-
sum_cost =
|
|
628
|
+
sum_cost = stable_sum(float(m["cost_usd"]) for m in months)
|
|
595
629
|
return _LS.ShareSnapshot(
|
|
596
630
|
cmd="monthly",
|
|
597
631
|
title=f"Monthly — last {len(months)} month{'s' if len(months) != 1 else ''}",
|
|
@@ -761,7 +795,7 @@ def _build_sessions_recap(*, panel_data, options):
|
|
|
761
795
|
sessions = panel_data.get("sessions") or []
|
|
762
796
|
cap = options.get("top_n", 15)
|
|
763
797
|
rows_iter = sessions[:cap]
|
|
764
|
-
sum_cost =
|
|
798
|
+
sum_cost = stable_sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
|
|
765
799
|
starts = [_parse_iso_utc(s["started_at"]) for s in rows_iter if s.get("started_at")]
|
|
766
800
|
start = min(starts) if starts else _utc_now()
|
|
767
801
|
end = max(starts) if starts else start
|
|
@@ -1067,7 +1101,7 @@ def _build_daily_visual(*, panel_data, options):
|
|
|
1067
1101
|
start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
|
|
1068
1102
|
end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
|
|
1069
1103
|
end = end_anchor + _dt.timedelta(days=1)
|
|
1070
|
-
sum_cost =
|
|
1104
|
+
sum_cost = stable_sum(float(d["cost_usd"]) for d in days)
|
|
1071
1105
|
return _LS.ShareSnapshot(
|
|
1072
1106
|
cmd="daily",
|
|
1073
1107
|
title=f"Daily visual — last {len(days)} day{'s' if len(days) != 1 else ''}",
|
|
@@ -1103,7 +1137,7 @@ def _build_daily_detail(*, panel_data, options):
|
|
|
1103
1137
|
start = _parse_iso_utc(days[0]["date"]) if days else _utc_now()
|
|
1104
1138
|
end_anchor = _parse_iso_utc(days[-1]["date"]) if days else start
|
|
1105
1139
|
end = end_anchor + _dt.timedelta(days=1)
|
|
1106
|
-
sum_cost =
|
|
1140
|
+
sum_cost = stable_sum(float(d["cost_usd"]) for d in days)
|
|
1107
1141
|
top_n = max(int(options.get("top_n", 5)), 1)
|
|
1108
1142
|
|
|
1109
1143
|
breakdowns = [dict(d.get("projects") or {}) for d in days]
|
|
@@ -1170,7 +1204,7 @@ def _build_monthly_visual(*, panel_data, options):
|
|
|
1170
1204
|
end = end_anchor.replace(day=1) - _dt.timedelta(days=1)
|
|
1171
1205
|
else:
|
|
1172
1206
|
end = start
|
|
1173
|
-
sum_cost =
|
|
1207
|
+
sum_cost = stable_sum(float(m["cost_usd"]) for m in months)
|
|
1174
1208
|
return _LS.ShareSnapshot(
|
|
1175
1209
|
cmd="monthly",
|
|
1176
1210
|
title=f"Monthly visual — last {len(months)} month{'s' if len(months) != 1 else ''}",
|
|
@@ -1211,7 +1245,7 @@ def _build_monthly_detail(*, panel_data, options):
|
|
|
1211
1245
|
end = end_anchor.replace(day=1) - _dt.timedelta(days=1)
|
|
1212
1246
|
else:
|
|
1213
1247
|
end = start
|
|
1214
|
-
sum_cost =
|
|
1248
|
+
sum_cost = stable_sum(float(m["cost_usd"]) for m in months)
|
|
1215
1249
|
top_n = max(int(options.get("top_n", 5)), 1)
|
|
1216
1250
|
|
|
1217
1251
|
breakdowns = [dict(m.get("models") or {}) for m in months]
|
|
@@ -1476,7 +1510,7 @@ def _build_sessions_visual(*, panel_data, options):
|
|
|
1476
1510
|
sessions = panel_data.get("sessions") or []
|
|
1477
1511
|
cap = int(options.get("top_n", 8))
|
|
1478
1512
|
rows_iter = sessions[:cap]
|
|
1479
|
-
sum_cost =
|
|
1513
|
+
sum_cost = stable_sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
|
|
1480
1514
|
starts = [_parse_iso_utc(s["started_at"]) for s in rows_iter if s.get("started_at")]
|
|
1481
1515
|
start = min(starts) if starts else _utc_now()
|
|
1482
1516
|
end = max(starts) if starts else start
|
|
@@ -1522,7 +1556,7 @@ def _build_sessions_detail(*, panel_data, options):
|
|
|
1522
1556
|
sessions = panel_data.get("sessions") or []
|
|
1523
1557
|
cap = options.get("top_n", 50)
|
|
1524
1558
|
rows_iter = sessions[:cap]
|
|
1525
|
-
sum_cost =
|
|
1559
|
+
sum_cost = stable_sum(float(s.get("cost_usd") or 0.0) for s in rows_iter)
|
|
1526
1560
|
starts = [_parse_iso_utc(s["started_at"]) for s in rows_iter if s.get("started_at")]
|
|
1527
1561
|
start = min(starts) if starts else _utc_now()
|
|
1528
1562
|
end = max(starts) if starts else start
|
package/bin/_lib_view_models.py
CHANGED
|
@@ -102,6 +102,13 @@ def _load_lib(name: str):
|
|
|
102
102
|
return mod
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
# ``_lib_fmt``'s only intra-repo deps are the leaf kernels ``_cctally_core`` +
|
|
106
|
+
# ``_lib_display_tz`` (neither imports back), so loading it here is acyclic.
|
|
107
|
+
# ``stable_sum`` is the interpreter-stable
|
|
108
|
+
# float-summation chokepoint (math.fsum) used for output-bound totals.
|
|
109
|
+
stable_sum = _load_lib("_lib_fmt").stable_sum
|
|
110
|
+
|
|
111
|
+
|
|
105
112
|
# === Row dataclasses (Task 2: moved verbatim from _cctally_tui.py) =========
|
|
106
113
|
# Field order, types, and defaults match the originals byte-stable.
|
|
107
114
|
|
|
@@ -928,7 +935,7 @@ def build_trend_view(conn, *, now_utc, n=8, display_tz=None):
|
|
|
928
935
|
# least 3 samples qualify.
|
|
929
936
|
valid_dpps = [r.dollars_per_percent for r in rows
|
|
930
937
|
if r.dollars_per_percent is not None]
|
|
931
|
-
avg = (
|
|
938
|
+
avg = (stable_sum(valid_dpps) / len(valid_dpps)) if len(valid_dpps) >= 3 else None
|
|
932
939
|
|
|
933
940
|
# Issue #59 — pre-compute the 4-week-median-non-current dpp scalar
|
|
934
941
|
# the dashboard's Trend modal hero KV displays. Rule mirrors
|
|
@@ -1290,7 +1297,7 @@ def build_blocks_view_from_table_rows(
|
|
|
1290
1297
|
(``cmd_five_hour_blocks`` produces newest-first DESC).
|
|
1291
1298
|
"""
|
|
1292
1299
|
rows_seq = list(block_dicts)
|
|
1293
|
-
total_cost =
|
|
1300
|
+
total_cost = stable_sum(
|
|
1294
1301
|
float(d.get("total_cost_usd") or 0.0) for d in rows_seq
|
|
1295
1302
|
)
|
|
1296
1303
|
total_tok = sum(
|
package/bin/cctally
CHANGED
|
@@ -13,7 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import sys
|
|
15
15
|
|
|
16
|
-
__min_python_version__ = (3,
|
|
16
|
+
__min_python_version__ = (3, 11)
|
|
17
17
|
|
|
18
18
|
if sys.version_info < __min_python_version__:
|
|
19
19
|
print(
|
|
@@ -2720,7 +2720,19 @@ def _post_command_update_hooks(command: str | None, args) -> None:
|
|
|
2720
2720
|
``_spawn_background_update_check`` here would create ``config.json``,
|
|
2721
2721
|
``update-state.json``, and ``update.log`` on a fresh install where
|
|
2722
2722
|
the existing-install gate already makes the symlink work a no-op.
|
|
2723
|
-
Same rationale as doctor.
|
|
2723
|
+
Same rationale as doctor.
|
|
2724
|
+
|
|
2725
|
+
Test/CI seam: ``CCTALLY_DISABLE_UPDATE_CHECK`` short-circuits the whole
|
|
2726
|
+
hook (mirrors ``CCTALLY_DISABLE_DEV_AUTODETECT``). The background
|
|
2727
|
+
``_spawn_background_update_check`` is a DETACHED process that
|
|
2728
|
+
``mkdir``s APP_DIR to write ``update-state.json`` / ``update.log``; a
|
|
2729
|
+
fixture harness that runs a command and then asserts on APP_DIR (e.g.
|
|
2730
|
+
``cctally-setup-test``'s uninstall-purge "data dir is gone" check)
|
|
2731
|
+
otherwise races that detached writer re-creating the dir after the
|
|
2732
|
+
command returns. Setting this env var makes such harnesses
|
|
2733
|
+
deterministic without disabling the feature for real users."""
|
|
2734
|
+
if os.environ.get("CCTALLY_DISABLE_UPDATE_CHECK"):
|
|
2735
|
+
return
|
|
2724
2736
|
if command == "setup" and getattr(args, "uninstall", False):
|
|
2725
2737
|
return
|
|
2726
2738
|
if command == "doctor":
|
package/bin/cctally-npm-shim.js
CHANGED
|
@@ -25,7 +25,7 @@ const result = spawnSync(python, [scriptPath, ...process.argv.slice(2)], {
|
|
|
25
25
|
if (result.error) {
|
|
26
26
|
if (result.error.code === 'ENOENT') {
|
|
27
27
|
console.error(
|
|
28
|
-
`cctally: cannot find ${python}. Install Python 3.
|
|
28
|
+
`cctally: cannot find ${python}. Install Python 3.11+ ` +
|
|
29
29
|
'or set CCTALLY_PYTHON to its path.'
|
|
30
30
|
);
|
|
31
31
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.1",
|
|
4
4
|
"description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
|
|
5
5
|
"homepage": "https://github.com/omrikais/cctally",
|
|
6
6
|
"repository": {
|