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 CHANGED
@@ -5,6 +5,30 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.75.0] - 2026-07-20
9
+
10
+ ### Added
11
+ - 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.
12
+ - 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)
13
+
14
+ ### Fixed
15
+ - 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.
16
+ - 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)
17
+ - 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)
18
+ - 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.
19
+ - 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`.
20
+
21
+ ## [1.74.0] - 2026-07-19
22
+
23
+ ### Fixed
24
+ - Dashboard Daily, Weekly, and Monthly details now use the same bounded history density across Claude, Codex, and All sources—30 days, 12 weeks, and 8 months—with compact date labels and contained mobile tables; Codex Blocks remains explicitly unavailable when native 5-hour activity does not exist. (#319)
25
+ - Dashboard Trend, Projects, Cache Report, Forecast, and Alerts details now keep their canonical composed structure for Codex and All sources, show provider-native values or explicit unavailable reasons in every slot, and route provider-qualified project rows through the source-bound detail foundation. (#319)
26
+ - Dashboard source-qualified Session and Project details now preserve Codex accounting totals when project metadata is incomplete; the combined source retains server-issued Claude Block identities and opens the canonical Block modal; source details share the standard focus, Escape, return-focus, and scroll-lock lifecycle; and desktop Share previews retain the captured provider. (#319)
27
+ - Codex accounting no longer double-counts parent token events copied into a forked/subagent rollout before its first model context. Those records remain available to the physical conversation reader but are excluded from accounting; cache migration 027 safely replays retained Codex rollouts to remove existing `unknown` duplicates. Consequently, `cctally pricing-check` and `doctor` no longer report those ingest artifacts as a missing embedded price, while real unknown model IDs remain actionable. The complete scoped OpenAI model set and tracked rates were reverified against the live pricing sources, and the embedded pricing snapshot date is now 2026-07-19.
28
+ - Dashboard Codex Weekly rows now follow observed native seven-day quota-cycle re-anchors instead of calendar weeks, collapsing reset jitter and clipping an older cycle when OpenAI grants an early reset. Codex quota forecasts now use elapsed native-cycle pace rather than activity-only bursts, avoiding misleading projections pinned at 100%. (#312)
29
+ - Dashboard Codex source reads now retain accounting, quota, budget, session, and forensics panels when retained historical rows still lack project attribution. The cache replay path repairs this metadata when possible; until then the source is fresh-but-partial and disables Projects only, with a rebuild remedy. (#312)
30
+ - Dashboard Codex hero cost and token counters now cover the active native seven-day reset cycle, rather than an unrelated calendar slice. A missing or conflicting active seven-day boundary leaves quota and budget visible while clearly marking the hero unavailable; Codex panels regain bounded native chrome and source warnings degrade only the affected domain. (#312)
31
+
8
32
  ## [1.73.0] - 2026-07-18
9
33
 
10
34
  ### Added
@@ -813,8 +813,30 @@ def _delete_codex_file_derived_rows(
813
813
  )
814
814
 
815
815
 
816
- def _clear_codex_derived_rows(conn: sqlite3.Connection) -> None:
817
- """Clear every re-derivable Codex row family in child-before-root order."""
816
+ def _clear_codex_derived_rows(conn: sqlite3.Connection) -> bool:
817
+ """Clear every re-derivable Codex row family and report whether state changed.
818
+
819
+ The FTS5 ``delete-all`` command can increment SQLite's cumulative change
820
+ counter even when the semantic Codex surface was already empty. Callers
821
+ use this return value, rather than ``Connection.total_changes``, when
822
+ deciding whether to advance the physical-mutation sequence.
823
+ """
824
+ state_changed = any(
825
+ conn.execute(query).fetchone() is not None
826
+ for query in (
827
+ "SELECT 1 FROM codex_session_entries LIMIT 1",
828
+ "SELECT 1 FROM quota_window_snapshots WHERE source = 'codex' LIMIT 1",
829
+ "SELECT 1 FROM codex_conversation_threads LIMIT 1",
830
+ "SELECT 1 FROM codex_conversation_events LIMIT 1",
831
+ "SELECT 1 FROM codex_session_files LIMIT 1",
832
+ "SELECT 1 FROM codex_source_roots LIMIT 1",
833
+ "SELECT 1 FROM codex_conversation_messages LIMIT 1",
834
+ "SELECT 1 FROM codex_conversation_file_touches LIMIT 1",
835
+ "SELECT 1 FROM codex_conversation_rollups LIMIT 1",
836
+ "SELECT 1 FROM cache_meta "
837
+ "WHERE key='codex_quota_projection_certificate' LIMIT 1",
838
+ )
839
+ )
818
840
  conn.execute("DELETE FROM codex_session_entries")
819
841
  conn.execute("DELETE FROM quota_window_snapshots WHERE source = 'codex'")
820
842
  conn.execute("DELETE FROM codex_conversation_threads")
@@ -832,6 +854,7 @@ def _clear_codex_derived_rows(conn: sqlite3.Connection) -> None:
832
854
  conn.execute(
833
855
  "DELETE FROM cache_meta WHERE key='codex_quota_projection_certificate'"
834
856
  )
857
+ return state_changed
835
858
 
836
859
 
837
860
  def _bump_codex_physical_mutation_seq(conn: sqlite3.Connection) -> None:
@@ -3903,9 +3926,7 @@ def sync_codex_cache(
3903
3926
  # Clear INSIDE the lock — see sync_cache() for the full
3904
3927
  # rationale. Done before the existing SELECT so delta
3905
3928
  # detection sees an empty baseline.
3906
- before_clear = conn.total_changes
3907
- _clear_codex_derived_rows(conn)
3908
- if conn.total_changes != before_clear:
3929
+ if _clear_codex_derived_rows(conn):
3909
3930
  _bump_codex_physical_mutation_seq(conn)
3910
3931
  conn.commit()
3911
3932
  eprint("[cache-sync] rebuild: cleared Codex cached entries")
@@ -4732,6 +4753,15 @@ def open_cache_db() -> sqlite3.Connection:
4732
4753
  except OSError as exc:
4733
4754
  eprint(f"[cache] could not chmod cache.db 0600 ({exc}); continuing")
4734
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")
4735
4765
  conn.execute("PRAGMA journal_mode=WAL")
4736
4766
  # #297: 15 s (was 5 s) lets a writer wait out a slow-but-normal sync (>5 s)
4737
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,
@@ -763,6 +770,34 @@ def _build_codex_source_detail(snapshot, *, resource: str, key: str) -> dict[str
763
770
  raise SourceResourceNotFound()
764
771
 
765
772
 
773
+ def _codex_partial_source_detail(snapshot, row: Mapping, *, resource: str,
774
+ key: str) -> dict[str, Any]:
775
+ """Keep a published cache-only row usable when project metadata is partial."""
776
+ detail = _source_safe_native_detail(
777
+ row, source="codex", resource=resource, key=key,
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
+ })
785
+ if resource == "session":
786
+ detail.setdefault("models", [])
787
+ detail.setdefault("model_breakdowns", [])
788
+ elif resource == "project":
789
+ generated_at = getattr(snapshot, "generated_at", None)
790
+ detail.setdefault("range_start", detail.get("first_seen"))
791
+ detail.setdefault(
792
+ "range_end",
793
+ generated_at.astimezone(dt.timezone.utc).isoformat()
794
+ if isinstance(generated_at, dt.datetime) else detail.get("last_seen"),
795
+ )
796
+ detail.setdefault("models", [])
797
+ detail.setdefault("sessions", [])
798
+ return detail
799
+
800
+
766
801
  def _source_safe_claude_session_detail(detail, *, key: str) -> dict[str, Any]:
767
802
  """Adapt the existing Claude session detail builder to S4's safe route."""
768
803
  payload = _session_detail_to_envelope(detail)
@@ -958,7 +993,24 @@ def build_source_detail(*, snapshot, source: str, resource: str,
958
993
  row = source_detail_lookup(snapshot.source_bundle, source, resource, key)
959
994
  if source == "claude":
960
995
  return _build_claude_source_detail(snapshot, resource=resource, key=key)
961
- return _build_codex_source_detail(snapshot, resource=resource, key=key)
996
+ try:
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
1010
+ except sys.modules["_cctally_source_analytics"].QualifiedMetadataUnavailable:
1011
+ return _codex_partial_source_detail(
1012
+ snapshot, row, resource=resource, key=key,
1013
+ )
962
1014
 
963
1015
  _ensure_sibling_loaded("_cctally_dashboard_share")
964
1016
  from _cctally_dashboard_share import (
@@ -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():
@@ -1404,6 +1404,8 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1404
1404
  raise ValueError("source capability unavailable")
1405
1405
  required_domain = {
1406
1406
  "current-week": "hero",
1407
+ "trend": "periods",
1408
+ "forecast": "quota",
1407
1409
  "daily": "periods",
1408
1410
  "monthly": "periods",
1409
1411
  "weekly": "periods",
@@ -1425,14 +1427,47 @@ def _build_codex_source_share_snapshot(ls, *, state, panel: str,
1425
1427
  source_rows = all_rows[-1:] if all_rows else ()
1426
1428
  command = "codex-weekly"
1427
1429
  display_tz = str(weekly.get("display_tz") or "UTC")
1428
- elif panel in ("daily", "monthly", "weekly"):
1430
+ elif panel in ("daily", "monthly", "weekly", "trend"):
1429
1431
  periods = data.get("periods")
1430
- panel_data = periods.get(panel) if isinstance(periods, Mapping) else {}
1432
+ period_key = "weekly" if panel == "trend" else panel
1433
+ panel_data = periods.get(period_key) if isinstance(periods, Mapping) else {}
1431
1434
  if not isinstance(panel_data, Mapping):
1432
1435
  raise ValueError("source capability unavailable")
1433
1436
  source_rows = tuple(panel_data.get("rows", ()))
1434
- command = f"codex-{panel}"
1437
+ command = f"codex-{period_key}"
1435
1438
  display_tz = str(panel_data.get("display_tz") or "UTC")
1439
+ elif panel == "forecast":
1440
+ quota = data.get("quota")
1441
+ panel_data = quota if isinstance(quota, Mapping) else {}
1442
+ source_rows = tuple(panel_data.get("histories", ())) if isinstance(panel_data, Mapping) else ()
1443
+ start, end = _share_codex_period_bounds(
1444
+ state=state, panel="weekly", options=options, rows=source_rows,
1445
+ )
1446
+ rows = []
1447
+ for row in source_rows:
1448
+ if not isinstance(row, Mapping):
1449
+ continue
1450
+ forecast = row.get("forecast") if isinstance(row.get("forecast"), Mapping) else {}
1451
+ current = row.get("current_percent")
1452
+ projected = forecast.get("projected_percent")
1453
+ rows.append(ls.Row(cells={
1454
+ "limit": ls.TextCell(str(row.get("label") or "Codex quota")),
1455
+ "current": ls.TextCell("—" if current is None else f"{float(current):.1f}%"),
1456
+ "projected": ls.TextCell("—" if projected is None else f"{float(projected):.1f}%"),
1457
+ }))
1458
+ return ls.ShareSnapshot(
1459
+ cmd="codex-quota", title="Codex Quota Forecast", subtitle=None,
1460
+ period=ls.PeriodSpec(start=start, end=end, display_tz="UTC", label=None),
1461
+ columns=(
1462
+ ls.ColumnSpec(key="limit", label="Limit"),
1463
+ ls.ColumnSpec(key="current", label="Current", align="right"),
1464
+ ls.ColumnSpec(key="projected", label="Projected", align="right"),
1465
+ ),
1466
+ rows=tuple(rows), chart=None, totals=(), notes=(), generated_at=end,
1467
+ version=sys.modules["cctally"]._share_resolve_version(),
1468
+ template_id=template_id, source="codex", source_label="Codex",
1469
+ availability=availability, availability_reason=reason,
1470
+ )
1436
1471
  elif panel == "sessions":
1437
1472
  panel_data = data.get(panel) if isinstance(data.get(panel), Mapping) else {}
1438
1473
  source_rows = tuple(panel_data.get("rows", ())) if isinstance(panel_data, Mapping) else ()