cctally 1.73.0 → 1.75.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 +24 -0
- package/bin/_cctally_cache.py +35 -5
- package/bin/_cctally_config.py +3 -3
- package/bin/_cctally_core.py +12 -3
- package/bin/_cctally_dashboard.py +57 -5
- package/bin/_cctally_dashboard_conversation.py +2 -2
- package/bin/_cctally_dashboard_share.py +38 -3
- package/bin/_cctally_dashboard_sources.py +1199 -78
- package/bin/_cctally_db.py +798 -2
- package/bin/_cctally_doctor.py +27 -4
- package/bin/_cctally_parser.py +100 -6
- package/bin/_cctally_percent_breakdown.py +59 -34
- package/bin/_cctally_quota.py +311 -45
- package/bin/_cctally_record.py +52 -1
- package/bin/_cctally_refresh.py +26 -0
- package/bin/_cctally_source_analytics.py +288 -3
- package/bin/_cctally_statusline.py +3 -3
- package/bin/_cctally_tui.py +20 -14
- package/bin/_lib_aggregators.py +55 -34
- package/bin/_lib_cache_report.py +25 -9
- package/bin/_lib_conversation_retention.py +22 -2
- package/bin/_lib_dashboard_sources.py +20 -7
- package/bin/_lib_doctor.py +48 -4
- package/bin/_lib_jsonl.py +11 -0
- package/bin/_lib_pricing.py +7 -4
- package/bin/_lib_pricing_check.py +7 -1
- package/bin/_lib_quota.py +12 -11
- package/bin/_lib_view_models.py +50 -14
- package/bin/cctally +27 -1
- package/dashboard/static/assets/index-fZRxsSf5.js +80 -0
- package/dashboard/static/assets/{index-D2nwo_ln.css → index-yftBNnLR.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-BWadAHxC.js +0 -80
|
@@ -272,8 +272,9 @@ def unavailable_source_state(
|
|
|
272
272
|
|
|
273
273
|
def _coherent_provider(state: SourceDashboardState) -> bool:
|
|
274
274
|
return (
|
|
275
|
-
state.availability in ("ok", "empty")
|
|
275
|
+
state.availability in ("ok", "empty", "partial")
|
|
276
276
|
and state.freshness == "fresh"
|
|
277
|
+
and bool(state.data_version)
|
|
277
278
|
and state.data is not None
|
|
278
279
|
)
|
|
279
280
|
|
|
@@ -302,6 +303,10 @@ def _combined_metrics(
|
|
|
302
303
|
) -> Mapping[str, object] | None:
|
|
303
304
|
if not (_coherent_provider(claude) and _coherent_provider(codex)):
|
|
304
305
|
return None
|
|
306
|
+
for state in (claude, codex):
|
|
307
|
+
hero_capability = state.capabilities.get("hero")
|
|
308
|
+
if hero_capability is None or hero_capability.status not in {"supported", "derived"}:
|
|
309
|
+
return None
|
|
305
310
|
try:
|
|
306
311
|
claude_hero = claude.data["hero"]
|
|
307
312
|
codex_hero = codex.data["hero"]
|
|
@@ -360,19 +365,27 @@ def compose_all_state(
|
|
|
360
365
|
if claude.source != "claude" or codex.source != "codex":
|
|
361
366
|
raise ValueError("all composition requires Claude and Codex provider states")
|
|
362
367
|
combined = _combined_metrics(claude, codex)
|
|
363
|
-
|
|
364
|
-
if
|
|
368
|
+
providers_coherent = _coherent_provider(claude) and _coherent_provider(codex)
|
|
369
|
+
if providers_coherent:
|
|
365
370
|
availability: Availability = (
|
|
366
|
-
"
|
|
367
|
-
if
|
|
368
|
-
else
|
|
371
|
+
"partial"
|
|
372
|
+
if combined is None or "partial" in (claude.availability, codex.availability)
|
|
373
|
+
else (
|
|
374
|
+
"empty"
|
|
375
|
+
if claude.availability == "empty" and codex.availability == "empty"
|
|
376
|
+
else "ok"
|
|
377
|
+
)
|
|
369
378
|
)
|
|
370
379
|
freshness: Freshness = "fresh"
|
|
371
380
|
else:
|
|
372
381
|
availability = "partial"
|
|
373
382
|
freshness = "stale"
|
|
374
383
|
version_material = json.dumps(
|
|
375
|
-
[
|
|
384
|
+
[
|
|
385
|
+
claude.data_version, claude.availability, claude.freshness,
|
|
386
|
+
codex.data_version, codex.availability, codex.freshness,
|
|
387
|
+
combined is not None,
|
|
388
|
+
],
|
|
376
389
|
separators=(",", ":"),
|
|
377
390
|
).encode("utf-8")
|
|
378
391
|
data_version = "all:" + hashlib.sha256(version_material).hexdigest()[:24]
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -218,6 +218,12 @@ class DoctorState:
|
|
|
218
218
|
# arbitration pipeline. None means the gather could not inspect it;
|
|
219
219
|
# absent transport/tombstones are normal when Claude is closed.
|
|
220
220
|
statusline_pipeline: Optional[dict] = None
|
|
221
|
+
# #312: all-history, root-qualified Codex accounting metadata partition.
|
|
222
|
+
# The I/O layer reports an explicit error instead of silently converting a
|
|
223
|
+
# failed health query into zero rows, so doctor can distinguish an empty
|
|
224
|
+
# retained corpus from unreadable metadata.
|
|
225
|
+
codex_project_metadata_health: Optional[dict] = None
|
|
226
|
+
codex_project_metadata_error: Optional[str] = None
|
|
221
227
|
|
|
222
228
|
|
|
223
229
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -876,6 +882,43 @@ def _check_data_codex_cache(s: DoctorState) -> CheckResult:
|
|
|
876
882
|
)
|
|
877
883
|
|
|
878
884
|
|
|
885
|
+
def _check_data_codex_project_metadata(s: DoctorState) -> CheckResult:
|
|
886
|
+
"""Report the identity-safe all-history Codex metadata partition."""
|
|
887
|
+
if s.codex_project_metadata_error is not None:
|
|
888
|
+
return CheckResult(
|
|
889
|
+
id="data.codex_project_metadata", title="Codex project metadata",
|
|
890
|
+
severity="fail", summary="metadata health could not be read",
|
|
891
|
+
remediation="Run `cctally doctor --verbose` after checking cache.db.",
|
|
892
|
+
details={"error": s.codex_project_metadata_error},
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
health = s.codex_project_metadata_health or {}
|
|
896
|
+
total_rows = int(health.get("total_rows", 0))
|
|
897
|
+
qualified_rows = int(health.get("qualified_rows", 0))
|
|
898
|
+
missing_key_rows = int(health.get("missing_conversation_key_rows", 0))
|
|
899
|
+
missing_join_rows = int(health.get("missing_thread_join_rows", 0))
|
|
900
|
+
incomplete_rows = missing_key_rows + missing_join_rows
|
|
901
|
+
details = {
|
|
902
|
+
"total_rows": total_rows,
|
|
903
|
+
"qualified_rows": qualified_rows,
|
|
904
|
+
"missing_conversation_key_rows": missing_key_rows,
|
|
905
|
+
"missing_thread_join_rows": missing_join_rows,
|
|
906
|
+
"incomplete_rows": incomplete_rows,
|
|
907
|
+
}
|
|
908
|
+
if incomplete_rows:
|
|
909
|
+
return CheckResult(
|
|
910
|
+
id="data.codex_project_metadata", title="Codex project metadata",
|
|
911
|
+
severity="warn",
|
|
912
|
+
summary=f"{incomplete_rows} accounting row(s) need metadata repair",
|
|
913
|
+
remediation="Run `cctally cache-sync --source codex --rebuild`.",
|
|
914
|
+
details=details,
|
|
915
|
+
)
|
|
916
|
+
return CheckResult(
|
|
917
|
+
id="data.codex_project_metadata", title="Codex project metadata",
|
|
918
|
+
severity="ok", summary="qualified", remediation=None, details=details,
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
|
|
879
922
|
def _check_data_codex_quota(s: DoctorState) -> CheckResult:
|
|
880
923
|
"""Report local physical Codex quota evidence without fabricating a window."""
|
|
881
924
|
raw_windows = s.codex_quota_windows or []
|
|
@@ -1607,10 +1650,10 @@ def _check_db_integrity(s: DoctorState) -> CheckResult:
|
|
|
1607
1650
|
summary=f"stats.db quick_check: {s.stats_db_quick_check}",
|
|
1608
1651
|
remediation=(
|
|
1609
1652
|
"stats.db (the non-re-derivable DB) reports corruption. "
|
|
1610
|
-
"
|
|
1611
|
-
"`
|
|
1612
|
-
"
|
|
1613
|
-
"
|
|
1653
|
+
"Stop the dashboard and other cctally processes, then run "
|
|
1654
|
+
"`cctally db repair --db stats --yes`. The command preserves "
|
|
1655
|
+
"a backup of the corrupt original before replacing anything. "
|
|
1656
|
+
"Do not copy, restore, move, or delete the live DB by hand."),
|
|
1614
1657
|
details=details,
|
|
1615
1658
|
)
|
|
1616
1659
|
if s.cache_db_quick_check is not None and s.cache_db_quick_check != "ok":
|
|
@@ -1722,6 +1765,7 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
1722
1765
|
("data.statusline_pipeline", "_check_statusline_pipeline"),
|
|
1723
1766
|
("data.cache_sync_state", "_check_data_cache_sync_state"),
|
|
1724
1767
|
("data.codex_cache", "_check_data_codex_cache"),
|
|
1768
|
+
("data.codex_project_metadata", "_check_data_codex_project_metadata"),
|
|
1725
1769
|
("data.codex_quota", "_check_data_codex_quota"),
|
|
1726
1770
|
("data.parse_health", "_check_data_parse_health"),
|
|
1727
1771
|
("data.forked_buckets", "_check_data_forked_buckets"),
|
package/bin/_lib_jsonl.py
CHANGED
|
@@ -720,6 +720,14 @@ def _accounting_from_record(
|
|
|
720
720
|
_codex_skip(state, "no-last-token-usage")
|
|
721
721
|
return None, last_total_tokens, filename_session_id_warned
|
|
722
722
|
|
|
723
|
+
# Forked/subagent rollouts can begin with a copied slice of their parent's
|
|
724
|
+
# physical history before the child's first model-bearing turn_context.
|
|
725
|
+
# Those records remain in the physical event stream, but projecting them
|
|
726
|
+
# into accounting would duplicate the parent's usage under model="unknown".
|
|
727
|
+
if (state.model is None and state.thread is not None
|
|
728
|
+
and state.thread.parent_thread_id is not None):
|
|
729
|
+
return None, last_total_tokens, filename_session_id_warned
|
|
730
|
+
|
|
723
731
|
total_token_usage = info.get("total_token_usage")
|
|
724
732
|
if isinstance(total_token_usage, dict):
|
|
725
733
|
try:
|
|
@@ -839,6 +847,9 @@ def _iter_codex_fused_records_with_offsets(
|
|
|
839
847
|
session_id = _codex_string(payload.get("id"))
|
|
840
848
|
if session_id is not None:
|
|
841
849
|
state.session_id = session_id
|
|
850
|
+
session_model = _codex_string(payload.get("model"))
|
|
851
|
+
if session_model is not None:
|
|
852
|
+
state.model = session_model.strip()
|
|
842
853
|
thread_update = _thread_metadata_from_session_meta(
|
|
843
854
|
payload, path_str, source_root_key
|
|
844
855
|
)
|
package/bin/_lib_pricing.py
CHANGED
|
@@ -53,7 +53,7 @@ def _chip_for_model(name: str) -> str:
|
|
|
53
53
|
# Date the embedded pricing snapshots below were last verified against
|
|
54
54
|
# vendor sources. Bump whenever CLAUDE_MODEL_PRICING / CODEX_MODEL_PRICING
|
|
55
55
|
# is synced. Read by `pricing-check` + the release pre-flight staleness nudge.
|
|
56
|
-
PRICING_SNAPSHOT_DATE = "2026-07-
|
|
56
|
+
PRICING_SNAPSHOT_DATE = "2026-07-19"
|
|
57
57
|
PRICING_STALENESS_DAYS = 60 # release pre-flight WARNs past this age
|
|
58
58
|
|
|
59
59
|
# Canonical machine-readable pricing source (Claude values + Codex values).
|
|
@@ -99,7 +99,7 @@ PRICING_DRIFT_ALLOWLIST: list[dict] = [
|
|
|
99
99
|
|
|
100
100
|
# Anthropic API pricing snapshot:
|
|
101
101
|
# - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
|
|
102
|
-
# - Captured: 2026-07-
|
|
102
|
+
# - Captured: 2026-07-19 (see PRICING_SNAPSHOT_DATE)
|
|
103
103
|
# - Verified by maintainer against docs.claude.com/en/docs/about-claude/pricing;
|
|
104
104
|
# update in PRs touching this table.
|
|
105
105
|
# 2026-06-10: added claude-fable-5 ($10/$50 per MTok; 1M context, no
|
|
@@ -321,8 +321,8 @@ _unknown_model_warnings: set[str] = set()
|
|
|
321
321
|
#
|
|
322
322
|
# Codex (OpenAI) API pricing snapshot:
|
|
323
323
|
# - Source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json
|
|
324
|
-
# - Captured: 2026-
|
|
325
|
-
# - As of the 2026-
|
|
324
|
+
# - Captured: 2026-07-19 (see PRICING_SNAPSHOT_DATE)
|
|
325
|
+
# - As of the 2026-07-19 sync this carries every openai-provider
|
|
326
326
|
# gpt-5* model the LiteLLM snapshot lists, so `pricing-check`'s scope finds
|
|
327
327
|
# nothing missing. Models absent from this table still fall back to `gpt-5`
|
|
328
328
|
# pricing with isFallback=true (matches upstream's LEGACY_FALLBACK_MODEL
|
|
@@ -332,6 +332,9 @@ _unknown_model_warnings: set[str] = set()
|
|
|
332
332
|
# `pricing-check` as missing_from_us (gpt-5.6-sol had real usage falling back
|
|
333
333
|
# to gpt-5). Exact model_prices_and_context_window.json values, each with the
|
|
334
334
|
# above-272k tier (max_input_tokens 1050000).
|
|
335
|
+
# 2026-07-19: verified all tracked values and the complete openai-provider
|
|
336
|
+
# gpt-5* model set against the live LiteLLM snapshot; no named table entries
|
|
337
|
+
# were missing. Official OpenAI model pages were used as the vendor cross-check.
|
|
335
338
|
#
|
|
336
339
|
# Billing rules:
|
|
337
340
|
# - reasoning_output_tokens is billed at the *output* rate (matches
|
|
@@ -34,11 +34,17 @@ class DriftResult:
|
|
|
34
34
|
ahead_of_litellm: list[str] # informational; never actionable
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
_CODEX_UNATTRIBUTED_MODEL = "unknown"
|
|
38
|
+
|
|
39
|
+
|
|
37
40
|
def classify_coverage(observed, resolve_claude, is_codex_fallback) -> list[CoverageGap]:
|
|
38
41
|
"""observed: iterable of (provider, model, entry_count, token_total).
|
|
39
42
|
|
|
40
43
|
Claude model with resolve_claude(model) is None -> kind="unpriced".
|
|
41
44
|
Codex model with is_codex_fallback(model) True -> kind="fallback".
|
|
45
|
+
The ingest-only Codex ``unknown`` sentinel is skipped because it records
|
|
46
|
+
missing model metadata, not a model identifier that can be added to the
|
|
47
|
+
embedded pricing table.
|
|
42
48
|
Priced models produce no gap. Order preserved.
|
|
43
49
|
"""
|
|
44
50
|
gaps: list[CoverageGap] = []
|
|
@@ -46,7 +52,7 @@ def classify_coverage(observed, resolve_claude, is_codex_fallback) -> list[Cover
|
|
|
46
52
|
if provider == "claude":
|
|
47
53
|
if resolve_claude(model) is None:
|
|
48
54
|
gaps.append(CoverageGap("claude", model, "unpriced", entry_count, token_total))
|
|
49
|
-
elif provider == "codex":
|
|
55
|
+
elif provider == "codex" and model != _CODEX_UNATTRIBUTED_MODEL:
|
|
50
56
|
if is_codex_fallback(model):
|
|
51
57
|
gaps.append(CoverageGap("codex", model, "fallback", entry_count, token_total))
|
|
52
58
|
return gaps
|
package/bin/_lib_quota.py
CHANGED
|
@@ -407,7 +407,7 @@ def _single_identity_history(observations: Iterable[QuotaObservation]) -> QuotaH
|
|
|
407
407
|
def forecast_quota(
|
|
408
408
|
observations: Iterable[QuotaObservation], as_of: dt.datetime,
|
|
409
409
|
) -> QuotaForecast:
|
|
410
|
-
"""
|
|
410
|
+
"""Forecast reset-scoped quota use from elapsed native-cycle pace."""
|
|
411
411
|
_require_aware(as_of, "as_of")
|
|
412
412
|
history = _single_identity_history(observations)
|
|
413
413
|
if history is None:
|
|
@@ -423,28 +423,29 @@ def forecast_quota(
|
|
|
423
423
|
if observation.resets_at == baseline.resets_at and observation.captured_at <= as_of
|
|
424
424
|
)
|
|
425
425
|
points = tuple(sorted(points, key=physical_order_key))
|
|
426
|
-
usable_elapsed_seconds = 0.0
|
|
427
|
-
positive_delta_percent = 0.0
|
|
428
426
|
sample_count = 0
|
|
429
427
|
for prior, current in zip(points, points[1:]):
|
|
430
428
|
elapsed_seconds = (current.captured_at - prior.captured_at).total_seconds()
|
|
431
429
|
delta_percent = current.used_percent - prior.used_percent
|
|
432
430
|
if elapsed_seconds > 0 and delta_percent > 0:
|
|
433
|
-
usable_elapsed_seconds += elapsed_seconds
|
|
434
|
-
positive_delta_percent += delta_percent
|
|
435
431
|
sample_count += 1
|
|
436
432
|
|
|
437
433
|
remaining_seconds = max(0, int((baseline.resets_at - as_of).total_seconds()))
|
|
438
|
-
|
|
439
|
-
|
|
434
|
+
native_window_seconds = baseline.identity.window_minutes * 60
|
|
435
|
+
cycle_start = baseline.resets_at - dt.timedelta(minutes=baseline.identity.window_minutes)
|
|
436
|
+
cycle_elapsed_seconds = min(
|
|
437
|
+
float(native_window_seconds),
|
|
438
|
+
max(0.0, (as_of - cycle_start).total_seconds()),
|
|
439
|
+
)
|
|
440
|
+
if sample_count > 0 and cycle_elapsed_seconds > 0:
|
|
441
|
+
rate = baseline.used_percent / (cycle_elapsed_seconds / 3600)
|
|
440
442
|
projected = min(100.0, max(
|
|
441
443
|
baseline.used_percent,
|
|
442
444
|
baseline.used_percent + rate * (remaining_seconds / 3600),
|
|
443
445
|
))
|
|
444
|
-
|
|
445
|
-
if sample_count >= 4 and usable_elapsed_seconds >= native_window_seconds * 0.25:
|
|
446
|
+
if sample_count >= 4 and cycle_elapsed_seconds >= native_window_seconds * 0.25:
|
|
446
447
|
confidence: Literal["high", "medium", "low"] = "high"
|
|
447
|
-
elif sample_count >= 2 and
|
|
448
|
+
elif sample_count >= 2 and cycle_elapsed_seconds >= native_window_seconds * 0.10:
|
|
448
449
|
confidence = "medium"
|
|
449
450
|
else:
|
|
450
451
|
confidence = "low"
|
|
@@ -469,7 +470,7 @@ def forecast_quota(
|
|
|
469
470
|
resets_at=baseline.resets_at,
|
|
470
471
|
remaining_seconds=remaining_seconds,
|
|
471
472
|
sample_count=sample_count,
|
|
472
|
-
sample_span_seconds=int(
|
|
473
|
+
sample_span_seconds=int(cycle_elapsed_seconds) if sample_count > 0 else 0,
|
|
473
474
|
confidence=confidence,
|
|
474
475
|
)
|
|
475
476
|
|
package/bin/_lib_view_models.py
CHANGED
|
@@ -1884,20 +1884,8 @@ def build_codex_weekly_view(entries, *, now_utc, tz_name=None,
|
|
|
1884
1884
|
)
|
|
1885
1885
|
|
|
1886
1886
|
|
|
1887
|
-
def
|
|
1888
|
-
"""
|
|
1889
|
-
|
|
1890
|
-
``rows`` order mirrors the aggregator: descending by
|
|
1891
|
-
``last_activity`` (upstream parity).
|
|
1892
|
-
``cmd_codex_session`` reverses to ASC when ``--order asc``.
|
|
1893
|
-
|
|
1894
|
-
``period_start`` is set to ``min(s.last_activity)`` across emitted
|
|
1895
|
-
sessions when any exist — best-available approximation since
|
|
1896
|
-
``CodexSessionUsage`` doesn't carry a ``first_activity`` field (the
|
|
1897
|
-
aggregator only tracks ``last`` per session). ``None`` on empty.
|
|
1898
|
-
"""
|
|
1899
|
-
_agg = _load_lib("_lib_aggregators")
|
|
1900
|
-
sessions = _agg._aggregate_codex_sessions(entries, speed=speed)
|
|
1887
|
+
def _build_codex_session_view_from_rows(sessions, *, now_utc, tz_name):
|
|
1888
|
+
"""Wrap already-aggregated Codex sessions in the public view contract."""
|
|
1901
1889
|
total_cost = 0.0
|
|
1902
1890
|
total_tok = 0
|
|
1903
1891
|
earliest = None
|
|
@@ -1915,3 +1903,51 @@ def build_codex_session_view(entries, *, now_utc, tz_name=None, speed="standard"
|
|
|
1915
1903
|
period_end=now_utc,
|
|
1916
1904
|
display_tz_label=_codex_tz_label(tz_name),
|
|
1917
1905
|
)
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
def build_codex_session_view(entries, *, now_utc, tz_name=None, speed="standard"):
|
|
1909
|
+
"""Build a ``CodexSessionView`` from CLI ``CodexEntry`` rows (issue #58)."""
|
|
1910
|
+
_agg = _load_lib("_lib_aggregators")
|
|
1911
|
+
return _build_codex_session_view_from_rows(
|
|
1912
|
+
_agg._aggregate_codex_sessions(entries, speed=speed),
|
|
1913
|
+
now_utc=now_utc,
|
|
1914
|
+
tz_name=tz_name,
|
|
1915
|
+
)
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
def build_rooted_codex_session_view(entries, *, now_utc, tz_name=None, speed="standard"):
|
|
1919
|
+
"""Build dashboard sessions from cache-rooted accounting without I/O.
|
|
1920
|
+
|
|
1921
|
+
The file identity is lexical: ``(source_root_key, source_path)``. It
|
|
1922
|
+
deliberately does not inspect configured roots or touch the filesystem,
|
|
1923
|
+
and a native session id remains payload rather than a grouping key.
|
|
1924
|
+
"""
|
|
1925
|
+
_agg = _load_lib("_lib_aggregators")
|
|
1926
|
+
keyed = []
|
|
1927
|
+
for entry in entries:
|
|
1928
|
+
source_path = str(getattr(entry, "source_path"))
|
|
1929
|
+
source_root_key = str(getattr(entry, "source_root_key"))
|
|
1930
|
+
path = pathlib.PurePosixPath(source_path)
|
|
1931
|
+
codex_entry = _agg.CodexEntry(
|
|
1932
|
+
timestamp=getattr(entry, "timestamp"),
|
|
1933
|
+
session_id=str(getattr(entry, "session_id") or ""),
|
|
1934
|
+
model=str(getattr(entry, "model")),
|
|
1935
|
+
input_tokens=int(getattr(entry, "input_tokens")),
|
|
1936
|
+
cached_input_tokens=int(getattr(entry, "cached_input_tokens")),
|
|
1937
|
+
output_tokens=int(getattr(entry, "output_tokens")),
|
|
1938
|
+
reasoning_output_tokens=int(getattr(entry, "reasoning_output_tokens")),
|
|
1939
|
+
total_tokens=int(getattr(entry, "total_tokens")),
|
|
1940
|
+
source_path=source_path,
|
|
1941
|
+
)
|
|
1942
|
+
keyed.append((codex_entry, _agg.CodexSessionIdentity(
|
|
1943
|
+
group_key=(source_root_key, source_path),
|
|
1944
|
+
session_id_path=source_path,
|
|
1945
|
+
session_file=path.stem,
|
|
1946
|
+
directory=str(path.parent),
|
|
1947
|
+
codex_root=source_root_key,
|
|
1948
|
+
)))
|
|
1949
|
+
return _build_codex_session_view_from_rows(
|
|
1950
|
+
_agg._aggregate_codex_sessions_keyed(keyed, speed=speed),
|
|
1951
|
+
now_utc=now_utc,
|
|
1952
|
+
tz_name=tz_name,
|
|
1953
|
+
)
|
package/bin/cctally
CHANGED
|
@@ -933,6 +933,9 @@ Migration = _cctally_db.Migration
|
|
|
933
933
|
DowngradeDetected = _cctally_db.DowngradeDetected
|
|
934
934
|
ProdMigrationRefused = _cctally_db.ProdMigrationRefused
|
|
935
935
|
StatsDbCorruptError = _cctally_db.StatsDbCorruptError
|
|
936
|
+
StatsDbMaintenanceError = _cctally_db.StatsDbMaintenanceError
|
|
937
|
+
_is_sqlite_corruption_error = _cctally_db._is_sqlite_corruption_error
|
|
938
|
+
_stats_corruption_guidance = _cctally_db._stats_corruption_guidance
|
|
936
939
|
_STATS_MIGRATIONS = _cctally_db._STATS_MIGRATIONS
|
|
937
940
|
_CACHE_MIGRATIONS = _cctally_db._CACHE_MIGRATIONS
|
|
938
941
|
_make_migration_decorator = _cctally_db._make_migration_decorator
|
|
@@ -957,6 +960,8 @@ _db_path_for_label = _cctally_db._db_path_for_label
|
|
|
957
960
|
cmd_db_skip = _cctally_db.cmd_db_skip
|
|
958
961
|
cmd_db_unskip = _cctally_db.cmd_db_unskip
|
|
959
962
|
cmd_db_recover = _cctally_db.cmd_db_recover
|
|
963
|
+
cmd_db_repair = _cctally_db.cmd_db_repair
|
|
964
|
+
cmd_db_backup = _cctally_db.cmd_db_backup
|
|
960
965
|
cmd_db_checkpoint = _cctally_db.cmd_db_checkpoint
|
|
961
966
|
cmd_db_vacuum = _cctally_db.cmd_db_vacuum
|
|
962
967
|
|
|
@@ -1384,6 +1389,7 @@ cmd_codex_quota_statusline = _cctally_quota.cmd_codex_quota_statusline
|
|
|
1384
1389
|
cmd_codex_quota_forecast = _cctally_quota.cmd_codex_quota_forecast
|
|
1385
1390
|
cmd_codex_quota_blocks = _cctally_quota.cmd_codex_quota_blocks
|
|
1386
1391
|
cmd_codex_quota_breakdown = _cctally_quota.cmd_codex_quota_breakdown
|
|
1392
|
+
cmd_codex_percent_breakdown = _cctally_quota.cmd_codex_percent_breakdown
|
|
1387
1393
|
|
|
1388
1394
|
# S3's qualified Codex accounting adapter is intentionally cache-only: callers
|
|
1389
1395
|
# that need project identity must not downgrade to an unqualified rollout parse.
|
|
@@ -2926,6 +2932,7 @@ _is_reset_drop = _cctally_weekrefs._is_reset_drop
|
|
|
2926
2932
|
# handler (C8, #125 Batch B). Parser dispatch reaches c.cmd_percent_breakdown.
|
|
2927
2933
|
_cctally_percent_breakdown = _load_sibling("_cctally_percent_breakdown")
|
|
2928
2934
|
cmd_percent_breakdown = _cctally_percent_breakdown.cmd_percent_breakdown
|
|
2935
|
+
_render_percent_breakdown_terminal = _cctally_percent_breakdown._render_percent_breakdown_terminal
|
|
2929
2936
|
|
|
2930
2937
|
|
|
2931
2938
|
# _is_cctally_hook_command, SetupError, _load_claude_settings,
|
|
@@ -3207,7 +3214,26 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
3207
3214
|
# + recovery guidance, exit 2. Caught before the generic handler so it
|
|
3208
3215
|
# never renders as a raw traceback.
|
|
3209
3216
|
eprint(f"cctally: {exc}")
|
|
3210
|
-
return
|
|
3217
|
+
return 3
|
|
3218
|
+
except StatsDbMaintenanceError as exc:
|
|
3219
|
+
eprint(f"cctally: {exc}")
|
|
3220
|
+
return 3
|
|
3221
|
+
except sqlite3.DatabaseError as exc:
|
|
3222
|
+
if _is_sqlite_corruption_error(exc):
|
|
3223
|
+
eprint(
|
|
3224
|
+
"cctally: a SQLite database is corrupt — run `cctally doctor` "
|
|
3225
|
+
"to identify it. For stats.db run `cctally db repair --db "
|
|
3226
|
+
"stats --yes`; cache.db is re-derivable via `cctally "
|
|
3227
|
+
"cache-sync --rebuild`."
|
|
3228
|
+
)
|
|
3229
|
+
return 3
|
|
3230
|
+
if _lib_log.debug_enabled():
|
|
3231
|
+
_lib_log.get_logger("cli").debug(
|
|
3232
|
+
"unhandled exception in %r",
|
|
3233
|
+
getattr(args, "command", None), exc_info=True,
|
|
3234
|
+
)
|
|
3235
|
+
eprint(f"Error: {exc}")
|
|
3236
|
+
rc = 1
|
|
3211
3237
|
except Exception as exc: # pragma: no cover
|
|
3212
3238
|
# #279 S2 F2: CCTALLY_DEBUG=1 surfaces the full traceback on stderr
|
|
3213
3239
|
# via the _lib_log chokepoint; default mode stays byte-identical.
|