cctally 1.10.2 → 1.11.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 +22 -0
- package/bin/_cctally_cache_report.py +938 -0
- package/bin/_cctally_dashboard.py +619 -6
- package/bin/_cctally_tui.py +45 -0
- package/bin/_lib_blocks.py +4 -0
- package/bin/_lib_render.py +5 -4
- package/bin/cctally +102 -386
- 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_tui.py
CHANGED
|
@@ -1071,6 +1071,16 @@ class DataSnapshot:
|
|
|
1071
1071
|
# declares ``ProjectsEnvelope | null`` and the client renders the
|
|
1072
1072
|
# panel-empty state until the next tick replaces it.
|
|
1073
1073
|
projects_envelope: dict | None = None
|
|
1074
|
+
# Cache-report panel + modal envelope block (spec
|
|
1075
|
+
# 2026-05-21-cache-report-panel-design.md §4.2). Populated on the
|
|
1076
|
+
# sync thread by ``build_cache_report_snapshot`` alongside the
|
|
1077
|
+
# existing projects build. The dashboard's
|
|
1078
|
+
# ``snapshot_to_envelope`` reads this back unchanged and assigns it
|
|
1079
|
+
# to ``envelope["cache_report"]``. ``None`` on first tick before
|
|
1080
|
+
# sync completes — the TS envelope mirror declares
|
|
1081
|
+
# ``CacheReportEnvelope | null`` and the client renders the
|
|
1082
|
+
# panel-empty state until the next tick replaces it.
|
|
1083
|
+
cache_report: Any | None = None
|
|
1074
1084
|
|
|
1075
1085
|
@classmethod
|
|
1076
1086
|
def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
|
|
@@ -2113,6 +2123,40 @@ def _tui_build_snapshot(
|
|
|
2113
2123
|
sessions = annotated
|
|
2114
2124
|
except Exception as exc:
|
|
2115
2125
|
errors.append(f"projects-cross-nav-bind: {exc}")
|
|
2126
|
+
|
|
2127
|
+
# Cache-report panel + modal envelope block (spec
|
|
2128
|
+
# 2026-05-21-cache-report-panel-design.md §5.2). Per-tick build
|
|
2129
|
+
# alongside the projects envelope. Threshold is read from
|
|
2130
|
+
# ``config.json:cache_report.anomaly_threshold_pp`` (default
|
|
2131
|
+
# 15); ``anomaly_window_days`` is hardcoded at 14 in v1.
|
|
2132
|
+
# display_tz inherits the same resolved zone as every other
|
|
2133
|
+
# panel so today-bucketing matches the envelope's ``display``
|
|
2134
|
+
# block. Errors record on ``last_sync_error``; ``None`` lands
|
|
2135
|
+
# on the DataSnapshot field and the client renders the empty
|
|
2136
|
+
# state.
|
|
2137
|
+
cache_report_block = None
|
|
2138
|
+
try:
|
|
2139
|
+
cfg_cr = load_config().get("cache_report") or {}
|
|
2140
|
+
threshold_raw = cfg_cr.get("anomaly_threshold_pp", 15)
|
|
2141
|
+
try:
|
|
2142
|
+
threshold_pp = int(threshold_raw)
|
|
2143
|
+
except (TypeError, ValueError):
|
|
2144
|
+
threshold_pp = 15
|
|
2145
|
+
if threshold_pp < 1 or threshold_pp > 100:
|
|
2146
|
+
threshold_pp = 15
|
|
2147
|
+
_dash_mod = sys.modules["_cctally_dashboard"]
|
|
2148
|
+
_bcr = _dash_mod.build_cache_report_snapshot
|
|
2149
|
+
cache_report_block = _bcr(
|
|
2150
|
+
now_utc=now_utc,
|
|
2151
|
+
anomaly_threshold_pp=threshold_pp,
|
|
2152
|
+
# Hardcoded for v1; F10 tracks lifting via cache_report.anomaly_window_days config.
|
|
2153
|
+
anomaly_window_days=_dash_mod.CACHE_REPORT_ANOMALY_WINDOW_DAYS,
|
|
2154
|
+
display_tz=_build_display_tz,
|
|
2155
|
+
skip_sync=skip_sync,
|
|
2156
|
+
)
|
|
2157
|
+
except Exception as exc:
|
|
2158
|
+
errors.append(f"cache-report: {exc}")
|
|
2159
|
+
|
|
2116
2160
|
return DataSnapshot(
|
|
2117
2161
|
current_week=cw,
|
|
2118
2162
|
forecast=fc,
|
|
@@ -2141,6 +2185,7 @@ def _tui_build_snapshot(
|
|
|
2141
2185
|
trend_history_median_dpp=history_median_dpp,
|
|
2142
2186
|
forecast_view=fc_view,
|
|
2143
2187
|
projects_envelope=projects_envelope_block,
|
|
2188
|
+
cache_report=cache_report_block,
|
|
2144
2189
|
)
|
|
2145
2190
|
finally:
|
|
2146
2191
|
conn.close()
|
package/bin/_lib_blocks.py
CHANGED
|
@@ -290,6 +290,10 @@ def _group_entries_into_blocks(
|
|
|
290
290
|
if prev.end_time < b.start_time:
|
|
291
291
|
first_entry_ts = first_entry_ts_by_block.get(id(b), b.start_time)
|
|
292
292
|
prev_actual_end = prev.actual_end_time or prev.end_time
|
|
293
|
+
gap_seconds = (first_entry_ts - prev_actual_end).total_seconds()
|
|
294
|
+
if gap_seconds < 60:
|
|
295
|
+
final_blocks.append(b)
|
|
296
|
+
continue
|
|
293
297
|
final_blocks.append(Block(
|
|
294
298
|
start_time=prev_actual_end,
|
|
295
299
|
end_time=first_entry_ts,
|
package/bin/_lib_render.py
CHANGED
|
@@ -206,10 +206,11 @@ def _render_blocks_table(
|
|
|
206
206
|
return f"{h}h {m:02d}m"
|
|
207
207
|
|
|
208
208
|
def _fmt_gap_duration(total_seconds: float) -> str:
|
|
209
|
-
|
|
210
|
-
if
|
|
211
|
-
|
|
212
|
-
|
|
209
|
+
total_minutes = int(total_seconds / 60)
|
|
210
|
+
if total_minutes < 60:
|
|
211
|
+
return f"{max(total_minutes, 1)}m gap"
|
|
212
|
+
h, m = divmod(total_minutes, 60)
|
|
213
|
+
return f"{h}h {m:02d}m gap" if m else f"{h}h gap"
|
|
213
214
|
|
|
214
215
|
# ── determine if % column is needed ─────────────────────────────────
|
|
215
216
|
max_completed_tokens = 0
|
package/bin/cctally
CHANGED
|
@@ -2179,71 +2179,24 @@ def _trend_row_recency_seconds(row: dict[str, Any]) -> float:
|
|
|
2179
2179
|
return 0.0
|
|
2180
2180
|
|
|
2181
2181
|
|
|
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
|
|
2182
|
+
# === Cache-report kernel re-exports (Task A2 onward) =========================
|
|
2183
|
+
# The dataclasses + pure helpers below previously lived inline in bin/cctally;
|
|
2184
|
+
# the cache-report panel/modal effort moved them to bin/_cctally_cache_report
|
|
2185
|
+
# so the dashboard sync builder can reuse the same pure aggregation as the
|
|
2186
|
+
# CLI. cctally-side callers continue to reach for ``CacheRow`` /
|
|
2187
|
+
# ``CacheModelBreakdown`` / ``_compute_cache_hit_percent`` /
|
|
2188
|
+
# ``_compute_entry_cache_dollars`` by bare name (extensive — every cache-report
|
|
2189
|
+
# renderer + JSON emitter); per-symbol re-export here preserves the call sites
|
|
2190
|
+
# unchanged. ``_compute_entry_cache_dollars`` keeps its pre-extraction
|
|
2191
|
+
# signature on this side by wrapping the kernel version with the embedded
|
|
2192
|
+
# ``CLAUDE_MODEL_PRICING`` injected as the ``pricing`` kwarg.
|
|
2193
|
+
#
|
|
2194
|
+
# Spec: docs/superpowers/specs/2026-05-21-cache-report-panel-design.md §5.2
|
|
2195
|
+
_cctally_cache_report = _load_sibling("_cctally_cache_report")
|
|
2196
|
+
CacheModelBreakdown = _cctally_cache_report.CacheModelBreakdown
|
|
2197
|
+
CacheRow = _cctally_cache_report.CacheRow
|
|
2198
|
+
_compute_cache_hit_percent = _cctally_cache_report._compute_cache_hit_percent
|
|
2199
|
+
_compute_entry_cache_dollars_kernel = _cctally_cache_report._compute_entry_cache_dollars
|
|
2247
2200
|
|
|
2248
2201
|
|
|
2249
2202
|
def _compute_entry_cache_dollars(
|
|
@@ -2251,142 +2204,57 @@ def _compute_entry_cache_dollars(
|
|
|
2251
2204
|
cache_creation_tokens: int,
|
|
2252
2205
|
cache_read_tokens: int,
|
|
2253
2206
|
) -> 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.
|
|
2207
|
+
"""Compatibility wrapper — pre-extraction signature.
|
|
2208
|
+
|
|
2209
|
+
The kernel function takes ``pricing`` explicitly so it stays pure;
|
|
2210
|
+
bin/cctally callers inject the embedded ``CLAUDE_MODEL_PRICING``.
|
|
2211
|
+
``_lookup_pricing`` inside the kernel handles the ``anthropic/`` /
|
|
2212
|
+
``anthropic.`` alias-stripping that the legacy ``_resolve_model_pricing``
|
|
2213
|
+
did, but without the stderr warning (the warning is the CLI's concern
|
|
2214
|
+
and already fires elsewhere via ``_calculate_entry_cost``).
|
|
2269
2215
|
"""
|
|
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)
|
|
2216
|
+
return _compute_entry_cache_dollars_kernel(
|
|
2217
|
+
model, cache_creation_tokens, cache_read_tokens,
|
|
2218
|
+
pricing=CLAUDE_MODEL_PRICING,
|
|
2219
|
+
tiered_threshold=TIERED_THRESHOLD,
|
|
2220
|
+
)
|
|
2309
2221
|
|
|
2310
2222
|
|
|
2311
2223
|
def _aggregate_cache_by_day(
|
|
2312
2224
|
since: dt.datetime,
|
|
2313
2225
|
until: dt.datetime,
|
|
2314
2226
|
project: str | None = None,
|
|
2227
|
+
*,
|
|
2228
|
+
display_tz: "ZoneInfo | None" = None,
|
|
2315
2229
|
) -> 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
|
-
|
|
2230
|
+
"""CLI adapter: pulls entries from ``get_entries`` and delegates to the
|
|
2231
|
+
pure-fn kernel ``_cctally_cache_report._aggregate_cache_by_day``.
|
|
2232
|
+
|
|
2233
|
+
Adds an explicit ``display_tz`` kwarg (closes the pre-existing minor bug
|
|
2234
|
+
where ``--tz`` shifted the window edges but not the day-bucketing —
|
|
2235
|
+
spec §1.6, plan A3). Passes the embedded ``CLAUDE_MODEL_PRICING`` +
|
|
2236
|
+
``_calculate_entry_cost`` into the kernel so the kernel itself stays
|
|
2237
|
+
free of pricing globals / cost-dispatch I/O.
|
|
2238
|
+
|
|
2239
|
+
Direct callers that don't pass ``display_tz`` (legacy contract) fall
|
|
2240
|
+
back to host-local via the kernel's ``None``-tz handling, matching
|
|
2241
|
+
pre-extraction behavior byte-for-byte. ``since`` / ``until`` bound
|
|
2242
|
+
the I/O query here; the kernel itself trusts the caller's pre-filter.
|
|
2243
|
+
"""
|
|
2244
|
+
entries = list(get_entries(since, until, project=project))
|
|
2245
|
+
return _cctally_cache_report._aggregate_cache_by_day(
|
|
2246
|
+
entries,
|
|
2247
|
+
display_tz=display_tz,
|
|
2248
|
+
pricing=CLAUDE_MODEL_PRICING,
|
|
2249
|
+
cost_calculator=_calculate_entry_cost,
|
|
2250
|
+
)
|
|
2378
2251
|
|
|
2379
|
-
def _filename_uuid_stem(path: str) -> str:
|
|
2380
|
-
"""Extract the UUID stem from a JSONL filename.
|
|
2381
2252
|
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
base = os.path.basename(path)
|
|
2388
|
-
stem, _, _ = base.partition(".")
|
|
2389
|
-
return stem
|
|
2253
|
+
# Re-export the kernel's filename stem helper so any bare-name callers
|
|
2254
|
+
# inside bin/cctally (and tests poking via ``ns["_filename_uuid_stem"]``)
|
|
2255
|
+
# resolve unchanged. Kernel is pure-string; ``os.path.basename``
|
|
2256
|
+
# equivalence is asserted by ``test_aggregate_by_session_falls_back_*``.
|
|
2257
|
+
_filename_uuid_stem = _cctally_cache_report._filename_uuid_stem
|
|
2390
2258
|
|
|
2391
2259
|
|
|
2392
2260
|
def _aggregate_cache_by_session(
|
|
@@ -2394,135 +2262,40 @@ def _aggregate_cache_by_session(
|
|
|
2394
2262
|
until: dt.datetime,
|
|
2395
2263
|
project: str | None = None,
|
|
2396
2264
|
) -> list[CacheRow]:
|
|
2397
|
-
"""
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
the
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2265
|
+
"""CLI adapter: pulls Claude session entries from
|
|
2266
|
+
``get_claude_session_entries`` and delegates to the pure-fn kernel
|
|
2267
|
+
``_cctally_cache_report._aggregate_cache_by_session``.
|
|
2268
|
+
|
|
2269
|
+
Preserves the legacy one-shot ``Warning: N entries lacked
|
|
2270
|
+
session_files rows (cache may be catching up).`` stderr line by
|
|
2271
|
+
consuming the kernel's ``fallback_count`` and calling ``eprint``
|
|
2272
|
+
here (kept on the I/O side; kernel stays pure). Injects
|
|
2273
|
+
``CLAUDE_MODEL_PRICING`` + ``_calculate_entry_cost`` +
|
|
2274
|
+
``_decode_escaped_cwd`` so the kernel doesn't reach for cctally
|
|
2275
|
+
globals. ``since`` / ``until`` bound the I/O query; the kernel
|
|
2276
|
+
itself trusts the caller's pre-filter.
|
|
2405
2277
|
"""
|
|
2406
2278
|
entries = get_claude_session_entries(since, until, project=project)
|
|
2407
2279
|
if not entries:
|
|
2408
2280
|
return []
|
|
2409
2281
|
|
|
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)."
|
|
2282
|
+
def _project_decoder(source_path: str) -> str:
|
|
2283
|
+
return _decode_escaped_cwd(
|
|
2284
|
+
os.path.basename(os.path.dirname(source_path))
|
|
2449
2285
|
)
|
|
2450
2286
|
|
|
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"]),
|
|
2287
|
+
agg = _cctally_cache_report._aggregate_cache_by_session(
|
|
2288
|
+
entries,
|
|
2289
|
+
pricing=CLAUDE_MODEL_PRICING,
|
|
2290
|
+
cost_calculator=_calculate_entry_cost,
|
|
2291
|
+
project_decoder=_project_decoder,
|
|
2292
|
+
)
|
|
2293
|
+
if agg.fallback_count:
|
|
2294
|
+
eprint(
|
|
2295
|
+
f"Warning: {agg.fallback_count} entries lacked session_files rows "
|
|
2296
|
+
"(cache may be catching up)."
|
|
2490
2297
|
)
|
|
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
|
|
2298
|
+
return agg.rows
|
|
2526
2299
|
|
|
2527
2300
|
|
|
2528
2301
|
def _annotate_anomalies(
|
|
@@ -2532,83 +2305,20 @@ def _annotate_anomalies(
|
|
|
2532
2305
|
*,
|
|
2533
2306
|
enabled: bool = True,
|
|
2534
2307
|
) -> None:
|
|
2535
|
-
"""
|
|
2308
|
+
"""CLI adapter: thin shim around the kernel's ``_classify_anomalies``.
|
|
2536
2309
|
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
or 10 (session) baseline samples; silently skipped otherwise.
|
|
2542
|
-
|
|
2543
|
-
Mode is inferred from the first row: if it has a session_id, session
|
|
2544
|
-
mode (window_days back to <= last_activity - 1s); else daily mode
|
|
2545
|
-
(window_days back to <= date - 1 day).
|
|
2310
|
+
Kept under the original name so the existing call site in
|
|
2311
|
+
``cmd_cache_report`` resolves unchanged. The kernel mutates each row
|
|
2312
|
+
in place (same contract as the pre-extraction implementation —
|
|
2313
|
+
``anomaly_triggered`` / ``anomaly_reasons`` set on each ``CacheRow``).
|
|
2546
2314
|
"""
|
|
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)
|
|
2315
|
+
_cctally_cache_report._classify_anomalies(
|
|
2316
|
+
rows,
|
|
2317
|
+
threshold_pp=threshold_pp,
|
|
2318
|
+
window_days=window_days,
|
|
2319
|
+
enabled=enabled,
|
|
2577
2320
|
)
|
|
2578
2321
|
|
|
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
2322
|
|
|
2613
2323
|
@dataclass
|
|
2614
2324
|
class WeekCostResult:
|
|
@@ -8444,7 +8154,13 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
|
8444
8154
|
if mode == "session":
|
|
8445
8155
|
rows = _aggregate_cache_by_session(since, until, project=args.project)
|
|
8446
8156
|
else:
|
|
8447
|
-
|
|
8157
|
+
# Task A3: pass the resolved display_tz so day buckets match the
|
|
8158
|
+
# ``--tz`` flag (closes the pre-existing minor bug where the
|
|
8159
|
+
# window edges shifted but day buckets stayed on host-local —
|
|
8160
|
+
# spec §1.6 / plan A3).
|
|
8161
|
+
rows = _aggregate_cache_by_day(
|
|
8162
|
+
since, until, project=args.project, display_tz=tz,
|
|
8163
|
+
)
|
|
8448
8164
|
|
|
8449
8165
|
if not rows:
|
|
8450
8166
|
if args.json:
|