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 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
@@ -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
@@ -333,7 +333,7 @@ ALLOWED_CONFIG_KEYS = (
333
333
 
334
334
  _CODEX_BUDGET_LEAF_PREFIX = "budget.codex."
335
335
 
336
- _DEFAULT_CONVERSATION_RETENTION_DAYS = 180
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 ``180``; ``0`` disables retention (keep forever). Any malformed
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 180 default rather than raising.
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
@@ -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 2 instead of
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
- f'Recovery: back up the file, then try `sqlite3 "{DB_PATH}" ".recover"`, '
1137
- f"or move it aside to start fresh. If this recurs, please file an issue."
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
- "last_activity", "cost_usd", "input_tokens", "cached_input_tokens",
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", "cost_usd", "input_tokens",
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
- "resets_at", "current_percent", "orphaned", "forecast",
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
- detail.update({
773
- "metadata_availability": "partial",
774
- "metadata_reason": (
775
- "Codex project metadata is incomplete; available accounting totals "
776
- "are preserved."
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["model_breakdowns"] = []
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["models"] = []
791
- detail["sessions"] = []
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
- return _build_codex_source_detail(snapshot, resource=resource, key=key)
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
- import re as _re_qs # noqa: E402 — module-level compile for the browse-cursor check
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():