cctally 1.10.3 → 1.11.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 +22 -0
- package/bin/_cctally_alerts.py +14 -18
- package/bin/_cctally_cache.py +28 -29
- package/bin/_cctally_cache_report.py +938 -0
- package/bin/_cctally_config.py +31 -22
- package/bin/_cctally_core.py +94 -8
- package/bin/_cctally_dashboard.py +621 -7
- package/bin/_cctally_db.py +42 -30
- package/bin/_cctally_record.py +26 -26
- package/bin/_cctally_setup.py +28 -26
- package/bin/_cctally_tui.py +47 -1
- package/bin/_cctally_update.py +41 -33
- package/bin/_lib_changelog.py +3 -1
- package/bin/_lib_share_templates.py +31 -13
- package/bin/cctally +214 -495
- package/dashboard/static/assets/index-BJ16SzRL.js +18 -0
- package/dashboard/static/assets/index-C1xH9GBW.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-Cy59E7Ru.js +0 -18
- package/dashboard/static/assets/index-Dp14ELVt.css +0 -1
package/bin/cctally
CHANGED
|
@@ -135,15 +135,8 @@ def _load_sibling(name: str):
|
|
|
135
135
|
_THIS_MODULE = sys.modules.get(__name__) or sys.modules.get("cctally")
|
|
136
136
|
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
LEGACY_APP_DIR = pathlib.Path.home() / ".local" / "share" / "ccusage-subscription"
|
|
140
|
-
# Hook-tick runtime artifacts (Section 1 of onboarding spec).
|
|
141
|
-
HOOK_TICK_LOG_DIR = APP_DIR / "logs"
|
|
142
|
-
HOOK_TICK_LOG_PATH = HOOK_TICK_LOG_DIR / "hook-tick.log"
|
|
143
|
-
HOOK_TICK_LOG_ROTATED_PATH = HOOK_TICK_LOG_DIR / "hook-tick.log.1"
|
|
138
|
+
# Hook-tick non-path constants (Section 1 of onboarding spec).
|
|
144
139
|
HOOK_TICK_LOG_ROTATE_BYTES = 1024 * 1024 # 1 MB
|
|
145
|
-
HOOK_TICK_THROTTLE_PATH = APP_DIR / "hook-tick.last-fetch"
|
|
146
|
-
HOOK_TICK_THROTTLE_LOCK_PATH = APP_DIR / "hook-tick.last-fetch.lock"
|
|
147
140
|
HOOK_TICK_DEFAULT_THROTTLE_SECONDS = 30.0
|
|
148
141
|
|
|
149
142
|
# User-facing executables symlinked by `cctally setup` (Section 2.2 of spec).
|
|
@@ -167,19 +160,6 @@ SETUP_HOOK_EVENTS = ("PostToolBatch", "Stop", "SubagentStop")
|
|
|
167
160
|
|
|
168
161
|
# === Release automation (issue #24) ===
|
|
169
162
|
|
|
170
|
-
_CHANGELOG_OVERRIDE = os.environ.get("CCTALLY_TEST_CHANGELOG_PATH")
|
|
171
|
-
if _CHANGELOG_OVERRIDE:
|
|
172
|
-
# Fixture-stability hook for `bin/cctally-share-test` — points
|
|
173
|
-
# `_share_resolve_version()` (and the broader release machinery) at a
|
|
174
|
-
# per-scenario CHANGELOG so version stamping in goldens stays
|
|
175
|
-
# deterministic regardless of the in-tree CHANGELOG state. Mirrors the
|
|
176
|
-
# `CCTALLY_AS_OF` env-only precedent: not in --help, no docstring
|
|
177
|
-
# surface; consumed exclusively by harness wrappers.
|
|
178
|
-
CHANGELOG_PATH = pathlib.Path(_CHANGELOG_OVERRIDE)
|
|
179
|
-
else:
|
|
180
|
-
CHANGELOG_PATH = pathlib.Path(__file__).resolve().parent.parent / "CHANGELOG.md"
|
|
181
|
-
|
|
182
|
-
|
|
183
163
|
# Public mirror repo identity (the GitHub `<owner>/<repo>` slug). Used by
|
|
184
164
|
# Phase 3 (mirror push), Phase 4 (`gh release create` / view), and the
|
|
185
165
|
# fallback printout. Distinct from `.mirror-allowlist`, which classifies
|
|
@@ -230,6 +210,45 @@ make_week_ref = _cctally_core.make_week_ref
|
|
|
230
210
|
_get_latest_row_for_week = _cctally_core._get_latest_row_for_week
|
|
231
211
|
get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
|
|
232
212
|
|
|
213
|
+
# === Path constants — re-exported from _cctally_core ================
|
|
214
|
+
#
|
|
215
|
+
# Promoted 2026-05-22 (docs/superpowers/specs/2026-05-22-cctally-core-data-globals.md).
|
|
216
|
+
# `_cctally_core` is the single source of truth and the only legal
|
|
217
|
+
# monkeypatch target. These re-exports exist for ad-hoc REPL / scripts
|
|
218
|
+
# that read `cctally.APP_DIR` directly.
|
|
219
|
+
|
|
220
|
+
APP_DIR = _cctally_core.APP_DIR
|
|
221
|
+
LEGACY_APP_DIR = _cctally_core.LEGACY_APP_DIR
|
|
222
|
+
LOG_DIR = _cctally_core.LOG_DIR
|
|
223
|
+
|
|
224
|
+
DB_PATH = _cctally_core.DB_PATH
|
|
225
|
+
CACHE_DB_PATH = _cctally_core.CACHE_DB_PATH
|
|
226
|
+
|
|
227
|
+
CACHE_LOCK_PATH = _cctally_core.CACHE_LOCK_PATH
|
|
228
|
+
CACHE_LOCK_CODEX_PATH = _cctally_core.CACHE_LOCK_CODEX_PATH
|
|
229
|
+
CONFIG_LOCK_PATH = _cctally_core.CONFIG_LOCK_PATH
|
|
230
|
+
|
|
231
|
+
CONFIG_PATH = _cctally_core.CONFIG_PATH
|
|
232
|
+
|
|
233
|
+
MIGRATION_ERROR_LOG_PATH = _cctally_core.MIGRATION_ERROR_LOG_PATH
|
|
234
|
+
|
|
235
|
+
CHANGELOG_PATH = _cctally_core.CHANGELOG_PATH
|
|
236
|
+
|
|
237
|
+
HOOK_TICK_LOG_DIR = _cctally_core.HOOK_TICK_LOG_DIR
|
|
238
|
+
HOOK_TICK_LOG_PATH = _cctally_core.HOOK_TICK_LOG_PATH
|
|
239
|
+
HOOK_TICK_LOG_ROTATED_PATH = _cctally_core.HOOK_TICK_LOG_ROTATED_PATH
|
|
240
|
+
HOOK_TICK_THROTTLE_PATH = _cctally_core.HOOK_TICK_THROTTLE_PATH
|
|
241
|
+
HOOK_TICK_THROTTLE_LOCK_PATH = _cctally_core.HOOK_TICK_THROTTLE_LOCK_PATH
|
|
242
|
+
|
|
243
|
+
UPDATE_STATE_PATH = _cctally_core.UPDATE_STATE_PATH
|
|
244
|
+
UPDATE_SUPPRESS_PATH = _cctally_core.UPDATE_SUPPRESS_PATH
|
|
245
|
+
UPDATE_LOCK_PATH = _cctally_core.UPDATE_LOCK_PATH
|
|
246
|
+
UPDATE_LOG_PATH = _cctally_core.UPDATE_LOG_PATH
|
|
247
|
+
UPDATE_LOG_ROTATED_PATH = _cctally_core.UPDATE_LOG_ROTATED_PATH
|
|
248
|
+
UPDATE_CHECK_LAST_FETCH_PATH = _cctally_core.UPDATE_CHECK_LAST_FETCH_PATH
|
|
249
|
+
|
|
250
|
+
CLAUDE_SETTINGS_PATH = _cctally_core.CLAUDE_SETTINGS_PATH
|
|
251
|
+
|
|
233
252
|
_lib_semver = _load_sibling("_lib_semver")
|
|
234
253
|
_SEMVER_NUM = _lib_semver._SEMVER_NUM
|
|
235
254
|
_SEMVER_RE = _lib_semver._SEMVER_RE
|
|
@@ -648,11 +667,15 @@ _saved_dict_from_usage_row = _cctally_record._saved_dict_from_usage_row
|
|
|
648
667
|
# the ``c = _cctally()`` accessor at call time so
|
|
649
668
|
# ``setitem(ns, "_do_update_check", mock)`` propagates into
|
|
650
669
|
# ``cmd_update_check_internal`` / ``_DashboardUpdateCheckThread.run``.
|
|
651
|
-
# Path constants
|
|
652
|
-
#
|
|
653
|
-
#
|
|
654
|
-
#
|
|
655
|
-
#
|
|
670
|
+
# Path constants (UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH,
|
|
671
|
+
# UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH,
|
|
672
|
+
# UPDATE_CHECK_LAST_FETCH_PATH) were promoted to _cctally_core
|
|
673
|
+
# 2026-05-22 (#84). Moved bodies in _cctally_update read them via
|
|
674
|
+
# call-time ``_cctally_core.UPDATE_STATE_PATH`` etc.; tests patch via
|
|
675
|
+
# ``monkeypatch.setattr(_cctally_core, "X", v)`` (the conftest
|
|
676
|
+
# ``redirect_paths()`` helper covers the full set). The legacy
|
|
677
|
+
# ``setitem(ns, …)`` pattern is forbidden by
|
|
678
|
+
# ``test_no_old_style_test_patches_for_promoted_globals``.
|
|
656
679
|
_cctally_update = _load_sibling("_cctally_update")
|
|
657
680
|
UpdateError = _cctally_update.UpdateError
|
|
658
681
|
UpdateValidationError = _cctally_update.UpdateValidationError
|
|
@@ -822,64 +845,26 @@ LEGACY_STATUSLINE_PATHS = (
|
|
|
822
845
|
)
|
|
823
846
|
LEGACY_STATUSLINE_NEEDLE = "cctally record-usage"
|
|
824
847
|
|
|
825
|
-
DB_PATH = APP_DIR / "stats.db"
|
|
826
|
-
CACHE_DB_PATH = APP_DIR / "cache.db"
|
|
827
|
-
CACHE_LOCK_PATH = APP_DIR / "cache.db.lock"
|
|
828
|
-
CACHE_LOCK_CODEX_PATH = APP_DIR / "cache.db.codex.lock"
|
|
829
848
|
CODEX_SESSIONS_DIR = pathlib.Path.home() / ".codex" / "sessions"
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
LOG_DIR
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
#
|
|
836
|
-
#
|
|
837
|
-
#
|
|
838
|
-
#
|
|
839
|
-
#
|
|
840
|
-
# `
|
|
841
|
-
#
|
|
842
|
-
#
|
|
843
|
-
#
|
|
844
|
-
# fixture also work because `redirect_paths` already patches the
|
|
845
|
-
# cctally-side path constants AND will additionally need to patch the
|
|
846
|
-
# sibling-side copy below — that's why we expose this seed: tests can
|
|
847
|
-
# discover the sibling via `cctally_module._cctally_db` and patch via
|
|
848
|
-
# `monkeypatch.setitem(_cctally_db.__dict__, "X", tmp)` if needed.
|
|
849
|
-
_cctally_db.LOG_DIR = LOG_DIR
|
|
850
|
-
_cctally_db.MIGRATION_ERROR_LOG_PATH = MIGRATION_ERROR_LOG_PATH
|
|
851
|
-
_cctally_db.DB_PATH = DB_PATH
|
|
852
|
-
_cctally_db.CACHE_DB_PATH = CACHE_DB_PATH
|
|
853
|
-
|
|
854
|
-
# Note: `_cctally_cache.py` does NOT need a path-constant seed block
|
|
855
|
-
# parallel to `_cctally_db` above — its moved bodies reach
|
|
856
|
-
# `APP_DIR` / `CACHE_DB_PATH` / `CACHE_LOCK_PATH` /
|
|
857
|
-
# `CACHE_LOCK_CODEX_PATH` / `CODEX_SESSIONS_DIR` via the
|
|
858
|
-
# `c = _cctally()` call-time accessor (spec §5.5). That defers every
|
|
859
|
-
# read until call-time `sys.modules['cctally'].X`, so
|
|
860
|
-
# `monkeypatch.setitem(ns, "CACHE_DB_PATH", tmp)` test patches and
|
|
861
|
-
# conftest `redirect_paths` HOME redirects propagate without
|
|
862
|
-
# touching any sibling-side seeded copy. We chose `c.X` over the
|
|
863
|
-
# `_cctally_db`-style seed block here because cache test sites are
|
|
864
|
-
# widely scattered (record-usage tick, dashboard panels, share
|
|
865
|
-
# render kernel, block tests, every JSONL-reading subcommand
|
|
866
|
-
# fixture) — inline patches against ns by setitem are the
|
|
867
|
-
# established idiom, and the c.X path keeps them all working with
|
|
868
|
-
# zero test-file edits.
|
|
849
|
+
|
|
850
|
+
# Note: `_cctally_db` reads its four path constants
|
|
851
|
+
# (`LOG_DIR`/`MIGRATION_ERROR_LOG_PATH`/`DB_PATH`/`CACHE_DB_PATH`) via
|
|
852
|
+
# `_cctally_core.X` at call time — the canonical sibling pattern after
|
|
853
|
+
# the data-globals promotion (2026-05-22, issue #84). The previous
|
|
854
|
+
# eager-seed block here is no longer needed: `_cctally_core` is the
|
|
855
|
+
# single source of truth, and `monkeypatch.setattr(_cctally_core, "X",
|
|
856
|
+
# v)` propagates into every reader without a sibling-side mirror.
|
|
857
|
+
#
|
|
858
|
+
# Note: `_cctally_cache.py` reaches `APP_DIR` / `CACHE_DB_PATH` /
|
|
859
|
+
# `CACHE_LOCK_PATH` / `CACHE_LOCK_CODEX_PATH` via call-time
|
|
860
|
+
# `_cctally_core.X` (promoted 2026-05-22, #84). Only `CODEX_SESSIONS_DIR`
|
|
861
|
+
# is out of scope for #84 — that one is still read via the
|
|
862
|
+
# `c = _cctally()` accessor and lives in this file.
|
|
869
863
|
|
|
870
864
|
# === Update subcommand (Section 1 of update-subcommand spec) ===
|
|
871
|
-
#
|
|
872
|
-
#
|
|
873
|
-
# tests/conftest.py:redirect_paths can monkeypatch them, and so later
|
|
874
|
-
# tasks (install detection, version-check pipeline, dashboard worker)
|
|
875
|
-
# don't have to revisit constant placement.
|
|
876
|
-
UPDATE_STATE_PATH = APP_DIR / "update-state.json"
|
|
877
|
-
UPDATE_SUPPRESS_PATH = APP_DIR / "update-suppress.json"
|
|
878
|
-
UPDATE_LOCK_PATH = APP_DIR / "update.lock"
|
|
879
|
-
UPDATE_LOG_PATH = APP_DIR / "update.log"
|
|
880
|
-
UPDATE_LOG_ROTATED_PATH = APP_DIR / "update.log.1"
|
|
865
|
+
# Non-path constants for the `cctally update` feature. Path constants
|
|
866
|
+
# now live in bin/_cctally_core.py (promoted 2026-05-22; see #84).
|
|
881
867
|
UPDATE_LOG_ROTATE_BYTES = 1024 * 1024 # 1 MB; spec §1.5
|
|
882
|
-
UPDATE_CHECK_LAST_FETCH_PATH = APP_DIR / "update-check.last-fetch"
|
|
883
868
|
|
|
884
869
|
UPDATE_NPM_REGISTRY_URL = os.environ.get(
|
|
885
870
|
"CCTALLY_TEST_UPDATE_NPM_URL",
|
|
@@ -904,14 +889,14 @@ def _migrate_legacy_data_dir() -> None:
|
|
|
904
889
|
Removable in a future major version once early users have been on
|
|
905
890
|
cctally long enough that the legacy dir is gone everywhere.
|
|
906
891
|
"""
|
|
907
|
-
if APP_DIR.exists():
|
|
892
|
+
if _cctally_core.APP_DIR.exists():
|
|
908
893
|
return # already migrated, or fresh install at the new path
|
|
909
|
-
if not LEGACY_APP_DIR.exists():
|
|
894
|
+
if not _cctally_core.LEGACY_APP_DIR.exists():
|
|
910
895
|
return # fresh install, no legacy data
|
|
911
|
-
APP_DIR.parent.mkdir(parents=True, exist_ok=True)
|
|
912
|
-
os.rename(LEGACY_APP_DIR, APP_DIR)
|
|
896
|
+
_cctally_core.APP_DIR.parent.mkdir(parents=True, exist_ok=True)
|
|
897
|
+
os.rename(_cctally_core.LEGACY_APP_DIR, _cctally_core.APP_DIR)
|
|
913
898
|
print(
|
|
914
|
-
f"cctally: migrated data dir {LEGACY_APP_DIR} -> {APP_DIR}",
|
|
899
|
+
f"cctally: migrated data dir {_cctally_core.LEGACY_APP_DIR} -> {_cctally_core.APP_DIR}",
|
|
915
900
|
file=sys.stderr,
|
|
916
901
|
)
|
|
917
902
|
|
|
@@ -2179,71 +2164,24 @@ def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
|
|
|
2179
2164
|
return 0.0
|
|
2180
2165
|
|
|
2181
2166
|
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
session_id: str | None = None
|
|
2201
|
-
project_path: str | None = None
|
|
2202
|
-
last_activity: dt.datetime | None = None
|
|
2203
|
-
source_paths: list[str] = field(default_factory=list)
|
|
2204
|
-
|
|
2205
|
-
# Token counters
|
|
2206
|
-
input_tokens: int = 0
|
|
2207
|
-
output_tokens: int = 0
|
|
2208
|
-
cache_creation_tokens: int = 0
|
|
2209
|
-
cache_read_tokens: int = 0
|
|
2210
|
-
|
|
2211
|
-
# Financials (populated by Task 2; zero here)
|
|
2212
|
-
cost: float = 0.0
|
|
2213
|
-
saved_usd: float = 0.0
|
|
2214
|
-
wasted_usd: float = 0.0
|
|
2215
|
-
net_usd: float = 0.0
|
|
2216
|
-
|
|
2217
|
-
# Per-model breakdown children
|
|
2218
|
-
model_breakdowns: list[CacheModelBreakdown] = field(default_factory=list)
|
|
2219
|
-
|
|
2220
|
-
# Anomaly (populated by Task 5; defaults here)
|
|
2221
|
-
anomaly_triggered: bool = False
|
|
2222
|
-
anomaly_reasons: list[str] = field(default_factory=list)
|
|
2223
|
-
|
|
2224
|
-
@property
|
|
2225
|
-
def total_tokens(self) -> int:
|
|
2226
|
-
return (
|
|
2227
|
-
self.input_tokens + self.output_tokens
|
|
2228
|
-
+ self.cache_creation_tokens + self.cache_read_tokens
|
|
2229
|
-
)
|
|
2230
|
-
|
|
2231
|
-
@property
|
|
2232
|
-
def cache_hit_percent(self) -> float:
|
|
2233
|
-
return _compute_cache_hit_percent(
|
|
2234
|
-
self.input_tokens, self.cache_creation_tokens, self.cache_read_tokens
|
|
2235
|
-
)
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
def _compute_cache_hit_percent(
|
|
2239
|
-
input_tokens: int,
|
|
2240
|
-
cache_creation_tokens: int,
|
|
2241
|
-
cache_read_tokens: int,
|
|
2242
|
-
) -> float:
|
|
2243
|
-
total_input = input_tokens + cache_creation_tokens + cache_read_tokens
|
|
2244
|
-
if total_input == 0:
|
|
2245
|
-
return 0.0
|
|
2246
|
-
return (cache_read_tokens / total_input) * 100
|
|
2167
|
+
# === Cache-report kernel re-exports (Task A2 onward) =========================
|
|
2168
|
+
# The dataclasses + pure helpers below previously lived inline in bin/cctally;
|
|
2169
|
+
# the cache-report panel/modal effort moved them to bin/_cctally_cache_report
|
|
2170
|
+
# so the dashboard sync builder can reuse the same pure aggregation as the
|
|
2171
|
+
# CLI. cctally-side callers continue to reach for ``CacheRow`` /
|
|
2172
|
+
# ``CacheModelBreakdown`` / ``_compute_cache_hit_percent`` /
|
|
2173
|
+
# ``_compute_entry_cache_dollars`` by bare name (extensive — every cache-report
|
|
2174
|
+
# renderer + JSON emitter); per-symbol re-export here preserves the call sites
|
|
2175
|
+
# unchanged. ``_compute_entry_cache_dollars`` keeps its pre-extraction
|
|
2176
|
+
# signature on this side by wrapping the kernel version with the embedded
|
|
2177
|
+
# ``CLAUDE_MODEL_PRICING`` injected as the ``pricing`` kwarg.
|
|
2178
|
+
#
|
|
2179
|
+
# Spec: docs/superpowers/specs/2026-05-21-cache-report-panel-design.md §5.2
|
|
2180
|
+
_cctally_cache_report = _load_sibling("_cctally_cache_report")
|
|
2181
|
+
CacheModelBreakdown = _cctally_cache_report.CacheModelBreakdown
|
|
2182
|
+
CacheRow = _cctally_cache_report.CacheRow
|
|
2183
|
+
_compute_cache_hit_percent = _cctally_cache_report._compute_cache_hit_percent
|
|
2184
|
+
_compute_entry_cache_dollars_kernel = _cctally_cache_report._compute_entry_cache_dollars
|
|
2247
2185
|
|
|
2248
2186
|
|
|
2249
2187
|
def _compute_entry_cache_dollars(
|
|
@@ -2251,142 +2189,57 @@ def _compute_entry_cache_dollars(
|
|
|
2251
2189
|
cache_creation_tokens: int,
|
|
2252
2190
|
cache_read_tokens: int,
|
|
2253
2191
|
) -> tuple[float, float, float]:
|
|
2254
|
-
"""
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
Applies Anthropic's per-call >200K-tokens tier (mirrors the
|
|
2264
|
-
`_tiered` helper in `_calculate_entry_cost`). Aggregating tokens
|
|
2265
|
-
across multiple calls and then pricing would under-count savings on
|
|
2266
|
-
any single call that crossed the tier. Resolves `anthropic/` and
|
|
2267
|
-
`anthropic.` aliases via `_resolve_model_pricing` so cache-dollar
|
|
2268
|
-
numbers stay aligned with cost numbers.
|
|
2192
|
+
"""Compatibility wrapper — pre-extraction signature.
|
|
2193
|
+
|
|
2194
|
+
The kernel function takes ``pricing`` explicitly so it stays pure;
|
|
2195
|
+
bin/cctally callers inject the embedded ``CLAUDE_MODEL_PRICING``.
|
|
2196
|
+
``_lookup_pricing`` inside the kernel handles the ``anthropic/`` /
|
|
2197
|
+
``anthropic.`` alias-stripping that the legacy ``_resolve_model_pricing``
|
|
2198
|
+
did, but without the stderr warning (the warning is the CLI's concern
|
|
2199
|
+
and already fires elsewhere via ``_calculate_entry_cost``).
|
|
2269
2200
|
"""
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
tiered_rate = pricing.get(tiered_key)
|
|
2276
|
-
if tokens <= 0:
|
|
2277
|
-
return 0.0
|
|
2278
|
-
if tokens > TIERED_THRESHOLD and tiered_rate is not None:
|
|
2279
|
-
below = TIERED_THRESHOLD
|
|
2280
|
-
above = tokens - TIERED_THRESHOLD
|
|
2281
|
-
return (below * base_rate + above * tiered_rate) / tokens
|
|
2282
|
-
return base_rate
|
|
2283
|
-
|
|
2284
|
-
base_for_read = _tiered_rate(
|
|
2285
|
-
cache_read_tokens,
|
|
2286
|
-
"input_cost_per_token",
|
|
2287
|
-
"input_cost_per_token_above_200k_tokens",
|
|
2288
|
-
)
|
|
2289
|
-
read_rate = _tiered_rate(
|
|
2290
|
-
cache_read_tokens,
|
|
2291
|
-
"cache_read_input_token_cost",
|
|
2292
|
-
"cache_read_input_token_cost_above_200k_tokens",
|
|
2293
|
-
)
|
|
2294
|
-
base_for_create = _tiered_rate(
|
|
2295
|
-
cache_creation_tokens,
|
|
2296
|
-
"input_cost_per_token",
|
|
2297
|
-
"input_cost_per_token_above_200k_tokens",
|
|
2298
|
-
)
|
|
2299
|
-
create_rate = _tiered_rate(
|
|
2300
|
-
cache_creation_tokens,
|
|
2301
|
-
"cache_creation_input_token_cost",
|
|
2302
|
-
"cache_creation_input_token_cost_above_200k_tokens",
|
|
2303
|
-
)
|
|
2304
|
-
|
|
2305
|
-
saved = cache_read_tokens * max(0.0, base_for_read - read_rate)
|
|
2306
|
-
wasted = cache_creation_tokens * max(0.0, create_rate - base_for_create)
|
|
2307
|
-
net = saved - wasted
|
|
2308
|
-
return (saved, wasted, net)
|
|
2201
|
+
return _compute_entry_cache_dollars_kernel(
|
|
2202
|
+
model, cache_creation_tokens, cache_read_tokens,
|
|
2203
|
+
pricing=CLAUDE_MODEL_PRICING,
|
|
2204
|
+
tiered_threshold=TIERED_THRESHOLD,
|
|
2205
|
+
)
|
|
2309
2206
|
|
|
2310
2207
|
|
|
2311
2208
|
def _aggregate_cache_by_day(
|
|
2312
2209
|
since: dt.datetime,
|
|
2313
2210
|
until: dt.datetime,
|
|
2314
2211
|
project: str | None = None,
|
|
2212
|
+
*,
|
|
2213
|
+
display_tz: "ZoneInfo | None" = None,
|
|
2315
2214
|
) -> list[CacheRow]:
|
|
2316
|
-
"""
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
b["inputTokens"] += entry.usage.get("input_tokens", 0)
|
|
2338
|
-
b["outputTokens"] += entry.usage.get("output_tokens", 0)
|
|
2339
|
-
b["cacheCreationTokens"] += create_tok
|
|
2340
|
-
b["cacheReadTokens"] += read_tok
|
|
2341
|
-
b["cost"] += cost
|
|
2342
|
-
b["savedUsd"] += saved
|
|
2343
|
-
b["wastedUsd"] += wasted
|
|
2344
|
-
b["netUsd"] += net
|
|
2345
|
-
|
|
2346
|
-
result: list[CacheRow] = []
|
|
2347
|
-
for day_key in sorted(day_model_buckets.keys()):
|
|
2348
|
-
models = day_model_buckets[day_key]
|
|
2349
|
-
row = CacheRow(date=day_key)
|
|
2350
|
-
for model_name in sorted(models.keys()):
|
|
2351
|
-
b = models[model_name]
|
|
2352
|
-
mb = CacheModelBreakdown(
|
|
2353
|
-
model_name=model_name,
|
|
2354
|
-
input_tokens=b["inputTokens"],
|
|
2355
|
-
output_tokens=b["outputTokens"],
|
|
2356
|
-
cache_creation_tokens=b["cacheCreationTokens"],
|
|
2357
|
-
cache_read_tokens=b["cacheReadTokens"],
|
|
2358
|
-
cache_hit_percent=_compute_cache_hit_percent(
|
|
2359
|
-
b["inputTokens"], b["cacheCreationTokens"], b["cacheReadTokens"]
|
|
2360
|
-
),
|
|
2361
|
-
cost=b["cost"],
|
|
2362
|
-
saved_usd=b["savedUsd"],
|
|
2363
|
-
wasted_usd=b["wastedUsd"],
|
|
2364
|
-
net_usd=b["netUsd"],
|
|
2365
|
-
)
|
|
2366
|
-
row.model_breakdowns.append(mb)
|
|
2367
|
-
row.input_tokens += mb.input_tokens
|
|
2368
|
-
row.output_tokens += mb.output_tokens
|
|
2369
|
-
row.cache_creation_tokens += mb.cache_creation_tokens
|
|
2370
|
-
row.cache_read_tokens += mb.cache_read_tokens
|
|
2371
|
-
row.cost += mb.cost
|
|
2372
|
-
row.saved_usd += mb.saved_usd
|
|
2373
|
-
row.wasted_usd += mb.wasted_usd
|
|
2374
|
-
row.net_usd += mb.net_usd
|
|
2375
|
-
result.append(row)
|
|
2376
|
-
return result
|
|
2377
|
-
|
|
2215
|
+
"""CLI adapter: pulls entries from ``get_entries`` and delegates to the
|
|
2216
|
+
pure-fn kernel ``_cctally_cache_report._aggregate_cache_by_day``.
|
|
2217
|
+
|
|
2218
|
+
Adds an explicit ``display_tz`` kwarg (closes the pre-existing minor bug
|
|
2219
|
+
where ``--tz`` shifted the window edges but not the day-bucketing —
|
|
2220
|
+
spec §1.6, plan A3). Passes the embedded ``CLAUDE_MODEL_PRICING`` +
|
|
2221
|
+
``_calculate_entry_cost`` into the kernel so the kernel itself stays
|
|
2222
|
+
free of pricing globals / cost-dispatch I/O.
|
|
2223
|
+
|
|
2224
|
+
Direct callers that don't pass ``display_tz`` (legacy contract) fall
|
|
2225
|
+
back to host-local via the kernel's ``None``-tz handling, matching
|
|
2226
|
+
pre-extraction behavior byte-for-byte. ``since`` / ``until`` bound
|
|
2227
|
+
the I/O query here; the kernel itself trusts the caller's pre-filter.
|
|
2228
|
+
"""
|
|
2229
|
+
entries = list(get_entries(since, until, project=project))
|
|
2230
|
+
return _cctally_cache_report._aggregate_cache_by_day(
|
|
2231
|
+
entries,
|
|
2232
|
+
display_tz=display_tz,
|
|
2233
|
+
pricing=CLAUDE_MODEL_PRICING,
|
|
2234
|
+
cost_calculator=_calculate_entry_cost,
|
|
2235
|
+
)
|
|
2378
2236
|
|
|
2379
|
-
def _filename_uuid_stem(path: str) -> str:
|
|
2380
|
-
"""Extract the UUID stem from a JSONL filename.
|
|
2381
2237
|
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
base = os.path.basename(path)
|
|
2388
|
-
stem, _, _ = base.partition(".")
|
|
2389
|
-
return stem
|
|
2238
|
+
# Re-export the kernel's filename stem helper so any bare-name callers
|
|
2239
|
+
# inside bin/cctally (and tests poking via ``ns["_filename_uuid_stem"]``)
|
|
2240
|
+
# resolve unchanged. Kernel is pure-string; ``os.path.basename``
|
|
2241
|
+
# equivalence is asserted by ``test_aggregate_by_session_falls_back_*``.
|
|
2242
|
+
_filename_uuid_stem = _cctally_cache_report._filename_uuid_stem
|
|
2390
2243
|
|
|
2391
2244
|
|
|
2392
2245
|
def _aggregate_cache_by_session(
|
|
@@ -2394,135 +2247,40 @@ def _aggregate_cache_by_session(
|
|
|
2394
2247
|
until: dt.datetime,
|
|
2395
2248
|
project: str | None = None,
|
|
2396
2249
|
) -> list[CacheRow]:
|
|
2397
|
-
"""
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
the
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2250
|
+
"""CLI adapter: pulls Claude session entries from
|
|
2251
|
+
``get_claude_session_entries`` and delegates to the pure-fn kernel
|
|
2252
|
+
``_cctally_cache_report._aggregate_cache_by_session``.
|
|
2253
|
+
|
|
2254
|
+
Preserves the legacy one-shot ``Warning: N entries lacked
|
|
2255
|
+
session_files rows (cache may be catching up).`` stderr line by
|
|
2256
|
+
consuming the kernel's ``fallback_count`` and calling ``eprint``
|
|
2257
|
+
here (kept on the I/O side; kernel stays pure). Injects
|
|
2258
|
+
``CLAUDE_MODEL_PRICING`` + ``_calculate_entry_cost`` +
|
|
2259
|
+
``_decode_escaped_cwd`` so the kernel doesn't reach for cctally
|
|
2260
|
+
globals. ``since`` / ``until`` bound the I/O query; the kernel
|
|
2261
|
+
itself trusts the caller's pre-filter.
|
|
2405
2262
|
"""
|
|
2406
2263
|
entries = get_claude_session_entries(since, until, project=project)
|
|
2407
2264
|
if not entries:
|
|
2408
2265
|
return []
|
|
2409
2266
|
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
fallback_count = 0
|
|
2414
|
-
for entry in entries:
|
|
2415
|
-
# Skip synthetic entries (Claude Code internal markers, not real
|
|
2416
|
-
# model calls). Mirrors `_aggregate_claude_sessions` (line ~2992).
|
|
2417
|
-
# Must occur before the session_id fallback so synthetic entries
|
|
2418
|
-
# don't inflate fallback_count either.
|
|
2419
|
-
if entry.model == "<synthetic>":
|
|
2420
|
-
continue
|
|
2421
|
-
sid = entry.session_id
|
|
2422
|
-
if sid is None:
|
|
2423
|
-
sid = _filename_uuid_stem(entry.source_path)
|
|
2424
|
-
fallback_count += 1
|
|
2425
|
-
b = buckets.setdefault(sid, {
|
|
2426
|
-
"entries": [],
|
|
2427
|
-
# Seed with decoded-cwd fallback so rows still resolve a
|
|
2428
|
-
# Project cell while session_files backfill is incomplete.
|
|
2429
|
-
# Real project_path from session_files (if present on any
|
|
2430
|
-
# joined row) overrides below.
|
|
2431
|
-
"project_path": _decode_escaped_cwd(
|
|
2432
|
-
os.path.basename(os.path.dirname(entry.source_path))
|
|
2433
|
-
),
|
|
2434
|
-
"last_activity": None,
|
|
2435
|
-
"source_paths": set(),
|
|
2436
|
-
})
|
|
2437
|
-
b["entries"].append(entry)
|
|
2438
|
-
b["source_paths"].add(entry.source_path)
|
|
2439
|
-
if b["last_activity"] is None or entry.timestamp > b["last_activity"]:
|
|
2440
|
-
b["last_activity"] = entry.timestamp
|
|
2441
|
-
# Project path from most-recent in-window entry that has it.
|
|
2442
|
-
if entry.project_path:
|
|
2443
|
-
b["project_path"] = entry.project_path
|
|
2444
|
-
|
|
2445
|
-
if fallback_count:
|
|
2446
|
-
eprint(
|
|
2447
|
-
f"Warning: {fallback_count} entries lacked session_files rows "
|
|
2448
|
-
"(cache may be catching up)."
|
|
2267
|
+
def _project_decoder(source_path: str) -> str:
|
|
2268
|
+
return _decode_escaped_cwd(
|
|
2269
|
+
os.path.basename(os.path.dirname(source_path))
|
|
2449
2270
|
)
|
|
2450
2271
|
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
mb_raw["inputTokens"] += entry.input_tokens
|
|
2462
|
-
mb_raw["outputTokens"] += entry.output_tokens
|
|
2463
|
-
mb_raw["cacheCreationTokens"] += entry.cache_creation_tokens
|
|
2464
|
-
mb_raw["cacheReadTokens"] += entry.cache_read_tokens
|
|
2465
|
-
mb_raw["cost"] += _calculate_entry_cost(
|
|
2466
|
-
entry.model,
|
|
2467
|
-
{
|
|
2468
|
-
"input_tokens": entry.input_tokens,
|
|
2469
|
-
"output_tokens": entry.output_tokens,
|
|
2470
|
-
"cache_creation_input_tokens": entry.cache_creation_tokens,
|
|
2471
|
-
"cache_read_input_tokens": entry.cache_read_tokens,
|
|
2472
|
-
},
|
|
2473
|
-
mode="auto",
|
|
2474
|
-
cost_usd=entry.cost_usd,
|
|
2475
|
-
)
|
|
2476
|
-
saved, wasted, net = _compute_entry_cache_dollars(
|
|
2477
|
-
entry.model,
|
|
2478
|
-
entry.cache_creation_tokens,
|
|
2479
|
-
entry.cache_read_tokens,
|
|
2480
|
-
)
|
|
2481
|
-
mb_raw["savedUsd"] += saved
|
|
2482
|
-
mb_raw["wastedUsd"] += wasted
|
|
2483
|
-
mb_raw["netUsd"] += net
|
|
2484
|
-
|
|
2485
|
-
row = CacheRow(
|
|
2486
|
-
session_id=sid,
|
|
2487
|
-
project_path=b["project_path"],
|
|
2488
|
-
last_activity=b["last_activity"],
|
|
2489
|
-
source_paths=sorted(b["source_paths"]),
|
|
2272
|
+
agg = _cctally_cache_report._aggregate_cache_by_session(
|
|
2273
|
+
entries,
|
|
2274
|
+
pricing=CLAUDE_MODEL_PRICING,
|
|
2275
|
+
cost_calculator=_calculate_entry_cost,
|
|
2276
|
+
project_decoder=_project_decoder,
|
|
2277
|
+
)
|
|
2278
|
+
if agg.fallback_count:
|
|
2279
|
+
eprint(
|
|
2280
|
+
f"Warning: {agg.fallback_count} entries lacked session_files rows "
|
|
2281
|
+
"(cache may be catching up)."
|
|
2490
2282
|
)
|
|
2491
|
-
|
|
2492
|
-
mb_raw = model_buckets[model_name]
|
|
2493
|
-
mb = CacheModelBreakdown(
|
|
2494
|
-
model_name=model_name,
|
|
2495
|
-
input_tokens=mb_raw["inputTokens"],
|
|
2496
|
-
output_tokens=mb_raw["outputTokens"],
|
|
2497
|
-
cache_creation_tokens=mb_raw["cacheCreationTokens"],
|
|
2498
|
-
cache_read_tokens=mb_raw["cacheReadTokens"],
|
|
2499
|
-
cache_hit_percent=_compute_cache_hit_percent(
|
|
2500
|
-
mb_raw["inputTokens"],
|
|
2501
|
-
mb_raw["cacheCreationTokens"],
|
|
2502
|
-
mb_raw["cacheReadTokens"],
|
|
2503
|
-
),
|
|
2504
|
-
cost=mb_raw["cost"],
|
|
2505
|
-
saved_usd=mb_raw["savedUsd"],
|
|
2506
|
-
wasted_usd=mb_raw["wastedUsd"],
|
|
2507
|
-
net_usd=mb_raw["netUsd"],
|
|
2508
|
-
)
|
|
2509
|
-
row.model_breakdowns.append(mb)
|
|
2510
|
-
row.input_tokens += mb.input_tokens
|
|
2511
|
-
row.output_tokens += mb.output_tokens
|
|
2512
|
-
row.cache_creation_tokens += mb.cache_creation_tokens
|
|
2513
|
-
row.cache_read_tokens += mb.cache_read_tokens
|
|
2514
|
-
row.cost += mb.cost
|
|
2515
|
-
row.saved_usd += mb.saved_usd
|
|
2516
|
-
row.wasted_usd += mb.wasted_usd
|
|
2517
|
-
row.net_usd += mb.net_usd
|
|
2518
|
-
result.append(row)
|
|
2519
|
-
|
|
2520
|
-
# Initial ordering descending by last_activity; Task 6 adds --sort and
|
|
2521
|
-
# will change the session-mode default. Use tz-aware sentinel to avoid
|
|
2522
|
-
# naive-vs-aware comparison errors on rows missing last_activity.
|
|
2523
|
-
_min_dt = dt.datetime.min.replace(tzinfo=dt.timezone.utc)
|
|
2524
|
-
result.sort(key=lambda r: r.last_activity or _min_dt, reverse=True)
|
|
2525
|
-
return result
|
|
2283
|
+
return agg.rows
|
|
2526
2284
|
|
|
2527
2285
|
|
|
2528
2286
|
def _annotate_anomalies(
|
|
@@ -2532,83 +2290,20 @@ def _annotate_anomalies(
|
|
|
2532
2290
|
*,
|
|
2533
2291
|
enabled: bool = True,
|
|
2534
2292
|
) -> None:
|
|
2535
|
-
"""
|
|
2536
|
-
|
|
2537
|
-
Trigger 1 (net_negative): net_usd < 0 (strict). Skipped when the row has
|
|
2538
|
-
zero cache activity (no-op session, not a bug).
|
|
2539
|
-
Trigger 2 (cache_drop): cache_hit_percent is >= threshold_pp below the
|
|
2540
|
-
trailing window_days median of OTHER rows. Requires minimum 5 (daily)
|
|
2541
|
-
or 10 (session) baseline samples; silently skipped otherwise.
|
|
2293
|
+
"""CLI adapter: thin shim around the kernel's ``_classify_anomalies``.
|
|
2542
2294
|
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
(
|
|
2295
|
+
Kept under the original name so the existing call site in
|
|
2296
|
+
``cmd_cache_report`` resolves unchanged. The kernel mutates each row
|
|
2297
|
+
in place (same contract as the pre-extraction implementation —
|
|
2298
|
+
``anomaly_triggered`` / ``anomaly_reasons`` set on each ``CacheRow``).
|
|
2546
2299
|
"""
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
row.anomaly_reasons = []
|
|
2553
|
-
return
|
|
2554
|
-
if not rows:
|
|
2555
|
-
return
|
|
2556
|
-
|
|
2557
|
-
# Determine mode + baseline minimum from the first row's identity.
|
|
2558
|
-
is_session_mode = rows[0].session_id is not None
|
|
2559
|
-
min_baseline = 10 if is_session_mode else 5
|
|
2560
|
-
|
|
2561
|
-
def _row_anchor(r: CacheRow) -> dt.datetime | None:
|
|
2562
|
-
"""Return the row's position in time for baseline-window comparison."""
|
|
2563
|
-
if r.last_activity is not None:
|
|
2564
|
-
return r.last_activity
|
|
2565
|
-
if r.date:
|
|
2566
|
-
# Use .astimezone() (not .replace(tzinfo=...)) so the OS tzdb
|
|
2567
|
-
# gives the correct offset for the given date — avoids DST drift
|
|
2568
|
-
# on dates that straddle a DST boundary. Mirrors the idiom in
|
|
2569
|
-
# _parse_cli_date_range.
|
|
2570
|
-
# internal fallback: host-local intentional
|
|
2571
|
-
return dt.datetime.strptime(r.date, "%Y-%m-%d").astimezone()
|
|
2572
|
-
return None
|
|
2573
|
-
|
|
2574
|
-
window = dt.timedelta(days=window_days)
|
|
2575
|
-
upper_offset = (
|
|
2576
|
-
dt.timedelta(seconds=1) if is_session_mode else dt.timedelta(days=1)
|
|
2300
|
+
_cctally_cache_report._classify_anomalies(
|
|
2301
|
+
rows,
|
|
2302
|
+
threshold_pp=threshold_pp,
|
|
2303
|
+
window_days=window_days,
|
|
2304
|
+
enabled=enabled,
|
|
2577
2305
|
)
|
|
2578
2306
|
|
|
2579
|
-
# Pre-compute anchors once to avoid O(n^2 * datetime-parse) overhead.
|
|
2580
|
-
anchors: list[dt.datetime | None] = [_row_anchor(r) for r in rows]
|
|
2581
|
-
|
|
2582
|
-
for i, row in enumerate(rows):
|
|
2583
|
-
reasons: list[str] = []
|
|
2584
|
-
|
|
2585
|
-
# Trigger 1: net_negative (no baseline needed; cache-activity guard).
|
|
2586
|
-
if row.cache_creation_tokens + row.cache_read_tokens > 0:
|
|
2587
|
-
if row.net_usd < 0:
|
|
2588
|
-
reasons.append("net_negative")
|
|
2589
|
-
|
|
2590
|
-
# Trigger 2: cache_drop (requires baseline).
|
|
2591
|
-
anchor = anchors[i]
|
|
2592
|
-
if anchor is not None:
|
|
2593
|
-
lower_bound = anchor - window
|
|
2594
|
-
upper_bound = anchor - upper_offset
|
|
2595
|
-
baseline_values: list[float] = []
|
|
2596
|
-
for j, other in enumerate(rows):
|
|
2597
|
-
if j == i:
|
|
2598
|
-
continue
|
|
2599
|
-
other_anchor = anchors[j]
|
|
2600
|
-
if other_anchor is None:
|
|
2601
|
-
continue
|
|
2602
|
-
if lower_bound <= other_anchor <= upper_bound:
|
|
2603
|
-
baseline_values.append(other.cache_hit_percent)
|
|
2604
|
-
if len(baseline_values) >= min_baseline:
|
|
2605
|
-
median = statistics.median(baseline_values)
|
|
2606
|
-
if (median - row.cache_hit_percent) >= threshold_pp:
|
|
2607
|
-
reasons.append("cache_drop")
|
|
2608
|
-
|
|
2609
|
-
row.anomaly_reasons = reasons
|
|
2610
|
-
row.anomaly_triggered = bool(reasons)
|
|
2611
|
-
|
|
2612
2307
|
|
|
2613
2308
|
@dataclass
|
|
2614
2309
|
class WeekCostResult:
|
|
@@ -8013,11 +7708,17 @@ class SetupError(RuntimeError):
|
|
|
8013
7708
|
"""Raised when setup hits a hard prerequisite failure (Section 2 of spec)."""
|
|
8014
7709
|
|
|
8015
7710
|
|
|
8016
|
-
|
|
8017
|
-
|
|
7711
|
+
def _load_claude_settings(path: pathlib.Path | None = None) -> dict:
|
|
7712
|
+
"""Read ~/.claude/settings.json. Empty/missing → {}. Malformed → SetupError.
|
|
8018
7713
|
|
|
8019
|
-
|
|
8020
|
-
|
|
7714
|
+
``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
|
|
7715
|
+
TIME when omitted, so ``monkeypatch.setattr(_cctally_core,
|
|
7716
|
+
"CLAUDE_SETTINGS_PATH", tmp)`` propagates without needing to swap
|
|
7717
|
+
out this callable. Capturing the default at def-time would silently
|
|
7718
|
+
pin the maintainer's real ``~/.claude/settings.json``.
|
|
7719
|
+
"""
|
|
7720
|
+
if path is None:
|
|
7721
|
+
path = _cctally_core.CLAUDE_SETTINGS_PATH
|
|
8021
7722
|
if not path.exists():
|
|
8022
7723
|
return {}
|
|
8023
7724
|
raw = path.read_text(encoding="utf-8")
|
|
@@ -8034,8 +7735,14 @@ def _load_claude_settings(path: pathlib.Path = CLAUDE_SETTINGS_PATH) -> dict:
|
|
|
8034
7735
|
return data
|
|
8035
7736
|
|
|
8036
7737
|
|
|
8037
|
-
def _backup_claude_settings(path: pathlib.Path =
|
|
8038
|
-
"""Best-effort daily backup; return backup path or None.
|
|
7738
|
+
def _backup_claude_settings(path: pathlib.Path | None = None) -> pathlib.Path | None:
|
|
7739
|
+
"""Best-effort daily backup; return backup path or None.
|
|
7740
|
+
|
|
7741
|
+
``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
|
|
7742
|
+
TIME when omitted (see ``_load_claude_settings`` for rationale).
|
|
7743
|
+
"""
|
|
7744
|
+
if path is None:
|
|
7745
|
+
path = _cctally_core.CLAUDE_SETTINGS_PATH
|
|
8039
7746
|
if not path.exists():
|
|
8040
7747
|
return None
|
|
8041
7748
|
today = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d")
|
|
@@ -8051,9 +7758,15 @@ def _backup_claude_settings(path: pathlib.Path = CLAUDE_SETTINGS_PATH) -> pathli
|
|
|
8051
7758
|
|
|
8052
7759
|
|
|
8053
7760
|
def _write_claude_settings_atomic(
|
|
8054
|
-
settings: dict, path: pathlib.Path =
|
|
7761
|
+
settings: dict, path: pathlib.Path | None = None
|
|
8055
7762
|
) -> None:
|
|
8056
|
-
"""Atomic write with 2-space indent, trailing newline.
|
|
7763
|
+
"""Atomic write with 2-space indent, trailing newline.
|
|
7764
|
+
|
|
7765
|
+
``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
|
|
7766
|
+
TIME when omitted (see ``_load_claude_settings`` for rationale).
|
|
7767
|
+
"""
|
|
7768
|
+
if path is None:
|
|
7769
|
+
path = _cctally_core.CLAUDE_SETTINGS_PATH
|
|
8057
7770
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
8058
7771
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
8059
7772
|
tmp.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
@@ -8444,7 +8157,13 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
|
8444
8157
|
if mode == "session":
|
|
8445
8158
|
rows = _aggregate_cache_by_session(since, until, project=args.project)
|
|
8446
8159
|
else:
|
|
8447
|
-
|
|
8160
|
+
# Task A3: pass the resolved display_tz so day buckets match the
|
|
8161
|
+
# ``--tz`` flag (closes the pre-existing minor bug where the
|
|
8162
|
+
# window edges shifted but day buckets stayed on host-local —
|
|
8163
|
+
# spec §1.6 / plan A3).
|
|
8164
|
+
rows = _aggregate_cache_by_day(
|
|
8165
|
+
since, until, project=args.project, display_tz=tz,
|
|
8166
|
+
)
|
|
8448
8167
|
|
|
8449
8168
|
if not rows:
|
|
8450
8169
|
if args.json:
|
|
@@ -8699,19 +8418,19 @@ def doctor_gather_state(
|
|
|
8699
8418
|
|
|
8700
8419
|
# ── DB ───────────────────────────────────────────────────────────
|
|
8701
8420
|
try:
|
|
8702
|
-
stats_db_status = _db_status_for(DB_PATH, _STATS_MIGRATIONS, "stats.db")
|
|
8703
|
-
if not DB_PATH.exists():
|
|
8421
|
+
stats_db_status = _db_status_for(_cctally_core.DB_PATH, _STATS_MIGRATIONS, "stats.db")
|
|
8422
|
+
if not _cctally_core.DB_PATH.exists():
|
|
8704
8423
|
stats_db_status["_file_exists"] = False
|
|
8705
8424
|
except sqlite3.Error as exc:
|
|
8706
|
-
stats_db_status = {"path": str(DB_PATH), "user_version": 0,
|
|
8425
|
+
stats_db_status = {"path": str(_cctally_core.DB_PATH), "user_version": 0,
|
|
8707
8426
|
"registry_size": len(_STATS_MIGRATIONS),
|
|
8708
8427
|
"migrations": [], "_open_error": str(exc)}
|
|
8709
8428
|
try:
|
|
8710
|
-
cache_db_status = _db_status_for(CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db")
|
|
8711
|
-
if not CACHE_DB_PATH.exists():
|
|
8429
|
+
cache_db_status = _db_status_for(_cctally_core.CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db")
|
|
8430
|
+
if not _cctally_core.CACHE_DB_PATH.exists():
|
|
8712
8431
|
cache_db_status["_file_exists"] = False
|
|
8713
8432
|
except sqlite3.Error as exc:
|
|
8714
|
-
cache_db_status = {"path": str(CACHE_DB_PATH), "user_version": 0,
|
|
8433
|
+
cache_db_status = {"path": str(_cctally_core.CACHE_DB_PATH), "user_version": 0,
|
|
8715
8434
|
"registry_size": len(_CACHE_MIGRATIONS),
|
|
8716
8435
|
"migrations": [], "_open_error": str(exc)}
|
|
8717
8436
|
|
|
@@ -8720,8 +8439,8 @@ def doctor_gather_state(
|
|
|
8720
8439
|
forked_bucket_counts: dict | None = None
|
|
8721
8440
|
credited_weeks: list[dict] | None = None
|
|
8722
8441
|
try:
|
|
8723
|
-
if DB_PATH.exists():
|
|
8724
|
-
conn = sqlite3.connect(str(DB_PATH))
|
|
8442
|
+
if _cctally_core.DB_PATH.exists():
|
|
8443
|
+
conn = sqlite3.connect(str(_cctally_core.DB_PATH))
|
|
8725
8444
|
try:
|
|
8726
8445
|
try:
|
|
8727
8446
|
row = conn.execute(
|
|
@@ -8818,8 +8537,8 @@ def doctor_gather_state(
|
|
|
8818
8537
|
cache_entries_count = None
|
|
8819
8538
|
cache_last_entry_at = None
|
|
8820
8539
|
try:
|
|
8821
|
-
if CACHE_DB_PATH.exists():
|
|
8822
|
-
conn = sqlite3.connect(str(CACHE_DB_PATH))
|
|
8540
|
+
if _cctally_core.CACHE_DB_PATH.exists():
|
|
8541
|
+
conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
|
|
8823
8542
|
try:
|
|
8824
8543
|
row = conn.execute(
|
|
8825
8544
|
"SELECT COUNT(*), MAX(timestamp_utc) FROM session_entries"
|
|
@@ -8848,8 +8567,8 @@ def doctor_gather_state(
|
|
|
8848
8567
|
codex_entries_count = None
|
|
8849
8568
|
codex_last_entry_at = None
|
|
8850
8569
|
try:
|
|
8851
|
-
if CACHE_DB_PATH.exists():
|
|
8852
|
-
conn = sqlite3.connect(str(CACHE_DB_PATH))
|
|
8570
|
+
if _cctally_core.CACHE_DB_PATH.exists():
|
|
8571
|
+
conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
|
|
8853
8572
|
try:
|
|
8854
8573
|
row = conn.execute(
|
|
8855
8574
|
"SELECT COUNT(*), MAX(timestamp_utc) FROM codex_session_entries"
|
|
@@ -8891,8 +8610,8 @@ def doctor_gather_state(
|
|
|
8891
8610
|
# check surfaces the corruption separately.
|
|
8892
8611
|
dashboard_bind_stored = "loopback"
|
|
8893
8612
|
try:
|
|
8894
|
-
if CONFIG_PATH.exists():
|
|
8895
|
-
raw_cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
8613
|
+
if _cctally_core.CONFIG_PATH.exists():
|
|
8614
|
+
raw_cfg = json.loads(_cctally_core.CONFIG_PATH.read_text(encoding="utf-8"))
|
|
8896
8615
|
if isinstance(raw_cfg, dict):
|
|
8897
8616
|
dashboard_bind_stored = (
|
|
8898
8617
|
_config_known_value(raw_cfg, "dashboard.bind") or "loopback"
|
|
@@ -8906,8 +8625,8 @@ def doctor_gather_state(
|
|
|
8906
8625
|
# (codex H1).
|
|
8907
8626
|
config_json_error = None
|
|
8908
8627
|
try:
|
|
8909
|
-
if CONFIG_PATH.exists():
|
|
8910
|
-
json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
8628
|
+
if _cctally_core.CONFIG_PATH.exists():
|
|
8629
|
+
json.loads(_cctally_core.CONFIG_PATH.read_text(encoding="utf-8"))
|
|
8911
8630
|
except json.JSONDecodeError as exc:
|
|
8912
8631
|
config_json_error = f"{type(exc).__name__}: {exc}"
|
|
8913
8632
|
except OSError as exc:
|