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
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
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -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) ->
|
|
817
|
-
"""Clear every re-derivable Codex row family
|
|
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
|
-
|
|
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
|
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,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-{
|
|
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 ()
|