cctally 1.74.0 → 1.75.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 +19 -0
- package/bin/_cctally_cache.py +9 -0
- package/bin/_cctally_config.py +3 -3
- package/bin/_cctally_core.py +12 -3
- package/bin/_cctally_dashboard.py +33 -15
- package/bin/_cctally_dashboard_conversation.py +2 -2
- package/bin/_cctally_dashboard_sources.py +401 -18
- package/bin/_cctally_db.py +723 -1
- 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_statusline.py +3 -3
- package/bin/_cctally_tui.py +3 -0
- package/bin/_lib_aggregators.py +7 -0
- package/bin/_lib_cache_report.py +25 -9
- package/bin/_lib_conversation_retention.py +42 -2
- package/bin/_lib_doctor.py +4 -4
- package/bin/cctally +27 -1
- package/dashboard/static/assets/index-fZRxsSf5.js +80 -0
- package/dashboard/static/assets/{index-ZiPO8veo.css → index-yftBNnLR.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +1 -1
- package/dashboard/static/assets/index-DeQjEMO6.js +0 -80
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.75.1] - 2026-07-20
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- Database repair now rejects an SQLite shell without functional `.recover` support before creating a forensic copy, explains the required `SQLITE_ENABLE_DBPAGE_VTAB` capability, and runs its public Linux recovery tests against a checksum-pinned official SQLite shell instead of Ubuntu's incompatible distro build.
|
|
12
|
+
- Automatic transcript-space reclamation now drives zero-column `incremental_vacuum` pragmas through SQLite's completion path in bounded, progress-checked chunks, fixing the Python 3.11/Linux case that could leave freed pages on the freelist after retention pruning.
|
|
13
|
+
|
|
14
|
+
## [1.75.0] - 2026-07-20
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- Add `cctally codex percent-breakdown`, a cached native seven-day Codex milestone command with the same terminal design as `cctally percent-breakdown`, exact root/limit selection, current or retained cycle selection, query-time pricing, and matching five-hour context. The materialized dashboard projection is read by default; `--sync` explicitly refreshes it.
|
|
18
|
+
- Add guided `stats.db` corruption repair and SQLite-native online backups, with verified recovery, preserved usage history/schema version, safe original-file retention, active-writer and production guards, and actionable malformed-database errors. (#314)
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- Dashboard Codex hero now uses the exact Claude hero composition and metric order. Its native seven-day cycle supplies Week Usage, optional 5-hour usage, reset countdown, cycle spend, `$ / 1%`, forecast-at-reset, prior-cycle `$ / 1%` delta, and snapshot age; unavailable cycle accounting stays explicit instead of falling back to token or budget tiles.
|
|
22
|
+
- Force-refresh and automatic OAuth refresh now serialize their complete 429 backoff transitions under the same selected-state lock. Concurrent rate limits preserve both the consecutive-429 count and furthest deadline, while a completed force-refresh can no longer erase a newer automatic-refresh cooldown. (#309)
|
|
23
|
+
- Dashboard Codex modals now populate the canonical Claude-shaped current-cycle, session, period, project, cache, forecast, alert, and **$/1% Trend** views from native session metadata, quota observations, token accounting, and computed cache/model breakdowns instead of sparse placeholders or renamed semantics. The current-cycle modal reuses the complete durable quota breakdown, preserving every 1% crossing even when the dashboard's bounded observation tail begins later. (#319)
|
|
24
|
+
- The current 5-hour block no longer lingers as an approximate (`~`) window in `cctally blocks` and the dashboard Blocks panel while the statusline already shows the correct 5-hour data. When a new 5-hour window opened while your usage percentage was flat, `record-usage` deduped the unchanged tick and never wrote the new window's canonical anchor, so the active block fell back to its heuristic start until the percentage next moved (a lag of up to several minutes at window start). The dedup self-heal now also materializes the rolled-over window's anchor, so the active block reads as API-anchored right away; the statusline was always correct because it renders the live rate limits rather than the stored snapshot.
|
|
25
|
+
- Conversation-transcript retention now reclaims disk automatically and defaults to a tighter window, so `cache.db` no longer grows to multiple gigabytes of transcript text and search index that slow every dashboard sync tick — which, under multi-process contention (a second dashboard plus status-line hooks), could wedge the sync into a "sync paused" state and leave Codex quota/weekly panels blank. A freshly-created `cache.db` now uses SQLite `INCREMENTAL` auto-vacuum, and the once-a-day retention prune returns the freed pages to the OS (physically reclaimed on the next end-of-sync checkpoint) instead of requiring a manual `cctally db vacuum`; a `cache.db` created by an older version keeps the legacy behavior until a one-time `cctally db vacuum` or `cctally cache-sync --rebuild`, after which it too reclaims automatically. The default `conversation.retention_days` is now 90 (was 180) — set your own window or `off` with `cctally config set conversation.retention_days`.
|
|
26
|
+
|
|
8
27
|
## [1.74.0] - 2026-07-19
|
|
9
28
|
|
|
10
29
|
### Fixed
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -4753,6 +4753,15 @@ def open_cache_db() -> sqlite3.Connection:
|
|
|
4753
4753
|
except OSError as exc:
|
|
4754
4754
|
eprint(f"[cache] could not chmod cache.db 0600 ({exc}); continuing")
|
|
4755
4755
|
|
|
4756
|
+
# #313 P3 reclaim: create a fresh cache.db in INCREMENTAL auto-vacuum mode so
|
|
4757
|
+
# the transcript-retention prune can return freed pages to the OS via
|
|
4758
|
+
# `PRAGMA incremental_vacuum` instead of only growing the freelist — the
|
|
4759
|
+
# conversation-transcript store is what bloated cache.db to multiple GB and
|
|
4760
|
+
# slowed every sync tick. This MUST run before the first page is written
|
|
4761
|
+
# (before journal_mode=WAL / any CREATE TABLE) to take effect; it is a
|
|
4762
|
+
# harmless no-op on an already-created DB, which keeps its existing mode
|
|
4763
|
+
# until a full VACUUM (`cctally db vacuum`) or a `cache-sync --rebuild`.
|
|
4764
|
+
conn.execute("PRAGMA auto_vacuum=INCREMENTAL")
|
|
4756
4765
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
4757
4766
|
# #297: 15 s (was 5 s) lets a writer wait out a slow-but-normal sync (>5 s)
|
|
4758
4767
|
# instead of instantly erroring with "database is locked". Covers plain
|
package/bin/_cctally_config.py
CHANGED
|
@@ -333,7 +333,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
333
333
|
|
|
334
334
|
_CODEX_BUDGET_LEAF_PREFIX = "budget.codex."
|
|
335
335
|
|
|
336
|
-
_DEFAULT_CONVERSATION_RETENTION_DAYS =
|
|
336
|
+
_DEFAULT_CONVERSATION_RETENTION_DAYS = 90
|
|
337
337
|
|
|
338
338
|
|
|
339
339
|
def _validate_retention_days_value(raw: object) -> int:
|
|
@@ -374,9 +374,9 @@ def _validate_retention_days_value(raw: object) -> int:
|
|
|
374
374
|
def resolve_retention_days(config: dict) -> int:
|
|
375
375
|
"""Effective conversation transcript retention in days (F8).
|
|
376
376
|
|
|
377
|
-
Default is ``
|
|
377
|
+
Default is ``90``; ``0`` disables retention (keep forever). Any malformed
|
|
378
378
|
persisted value (non-int, boolean, negative, un-parseable string) degrades
|
|
379
|
-
to the safe
|
|
379
|
+
to the safe 90 default rather than raising.
|
|
380
380
|
"""
|
|
381
381
|
default = _DEFAULT_CONVERSATION_RETENTION_DAYS
|
|
382
382
|
block = config.get("conversation") if isinstance(config, dict) else None
|
package/bin/_cctally_core.py
CHANGED
|
@@ -1098,11 +1098,19 @@ def open_db() -> sqlite3.Connection:
|
|
|
1098
1098
|
_log_migration_error = c._log_migration_error
|
|
1099
1099
|
_clear_migration_error_log_entries = c._clear_migration_error_log_entries
|
|
1100
1100
|
|
|
1101
|
+
repair_marker = DB_PATH.with_name("stats.db.repairing")
|
|
1102
|
+
if repair_marker.exists():
|
|
1103
|
+
raise c.StatsDbMaintenanceError()
|
|
1101
1104
|
ensure_dirs()
|
|
1105
|
+
if repair_marker.exists():
|
|
1106
|
+
raise c.StatsDbMaintenanceError()
|
|
1102
1107
|
conn = sqlite3.connect(DB_PATH)
|
|
1108
|
+
if repair_marker.exists():
|
|
1109
|
+
conn.close()
|
|
1110
|
+
raise c.StatsDbMaintenanceError()
|
|
1103
1111
|
conn.row_factory = sqlite3.Row
|
|
1104
1112
|
# #279 S1 F4: probe connect + initial PRAGMAs so a corrupt stats.db (the
|
|
1105
|
-
# non-re-derivable DB) surfaces as a one-line diagnosis + exit
|
|
1113
|
+
# non-re-derivable DB) surfaces as a one-line diagnosis + staged exit 3 instead of
|
|
1106
1114
|
# a raw traceback. The catch boundary is DELIBERATELY narrow — ONLY the
|
|
1107
1115
|
# connect/PRAGMA/probe below. The DDL + `_run_pending_migrations` region
|
|
1108
1116
|
# further down is NOT wrapped: migration-handler failures have their own
|
|
@@ -1133,8 +1141,9 @@ def open_db() -> sqlite3.Connection:
|
|
|
1133
1141
|
raise c.StatsDbCorruptError(
|
|
1134
1142
|
f"stats.db appears corrupt or unreadable ({exc}). path: {DB_PATH}. "
|
|
1135
1143
|
f"Not auto-recreated — it holds your recorded usage history. "
|
|
1136
|
-
|
|
1137
|
-
|
|
1144
|
+
"Recovery: run `cctally db repair --db stats --yes`; it preserves "
|
|
1145
|
+
"the corrupt original before replacing anything. Do not copy, "
|
|
1146
|
+
"restore, or move the live DB by hand."
|
|
1138
1147
|
) from exc
|
|
1139
1148
|
conn.execute(
|
|
1140
1149
|
"""
|
|
@@ -483,16 +483,20 @@ def _source_safe_native_detail(row: Mapping, *, source: str,
|
|
|
483
483
|
common = {"detail_kind": f"{source}_{resource}", "key": key}
|
|
484
484
|
allowed = {
|
|
485
485
|
"session": (
|
|
486
|
-
"
|
|
486
|
+
"label", "project", "started_at", "duration_min", "last_activity",
|
|
487
|
+
"cost_usd", "input_tokens", "cached_input_tokens",
|
|
487
488
|
"output_tokens", "reasoning_output_tokens", "total_tokens", "models",
|
|
489
|
+
"model_breakdowns",
|
|
488
490
|
),
|
|
489
491
|
"project": (
|
|
490
|
-
"first_seen", "last_seen", "
|
|
492
|
+
"label", "first_seen", "last_seen", "range_start", "range_end",
|
|
493
|
+
"cost_usd", "input_tokens",
|
|
491
494
|
"cached_input_tokens", "output_tokens", "reasoning_output_tokens",
|
|
492
|
-
"total_tokens", "session_count", "sessions_count",
|
|
495
|
+
"total_tokens", "session_count", "sessions_count", "models", "sessions",
|
|
493
496
|
),
|
|
494
497
|
"block": (
|
|
495
|
-
"
|
|
498
|
+
"label", "start_at", "end_at", "resets_at", "current_percent",
|
|
499
|
+
"orphaned", "is_active", "cost_usd", "model_breakdowns", "forecast",
|
|
496
500
|
"window_minutes", "observed_slot", "milestones",
|
|
497
501
|
),
|
|
498
502
|
}[resource]
|
|
@@ -570,6 +574,9 @@ def _codex_detail_inputs(snapshot):
|
|
|
570
574
|
week_start_name=semantics.week_start_name,
|
|
571
575
|
speed=semantics.speed,
|
|
572
576
|
codex_budget=semantics.codex_budget,
|
|
577
|
+
codex_quota_actual_thresholds=semantics.codex_quota_actual_thresholds,
|
|
578
|
+
codex_quota_projected_thresholds=semantics.codex_quota_projected_thresholds,
|
|
579
|
+
cache_report_anomaly_threshold_pp=semantics.cache_report_anomaly_threshold_pp,
|
|
573
580
|
)
|
|
574
581
|
qualified = load_qualified_codex_entries(
|
|
575
582
|
range_start,
|
|
@@ -769,16 +776,15 @@ def _codex_partial_source_detail(snapshot, row: Mapping, *, resource: str,
|
|
|
769
776
|
detail = _source_safe_native_detail(
|
|
770
777
|
row, source="codex", resource=resource, key=key,
|
|
771
778
|
)
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
"
|
|
776
|
-
"
|
|
777
|
-
)
|
|
778
|
-
})
|
|
779
|
+
metadata_missing = resource != "session" or not row.get("project")
|
|
780
|
+
if metadata_missing:
|
|
781
|
+
detail.update({
|
|
782
|
+
"metadata_availability": "partial",
|
|
783
|
+
"metadata_reason": "Project metadata is unavailable for this item.",
|
|
784
|
+
})
|
|
779
785
|
if resource == "session":
|
|
780
786
|
detail.setdefault("models", [])
|
|
781
|
-
detail
|
|
787
|
+
detail.setdefault("model_breakdowns", [])
|
|
782
788
|
elif resource == "project":
|
|
783
789
|
generated_at = getattr(snapshot, "generated_at", None)
|
|
784
790
|
detail.setdefault("range_start", detail.get("first_seen"))
|
|
@@ -787,8 +793,8 @@ def _codex_partial_source_detail(snapshot, row: Mapping, *, resource: str,
|
|
|
787
793
|
generated_at.astimezone(dt.timezone.utc).isoformat()
|
|
788
794
|
if isinstance(generated_at, dt.datetime) else detail.get("last_seen"),
|
|
789
795
|
)
|
|
790
|
-
detail
|
|
791
|
-
detail
|
|
796
|
+
detail.setdefault("models", [])
|
|
797
|
+
detail.setdefault("sessions", [])
|
|
792
798
|
return detail
|
|
793
799
|
|
|
794
800
|
|
|
@@ -988,7 +994,19 @@ def build_source_detail(*, snapshot, source: str, resource: str,
|
|
|
988
994
|
if source == "claude":
|
|
989
995
|
return _build_claude_source_detail(snapshot, resource=resource, key=key)
|
|
990
996
|
try:
|
|
991
|
-
|
|
997
|
+
detail = _build_codex_source_detail(snapshot, resource=resource, key=key)
|
|
998
|
+
if resource == "session":
|
|
999
|
+
for field in ("label", "project", "started_at", "duration_min"):
|
|
1000
|
+
detail[field] = row.get(field)
|
|
1001
|
+
elif resource == "project":
|
|
1002
|
+
detail["label"] = row.get("label")
|
|
1003
|
+
elif resource == "block":
|
|
1004
|
+
for field in (
|
|
1005
|
+
"label", "start_at", "end_at", "is_active", "cost_usd",
|
|
1006
|
+
"model_breakdowns",
|
|
1007
|
+
):
|
|
1008
|
+
detail[field] = row.get(field)
|
|
1009
|
+
return detail
|
|
992
1010
|
except sys.modules["_cctally_source_analytics"].QualifiedMetadataUnavailable:
|
|
993
1011
|
return _codex_partial_source_detail(
|
|
994
1012
|
snapshot, row, resource=resource, key=key,
|
|
@@ -39,6 +39,7 @@ edits — the forwarders hit ``sys.modules["_cctally_dashboard"]`` at call time
|
|
|
39
39
|
from __future__ import annotations
|
|
40
40
|
|
|
41
41
|
import json
|
|
42
|
+
import re
|
|
42
43
|
import socket
|
|
43
44
|
import sqlite3
|
|
44
45
|
import sys
|
|
@@ -224,8 +225,7 @@ _QUALIFIED_SEARCH_ACCEPTED = ("source", "q", "kind", "limit", "cursor")
|
|
|
224
225
|
_QUALIFIED_FACETS_ACCEPTED = ("source",)
|
|
225
226
|
# A raw browse cursor is a conversation key — printable + URL-safe by construction
|
|
226
227
|
# (§2.2). Syntactic-only validation: reject whitespace/control/empty; echo raw.
|
|
227
|
-
|
|
228
|
-
_BROWSE_CURSOR_RE = _re_qs.compile(r"\A[!-~]+\Z")
|
|
228
|
+
_BROWSE_CURSOR_RE = re.compile(r"\A[!-~]+\Z")
|
|
229
229
|
|
|
230
230
|
|
|
231
231
|
def _resolve_effective_speed():
|