cctally 1.96.2 → 1.97.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 +17 -0
- package/bin/_cctally_dashboard_envelope.py +14 -0
- package/bin/_cctally_dashboard_share.py +25 -5
- package/bin/_cctally_dashboard_sources.py +44 -23
- package/bin/_cctally_db.py +10 -0
- package/bin/_cctally_doctor.py +15 -10
- package/bin/_cctally_journal_repair.py +7 -6
- package/bin/_cctally_parser.py +47 -22
- package/bin/_cctally_tui.py +203 -34
- package/bin/_lib_dashboard_sources.py +376 -85
- package/bin/_lib_journal_router.py +14 -0
- package/bin/_lib_share.py +191 -36
- package/bin/cctally +35 -1
- package/dashboard/static/assets/index-BSESoPIK.css +1 -0
- package/dashboard/static/assets/index-DgsMz5hA.js +97 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-BRFMIN18.js +0 -97
- package/dashboard/static/assets/index-Crj7bzyj.css +0 -1
package/bin/_cctally_tui.py
CHANGED
|
@@ -334,6 +334,10 @@ def _sum_cost_for_range(*args, **kwargs):
|
|
|
334
334
|
return sys.modules["cctally"]._sum_cost_for_range(*args, **kwargs)
|
|
335
335
|
|
|
336
336
|
|
|
337
|
+
def _sum_cost_and_tokens_for_range(*args, **kwargs):
|
|
338
|
+
return sys.modules["cctally"]._sum_cost_and_tokens_for_range(*args, **kwargs)
|
|
339
|
+
|
|
340
|
+
|
|
337
341
|
def _compute_cost_for_weekref(*args, **kwargs):
|
|
338
342
|
return sys.modules["cctally"]._compute_cost_for_weekref(*args, **kwargs)
|
|
339
343
|
|
|
@@ -869,6 +873,11 @@ class TuiCurrentWeek:
|
|
|
869
873
|
# default `None` keeps fixture modules that construct TuiCurrentWeek
|
|
870
874
|
# directly (without this field) backwards-compatible.
|
|
871
875
|
five_hour_block: dict | None = None
|
|
876
|
+
# #556 S1 §3.3 — the current cycle's #104 token total, accumulated in the
|
|
877
|
+
# SAME pass that produces `spent_usd` so the two halves describe one entry
|
|
878
|
+
# set. Appended last with a default so fixture modules that construct
|
|
879
|
+
# `TuiCurrentWeek` positionally stay valid.
|
|
880
|
+
total_tokens: int = 0
|
|
872
881
|
|
|
873
882
|
|
|
874
883
|
# ---- View-model row dataclasses moved to bin/_lib_view_models.py ----
|
|
@@ -1607,7 +1616,11 @@ def _tui_build_current_week(
|
|
|
1607
1616
|
latest = samples[-1]
|
|
1608
1617
|
used_pct = float(latest[1])
|
|
1609
1618
|
five_hr_pct = float(latest[2]) if latest[2] is not None else None
|
|
1610
|
-
|
|
1619
|
+
# #556 S1 §3.3: one walk yields both halves. The range is whatever
|
|
1620
|
+
# `spent_usd` already used — taken AFTER `_apply_midweek_reset_override`
|
|
1621
|
+
# above, so a mid-week reset shortens the accumulation and the published
|
|
1622
|
+
# period together.
|
|
1623
|
+
spent, total_tokens = _sum_cost_and_tokens_for_range(
|
|
1611
1624
|
week_start_at, now_utc, mode="auto", skip_sync=skip_sync
|
|
1612
1625
|
)
|
|
1613
1626
|
dpp = (spent / used_pct) if used_pct > 0 else None
|
|
@@ -1678,6 +1691,7 @@ def _tui_build_current_week(
|
|
|
1678
1691
|
five_hour_block=_select_current_block_for_envelope(
|
|
1679
1692
|
conn, current_used_pct=used_pct, now_utc=now_utc,
|
|
1680
1693
|
),
|
|
1694
|
+
total_tokens=total_tokens,
|
|
1681
1695
|
)
|
|
1682
1696
|
|
|
1683
1697
|
|
|
@@ -2449,8 +2463,6 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
|
|
|
2449
2463
|
identity=(ordinal, raw.get("axis"), raw.get("threshold"), raw.get("alerted_at")),
|
|
2450
2464
|
))
|
|
2451
2465
|
|
|
2452
|
-
daily_total = daily.get("total_cost_usd", 0.0)
|
|
2453
|
-
daily_tokens = daily.get("total_tokens", 0)
|
|
2454
2466
|
budget_settings = _tui_source_copy(legacy.get("alerts_settings"))
|
|
2455
2467
|
if isinstance(budget_settings, dict):
|
|
2456
2468
|
# The legacy top-level settings mirror contains Codex capability flags
|
|
@@ -2465,8 +2477,15 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
|
|
|
2465
2477
|
budget_settings.pop(key, None)
|
|
2466
2478
|
return {
|
|
2467
2479
|
"hero": {
|
|
2468
|
-
|
|
2469
|
-
|
|
2480
|
+
# #556 S1 §3.4: current-cycle accounting, NOT the thirty-day
|
|
2481
|
+
# rollup. Both providers' `hero.cost_usd` / `hero.total_tokens`
|
|
2482
|
+
# now mean the same thing; the thirty-day figures keep their own
|
|
2483
|
+
# home in `periods.daily`. `None` when no current week resolves —
|
|
2484
|
+
# composition distinguishes an empty provider from an unresolved
|
|
2485
|
+
# cycle by the provider's availability, and a zero here would make
|
|
2486
|
+
# both read as observed spend.
|
|
2487
|
+
"cost_usd": current_week.get("spent_usd"),
|
|
2488
|
+
"total_tokens": current_week.get("total_tokens"),
|
|
2470
2489
|
"header": _tui_source_copy(legacy.get("header")),
|
|
2471
2490
|
"current_week": _tui_source_copy(current_week),
|
|
2472
2491
|
"forecast": _tui_source_copy(legacy.get("forecast")),
|
|
@@ -2499,15 +2518,41 @@ def _tui_project_claude_source_data(legacy_envelope: object) -> dict[str, object
|
|
|
2499
2518
|
}
|
|
2500
2519
|
|
|
2501
2520
|
|
|
2521
|
+
def _tui_claude_cycle_is_resolved(
|
|
2522
|
+
current_week: object, now_utc: dt.datetime,
|
|
2523
|
+
) -> bool:
|
|
2524
|
+
"""Whether Claude's current week resolves AND has not expired (#556 §4.1).
|
|
2525
|
+
|
|
2526
|
+
A stale percent observation does not enter this: the boundary can be
|
|
2527
|
+
perfectly resolved while the number that reports progress against it is an
|
|
2528
|
+
hour old.
|
|
2529
|
+
"""
|
|
2530
|
+
if not isinstance(current_week, dict):
|
|
2531
|
+
return False
|
|
2532
|
+
end = _tui_normalized_period_instant(current_week.get("reset_at_utc"))
|
|
2533
|
+
if end is None:
|
|
2534
|
+
return False
|
|
2535
|
+
return now_utc.astimezone(dt.timezone.utc) < dt.datetime.fromisoformat(end)
|
|
2536
|
+
|
|
2537
|
+
|
|
2502
2538
|
def _tui_claude_domain_freshness(
|
|
2503
2539
|
source_data: dict[str, object] | None,
|
|
2540
|
+
*,
|
|
2541
|
+
now_utc: dt.datetime,
|
|
2504
2542
|
) -> dict[str, str]:
|
|
2505
|
-
"""Derive Claude axes from its selected weekly snapshot evidence.
|
|
2543
|
+
"""Derive Claude's axes from its selected weekly snapshot evidence.
|
|
2544
|
+
|
|
2545
|
+
#556 S1 §4.1 repoints the two axes it publishes. ``quota`` carries the
|
|
2546
|
+
percent-OBSERVATION age, which is what it always described. ``hero`` now
|
|
2547
|
+
carries current-cycle ACCOUNTING resolvability: fresh while the boundary
|
|
2548
|
+
resolves and has not expired, so the backward-looking counters inside it
|
|
2549
|
+
are publishable. Pointing ``hero`` at the percent age is what kept All's
|
|
2550
|
+
combined caveat permanently on, because that clock's 90-second bound is
|
|
2551
|
+
forty times tighter than the Codex weekly one it was joined with.
|
|
2506
2552
|
|
|
2507
2553
|
The legacy current-week label has a third presentation-only ``aging``
|
|
2508
2554
|
state. The source contract deliberately keeps the frozen fresh/stale
|
|
2509
|
-
vocabulary: only the exact stale label moves the
|
|
2510
|
-
Missing evidence remains a capability/availability concern.
|
|
2555
|
+
vocabulary: only the exact stale label moves the quota axis.
|
|
2511
2556
|
"""
|
|
2512
2557
|
data = source_data if isinstance(source_data, dict) else {}
|
|
2513
2558
|
hero = data.get("hero")
|
|
@@ -2516,12 +2561,15 @@ def _tui_claude_domain_freshness(
|
|
|
2516
2561
|
current_week.get("freshness")
|
|
2517
2562
|
if isinstance(current_week, dict) else None
|
|
2518
2563
|
)
|
|
2519
|
-
|
|
2564
|
+
quota = (
|
|
2520
2565
|
"stale"
|
|
2521
2566
|
if isinstance(freshness, dict) and freshness.get("label") == "stale"
|
|
2522
2567
|
else "fresh"
|
|
2523
2568
|
)
|
|
2524
|
-
|
|
2569
|
+
accounting = (
|
|
2570
|
+
"fresh" if _tui_claude_cycle_is_resolved(current_week, now_utc) else "stale"
|
|
2571
|
+
)
|
|
2572
|
+
return {"hero": accounting, "quota": quota, "sessions": "fresh"}
|
|
2525
2573
|
|
|
2526
2574
|
|
|
2527
2575
|
def _refresh_claude_source_clock(
|
|
@@ -2536,29 +2584,41 @@ def _refresh_claude_source_clock(
|
|
|
2536
2584
|
return state
|
|
2537
2585
|
if now_utc.tzinfo is None or now_utc.utcoffset() is None:
|
|
2538
2586
|
raise ValueError("now_utc must be timezone-aware")
|
|
2539
|
-
captured = getattr(current_week, "latest_snapshot_at", None)
|
|
2540
|
-
if not isinstance(captured, dt.datetime):
|
|
2541
|
-
return state
|
|
2542
|
-
if captured.tzinfo is None or captured.utcoffset() is None:
|
|
2543
|
-
captured = captured.replace(tzinfo=dt.timezone.utc)
|
|
2544
|
-
age_seconds = max(
|
|
2545
|
-
0.0,
|
|
2546
|
-
(
|
|
2547
|
-
now_utc.astimezone(dt.timezone.utc)
|
|
2548
|
-
- captured.astimezone(dt.timezone.utc)
|
|
2549
|
-
).total_seconds(),
|
|
2550
|
-
)
|
|
2551
|
-
try:
|
|
2552
|
-
freshness_config = _get_oauth_usage_config(raw_config)
|
|
2553
|
-
except Exception:
|
|
2554
|
-
freshness_config = _OAUTH_USAGE_DEFAULTS
|
|
2555
|
-
weekly = (
|
|
2556
|
-
"stale"
|
|
2557
|
-
if _freshness_label(age_seconds, freshness_config) == "stale"
|
|
2558
|
-
else "fresh"
|
|
2559
|
-
)
|
|
2560
2587
|
domain_freshness = dict(state.domain_freshness or {})
|
|
2561
|
-
|
|
2588
|
+
# #556 S1 §4.1: the two axes advance from DIFFERENT evidence. Both legs run
|
|
2589
|
+
# on every tick — this used to write one percent-derived label to both, and
|
|
2590
|
+
# it also returned early on a missing capture, which left the accounting
|
|
2591
|
+
# axis frozen at its build-time value for the whole idle stretch.
|
|
2592
|
+
week_end = getattr(current_week, "week_end_at", None)
|
|
2593
|
+
domain_freshness["hero"] = (
|
|
2594
|
+
"fresh"
|
|
2595
|
+
if isinstance(week_end, dt.datetime)
|
|
2596
|
+
and now_utc.astimezone(dt.timezone.utc) < (
|
|
2597
|
+
week_end if week_end.tzinfo is not None
|
|
2598
|
+
else week_end.replace(tzinfo=dt.timezone.utc)
|
|
2599
|
+
).astimezone(dt.timezone.utc)
|
|
2600
|
+
else "stale"
|
|
2601
|
+
)
|
|
2602
|
+
captured = getattr(current_week, "latest_snapshot_at", None)
|
|
2603
|
+
if isinstance(captured, dt.datetime):
|
|
2604
|
+
if captured.tzinfo is None or captured.utcoffset() is None:
|
|
2605
|
+
captured = captured.replace(tzinfo=dt.timezone.utc)
|
|
2606
|
+
age_seconds = max(
|
|
2607
|
+
0.0,
|
|
2608
|
+
(
|
|
2609
|
+
now_utc.astimezone(dt.timezone.utc)
|
|
2610
|
+
- captured.astimezone(dt.timezone.utc)
|
|
2611
|
+
).total_seconds(),
|
|
2612
|
+
)
|
|
2613
|
+
try:
|
|
2614
|
+
freshness_config = _get_oauth_usage_config(raw_config)
|
|
2615
|
+
except Exception:
|
|
2616
|
+
freshness_config = _OAUTH_USAGE_DEFAULTS
|
|
2617
|
+
domain_freshness["quota"] = (
|
|
2618
|
+
"stale"
|
|
2619
|
+
if _freshness_label(age_seconds, freshness_config) == "stale"
|
|
2620
|
+
else "fresh"
|
|
2621
|
+
)
|
|
2562
2622
|
refreshed = dataclasses.replace(
|
|
2563
2623
|
state,
|
|
2564
2624
|
domain_freshness=domain_freshness,
|
|
@@ -2566,6 +2626,100 @@ def _refresh_claude_source_clock(
|
|
|
2566
2626
|
return state if refreshed == state else refreshed
|
|
2567
2627
|
|
|
2568
2628
|
|
|
2629
|
+
def _tui_normalized_period_instant(value: object) -> str | None:
|
|
2630
|
+
"""One canonical UTC spelling for a published cycle bound (#556 S1 §3.6).
|
|
2631
|
+
|
|
2632
|
+
Legacy rows carry local-offset spellings and current rows carry `Z`, so the
|
|
2633
|
+
raw text is not an identity: hashing it would rebuild the Claude source on
|
|
2634
|
+
a spelling change that moved no boundary. Returns ``None`` for anything
|
|
2635
|
+
that does not parse as an instant.
|
|
2636
|
+
"""
|
|
2637
|
+
if not isinstance(value, str) or not value:
|
|
2638
|
+
return None
|
|
2639
|
+
try:
|
|
2640
|
+
return parse_iso_datetime(value, "period bound").astimezone(
|
|
2641
|
+
dt.timezone.utc).isoformat()
|
|
2642
|
+
except ValueError:
|
|
2643
|
+
return None
|
|
2644
|
+
|
|
2645
|
+
|
|
2646
|
+
def _tui_claude_period_identity(claude_data: object) -> str:
|
|
2647
|
+
"""The effective current-cycle bounds, as a version fragment (§3.6).
|
|
2648
|
+
|
|
2649
|
+
`claude_version` carried the database signatures, the semantics identity
|
|
2650
|
+
and the accounts digest but no period, and `reuse_coherent_source_state`
|
|
2651
|
+
returns the exact prior object on an unchanged version — so a week that
|
|
2652
|
+
rolled over with no database movement could be republished as current.
|
|
2653
|
+
The snapshot-level `_snapshot_period_rolled_over` gate already covers the
|
|
2654
|
+
undecorated case; this is the second layer, so provider reuse cannot
|
|
2655
|
+
outlive the cycle even if that gate is later changed.
|
|
2656
|
+
|
|
2657
|
+
The bounds are read from the SAME resolved object the aggregation used
|
|
2658
|
+
(`current_week`, stored after `_apply_midweek_reset_override`), so a stale
|
|
2659
|
+
identity can never accompany a shortened range.
|
|
2660
|
+
|
|
2661
|
+
The literal `"none"` degradation is deliberate and is NOT an oversight. A
|
|
2662
|
+
caller that holds no `claude_data` — the capture and QA paths — contributes
|
|
2663
|
+
a constant fragment, so layer two adds no rollover protection there. Those
|
|
2664
|
+
paths are still covered by the snapshot-level `_snapshot_period_rolled_over`
|
|
2665
|
+
gate, which is layer one and is what production reuse actually runs behind.
|
|
2666
|
+
"""
|
|
2667
|
+
data = claude_data if isinstance(claude_data, dict) else {}
|
|
2668
|
+
hero = data.get("hero")
|
|
2669
|
+
current_week = hero.get("current_week") if isinstance(hero, dict) else None
|
|
2670
|
+
if not isinstance(current_week, dict):
|
|
2671
|
+
return "none"
|
|
2672
|
+
start = _tui_normalized_period_instant(current_week.get("week_start_at"))
|
|
2673
|
+
end = _tui_normalized_period_instant(current_week.get("reset_at_utc"))
|
|
2674
|
+
if start is None and end is None:
|
|
2675
|
+
return "none"
|
|
2676
|
+
return f"{start or ''}~{end or ''}"
|
|
2677
|
+
|
|
2678
|
+
|
|
2679
|
+
def _tui_resolve_account_scope(stats_conn, provider: str) -> dict[str, object] | None:
|
|
2680
|
+
"""Read one provider's authoritative REAL account count (#556 S1 §3.8).
|
|
2681
|
+
|
|
2682
|
+
Returns ``None`` when the count cannot be read. That is the FAIL-CLOSED
|
|
2683
|
+
path: composition withholds the combined figure rather than assuming a
|
|
2684
|
+
single account. It is deliberately the opposite of the builders' existing
|
|
2685
|
+
swallow-and-degrade behaviour for the accounts WIRE, where the fallback
|
|
2686
|
+
loses decoration styling; here it would publish a wrong number.
|
|
2687
|
+
|
|
2688
|
+
The catch is narrow on purpose. ``real_account_count`` is one SELECT over
|
|
2689
|
+
``accounts`` — no file I/O, no label lookups — so ``sqlite3.Error`` covers
|
|
2690
|
+
every failure the CALL can produce, and anything else is a real defect that
|
|
2691
|
+
must not be swallowed into a silent withholding. ``ImportError`` is caught
|
|
2692
|
+
alongside it because the deferred import is a different failure class from
|
|
2693
|
+
the query: an unimportable module is exactly the "count cannot be read"
|
|
2694
|
+
state this function exists to report, and letting it propagate would fail
|
|
2695
|
+
the whole dashboard tick instead of withholding one figure.
|
|
2696
|
+
"""
|
|
2697
|
+
try:
|
|
2698
|
+
import _cctally_account
|
|
2699
|
+
return {
|
|
2700
|
+
"real_account_count": int(
|
|
2701
|
+
_cctally_account.real_account_count(stats_conn, provider)
|
|
2702
|
+
),
|
|
2703
|
+
}
|
|
2704
|
+
except (ImportError, sqlite3.Error):
|
|
2705
|
+
return None
|
|
2706
|
+
|
|
2707
|
+
|
|
2708
|
+
def _tui_with_account_scope(
|
|
2709
|
+
state: SourceDashboardState, scope: dict[str, object] | None,
|
|
2710
|
+
) -> SourceDashboardState:
|
|
2711
|
+
"""Attach ``scope`` without breaking reuse-by-identity.
|
|
2712
|
+
|
|
2713
|
+
``reuse_coherent_source_state`` returns the EXACT prior object, and callers
|
|
2714
|
+
assert on that identity, so an unconditional ``dataclasses.replace`` would
|
|
2715
|
+
defeat reuse on every tick. Frozen mappings compare equal to plain dicts,
|
|
2716
|
+
so an unchanged count returns the prior object untouched.
|
|
2717
|
+
"""
|
|
2718
|
+
if state.account_scope == scope:
|
|
2719
|
+
return state
|
|
2720
|
+
return dataclasses.replace(state, account_scope=scope)
|
|
2721
|
+
|
|
2722
|
+
|
|
2569
2723
|
def _tui_build_source_bundle(
|
|
2570
2724
|
*,
|
|
2571
2725
|
stats_conn,
|
|
@@ -2659,11 +2813,15 @@ def _tui_build_source_bundle(
|
|
|
2659
2813
|
f"{signature.codex_physical_mutation_seq}:{stats_digest}:"
|
|
2660
2814
|
f"{semantics.codex_identity}{_acct_suffix}{_backlog_suffix}"
|
|
2661
2815
|
)
|
|
2816
|
+
# #556 S1 §3.6: normalized period identity, so a nominal week rollover
|
|
2817
|
+
# invalidates the generation even when no database signature moved.
|
|
2818
|
+
_period_identity = _tui_claude_period_identity(claude_data)
|
|
2662
2819
|
claude_version = (
|
|
2663
2820
|
f"claude:{signature.max_entry_id}:{signature.entry_mutation_seq}:"
|
|
2664
2821
|
f"{signature.max_wus_id}:{signature.max_wcs_id}:"
|
|
2665
2822
|
f"{signature.reset_sig[0]}:{signature.reset_sig[1]}:"
|
|
2666
|
-
f"{signature.generation}:{semantics.claude_identity}
|
|
2823
|
+
f"{signature.generation}:{semantics.claude_identity}"
|
|
2824
|
+
f":p{_period_identity}{_acct_suffix}"
|
|
2667
2825
|
)
|
|
2668
2826
|
prior_claude = (
|
|
2669
2827
|
prior_bundle.sources.get("claude")
|
|
@@ -2754,7 +2912,9 @@ def _tui_build_source_bundle(
|
|
|
2754
2912
|
),
|
|
2755
2913
|
**({"accounts": claude_accounts} if claude_accounts else {}),
|
|
2756
2914
|
},
|
|
2757
|
-
domain_freshness=_tui_claude_domain_freshness(
|
|
2915
|
+
domain_freshness=_tui_claude_domain_freshness(
|
|
2916
|
+
claude_data, now_utc=now_utc,
|
|
2917
|
+
),
|
|
2758
2918
|
)
|
|
2759
2919
|
if codex_ingest_failed:
|
|
2760
2920
|
warning = SourceDashboardWarning(
|
|
@@ -2838,6 +2998,15 @@ def _tui_build_source_bundle(
|
|
|
2838
2998
|
# guard, so a freshly built state is handed back unchanged. Claude is
|
|
2839
2999
|
# deliberately untouched.
|
|
2840
3000
|
codex = refresh_codex_source_clock(codex, now_utc=now_utc)
|
|
3001
|
+
# #556 S1 §3.8: the decoration fact reaches composition as authoritative
|
|
3002
|
+
# server-only metadata. It is attached HERE, after every build / reuse /
|
|
3003
|
+
# degrade / clock branch, so no branch can publish a state without it.
|
|
3004
|
+
claude = _tui_with_account_scope(
|
|
3005
|
+
claude, _tui_resolve_account_scope(stats_conn, "claude"),
|
|
3006
|
+
)
|
|
3007
|
+
codex = _tui_with_account_scope(
|
|
3008
|
+
codex, _tui_resolve_account_scope(stats_conn, "codex"),
|
|
3009
|
+
)
|
|
2841
3010
|
combined = compose_all_state(claude, codex)
|
|
2842
3011
|
bundle = SourceDashboardBundle(
|
|
2843
3012
|
source_schema_version=SOURCE_SCHEMA_VERSION,
|