cctally 1.22.1 → 1.22.3
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 +18 -0
- package/bin/_cctally_cache_report.py +1133 -880
- package/bin/_cctally_codex.py +518 -0
- package/bin/_cctally_dashboard.py +3 -3
- package/bin/_cctally_diff.py +240 -0
- package/bin/_cctally_doctor.py +479 -0
- package/bin/_cctally_five_hour.py +1688 -0
- package/bin/_cctally_forecast.py +1979 -0
- package/bin/_cctally_milestones.py +433 -0
- package/bin/_cctally_percent_breakdown.py +199 -0
- package/bin/_cctally_pricing_check.py +393 -0
- package/bin/_cctally_project.py +714 -0
- package/bin/_cctally_record.py +5 -3
- package/bin/_cctally_reporting.py +749 -0
- package/bin/_cctally_setup.py +172 -13
- package/bin/_cctally_statusline.py +630 -0
- package/bin/_cctally_sync_week.py +5 -4
- package/bin/_cctally_weekrefs.py +450 -0
- package/bin/_lib_cache_report.py +938 -0
- package/bin/_lib_pricing_debug.py +182 -0
- package/bin/_lib_subscription_weeks.py +2 -2
- package/bin/cctally +426 -9569
- package/package.json +15 -1
|
@@ -1,938 +1,1191 @@
|
|
|
1
|
-
"""
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
explicitly. ``None`` means host-local fallback (legacy behavior).
|
|
11
|
-
Callers pass the resolved IANA zone from ``resolve_display_tz``.
|
|
12
|
-
|
|
13
|
-
See ``docs/superpowers/specs/2026-05-21-cache-report-panel-design.md``
|
|
14
|
-
§5 for the full contract.
|
|
1
|
+
"""CLI glue for cctally cache-report: render + window resolution + IO
|
|
2
|
+
aggregation wrappers + the ``cache-report`` command. The pure data kernel
|
|
3
|
+
(bucketing, financial computation, anomaly classification) lives in
|
|
4
|
+
``bin/_lib_cache_report.py`` and is shared with the dashboard sync builder;
|
|
5
|
+
this module is CLI-only.
|
|
6
|
+
|
|
7
|
+
Accessor discipline (spec §2): ns-patchable / STAYS helpers are reached via
|
|
8
|
+
the call-time ``c = _cctally()`` accessor; ``_cctally_core`` kernel symbols are
|
|
9
|
+
honest-imported; the data kernel is reached as ``crk.<name>``.
|
|
15
10
|
"""
|
|
16
11
|
from __future__ import annotations
|
|
17
12
|
|
|
13
|
+
import argparse
|
|
18
14
|
import datetime as dt
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
# Anthropic's per-call >200K-tokens tier — kept in sync with bin/_lib_pricing.
|
|
25
|
-
# Callers may override via the ``tiered_threshold`` kwarg.
|
|
26
|
-
DEFAULT_TIERED_THRESHOLD = 200_000
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
# Minimum baseline samples for the per-row anomaly classifier.
|
|
30
|
-
# Daily mode: >=5 trailing days. Session mode: >=10 trailing sessions
|
|
31
|
-
# (richer signal per sample so a higher minimum keeps thin-baseline
|
|
32
|
-
# false positives down).
|
|
33
|
-
CACHE_REPORT_MIN_BASELINE_DAYS = 5
|
|
34
|
-
CACHE_REPORT_MIN_BASELINE_SESSIONS = 10
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
# Literal alias mirroring TS `CacheAnomalyReason` at
|
|
38
|
-
# dashboard/web/src/types/envelope.ts:71 — keeps the two surfaces in
|
|
39
|
-
# lockstep so a typo on either side fails type-check.
|
|
40
|
-
CacheAnomalyReason = Literal["net_negative", "cache_drop"]
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@dataclass
|
|
44
|
-
class CacheModelBreakdown:
|
|
45
|
-
model_name: str
|
|
46
|
-
input_tokens: int
|
|
47
|
-
output_tokens: int
|
|
48
|
-
cache_creation_tokens: int
|
|
49
|
-
cache_read_tokens: int
|
|
50
|
-
cache_hit_percent: float
|
|
51
|
-
cost: float
|
|
52
|
-
saved_usd: float = 0.0
|
|
53
|
-
wasted_usd: float = 0.0
|
|
54
|
-
net_usd: float = 0.0
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@dataclass
|
|
58
|
-
class CacheBreakdownRow:
|
|
59
|
-
"""One row of the panel/modal by-project / by-model breakdown.
|
|
60
|
-
|
|
61
|
-
Carried by the kernel so by-project and by-model share a single
|
|
62
|
-
aggregation path. The dashboard wraps each into the SSE-side frozen
|
|
63
|
-
``CacheReportBreakdownRow`` (same field shape — only ``key`` /
|
|
64
|
-
``cache_hit_percent`` / ``net_usd`` cross the envelope boundary)
|
|
65
|
-
without further transformation. The token fields stay internal:
|
|
66
|
-
they're populated so the tail-aggregate "(other)" row hit-% can
|
|
67
|
-
sum directly from the head rows rather than re-walking the raw
|
|
68
|
-
bucket map (EFF-4).
|
|
69
|
-
"""
|
|
70
|
-
key: str
|
|
71
|
-
cache_hit_percent: float
|
|
72
|
-
net_usd: float
|
|
73
|
-
input_tokens: int = 0
|
|
74
|
-
cache_creation_tokens: int = 0
|
|
75
|
-
cache_read_tokens: int = 0
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
@dataclass
|
|
79
|
-
class CacheRow:
|
|
80
|
-
# Day-mode rows carry ``date``. Session-mode rows carry ``session_id``,
|
|
81
|
-
# ``project_path``, ``last_activity``, ``source_paths``. The two are
|
|
82
|
-
# never populated together.
|
|
83
|
-
date: str | None = None
|
|
84
|
-
session_id: str | None = None
|
|
85
|
-
project_path: str | None = None
|
|
86
|
-
last_activity: dt.datetime | None = None
|
|
87
|
-
source_paths: list[str] = field(default_factory=list)
|
|
88
|
-
|
|
89
|
-
# Token counters
|
|
90
|
-
input_tokens: int = 0
|
|
91
|
-
output_tokens: int = 0
|
|
92
|
-
cache_creation_tokens: int = 0
|
|
93
|
-
cache_read_tokens: int = 0
|
|
94
|
-
|
|
95
|
-
# Financials
|
|
96
|
-
cost: float = 0.0
|
|
97
|
-
saved_usd: float = 0.0
|
|
98
|
-
wasted_usd: float = 0.0
|
|
99
|
-
net_usd: float = 0.0
|
|
100
|
-
|
|
101
|
-
# Per-model breakdown children
|
|
102
|
-
model_breakdowns: list[CacheModelBreakdown] = field(default_factory=list)
|
|
103
|
-
|
|
104
|
-
# Anomaly (populated by _classify_anomalies)
|
|
105
|
-
anomaly_triggered: bool = False
|
|
106
|
-
anomaly_reasons: list[CacheAnomalyReason] = field(default_factory=list)
|
|
107
|
-
|
|
108
|
-
@property
|
|
109
|
-
def total_tokens(self) -> int:
|
|
110
|
-
return (
|
|
111
|
-
self.input_tokens + self.output_tokens
|
|
112
|
-
+ self.cache_creation_tokens + self.cache_read_tokens
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
@property
|
|
116
|
-
def cache_hit_percent(self) -> float:
|
|
117
|
-
return _compute_cache_hit_percent(
|
|
118
|
-
self.input_tokens, self.cache_creation_tokens, self.cache_read_tokens
|
|
119
|
-
)
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any, Literal, Optional
|
|
19
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
120
20
|
|
|
21
|
+
from _cctally_core import eprint, now_utc_iso, parse_iso_datetime, _command_as_of
|
|
22
|
+
import _lib_cache_report as crk
|
|
121
23
|
|
|
122
|
-
@dataclass
|
|
123
|
-
class _Bucket:
|
|
124
|
-
"""Per-(day,model) / per-session / per-breakdown-key aggregation accumulator.
|
|
125
24
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
fields (``output_tokens`` / ``cost`` stay zero); that's fine — the
|
|
131
|
-
by-project / by-model paths don't surface them.
|
|
132
|
-
"""
|
|
133
|
-
input_tokens: int = 0
|
|
134
|
-
output_tokens: int = 0
|
|
135
|
-
cache_creation_tokens: int = 0
|
|
136
|
-
cache_read_tokens: int = 0
|
|
137
|
-
cost: float = 0.0
|
|
138
|
-
saved_usd: float = 0.0
|
|
139
|
-
wasted_usd: float = 0.0
|
|
140
|
-
net_usd: float = 0.0
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _compute_cache_hit_percent(
|
|
144
|
-
input_tokens: int,
|
|
145
|
-
cache_creation_tokens: int,
|
|
146
|
-
cache_read_tokens: int,
|
|
147
|
-
) -> float:
|
|
148
|
-
"""Compute cache hit percentage from token counts.
|
|
149
|
-
|
|
150
|
-
Formula: ``cache_read / (input + cache_creation + cache_read) * 100``.
|
|
151
|
-
Returns ``0.0`` when there are no tokens.
|
|
152
|
-
"""
|
|
153
|
-
total_input = input_tokens + cache_creation_tokens + cache_read_tokens
|
|
154
|
-
if total_input == 0:
|
|
155
|
-
return 0.0
|
|
156
|
-
return (cache_read_tokens / total_input) * 100
|
|
25
|
+
def _cctally():
|
|
26
|
+
"""Call-time accessor to the cctally module namespace (ns-patchable)."""
|
|
27
|
+
import sys
|
|
28
|
+
return sys.modules["cctally"]
|
|
157
29
|
|
|
158
30
|
|
|
159
|
-
def
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return p
|
|
167
|
-
for prefix in ("anthropic/", "anthropic."):
|
|
168
|
-
if model.startswith(prefix):
|
|
169
|
-
stripped = model[len(prefix):]
|
|
170
|
-
p = pricing.get(stripped)
|
|
171
|
-
if p is not None:
|
|
172
|
-
return p
|
|
173
|
-
return None
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
def _compute_entry_cache_dollars(
|
|
177
|
-
model: str,
|
|
178
|
-
cache_creation_tokens: int,
|
|
179
|
-
cache_read_tokens: int,
|
|
31
|
+
def _layout_cache_table(
|
|
32
|
+
headers: list[str],
|
|
33
|
+
aligns: list[str],
|
|
34
|
+
raw_rows: list[tuple[list[tuple[str, Any]], str]],
|
|
35
|
+
title: str,
|
|
36
|
+
color: bool,
|
|
37
|
+
unicode_ok: bool,
|
|
180
38
|
*,
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
39
|
+
expand_col_index: int,
|
|
40
|
+
numeric_col_indices: tuple[int, ...],
|
|
41
|
+
date_col_index: int | None,
|
|
42
|
+
wide_text_min: int = 15,
|
|
43
|
+
narrow_text_min: int = 12,
|
|
44
|
+
droppable_col_index: int | None = None,
|
|
45
|
+
compact: bool = False,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Shared responsive-width table layout for cache-report renderers.
|
|
48
|
+
|
|
49
|
+
Takes fully-built raw_rows (list of (cells, row_type) where cells is a
|
|
50
|
+
list of (text, color_fn|None)) plus column metadata and returns the
|
|
51
|
+
rendered table as a newline-joined string. Applies wide/narrow
|
|
52
|
+
responsive widths, multi-line cell splitting, cyan headers, row
|
|
53
|
+
separators, and a banner title.
|
|
54
|
+
|
|
55
|
+
- `expand_col_index`: the identity column that absorbs remainder width
|
|
56
|
+
in compact mode (e.g., 1 = Models in daily, 2 = Project in session).
|
|
57
|
+
- `numeric_col_indices`: column indices that get _truncate_num treatment
|
|
58
|
+
in compact mode.
|
|
59
|
+
- `date_col_index`: column index whose YYYY-MM-DD cells split across
|
|
60
|
+
two lines in compact mode (None disables the split; used by daily's
|
|
61
|
+
"Date" column).
|
|
62
|
+
- `wide_text_min`/`narrow_text_min`: minimum widths for the expandable
|
|
63
|
+
text column in wide/compact modes respectively.
|
|
64
|
+
- `droppable_col_index`: column to drop entirely when the sum of
|
|
65
|
+
narrow-mode minimums still exceeds the terminal width. Used as an
|
|
66
|
+
escape hatch so 120-col terminals can render the full-dollar cache
|
|
67
|
+
tables without overflow. Must not equal `expand_col_index`.
|
|
202
68
|
"""
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
"
|
|
222
|
-
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
69
|
+
c = _cctally()
|
|
70
|
+
num_cols = len(headers)
|
|
71
|
+
|
|
72
|
+
# Ultra-compact fallback: if even the narrow-mode minimums won't fit
|
|
73
|
+
# this terminal, drop the designated "low-value" column entirely. This
|
|
74
|
+
# preserves table integrity instead of silently overflowing.
|
|
75
|
+
def _narrow_min_floor(i: int) -> int:
|
|
76
|
+
if aligns[i] == "right":
|
|
77
|
+
return 7
|
|
78
|
+
if i == expand_col_index:
|
|
79
|
+
return narrow_text_min
|
|
80
|
+
if date_col_index is not None and i == date_col_index:
|
|
81
|
+
return 10
|
|
82
|
+
return 8
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
_term_width_probe = os.get_terminal_size().columns
|
|
86
|
+
except (OSError, ValueError):
|
|
87
|
+
_term_width_probe = int(os.environ.get("COLUMNS", "120"))
|
|
88
|
+
_border_overhead_probe = 3 * num_cols + 1
|
|
89
|
+
_min_total = sum(_narrow_min_floor(i) for i in range(num_cols)) + _border_overhead_probe
|
|
90
|
+
if (
|
|
91
|
+
droppable_col_index is not None
|
|
92
|
+
and droppable_col_index != expand_col_index
|
|
93
|
+
and _min_total > _term_width_probe
|
|
94
|
+
):
|
|
95
|
+
d = droppable_col_index
|
|
96
|
+
headers = headers[:d] + headers[d + 1:]
|
|
97
|
+
aligns = aligns[:d] + aligns[d + 1:]
|
|
98
|
+
raw_rows = [
|
|
99
|
+
(cells[:d] + cells[d + 1:], rt)
|
|
100
|
+
for cells, rt in raw_rows
|
|
101
|
+
]
|
|
102
|
+
if expand_col_index > d:
|
|
103
|
+
expand_col_index -= 1
|
|
104
|
+
numeric_col_indices = tuple(
|
|
105
|
+
(i - 1 if i > d else i)
|
|
106
|
+
for i in numeric_col_indices
|
|
107
|
+
if i != d
|
|
108
|
+
)
|
|
109
|
+
if date_col_index is not None:
|
|
110
|
+
if date_col_index == d:
|
|
111
|
+
date_col_index = None
|
|
112
|
+
elif date_col_index > d:
|
|
113
|
+
date_col_index -= 1
|
|
114
|
+
num_cols = len(headers)
|
|
115
|
+
|
|
116
|
+
def _dim(s: str) -> str:
|
|
117
|
+
return c._style_ansi(s, "90", color)
|
|
118
|
+
|
|
119
|
+
def _cyan(s: str) -> str:
|
|
120
|
+
return c._style_ansi(s, "36", color)
|
|
121
|
+
|
|
122
|
+
def _bold(s: str) -> str:
|
|
123
|
+
return c._style_ansi(s, "1", color)
|
|
124
|
+
|
|
125
|
+
def _max_line_width(s: str) -> int:
|
|
126
|
+
if not s:
|
|
127
|
+
return 0
|
|
128
|
+
return max(len(line) for line in s.split("\n"))
|
|
129
|
+
|
|
130
|
+
content_widths = [len(h) for h in headers]
|
|
131
|
+
for cells, _rt in raw_rows:
|
|
132
|
+
for i, (text, _c) in enumerate(cells):
|
|
133
|
+
content_widths[i] = max(content_widths[i], _max_line_width(text))
|
|
134
|
+
|
|
135
|
+
def _wide_width(i: int, content: int) -> int:
|
|
136
|
+
if aligns[i] == "right":
|
|
137
|
+
return max(content + 3, 11)
|
|
138
|
+
if i == expand_col_index:
|
|
139
|
+
return max(content + 2, wide_text_min)
|
|
140
|
+
return max(content + 2, 10)
|
|
141
|
+
|
|
142
|
+
col_widths = [_wide_width(i, content_widths[i]) for i in range(num_cols)]
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
term_width = os.get_terminal_size().columns
|
|
146
|
+
except (OSError, ValueError):
|
|
147
|
+
term_width = int(os.environ.get("COLUMNS", "120"))
|
|
148
|
+
|
|
149
|
+
border_overhead = 3 * num_cols + 1
|
|
150
|
+
# Issue #91 (Shape A): the ``compact`` kwarg forces the responsive
|
|
151
|
+
# scale-down path regardless of terminal width, mirroring
|
|
152
|
+
# ``_render_project_table`` / ``_render_bucket_table``. Auto-detected
|
|
153
|
+
# width-overflow continues to trigger the same path as before.
|
|
154
|
+
compact_mode = compact or (sum(col_widths) + border_overhead > term_width)
|
|
155
|
+
|
|
156
|
+
if compact_mode:
|
|
157
|
+
available = term_width - border_overhead
|
|
158
|
+
total_col = sum(col_widths)
|
|
159
|
+
scale = available / total_col if total_col > 0 else 1.0
|
|
160
|
+
|
|
161
|
+
def _narrow_min(i: int) -> int:
|
|
162
|
+
if aligns[i] == "right":
|
|
163
|
+
# 7 is enough for "100.0%", "$99.99", "+$9.99", and for
|
|
164
|
+
# larger values _truncate_num (via numeric_col_indices)
|
|
165
|
+
# adds an ellipsis tail instead of overflowing the cell.
|
|
166
|
+
return 7
|
|
167
|
+
if i == expand_col_index:
|
|
168
|
+
return narrow_text_min
|
|
169
|
+
if date_col_index is not None and i == date_col_index:
|
|
170
|
+
return 10
|
|
171
|
+
return 8
|
|
172
|
+
|
|
173
|
+
col_widths = [
|
|
174
|
+
max(int(w * scale), _narrow_min(i))
|
|
175
|
+
for i, w in enumerate(col_widths)
|
|
176
|
+
]
|
|
177
|
+
remainder = available - sum(col_widths)
|
|
178
|
+
if remainder > 0:
|
|
179
|
+
col_widths[expand_col_index] += remainder
|
|
180
|
+
else:
|
|
181
|
+
# Scaled widths still exceed available. Trim the widest
|
|
182
|
+
# non-expand column by 1 each round; once those all hit their
|
|
183
|
+
# floor, trim the expand col down to its floor too. Identity
|
|
184
|
+
# cells that exceed their (shrunken) column get `_truncate_num`
|
|
185
|
+
# ellipsis treatment via the render path.
|
|
186
|
+
while remainder < 0:
|
|
187
|
+
trim_i = -1
|
|
188
|
+
trim_w = 0
|
|
189
|
+
for i in range(num_cols):
|
|
190
|
+
if i == expand_col_index:
|
|
191
|
+
continue
|
|
192
|
+
if col_widths[i] <= _narrow_min(i):
|
|
193
|
+
continue
|
|
194
|
+
if col_widths[i] > trim_w:
|
|
195
|
+
trim_w = col_widths[i]
|
|
196
|
+
trim_i = i
|
|
197
|
+
if trim_i < 0:
|
|
198
|
+
# No non-expand col can shrink further. Fall back to
|
|
199
|
+
# shrinking the expand col (down to its own floor).
|
|
200
|
+
if col_widths[expand_col_index] > _narrow_min(expand_col_index):
|
|
201
|
+
trim_i = expand_col_index
|
|
202
|
+
else:
|
|
203
|
+
break
|
|
204
|
+
col_widths[trim_i] -= 1
|
|
205
|
+
remainder += 1
|
|
206
|
+
|
|
207
|
+
if compact_mode:
|
|
208
|
+
header_display = [h.replace(" ", "\n") for h in headers]
|
|
209
|
+
else:
|
|
210
|
+
header_display = headers[:]
|
|
211
|
+
|
|
212
|
+
def _split_cell(text: str) -> list[str]:
|
|
213
|
+
return text.split("\n") if text else [""]
|
|
214
|
+
|
|
215
|
+
# Detect glyph-prefixed dates (anomaly-flagged rows). `_split_date_if_compact`
|
|
216
|
+
# uses a strict ^YYYY-MM-DD$ regex that won't match "⚠︎ YYYY-MM-DD" (or
|
|
217
|
+
# the ASCII "! " fallback), so flagged rows would skip the split while
|
|
218
|
+
# unflagged rows got it — producing inconsistent row heights in compact
|
|
219
|
+
# mode. If ANY data row in this table is flagged, skip the date-split for
|
|
220
|
+
# the whole table so every row renders on a single line with uniform height.
|
|
221
|
+
any_anomaly_row = False
|
|
222
|
+
if date_col_index is not None:
|
|
223
|
+
for cells, row_type in raw_rows:
|
|
224
|
+
if row_type != "data":
|
|
225
|
+
continue
|
|
226
|
+
cell_text = cells[date_col_index][0]
|
|
227
|
+
stripped = re.sub(r"\033\[[0-9;]*m", "", cell_text)
|
|
228
|
+
if stripped.startswith("⚠") or stripped.startswith("!"):
|
|
229
|
+
any_anomaly_row = True
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
def _split_date_if_compact(text: str) -> str:
|
|
233
|
+
if (
|
|
234
|
+
compact_mode
|
|
235
|
+
and date_col_index is not None
|
|
236
|
+
and not any_anomaly_row
|
|
237
|
+
and re.match(r"^\d{4}-\d{2}-\d{2}$", text)
|
|
238
|
+
):
|
|
239
|
+
y, mm, dd = text.split("-")
|
|
240
|
+
return f"{y}\n{mm}-{dd}"
|
|
241
|
+
return text
|
|
242
|
+
|
|
243
|
+
display_rows: list[tuple[list[list[tuple[str, Any]]], str]] = []
|
|
244
|
+
for cells, row_type in raw_rows:
|
|
245
|
+
processed: list[tuple[str, Any]] = []
|
|
246
|
+
for i, (text, cfn) in enumerate(cells):
|
|
247
|
+
t = (
|
|
248
|
+
_split_date_if_compact(text)
|
|
249
|
+
if date_col_index is not None and i == date_col_index
|
|
250
|
+
else text
|
|
251
|
+
)
|
|
252
|
+
processed.append((t, cfn))
|
|
253
|
+
line_counts = [len(_split_cell(t)) for t, _ in processed]
|
|
254
|
+
n_lines = max(line_counts) if line_counts else 1
|
|
255
|
+
row_lines: list[list[tuple[str, Any]]] = []
|
|
256
|
+
for li in range(n_lines):
|
|
257
|
+
row_cells: list[tuple[str, Any]] = []
|
|
258
|
+
for (text, cfn) in processed:
|
|
259
|
+
parts = _split_cell(text)
|
|
260
|
+
row_cells.append((parts[li] if li < len(parts) else "", cfn))
|
|
261
|
+
row_lines.append(row_cells)
|
|
262
|
+
display_rows.append((row_lines, row_type))
|
|
263
|
+
|
|
264
|
+
header_line_counts = [len(_split_cell(h)) for h in header_display]
|
|
265
|
+
header_n_lines = max(header_line_counts) if header_line_counts else 1
|
|
266
|
+
header_lines: list[list[str]] = []
|
|
267
|
+
for li in range(header_n_lines):
|
|
268
|
+
line = []
|
|
269
|
+
for h in header_display:
|
|
270
|
+
parts = _split_cell(h)
|
|
271
|
+
line.append(parts[li] if li < len(parts) else "")
|
|
272
|
+
header_lines.append(line)
|
|
273
|
+
|
|
274
|
+
if unicode_ok:
|
|
275
|
+
ch = {
|
|
276
|
+
"tl": "┌", "tm": "┬", "tr": "┐",
|
|
277
|
+
"ml": "├", "mm": "┼", "mr": "┤",
|
|
278
|
+
"bl": "└", "bm": "┴", "br": "┘",
|
|
279
|
+
"h": "─", "v": "│",
|
|
280
|
+
}
|
|
281
|
+
else:
|
|
282
|
+
ch = {k: v for k, v in zip(
|
|
283
|
+
["tl", "tm", "tr", "ml", "mm", "mr", "bl", "bm", "br", "h", "v"],
|
|
284
|
+
"+++++++++-|",
|
|
285
|
+
)}
|
|
286
|
+
|
|
287
|
+
def hline(left: str, mid: str, right: str) -> str:
|
|
288
|
+
segs = [ch["h"] * (col_widths[i] + 2) for i in range(num_cols)]
|
|
289
|
+
return _dim(left + mid.join(segs) + right)
|
|
290
|
+
|
|
291
|
+
def padcell(text: str, width: int, align: str) -> str:
|
|
292
|
+
vis_len = len(re.sub(r"\033\[[0-9;]*m", "", text))
|
|
293
|
+
pad_needed = width - vis_len
|
|
294
|
+
if pad_needed <= 0:
|
|
295
|
+
return text
|
|
296
|
+
if align == "right":
|
|
297
|
+
return " " * pad_needed + text
|
|
298
|
+
return text + " " * pad_needed
|
|
299
|
+
|
|
300
|
+
def make_row(cells: list[str]) -> str:
|
|
301
|
+
parts: list[str] = []
|
|
302
|
+
for i, cell_text in enumerate(cells):
|
|
303
|
+
padded = padcell(cell_text, col_widths[i], aligns[i])
|
|
304
|
+
parts.append(f" {padded} ")
|
|
305
|
+
v = _dim(ch["v"])
|
|
306
|
+
return v + v.join(parts) + v
|
|
307
|
+
|
|
308
|
+
lines: list[str] = []
|
|
309
|
+
lines.append("")
|
|
310
|
+
title_padded = f" {title} "
|
|
311
|
+
tw = len(title_padded)
|
|
312
|
+
dash = "─" if unicode_ok else "-"
|
|
313
|
+
vb = "│" if unicode_ok else "|"
|
|
314
|
+
if unicode_ok:
|
|
315
|
+
banner_top = f" ╭{dash * tw}╮"
|
|
316
|
+
banner_bot = f" ╰{dash * tw}╯"
|
|
317
|
+
else:
|
|
318
|
+
banner_top = f" +{'-' * tw}+"
|
|
319
|
+
banner_bot = f" +{'-' * tw}+"
|
|
320
|
+
lines.append(banner_top)
|
|
321
|
+
lines.append(f" {vb}" + " " * tw + vb)
|
|
322
|
+
lines.append(f" {vb}" + _bold(title_padded) + vb)
|
|
323
|
+
lines.append(f" {vb}" + " " * tw + vb)
|
|
324
|
+
lines.append(banner_bot)
|
|
325
|
+
lines.append("")
|
|
326
|
+
|
|
327
|
+
lines.append(hline(ch["tl"], ch["tm"], ch["tr"]))
|
|
328
|
+
for line_cells in header_lines:
|
|
329
|
+
if compact_mode:
|
|
330
|
+
line_cells = [
|
|
331
|
+
c._truncate_num(cc, col_widths[i]) if len(cc) > col_widths[i] else cc
|
|
332
|
+
for i, cc in enumerate(line_cells)
|
|
333
|
+
]
|
|
334
|
+
lines.append(make_row([_cyan(cc) for cc in line_cells]))
|
|
335
|
+
lines.append(hline(ch["ml"], ch["mm"], ch["mr"]))
|
|
336
|
+
|
|
337
|
+
def _render_display_row(row_lines: list[list[tuple[str, Any]]]) -> None:
|
|
338
|
+
for line_cells in row_lines:
|
|
339
|
+
rendered: list[str] = []
|
|
340
|
+
for ci, (text, cfn) in enumerate(line_cells):
|
|
341
|
+
out = text
|
|
342
|
+
if compact_mode and out:
|
|
343
|
+
if ci in numeric_col_indices:
|
|
344
|
+
out = c._truncate_num(out, col_widths[ci])
|
|
345
|
+
elif len(c._ANSI_ESC_RE.sub("", out)) > col_widths[ci]:
|
|
346
|
+
# Left-aligned content wider than its column — truncate
|
|
347
|
+
# with ellipsis. Uses `_truncate_display` so anomaly
|
|
348
|
+
# rows (which inject a red-styled glyph into cell 0)
|
|
349
|
+
# aren't sliced mid-escape-sequence, which would
|
|
350
|
+
# bleed color into adjacent cells.
|
|
351
|
+
out = c._truncate_display(out, col_widths[ci])
|
|
352
|
+
if cfn is not None and out:
|
|
353
|
+
out = cfn(out)
|
|
354
|
+
rendered.append(out)
|
|
355
|
+
lines.append(make_row(rendered))
|
|
356
|
+
|
|
357
|
+
for idx, (row_lines, _rt) in enumerate(display_rows):
|
|
358
|
+
_render_display_row(row_lines)
|
|
359
|
+
if idx < len(display_rows) - 1:
|
|
360
|
+
lines.append(hline(ch["ml"], ch["mm"], ch["mr"]))
|
|
361
|
+
|
|
362
|
+
lines.append(hline(ch["bl"], ch["bm"], ch["br"]))
|
|
363
|
+
return "\n".join(lines)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _render_cache_day_rows(
|
|
367
|
+
rows: list["crk.CacheRow"], title: str, *, compact: bool = False,
|
|
368
|
+
) -> str:
|
|
369
|
+
"""Render daily-mode cache report.
|
|
370
|
+
|
|
371
|
+
Columns: Date, Models, Cache %, Input, Cache Create, Cache Read,
|
|
372
|
+
Total Tokens, Cost (USD), $ Saved, $ Wasted, Net $.
|
|
373
|
+
"""
|
|
374
|
+
c = _cctally()
|
|
375
|
+
color = c._supports_color_stdout()
|
|
376
|
+
unicode_ok = c._supports_unicode_stdout()
|
|
239
377
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
net = saved - wasted
|
|
243
|
-
return (saved, wasted, net)
|
|
378
|
+
def _yellow(s: str) -> str:
|
|
379
|
+
return c._style_ansi(s, "33", color)
|
|
244
380
|
|
|
381
|
+
def _gray(s: str) -> str:
|
|
382
|
+
return c._style_ansi(s, "90", color)
|
|
245
383
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
# ---------------------------------------------------------------------------
|
|
384
|
+
def _red(s: str) -> str:
|
|
385
|
+
return c._style_ansi(s, "31", color)
|
|
249
386
|
|
|
250
|
-
|
|
251
|
-
|
|
387
|
+
# U+FE0E (text-presentation variation selector) forces single-cell
|
|
388
|
+
# text rendering of U+26A0; without it, emoji-capable terminals
|
|
389
|
+
# (macOS Terminal, iTerm2) may render the glyph 2 cells wide and
|
|
390
|
+
# shift subsequent cells 1 column to the right on flagged rows.
|
|
391
|
+
anomaly_glyph = "⚠︎" if unicode_ok else "!"
|
|
252
392
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
# internal fallback: host-local intentional
|
|
263
|
-
return dt.datetime.now().astimezone().tzinfo # type: ignore[return-value]
|
|
393
|
+
headers = [
|
|
394
|
+
"Date", "Models", "Cache %", "Input",
|
|
395
|
+
"Cache Create", "Cache Read", "Total Tokens", "Cost (USD)",
|
|
396
|
+
"$ Saved", "$ Wasted", "Net $",
|
|
397
|
+
]
|
|
398
|
+
aligns = [
|
|
399
|
+
"left", "left", "right", "right", "right", "right", "right", "right",
|
|
400
|
+
"right", "right", "right",
|
|
401
|
+
]
|
|
264
402
|
|
|
403
|
+
arrow = " └─" if unicode_ok else " |_"
|
|
265
404
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
for future unification if the kernel ever takes an
|
|
294
|
-
``_lib_pricing`` dependency.
|
|
295
|
-
|
|
296
|
-
Callers pre-filter entries to the desired window via their own
|
|
297
|
-
``get_entries`` query; the kernel does not re-filter.
|
|
298
|
-
"""
|
|
299
|
-
tz = _resolve_bucket_tz(display_tz)
|
|
300
|
-
|
|
301
|
-
day_model_buckets: dict[str, dict[str, _Bucket]] = {}
|
|
302
|
-
for entry in entries:
|
|
303
|
-
# ``entry.timestamp`` is an aware UTC datetime per SessionEntry
|
|
304
|
-
# contract; ``astimezone(tz)`` shifts to the display tz before
|
|
305
|
-
# taking the calendar date.
|
|
306
|
-
day_key = entry.timestamp.astimezone(tz).strftime("%Y-%m-%d")
|
|
307
|
-
cost = cost_calculator(entry.model, entry.usage, "auto", entry.cost_usd)
|
|
308
|
-
create_tok = entry.usage.get("cache_creation_input_tokens", 0)
|
|
309
|
-
read_tok = entry.usage.get("cache_read_input_tokens", 0)
|
|
310
|
-
saved, wasted, net = _compute_entry_cache_dollars(
|
|
311
|
-
entry.model, create_tok, read_tok, pricing=pricing,
|
|
312
|
-
)
|
|
313
|
-
models = day_model_buckets.setdefault(day_key, {})
|
|
314
|
-
b = models.setdefault(entry.model, _Bucket())
|
|
315
|
-
b.input_tokens += entry.usage.get("input_tokens", 0)
|
|
316
|
-
b.output_tokens += entry.usage.get("output_tokens", 0)
|
|
317
|
-
b.cache_creation_tokens += create_tok
|
|
318
|
-
b.cache_read_tokens += read_tok
|
|
319
|
-
b.cost += cost
|
|
320
|
-
b.saved_usd += saved
|
|
321
|
-
b.wasted_usd += wasted
|
|
322
|
-
b.net_usd += net
|
|
323
|
-
|
|
324
|
-
result: list[CacheRow] = []
|
|
325
|
-
for day_key in sorted(day_model_buckets.keys()):
|
|
326
|
-
models = day_model_buckets[day_key]
|
|
327
|
-
row = CacheRow(date=day_key)
|
|
328
|
-
for model_name in sorted(models.keys()):
|
|
329
|
-
b = models[model_name]
|
|
330
|
-
mb = CacheModelBreakdown(
|
|
331
|
-
model_name=model_name,
|
|
332
|
-
input_tokens=b.input_tokens,
|
|
333
|
-
output_tokens=b.output_tokens,
|
|
334
|
-
cache_creation_tokens=b.cache_creation_tokens,
|
|
335
|
-
cache_read_tokens=b.cache_read_tokens,
|
|
336
|
-
cache_hit_percent=_compute_cache_hit_percent(
|
|
337
|
-
b.input_tokens, b.cache_creation_tokens, b.cache_read_tokens
|
|
338
|
-
),
|
|
339
|
-
cost=b.cost,
|
|
340
|
-
saved_usd=b.saved_usd,
|
|
341
|
-
wasted_usd=b.wasted_usd,
|
|
342
|
-
net_usd=b.net_usd,
|
|
405
|
+
ROW_DATA, ROW_BREAKDOWN, ROW_FOOTER = "data", "breakdown", "footer"
|
|
406
|
+
raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
|
|
407
|
+
|
|
408
|
+
for row in rows:
|
|
409
|
+
short_models = sorted({c._short_model_name(mb.model_name) for mb in row.model_breakdowns})
|
|
410
|
+
models_text = "\n".join(f"- {m}" for m in short_models) if short_models else ""
|
|
411
|
+
data_cells = [
|
|
412
|
+
(row.date or "", None),
|
|
413
|
+
(models_text, None),
|
|
414
|
+
(f"{row.cache_hit_percent:.1f}%", None),
|
|
415
|
+
(c._fmt_num(row.input_tokens), None),
|
|
416
|
+
(c._fmt_num(row.cache_creation_tokens), None),
|
|
417
|
+
(c._fmt_num(row.cache_read_tokens), None),
|
|
418
|
+
(c._fmt_num(row.total_tokens), None),
|
|
419
|
+
(f"${row.cost:.2f}", None),
|
|
420
|
+
(f"${row.saved_usd:.2f}", None),
|
|
421
|
+
(f"${row.wasted_usd:.2f}", None),
|
|
422
|
+
(f"${row.net_usd:+.2f}", None),
|
|
423
|
+
]
|
|
424
|
+
# Anomaly visual treatment (data rows only — never breakdown/footer).
|
|
425
|
+
# Cell-index map (daily): 0=Date, 1=Models, 2=Cache%, 3=Input, 4=CC,
|
|
426
|
+
# 5=CR, 6=Total, 7=Cost, 8=Saved, 9=Wasted, 10=Net $.
|
|
427
|
+
if row.anomaly_triggered:
|
|
428
|
+
first_text, first_style = data_cells[0]
|
|
429
|
+
data_cells[0] = (
|
|
430
|
+
f"{_red(anomaly_glyph)} {first_text}",
|
|
431
|
+
first_style,
|
|
343
432
|
)
|
|
344
|
-
row.
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
433
|
+
if "cache_drop" in row.anomaly_reasons:
|
|
434
|
+
txt, _cfn = data_cells[2]
|
|
435
|
+
data_cells[2] = (txt, _red)
|
|
436
|
+
if "net_negative" in row.anomaly_reasons:
|
|
437
|
+
txt, _cfn = data_cells[10]
|
|
438
|
+
data_cells[10] = (txt, _red)
|
|
439
|
+
raw_rows.append((data_cells, ROW_DATA))
|
|
440
|
+
|
|
441
|
+
for mb in row.model_breakdowns:
|
|
442
|
+
short = c._short_model_name(mb.model_name)
|
|
443
|
+
mb_input = int(mb.input_tokens)
|
|
444
|
+
mb_output = int(mb.output_tokens)
|
|
445
|
+
mb_cc = int(mb.cache_creation_tokens)
|
|
446
|
+
mb_cr = int(mb.cache_read_tokens)
|
|
447
|
+
mb_total = mb_input + mb_output + mb_cc + mb_cr
|
|
448
|
+
mb_cost = float(mb.cost)
|
|
449
|
+
mb_hit = float(mb.cache_hit_percent)
|
|
450
|
+
bd_cells = [
|
|
451
|
+
(f"{arrow} {short}", _gray),
|
|
452
|
+
("", None),
|
|
453
|
+
(f"{mb_hit:.1f}%", _gray),
|
|
454
|
+
(c._fmt_num(mb_input), _gray),
|
|
455
|
+
(c._fmt_num(mb_cc), _gray),
|
|
456
|
+
(c._fmt_num(mb_cr), _gray),
|
|
457
|
+
(c._fmt_num(mb_total), _gray),
|
|
458
|
+
(f"${mb_cost:.2f}", _gray),
|
|
459
|
+
(f"${mb.saved_usd:.2f}", _gray),
|
|
460
|
+
(f"${mb.wasted_usd:.2f}", _gray),
|
|
461
|
+
(f"${mb.net_usd:+.2f}", _gray),
|
|
462
|
+
]
|
|
463
|
+
raw_rows.append((bd_cells, ROW_BREAKDOWN))
|
|
464
|
+
|
|
465
|
+
tot_inp = sum(row.input_tokens for row in rows)
|
|
466
|
+
tot_out = sum(row.output_tokens for row in rows)
|
|
467
|
+
tot_cc = sum(row.cache_creation_tokens for row in rows)
|
|
468
|
+
tot_cr = sum(row.cache_read_tokens for row in rows)
|
|
469
|
+
tot_tokens = sum(row.total_tokens for row in rows)
|
|
470
|
+
tot_cost = sum(row.cost for row in rows)
|
|
471
|
+
tot_saved = sum(row.saved_usd for row in rows)
|
|
472
|
+
tot_wasted = sum(row.wasted_usd for row in rows)
|
|
473
|
+
tot_net = sum(row.net_usd for row in rows)
|
|
474
|
+
tot_hit = crk._compute_cache_hit_percent(tot_inp, tot_cc, tot_cr)
|
|
475
|
+
footer_cells = [
|
|
476
|
+
("Total", _yellow),
|
|
477
|
+
("", None),
|
|
478
|
+
(f"{tot_hit:.1f}%", _yellow),
|
|
479
|
+
(c._fmt_num(tot_inp), _yellow),
|
|
480
|
+
(c._fmt_num(tot_cc), _yellow),
|
|
481
|
+
(c._fmt_num(tot_cr), _yellow),
|
|
482
|
+
(c._fmt_num(tot_tokens), _yellow),
|
|
483
|
+
(f"${tot_cost:.2f}", _yellow),
|
|
484
|
+
(f"${tot_saved:.2f}", _yellow),
|
|
485
|
+
(f"${tot_wasted:.2f}", _yellow),
|
|
486
|
+
(f"${tot_net:+.2f}", _yellow),
|
|
487
|
+
]
|
|
488
|
+
raw_rows.append((footer_cells, ROW_FOOTER))
|
|
489
|
+
|
|
490
|
+
return _layout_cache_table(
|
|
491
|
+
headers, aligns, raw_rows, title, color, unicode_ok,
|
|
492
|
+
expand_col_index=1, # Models column
|
|
493
|
+
numeric_col_indices=(2, 3, 4, 5, 6, 7, 8, 9, 10), # %, 5x tokens, 3x $
|
|
494
|
+
date_col_index=0, # Date column
|
|
495
|
+
wide_text_min=15,
|
|
496
|
+
narrow_text_min=12,
|
|
497
|
+
droppable_col_index=3, # Input column
|
|
498
|
+
compact=compact,
|
|
499
|
+
)
|
|
391
500
|
|
|
392
501
|
|
|
393
|
-
def
|
|
394
|
-
|
|
395
|
-
*,
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
the decoded cwd from the source path's parent directory).
|
|
406
|
-
|
|
407
|
-
Synthetic entries (``model == '<synthetic>'``) are dropped — they're
|
|
408
|
-
Claude Code's internal markers, not real model calls — before any
|
|
409
|
-
bucketing, so they don't inflate the fallback count either.
|
|
410
|
-
|
|
411
|
-
Entries with ``session_id is None`` fall back to the filename UUID
|
|
412
|
-
stem (matching ``cctally session``); the count of such fallback
|
|
413
|
-
entries rides back on ``_SessionAggregationResult.fallback_count``
|
|
414
|
-
so the caller can emit the legacy one-shot stderr warning.
|
|
415
|
-
|
|
416
|
-
``cost_calculator`` / ``pricing`` / ``project_decoder`` are required
|
|
417
|
-
keyword-only — production callers inject ``_calculate_entry_cost`` +
|
|
418
|
-
``CLAUDE_MODEL_PRICING`` + a ``_decode_escaped_cwd``-backed decoder
|
|
419
|
-
so the kernel stays free of pricing globals / cost-dispatch I/O.
|
|
420
|
-
|
|
421
|
-
Callers pre-filter entries to the desired window via their own
|
|
422
|
-
``get_claude_session_entries`` query; the kernel does not re-filter.
|
|
502
|
+
def _render_cache_session_rows(
|
|
503
|
+
rows: list["crk.CacheRow"], title: str,
|
|
504
|
+
*, tz: "ZoneInfo | None" = None, compact: bool = False,
|
|
505
|
+
) -> str:
|
|
506
|
+
"""Render session-mode cache report.
|
|
507
|
+
|
|
508
|
+
Columns: SessionId, Last Activity, Project, Cache %, Input,
|
|
509
|
+
Cache Create, Cache Read, Total Tokens, Cost (USD), $ Saved,
|
|
510
|
+
$ Wasted, Net $.
|
|
511
|
+
|
|
512
|
+
``tz`` is the resolved display zone (None = host local). Last-Activity
|
|
513
|
+
cells are rendered in this zone.
|
|
423
514
|
"""
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
entry.model,
|
|
465
|
-
{
|
|
466
|
-
"input_tokens": entry.input_tokens,
|
|
467
|
-
"output_tokens": entry.output_tokens,
|
|
468
|
-
"cache_creation_input_tokens": entry.cache_creation_tokens,
|
|
469
|
-
"cache_read_input_tokens": entry.cache_read_tokens,
|
|
470
|
-
},
|
|
471
|
-
"auto",
|
|
472
|
-
entry.cost_usd,
|
|
473
|
-
)
|
|
474
|
-
saved, wasted, net = _compute_entry_cache_dollars(
|
|
475
|
-
entry.model,
|
|
476
|
-
entry.cache_creation_tokens,
|
|
477
|
-
entry.cache_read_tokens,
|
|
478
|
-
pricing=pricing,
|
|
479
|
-
)
|
|
480
|
-
mb_raw.saved_usd += saved
|
|
481
|
-
mb_raw.wasted_usd += wasted
|
|
482
|
-
mb_raw.net_usd += net
|
|
483
|
-
|
|
484
|
-
row = CacheRow(
|
|
485
|
-
session_id=sid,
|
|
486
|
-
project_path=b["project_path"],
|
|
487
|
-
last_activity=b["last_activity"],
|
|
488
|
-
source_paths=sorted(b["source_paths"]),
|
|
515
|
+
c = _cctally()
|
|
516
|
+
color = c._supports_color_stdout()
|
|
517
|
+
unicode_ok = c._supports_unicode_stdout()
|
|
518
|
+
|
|
519
|
+
def _yellow(s: str) -> str:
|
|
520
|
+
return c._style_ansi(s, "33", color)
|
|
521
|
+
|
|
522
|
+
def _gray(s: str) -> str:
|
|
523
|
+
return c._style_ansi(s, "90", color)
|
|
524
|
+
|
|
525
|
+
def _red(s: str) -> str:
|
|
526
|
+
return c._style_ansi(s, "31", color)
|
|
527
|
+
|
|
528
|
+
# U+FE0E (text-presentation variation selector) forces single-cell
|
|
529
|
+
# text rendering of U+26A0; without it, emoji-capable terminals
|
|
530
|
+
# (macOS Terminal, iTerm2) may render the glyph 2 cells wide and
|
|
531
|
+
# shift subsequent cells 1 column to the right on flagged rows.
|
|
532
|
+
anomaly_glyph = "⚠︎" if unicode_ok else "!"
|
|
533
|
+
|
|
534
|
+
headers = [
|
|
535
|
+
"SessionId", "Last Activity", "Project",
|
|
536
|
+
"Cache %", "Input", "Cache Create", "Cache Read",
|
|
537
|
+
"Total Tokens", "Cost (USD)", "$ Saved", "$ Wasted", "Net $",
|
|
538
|
+
]
|
|
539
|
+
aligns = [
|
|
540
|
+
"left", "left", "left",
|
|
541
|
+
"right", "right", "right", "right",
|
|
542
|
+
"right", "right", "right", "right", "right",
|
|
543
|
+
]
|
|
544
|
+
|
|
545
|
+
arrow = " └─" if unicode_ok else " |_"
|
|
546
|
+
|
|
547
|
+
ROW_DATA, ROW_BREAKDOWN, ROW_FOOTER = "data", "breakdown", "footer"
|
|
548
|
+
raw_rows: list[tuple[list[tuple[str, Any]], str]] = []
|
|
549
|
+
|
|
550
|
+
for row in rows:
|
|
551
|
+
sid_short = (row.session_id or "")[:8]
|
|
552
|
+
last_act = (
|
|
553
|
+
c.format_display_dt(row.last_activity, tz, fmt="%Y-%m-%d %H:%M", suffix=True)
|
|
554
|
+
if row.last_activity else ""
|
|
489
555
|
)
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
556
|
+
project_short = ""
|
|
557
|
+
if row.project_path:
|
|
558
|
+
project_short = os.path.basename(row.project_path.rstrip("/"))
|
|
559
|
+
|
|
560
|
+
data_cells = [
|
|
561
|
+
(sid_short, None),
|
|
562
|
+
(last_act, None),
|
|
563
|
+
(project_short, None),
|
|
564
|
+
(f"{row.cache_hit_percent:.1f}%", None),
|
|
565
|
+
(c._fmt_num(row.input_tokens), None),
|
|
566
|
+
(c._fmt_num(row.cache_creation_tokens), None),
|
|
567
|
+
(c._fmt_num(row.cache_read_tokens), None),
|
|
568
|
+
(c._fmt_num(row.total_tokens), None),
|
|
569
|
+
(f"${row.cost:.2f}", None),
|
|
570
|
+
(f"${row.saved_usd:.2f}", None),
|
|
571
|
+
(f"${row.wasted_usd:.2f}", None),
|
|
572
|
+
(f"${row.net_usd:+.2f}", None),
|
|
573
|
+
]
|
|
574
|
+
# Anomaly visual treatment (data rows only — never breakdown/footer).
|
|
575
|
+
# Cell-index map (session): 0=SessionId, 1=Last Activity, 2=Project,
|
|
576
|
+
# 3=Cache%, 4=Input, 5=CC, 6=CR, 7=Total, 8=Cost, 9=Saved, 10=Wasted,
|
|
577
|
+
# 11=Net $.
|
|
578
|
+
if row.anomaly_triggered:
|
|
579
|
+
first_text, first_style = data_cells[0]
|
|
580
|
+
data_cells[0] = (
|
|
581
|
+
f"{_red(anomaly_glyph)} {first_text}",
|
|
582
|
+
first_style,
|
|
507
583
|
)
|
|
508
|
-
row.
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
row.wasted_usd += mb.wasted_usd
|
|
516
|
-
row.net_usd += mb.net_usd
|
|
517
|
-
result.append(row)
|
|
518
|
-
|
|
519
|
-
# Initial ordering descending by last_activity; the CLI's
|
|
520
|
-
# ``_sort_cache_rows`` may resort under ``--sort``. Use tz-aware
|
|
521
|
-
# sentinel to avoid naive-vs-aware comparison errors on rows missing
|
|
522
|
-
# last_activity.
|
|
523
|
-
_min_dt = dt.datetime.min.replace(tzinfo=dt.timezone.utc)
|
|
524
|
-
result.sort(key=lambda r: r.last_activity or _min_dt, reverse=True)
|
|
525
|
-
return _SessionAggregationResult(rows=result, fallback_count=fallback_count)
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
# ---------------------------------------------------------------------------
|
|
529
|
-
# Anomaly classification + baseline median
|
|
530
|
-
# ---------------------------------------------------------------------------
|
|
531
|
-
|
|
532
|
-
def _row_anchor(r: CacheRow) -> dt.datetime | None:
|
|
533
|
-
"""Return the row's position in time for baseline-window comparison.
|
|
534
|
-
|
|
535
|
-
Session rows carry ``last_activity`` (an aware datetime); daily rows
|
|
536
|
-
carry ``date`` (an ISO-8601 ``YYYY-MM-DD``). For daily rows we use
|
|
537
|
-
``.astimezone()`` (not ``.replace(tzinfo=...)``) so the OS tzdb
|
|
538
|
-
gives the correct offset for the given date — avoids DST drift on
|
|
539
|
-
dates that straddle a DST boundary. Mirrors the idiom in
|
|
540
|
-
``_parse_cli_date_range``.
|
|
541
|
-
"""
|
|
542
|
-
if r.last_activity is not None:
|
|
543
|
-
return r.last_activity
|
|
544
|
-
if r.date:
|
|
545
|
-
# internal fallback: host-local intentional
|
|
546
|
-
return dt.datetime.strptime(r.date, "%Y-%m-%d").astimezone()
|
|
547
|
-
return None
|
|
584
|
+
if "cache_drop" in row.anomaly_reasons:
|
|
585
|
+
txt, _cfn = data_cells[3]
|
|
586
|
+
data_cells[3] = (txt, _red)
|
|
587
|
+
if "net_negative" in row.anomaly_reasons:
|
|
588
|
+
txt, _cfn = data_cells[11]
|
|
589
|
+
data_cells[11] = (txt, _red)
|
|
590
|
+
raw_rows.append((data_cells, ROW_DATA))
|
|
548
591
|
|
|
592
|
+
for mb in row.model_breakdowns:
|
|
593
|
+
short = c._short_model_name(mb.model_name)
|
|
594
|
+
mb_total = (
|
|
595
|
+
mb.input_tokens + mb.output_tokens
|
|
596
|
+
+ mb.cache_creation_tokens + mb.cache_read_tokens
|
|
597
|
+
)
|
|
598
|
+
bd_cells = [
|
|
599
|
+
(f"{arrow} {short}", _gray),
|
|
600
|
+
("", None),
|
|
601
|
+
("", None),
|
|
602
|
+
(f"{mb.cache_hit_percent:.1f}%", _gray),
|
|
603
|
+
(c._fmt_num(mb.input_tokens), _gray),
|
|
604
|
+
(c._fmt_num(mb.cache_creation_tokens), _gray),
|
|
605
|
+
(c._fmt_num(mb.cache_read_tokens), _gray),
|
|
606
|
+
(c._fmt_num(mb_total), _gray),
|
|
607
|
+
(f"${mb.cost:.2f}", _gray),
|
|
608
|
+
(f"${mb.saved_usd:.2f}", _gray),
|
|
609
|
+
(f"${mb.wasted_usd:.2f}", _gray),
|
|
610
|
+
(f"${mb.net_usd:+.2f}", _gray),
|
|
611
|
+
]
|
|
612
|
+
raw_rows.append((bd_cells, ROW_BREAKDOWN))
|
|
613
|
+
|
|
614
|
+
tot_inp = sum(r.input_tokens for r in rows)
|
|
615
|
+
tot_out = sum(r.output_tokens for r in rows)
|
|
616
|
+
tot_cc = sum(r.cache_creation_tokens for r in rows)
|
|
617
|
+
tot_cr = sum(r.cache_read_tokens for r in rows)
|
|
618
|
+
tot_tokens = sum(r.total_tokens for r in rows)
|
|
619
|
+
tot_cost = sum(r.cost for r in rows)
|
|
620
|
+
tot_saved = sum(r.saved_usd for r in rows)
|
|
621
|
+
tot_wasted = sum(r.wasted_usd for r in rows)
|
|
622
|
+
tot_net = sum(r.net_usd for r in rows)
|
|
623
|
+
tot_hit = crk._compute_cache_hit_percent(tot_inp, tot_cc, tot_cr)
|
|
624
|
+
|
|
625
|
+
footer_cells = [
|
|
626
|
+
("Total", _yellow),
|
|
627
|
+
(f"({len(rows)} sessions)", _yellow),
|
|
628
|
+
("", None),
|
|
629
|
+
(f"{tot_hit:.1f}%", _yellow),
|
|
630
|
+
(c._fmt_num(tot_inp), _yellow),
|
|
631
|
+
(c._fmt_num(tot_cc), _yellow),
|
|
632
|
+
(c._fmt_num(tot_cr), _yellow),
|
|
633
|
+
(c._fmt_num(tot_tokens), _yellow),
|
|
634
|
+
(f"${tot_cost:.2f}", _yellow),
|
|
635
|
+
(f"${tot_saved:.2f}", _yellow),
|
|
636
|
+
(f"${tot_wasted:.2f}", _yellow),
|
|
637
|
+
(f"${tot_net:+.2f}", _yellow),
|
|
638
|
+
]
|
|
639
|
+
raw_rows.append((footer_cells, ROW_FOOTER))
|
|
640
|
+
|
|
641
|
+
return _layout_cache_table(
|
|
642
|
+
headers, aligns, raw_rows, title, color, unicode_ok,
|
|
643
|
+
expand_col_index=2, # Project column
|
|
644
|
+
numeric_col_indices=(3, 4, 5, 6, 7, 8, 9, 10, 11), # %, 5x tokens, 3x $
|
|
645
|
+
date_col_index=None, # no date column
|
|
646
|
+
wide_text_min=18,
|
|
647
|
+
narrow_text_min=12,
|
|
648
|
+
droppable_col_index=4, # Input column
|
|
649
|
+
compact=compact,
|
|
650
|
+
)
|
|
549
651
|
|
|
550
|
-
def _compute_baseline_median(
|
|
551
|
-
rows: list[CacheRow],
|
|
552
|
-
*,
|
|
553
|
-
anchor: dt.datetime,
|
|
554
|
-
window_days: int,
|
|
555
|
-
min_samples: int,
|
|
556
|
-
exclude_row: CacheRow | None = None,
|
|
557
|
-
is_session_mode: bool = False,
|
|
558
|
-
) -> float | None:
|
|
559
|
-
"""Median ``cache_hit_percent`` across rows whose anchor falls in
|
|
560
|
-
``[anchor − window_days, anchor − upper_offset]``.
|
|
561
|
-
|
|
562
|
-
Returns ``None`` when fewer than ``min_samples`` rows qualify. The
|
|
563
|
-
upper offset is ``1s`` in session mode (recent sessions stay
|
|
564
|
-
eligible even when they collide on the second) and ``1d`` in daily
|
|
565
|
-
mode (yesterday IS in the baseline but today is excluded).
|
|
566
|
-
|
|
567
|
-
``exclude_row`` lets the per-row classifier skip the focal row when
|
|
568
|
-
computing the baseline median for that row — without this, a row's
|
|
569
|
-
own hit % would self-include in its baseline. Callers passing the
|
|
570
|
-
cross-row "median over the whole window" (e.g. the dashboard
|
|
571
|
-
spotlight) leave ``exclude_row=None``.
|
|
572
|
-
"""
|
|
573
|
-
import statistics
|
|
574
652
|
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
lower_bound = anchor - dt.timedelta(days=window_days)
|
|
579
|
-
upper_bound = anchor - upper_offset
|
|
580
|
-
values: list[float] = []
|
|
581
|
-
for r in rows:
|
|
582
|
-
if exclude_row is not None and r is exclude_row:
|
|
583
|
-
continue
|
|
584
|
-
ra = _row_anchor(r)
|
|
585
|
-
if ra is None:
|
|
586
|
-
continue
|
|
587
|
-
if lower_bound <= ra <= upper_bound:
|
|
588
|
-
values.append(r.cache_hit_percent)
|
|
589
|
-
if len(values) < min_samples:
|
|
590
|
-
return None
|
|
591
|
-
return statistics.median(values)
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
def _classify_anomalies(
|
|
595
|
-
rows: list[CacheRow],
|
|
653
|
+
def _render_cache_report_table(
|
|
654
|
+
rows: list["crk.CacheRow"],
|
|
655
|
+
title: str,
|
|
596
656
|
*,
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
) ->
|
|
601
|
-
"""
|
|
657
|
+
mode: Literal["day", "session"] = "day",
|
|
658
|
+
tz: "ZoneInfo | None" = None,
|
|
659
|
+
compact: bool = False,
|
|
660
|
+
) -> str:
|
|
661
|
+
"""Dispatcher: routes to daily or session renderer based on mode.
|
|
602
662
|
|
|
603
|
-
|
|
604
|
-
|
|
663
|
+
``tz`` is the resolved display zone (None = host local). Day-mode
|
|
664
|
+
rows have no clock-instant cells (date strings only) so the parameter
|
|
665
|
+
is currently consumed only by the session-mode renderer.
|
|
605
666
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
667
|
+
``compact`` (issue #91, Shape A) forces ``_layout_cache_table``'s
|
|
668
|
+
responsive scale-down branch regardless of terminal width.
|
|
669
|
+
"""
|
|
670
|
+
if mode == "session":
|
|
671
|
+
return _render_cache_session_rows(rows, title, tz=tz, compact=compact)
|
|
672
|
+
return _render_cache_day_rows(rows, title, compact=compact)
|
|
611
673
|
|
|
612
|
-
Reasons are appended in deterministic order: ``net_negative`` first
|
|
613
|
-
(no baseline needed), then ``cache_drop`` (matches the
|
|
614
|
-
pre-extraction order tests / fixtures expect).
|
|
615
674
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
675
|
+
def _aggregate_cache_by_day(
|
|
676
|
+
since: dt.datetime,
|
|
677
|
+
until: dt.datetime,
|
|
678
|
+
project: str | None = None,
|
|
679
|
+
*,
|
|
680
|
+
display_tz: "ZoneInfo | None" = None,
|
|
681
|
+
) -> list["crk.CacheRow"]:
|
|
682
|
+
"""CLI adapter: pulls entries from ``get_entries`` and delegates to the
|
|
683
|
+
pure-fn kernel ``_lib_cache_report._aggregate_cache_by_day``.
|
|
684
|
+
|
|
685
|
+
Adds an explicit ``display_tz`` kwarg (closes the pre-existing minor bug
|
|
686
|
+
where ``--tz`` shifted the window edges but not the day-bucketing —
|
|
687
|
+
spec §1.6, plan A3). Passes the embedded ``CLAUDE_MODEL_PRICING`` +
|
|
688
|
+
``_calculate_entry_cost`` into the kernel so the kernel itself stays
|
|
689
|
+
free of pricing globals / cost-dispatch I/O.
|
|
690
|
+
|
|
691
|
+
Direct callers that don't pass ``display_tz`` (legacy contract) fall
|
|
692
|
+
back to host-local via the kernel's ``None``-tz handling, matching
|
|
693
|
+
pre-extraction behavior byte-for-byte. ``since`` / ``until`` bound
|
|
694
|
+
the I/O query here; the kernel itself trusts the caller's pre-filter.
|
|
619
695
|
"""
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
is_session_mode = rows[0].session_id is not None
|
|
629
|
-
min_baseline = (
|
|
630
|
-
CACHE_REPORT_MIN_BASELINE_SESSIONS if is_session_mode
|
|
631
|
-
else CACHE_REPORT_MIN_BASELINE_DAYS
|
|
696
|
+
c = _cctally()
|
|
697
|
+
entries = list(c.get_entries(since, until, project=project))
|
|
698
|
+
return crk._aggregate_cache_by_day(
|
|
699
|
+
entries,
|
|
700
|
+
display_tz=display_tz,
|
|
701
|
+
pricing=c.CLAUDE_MODEL_PRICING,
|
|
702
|
+
cost_calculator=c._calculate_entry_cost,
|
|
632
703
|
)
|
|
633
704
|
|
|
634
|
-
# Pre-compute anchors once to avoid O(n²·datetime-parse) overhead.
|
|
635
|
-
anchors: list[dt.datetime | None] = [_row_anchor(r) for r in rows]
|
|
636
705
|
|
|
637
|
-
|
|
638
|
-
|
|
706
|
+
def _aggregate_cache_by_session(
|
|
707
|
+
since: dt.datetime,
|
|
708
|
+
until: dt.datetime,
|
|
709
|
+
project: str | None = None,
|
|
710
|
+
) -> list["crk.CacheRow"]:
|
|
711
|
+
"""CLI adapter: pulls Claude session entries from
|
|
712
|
+
``get_claude_session_entries`` and delegates to the pure-fn kernel
|
|
713
|
+
``_lib_cache_report._aggregate_cache_by_session``.
|
|
714
|
+
|
|
715
|
+
Preserves the legacy one-shot ``Warning: N entries lacked
|
|
716
|
+
session_files rows (cache may be catching up).`` stderr line by
|
|
717
|
+
consuming the kernel's ``fallback_count`` and calling ``eprint``
|
|
718
|
+
here (kept on the I/O side; kernel stays pure). Injects
|
|
719
|
+
``CLAUDE_MODEL_PRICING`` + ``_calculate_entry_cost`` +
|
|
720
|
+
``_decode_escaped_cwd`` so the kernel doesn't reach for cctally
|
|
721
|
+
globals. ``since`` / ``until`` bound the I/O query; the kernel
|
|
722
|
+
itself trusts the caller's pre-filter.
|
|
723
|
+
"""
|
|
724
|
+
c = _cctally()
|
|
725
|
+
entries = c.get_claude_session_entries(since, until, project=project)
|
|
726
|
+
if not entries:
|
|
727
|
+
return []
|
|
728
|
+
|
|
729
|
+
def _project_decoder(source_path: str) -> str:
|
|
730
|
+
return c._decode_escaped_cwd(
|
|
731
|
+
os.path.basename(os.path.dirname(source_path))
|
|
732
|
+
)
|
|
639
733
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
734
|
+
agg = crk._aggregate_cache_by_session(
|
|
735
|
+
entries,
|
|
736
|
+
pricing=c.CLAUDE_MODEL_PRICING,
|
|
737
|
+
cost_calculator=c._calculate_entry_cost,
|
|
738
|
+
project_decoder=_project_decoder,
|
|
739
|
+
)
|
|
740
|
+
if agg.fallback_count:
|
|
741
|
+
eprint(
|
|
742
|
+
f"Warning: {agg.fallback_count} entries lacked session_files rows "
|
|
743
|
+
"(cache may be catching up)."
|
|
744
|
+
)
|
|
745
|
+
return agg.rows
|
|
644
746
|
|
|
645
|
-
# Trigger 2: cache_drop (requires baseline).
|
|
646
|
-
anchor = anchors[i]
|
|
647
|
-
if anchor is not None:
|
|
648
|
-
median = _compute_baseline_median(
|
|
649
|
-
rows, anchor=anchor,
|
|
650
|
-
window_days=window_days, min_samples=min_baseline,
|
|
651
|
-
exclude_row=row, is_session_mode=is_session_mode,
|
|
652
|
-
)
|
|
653
|
-
if median is not None and (median - row.cache_hit_percent) >= threshold_pp:
|
|
654
|
-
reasons.append("cache_drop")
|
|
655
747
|
|
|
656
|
-
|
|
657
|
-
|
|
748
|
+
def _annotate_anomalies(
|
|
749
|
+
rows: list["crk.CacheRow"],
|
|
750
|
+
threshold_pp: int,
|
|
751
|
+
window_days: int,
|
|
752
|
+
*,
|
|
753
|
+
enabled: bool = True,
|
|
754
|
+
) -> None:
|
|
755
|
+
"""CLI adapter: thin shim around the kernel's ``_classify_anomalies``.
|
|
658
756
|
|
|
757
|
+
Kept under the original name so the existing call site in
|
|
758
|
+
``cmd_cache_report`` resolves unchanged. The kernel mutates each row
|
|
759
|
+
in place (same contract as the pre-extraction implementation —
|
|
760
|
+
``anomaly_triggered`` / ``anomaly_reasons`` set on each ``CacheRow``).
|
|
761
|
+
"""
|
|
762
|
+
crk._classify_anomalies(
|
|
763
|
+
rows,
|
|
764
|
+
threshold_pp=threshold_pp,
|
|
765
|
+
window_days=window_days,
|
|
766
|
+
enabled=enabled,
|
|
767
|
+
)
|
|
659
768
|
|
|
660
|
-
# ---------------------------------------------------------------------------
|
|
661
|
-
# Window-wide breakdown aggregator (by-project / by-model dedup)
|
|
662
|
-
# ---------------------------------------------------------------------------
|
|
663
769
|
|
|
664
|
-
def
|
|
665
|
-
|
|
770
|
+
def _resolve_cache_report_window(
|
|
771
|
+
args: argparse.Namespace,
|
|
666
772
|
*,
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
``
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
773
|
+
now_utc: dt.datetime | None = None,
|
|
774
|
+
tz_name: str | None = None,
|
|
775
|
+
) -> tuple[dt.datetime, dt.datetime]:
|
|
776
|
+
"""Resolve [since, until] from --since / --until / --days.
|
|
777
|
+
|
|
778
|
+
Priority:
|
|
779
|
+
- If both --since and --until present: use them verbatim.
|
|
780
|
+
- If only --since: until = now.
|
|
781
|
+
- If only --until: since = until - args.days.
|
|
782
|
+
- If neither: since = today - (args.days - 1) midnight; until = now.
|
|
783
|
+
|
|
784
|
+
Date-only args (YYYY-MM-DD or YYYYMMDD, no T/+/Z) are expanded to
|
|
785
|
+
full-day bounds in the resolved display tz when ``tz_name`` is set
|
|
786
|
+
(otherwise host-local tz, the legacy fallback). ``--since`` lands at
|
|
787
|
+
00:00:00.000000; ``--until`` at 23:59:59.999999 — matching cmd_blocks
|
|
788
|
+
and _parse_cli_date_range's inclusive-end-of-day convention. Full-ISO
|
|
789
|
+
args carry their own offset/Z and are tz-independent.
|
|
790
|
+
|
|
791
|
+
``now_utc`` is an optional testing-hook override for "now"; when
|
|
792
|
+
provided (via ``_command_as_of()``) it replaces the wall-clock default
|
|
793
|
+
used to build ``until`` when ``--until`` is absent, and the
|
|
794
|
+
``--days N`` trailing-window anchor when neither ``--since`` nor
|
|
795
|
+
``--until`` is supplied. Must be a tz-aware UTC datetime. Omit to
|
|
796
|
+
keep legacy wall-clock behavior.
|
|
797
|
+
|
|
798
|
+
``tz_name`` (typically derived from ``resolve_display_tz``) interprets
|
|
799
|
+
naive date-only ``--since`` / ``--until`` in that IANA zone instead of
|
|
800
|
+
host-local. Mirrors the contract documented in the CLAUDE.md gotcha
|
|
801
|
+
"display.tz controls render; date-bucketing commands also parse
|
|
802
|
+
--since/--until in display tz". Invalid zone raises ValueError (this
|
|
803
|
+
arg is plumbed from a pre-validated ZoneInfo, so the validation is
|
|
804
|
+
defensive — it should not trip in normal usage).
|
|
698
805
|
"""
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
806
|
+
c = _cctally()
|
|
807
|
+
tz: Any = None
|
|
808
|
+
if tz_name:
|
|
809
|
+
try:
|
|
810
|
+
tz = ZoneInfo(tz_name)
|
|
811
|
+
except (ZoneInfoNotFoundError, ValueError, OSError) as exc:
|
|
812
|
+
raise ValueError(
|
|
813
|
+
f"--tz must be a valid IANA zone, got {tz_name!r}"
|
|
814
|
+
) from exc
|
|
815
|
+
|
|
816
|
+
def _parse_window_arg(
|
|
817
|
+
raw: str, flag: str, *, is_upper_bound: bool
|
|
818
|
+
) -> dt.datetime:
|
|
819
|
+
# Full-ISO args carry an explicit time component or tz marker.
|
|
820
|
+
if "T" in raw or "+" in raw or "Z" in raw:
|
|
821
|
+
return parse_iso_datetime(raw, flag)
|
|
822
|
+
# Date-only: route through the centralized dual-form helper
|
|
823
|
+
# (spec §7.1.1) so YYYY-MM-DD / YYYYMMDD parsing and the error
|
|
824
|
+
# message stay consistent with cmd_blocks / cmd_daily / etc.
|
|
825
|
+
naive = c._try_dual_form_date(raw)
|
|
826
|
+
if naive is None:
|
|
827
|
+
# Second-chance (issue #101): the pre-Session-A code fell
|
|
828
|
+
# through to parse_iso_datetime here, so space-separated
|
|
829
|
+
# datetimes (`2026-05-01 12:30:00`) and ISO week-dates
|
|
830
|
+
# (`2026-W18-1`) — both accepted by datetime.fromisoformat but
|
|
831
|
+
# rejected by the dual-form parser — kept working. cache-report
|
|
832
|
+
# is the only date-taking command with this fallthrough; the
|
|
833
|
+
# other commands accept YYYY-MM-DD / YYYYMMDD only. Returned
|
|
834
|
+
# verbatim: a full datetime carries its own time component, so
|
|
835
|
+
# the is_upper_bound end-of-day expansion is NOT applied (it's
|
|
836
|
+
# for bare date-only forms). On TOTAL failure (neither dual-form
|
|
837
|
+
# nor ISO), fall back to the centralized dual-form diagnostic
|
|
838
|
+
# rather than parse_iso_datetime's more generic message.
|
|
839
|
+
try:
|
|
840
|
+
return parse_iso_datetime(raw, flag)
|
|
841
|
+
except ValueError:
|
|
842
|
+
c._parse_dual_form_date(raw, flag) # eprints + raises ValueError
|
|
843
|
+
raise # unreachable — the call above always raises here
|
|
844
|
+
if is_upper_bound:
|
|
845
|
+
naive = naive.replace(
|
|
846
|
+
hour=23, minute=59, second=59, microsecond=999999,
|
|
847
|
+
)
|
|
848
|
+
# When tz is supplied, attach it directly so `--tz utc --since
|
|
849
|
+
# 2026-05-01` lands at 2026-05-01T00:00Z regardless of host zone.
|
|
850
|
+
# Otherwise legacy: astimezone() on a naive datetime consults the
|
|
851
|
+
# OS tz database for the actual offset at that date (DST-safe).
|
|
852
|
+
if tz is not None:
|
|
853
|
+
return naive.replace(tzinfo=tz)
|
|
854
|
+
# internal fallback: host-local intentional
|
|
855
|
+
return naive.astimezone()
|
|
856
|
+
|
|
857
|
+
# Testing hook: when a pinned "now" is supplied via _command_as_of(),
|
|
858
|
+
# use it as the wall-clock anchor. When tz is supplied, project into
|
|
859
|
+
# that zone for parity with the date-only branch (so `since = today -
|
|
860
|
+
# (days-1) midnight` lands on the calendar boundary the user expects);
|
|
861
|
+
# otherwise fall back to host-local.
|
|
862
|
+
if now_utc is not None:
|
|
863
|
+
# internal fallback: host-local intentional (else branch)
|
|
864
|
+
now_local = (
|
|
865
|
+
now_utc.astimezone(tz) if tz is not None else now_utc.astimezone()
|
|
713
866
|
)
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
))
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
867
|
+
else:
|
|
868
|
+
now_local = (
|
|
869
|
+
dt.datetime.now(tz=tz) if tz is not None
|
|
870
|
+
# internal fallback: host-local intentional
|
|
871
|
+
else dt.datetime.now().astimezone()
|
|
872
|
+
)
|
|
873
|
+
# Empty-interval contract (spec design.md:47): `--since == --until` →
|
|
874
|
+
# empty window. Collapse before date-only expansion would push the
|
|
875
|
+
# upper bound to 23:59:59.999999 and flip the contract. We compare
|
|
876
|
+
# *parsed* lower-bound timestamps when both args are date-only so
|
|
877
|
+
# `--since 20260418 --until 2026-04-18` (same day, different
|
|
878
|
+
# accepted formats) also collapses. For full-ISO args, fall back to
|
|
879
|
+
# raw-string equality — parsing both as lower bounds would drop an
|
|
880
|
+
# explicit midnight upper bound silently.
|
|
881
|
+
if args.since and args.until:
|
|
882
|
+
since_is_iso = any(ch in args.since for ch in ("T", "+", "Z"))
|
|
883
|
+
until_is_iso = any(ch in args.until for ch in ("T", "+", "Z"))
|
|
884
|
+
if not since_is_iso and not until_is_iso:
|
|
885
|
+
since_p = _parse_window_arg(
|
|
886
|
+
args.since, "--since", is_upper_bound=False
|
|
887
|
+
)
|
|
888
|
+
until_p = _parse_window_arg(
|
|
889
|
+
args.until, "--until", is_upper_bound=False
|
|
890
|
+
)
|
|
891
|
+
if since_p == until_p:
|
|
892
|
+
return since_p, since_p
|
|
893
|
+
elif args.since == args.until:
|
|
894
|
+
since = _parse_window_arg(
|
|
895
|
+
args.since, "--since", is_upper_bound=False
|
|
896
|
+
)
|
|
897
|
+
return since, since
|
|
898
|
+
until = (
|
|
899
|
+
_parse_window_arg(args.until, "--until", is_upper_bound=True)
|
|
900
|
+
if args.until
|
|
901
|
+
else now_local
|
|
902
|
+
)
|
|
903
|
+
if args.since:
|
|
904
|
+
since = _parse_window_arg(args.since, "--since", is_upper_bound=False)
|
|
905
|
+
else:
|
|
906
|
+
days = args.days
|
|
907
|
+
if args.until:
|
|
908
|
+
# --until without --since: step back by args.days
|
|
909
|
+
since = until - dt.timedelta(days=days)
|
|
910
|
+
else:
|
|
911
|
+
# neither: midnight-anchored behavior matches pre-refactor baseline
|
|
912
|
+
since = dt.datetime.combine(
|
|
913
|
+
(now_local - dt.timedelta(days=days - 1)).date(),
|
|
914
|
+
dt.time(0, 0, 0),
|
|
915
|
+
tzinfo=now_local.tzinfo,
|
|
916
|
+
)
|
|
917
|
+
return since, until
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _emit_cache_report_json(
|
|
921
|
+
rows: list["crk.CacheRow"],
|
|
922
|
+
mode: str,
|
|
754
923
|
*,
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
``_aggregate_cache_breakdown`` a second time over the raw entries
|
|
765
|
-
iterable (which re-runs the tiered-pricing math per entry). Output
|
|
766
|
-
is byte-equivalent to ``_aggregate_cache_breakdown(entries, key_fn=
|
|
767
|
-
lambda e: e.model)`` modulo float-addition ordering.
|
|
768
|
-
|
|
769
|
-
``skip_synthetic`` drops the ``"<synthetic>"`` model bucket. Day-mode
|
|
770
|
-
keeps synthetic entries in ``row.model_breakdowns`` because that view
|
|
771
|
-
is intra-day diagnostic; the by-model view here is the user-facing
|
|
772
|
-
"where did the savings land" rollup, so synthetic is dropped to match
|
|
773
|
-
``_aggregate_cache_breakdown``'s contract.
|
|
924
|
+
now_utc: dt.datetime | None = None,
|
|
925
|
+
) -> str:
|
|
926
|
+
"""Serialize rows + totals to JSON matching the spec schema.
|
|
927
|
+
|
|
928
|
+
Daily mode keeps the top-level `days` key and per-row `totalCost` /
|
|
929
|
+
totals.totalCost aliases for backward compat. Session mode uses
|
|
930
|
+
`sessions` and adds sessionId / projectPath / lastActivity /
|
|
931
|
+
sourcePaths per row. Anomaly object is always emitted with
|
|
932
|
+
triggered=false / reasons=[] until Task 5 populates it.
|
|
774
933
|
"""
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
934
|
+
top_key = "sessions" if mode == "session" else "days"
|
|
935
|
+
|
|
936
|
+
def _row_to_dict(r: "crk.CacheRow") -> dict[str, Any]:
|
|
937
|
+
d: dict[str, Any] = {
|
|
938
|
+
"inputTokens": r.input_tokens,
|
|
939
|
+
"outputTokens": r.output_tokens,
|
|
940
|
+
"cacheCreationTokens": r.cache_creation_tokens,
|
|
941
|
+
"cacheReadTokens": r.cache_read_tokens,
|
|
942
|
+
"totalTokens": r.total_tokens,
|
|
943
|
+
"cost": round(r.cost, 6),
|
|
944
|
+
"cacheHitPercent": round(r.cache_hit_percent, 2),
|
|
945
|
+
"savedUsd": round(r.saved_usd, 6),
|
|
946
|
+
"wastedUsd": round(r.wasted_usd, 6),
|
|
947
|
+
"netUsd": round(r.net_usd, 6),
|
|
948
|
+
"anomaly": {
|
|
949
|
+
"triggered": r.anomaly_triggered,
|
|
950
|
+
"reasons": list(r.anomaly_reasons),
|
|
951
|
+
},
|
|
952
|
+
"modelBreakdowns": [
|
|
953
|
+
{
|
|
954
|
+
"modelName": mb.model_name,
|
|
955
|
+
"inputTokens": mb.input_tokens,
|
|
956
|
+
"outputTokens": mb.output_tokens,
|
|
957
|
+
"cacheCreationTokens": mb.cache_creation_tokens,
|
|
958
|
+
"cacheReadTokens": mb.cache_read_tokens,
|
|
959
|
+
"cacheHitPercent": round(mb.cache_hit_percent, 2),
|
|
960
|
+
"cost": round(mb.cost, 6),
|
|
961
|
+
"savedUsd": round(mb.saved_usd, 6),
|
|
962
|
+
"wastedUsd": round(mb.wasted_usd, 6),
|
|
963
|
+
"netUsd": round(mb.net_usd, 6),
|
|
964
|
+
}
|
|
965
|
+
for mb in r.model_breakdowns
|
|
966
|
+
],
|
|
967
|
+
}
|
|
968
|
+
if mode == "session":
|
|
969
|
+
d["sessionId"] = r.session_id
|
|
970
|
+
d["projectPath"] = r.project_path
|
|
971
|
+
d["lastActivity"] = (
|
|
972
|
+
r.last_activity.astimezone(dt.timezone.utc).isoformat()
|
|
973
|
+
if r.last_activity else None
|
|
974
|
+
)
|
|
975
|
+
d["sourcePaths"] = list(r.source_paths)
|
|
976
|
+
d["models"] = [mb.model_name for mb in r.model_breakdowns]
|
|
977
|
+
else:
|
|
978
|
+
d["date"] = r.date
|
|
979
|
+
d["models"] = [mb.model_name for mb in r.model_breakdowns]
|
|
980
|
+
return d
|
|
981
|
+
|
|
982
|
+
tot_inp = sum(r.input_tokens for r in rows)
|
|
983
|
+
tot_cc = sum(r.cache_creation_tokens for r in rows)
|
|
984
|
+
tot_cr = sum(r.cache_read_tokens for r in rows)
|
|
985
|
+
|
|
986
|
+
output: dict[str, Any] = {
|
|
987
|
+
top_key: [_row_to_dict(r) for r in rows],
|
|
988
|
+
"totals": {
|
|
989
|
+
"inputTokens": tot_inp,
|
|
990
|
+
"outputTokens": sum(r.output_tokens for r in rows),
|
|
991
|
+
"cacheCreationTokens": tot_cc,
|
|
992
|
+
"cacheReadTokens": tot_cr,
|
|
993
|
+
"totalTokens": sum(r.total_tokens for r in rows),
|
|
994
|
+
"cost": round(sum(r.cost for r in rows), 6),
|
|
995
|
+
"cacheHitPercent": round(
|
|
996
|
+
crk._compute_cache_hit_percent(tot_inp, tot_cc, tot_cr), 2
|
|
792
997
|
),
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
)
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
@dataclass
|
|
822
|
-
class _CacheReportResult:
|
|
823
|
-
"""Internal dataclass returned by ``_build_cache_report``.
|
|
824
|
-
|
|
825
|
-
Consumed by both the CLI renderer (which formats into table or JSON)
|
|
826
|
-
and the dashboard snapshot builder (which shapes into
|
|
827
|
-
``CacheReportSnapshot`` for the SSE envelope). ``display_tz_key`` is
|
|
828
|
-
the resolved IANA zone name (or ``None`` when the caller passed
|
|
829
|
-
``display_tz=None`` and the kernel fell back to host-local).
|
|
830
|
-
|
|
831
|
-
``today_baseline_median`` is the median cache_hit_percent across
|
|
832
|
-
"other" rows (excluding today's row) over the trailing
|
|
833
|
-
``anomaly_window_days`` — populated in day mode only (session mode
|
|
834
|
-
has no equivalent "today" concept). Surfaced here so the dashboard
|
|
835
|
-
snapshot builder can read it without re-running
|
|
836
|
-
``_compute_baseline_median`` over the same data (EFF-3).
|
|
837
|
-
"""
|
|
838
|
-
rows: list[CacheRow]
|
|
839
|
-
mode: Literal["day", "session"]
|
|
840
|
-
window_days: int
|
|
841
|
-
anomaly_threshold_pp: int
|
|
842
|
-
anomaly_window_days: int
|
|
843
|
-
display_tz_key: str | None
|
|
844
|
-
today_baseline_median: float | None = None
|
|
998
|
+
"savedUsd": round(sum(r.saved_usd for r in rows), 6),
|
|
999
|
+
"wastedUsd": round(sum(r.wasted_usd for r in rows), 6),
|
|
1000
|
+
"netUsd": round(sum(r.net_usd for r in rows), 6),
|
|
1001
|
+
},
|
|
1002
|
+
"generatedAt": now_utc_iso(now_utc=now_utc),
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
# Backward compat: daily mode previously emitted "totalCost" at row +
|
|
1006
|
+
# totals level. Preserve alongside "cost" so downstream consumers keep
|
|
1007
|
+
# working.
|
|
1008
|
+
if mode == "day":
|
|
1009
|
+
for row_dict in output[top_key]:
|
|
1010
|
+
row_dict["totalCost"] = row_dict["cost"]
|
|
1011
|
+
output["totals"]["totalCost"] = output["totals"]["cost"]
|
|
1012
|
+
return json.dumps(output, indent=2)
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _build_cache_report_title(args: argparse.Namespace, mode: str) -> str:
|
|
1016
|
+
"""Build the banner title for cache-report text output."""
|
|
1017
|
+
scope = "per session" if mode == "session" else "per model/day"
|
|
1018
|
+
if args.since or args.until:
|
|
1019
|
+
start = args.since or "auto"
|
|
1020
|
+
end = args.until or "now"
|
|
1021
|
+
return f"Cache Hit Report – [{start}, {end}] ({scope})"
|
|
1022
|
+
return (
|
|
1023
|
+
f"Cache Hit Report – Last {args.days} Day"
|
|
1024
|
+
f"{'s' if args.days != 1 else ''} ({scope})"
|
|
1025
|
+
)
|
|
845
1026
|
|
|
846
1027
|
|
|
847
|
-
def
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
Returns a ``_CacheReportResult`` that both the CLI renderer and the
|
|
864
|
-
dashboard snapshot builder consume. Pure-function — no I/O, no
|
|
865
|
-
logging, no environment reads. Callers (CLI / dashboard) own all
|
|
866
|
-
I/O via the ``entries`` iterable + the ``cost_calculator`` /
|
|
867
|
-
``project_decoder`` injections.
|
|
868
|
-
|
|
869
|
-
``mode="day"`` buckets entries by display-tz calendar date;
|
|
870
|
-
``mode="session"`` buckets by Claude ``sessionId`` (resume-merged
|
|
871
|
-
across JSONL files). Session mode requires ``project_decoder`` (the
|
|
872
|
-
CLI passes its ``_decode_escaped_cwd``-backed shim); day mode
|
|
873
|
-
ignores it.
|
|
874
|
-
|
|
875
|
-
The ``since`` window for both modes is ``now_utc − window_days``;
|
|
876
|
-
the kernel trusts callers to pre-filter via their own query
|
|
877
|
-
(``get_entries`` / ``get_claude_session_entries``).
|
|
1028
|
+
def _sort_cache_rows(
|
|
1029
|
+
rows: list["crk.CacheRow"],
|
|
1030
|
+
sort_key: str,
|
|
1031
|
+
mode: str,
|
|
1032
|
+
) -> None:
|
|
1033
|
+
"""Sort rows in place per spec Sec. 8.
|
|
1034
|
+
|
|
1035
|
+
Modes: 'session' or 'day'. Sort keys: date, net, cache, recent, cost,
|
|
1036
|
+
anomaly. Tiebreakers are hard-coded (not user-configurable):
|
|
1037
|
+
- net / cache / cost ties -> ascending (date | last_activity), then
|
|
1038
|
+
ascending session_id.
|
|
1039
|
+
- date / recent ties -> ascending session_id.
|
|
1040
|
+
|
|
1041
|
+
For 'anomaly', delegates to the mode default first, then stable-sorts
|
|
1042
|
+
with anomaly_triggered rows first.
|
|
878
1043
|
"""
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
1044
|
+
# Tiebreaker: ascending date/last_activity, then sessionId.
|
|
1045
|
+
def _time_tiebreaker(r: "crk.CacheRow") -> tuple[dt.datetime, str]:
|
|
1046
|
+
anchor = r.last_activity
|
|
1047
|
+
if anchor is None and r.date:
|
|
1048
|
+
# Use .astimezone() (OS tzdb) rather than .replace(tzinfo=...) so
|
|
1049
|
+
# DST-straddling dates resolve to the correct offset — same idiom
|
|
1050
|
+
# as _annotate_anomalies._row_anchor.
|
|
1051
|
+
# internal fallback: host-local intentional
|
|
1052
|
+
anchor = dt.datetime.strptime(r.date, "%Y-%m-%d").astimezone()
|
|
1053
|
+
# Use tz-aware sentinel to avoid naive-vs-aware comparison errors.
|
|
1054
|
+
fallback = dt.datetime.min.replace(tzinfo=dt.timezone.utc)
|
|
1055
|
+
return (anchor or fallback, r.session_id or "")
|
|
1056
|
+
|
|
1057
|
+
if sort_key == "date":
|
|
1058
|
+
if mode == "session":
|
|
1059
|
+
fallback = dt.datetime.min.replace(tzinfo=dt.timezone.utc)
|
|
1060
|
+
rows.sort(key=lambda r: (r.last_activity or fallback,
|
|
1061
|
+
r.session_id or ""))
|
|
1062
|
+
else:
|
|
1063
|
+
rows.sort(key=lambda r: (r.date or "", r.session_id or ""))
|
|
1064
|
+
elif sort_key == "net":
|
|
1065
|
+
rows.sort(key=lambda r: (r.net_usd, _time_tiebreaker(r)))
|
|
1066
|
+
elif sort_key == "cache":
|
|
1067
|
+
rows.sort(key=lambda r: (r.cache_hit_percent, _time_tiebreaker(r)))
|
|
1068
|
+
elif sort_key == "recent":
|
|
1069
|
+
if mode == "session":
|
|
1070
|
+
# Python sort is stable; negate timestamp so ascending sort yields
|
|
1071
|
+
# most-recent first. None -> epoch 0 (pushed to end when negated).
|
|
1072
|
+
rows.sort(
|
|
1073
|
+
key=lambda r: (
|
|
1074
|
+
-(r.last_activity.timestamp() if r.last_activity else 0.0),
|
|
1075
|
+
r.session_id or "",
|
|
1076
|
+
)
|
|
1077
|
+
)
|
|
1078
|
+
else:
|
|
1079
|
+
# Daily mode: descending by date. session_id tiebreaker is a
|
|
1080
|
+
# formality — daily rows never have session_id. Two-pass stable
|
|
1081
|
+
# sort: ascending session_id, then descending date preserves
|
|
1082
|
+
# primary desc + ascending tiebreaker.
|
|
1083
|
+
rows.sort(key=lambda r: r.session_id or "")
|
|
1084
|
+
rows.sort(key=lambda r: r.date or "", reverse=True)
|
|
1085
|
+
elif sort_key == "cost":
|
|
1086
|
+
rows.sort(key=lambda r: (-r.cost, _time_tiebreaker(r)))
|
|
1087
|
+
elif sort_key == "anomaly":
|
|
1088
|
+
# Anomalous rows first (stable), then mode default within each group.
|
|
1089
|
+
default_sub = "net" if mode == "session" else "date"
|
|
1090
|
+
_sort_cache_rows(rows, default_sub, mode)
|
|
1091
|
+
rows.sort(key=lambda r: 0 if r.anomaly_triggered else 1)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def cmd_cache_report(args: argparse.Namespace) -> int:
|
|
1095
|
+
c = _cctally()
|
|
1096
|
+
config = c._load_claude_config_for_args(args)
|
|
1097
|
+
# Session A (spec §7.2): bridge -z/--timezone into args.tz so the
|
|
1098
|
+
# existing resolve_display_tz precedence absorbs the new alias. The
|
|
1099
|
+
# canonical --tz still wins (it's set on the namespace before this
|
|
1100
|
+
# bridge fires); when --tz is unset and -z is supplied, use -z.
|
|
1101
|
+
c._bridge_z_into_tz(args, config)
|
|
1102
|
+
tz = c.resolve_display_tz(args, config)
|
|
1103
|
+
args._resolved_tz = tz
|
|
1104
|
+
|
|
1105
|
+
now_utc = _command_as_of()
|
|
1106
|
+
# Session A (spec §7.1.1): the dual-form helper eprints its own
|
|
1107
|
+
# diagnostic and raises a bare ValueError; catch it here so main()'s
|
|
1108
|
+
# generic ``Error: {exc}`` fallback doesn't double-print an empty
|
|
1109
|
+
# trailer. Mirrors the catch-and-return-1 shape in cmd_blocks.
|
|
1110
|
+
#
|
|
1111
|
+
# Session A note (Review-A P2-D): cache-report's argparse alias
|
|
1112
|
+
# surface lands in Implementor 2's scope (B-series). The try/except
|
|
1113
|
+
# here only routes _resolve_cache_report_window's bare ValueError
|
|
1114
|
+
# around main()'s generic Error: handler — no parser-level changes.
|
|
1115
|
+
try:
|
|
1116
|
+
since, until = _resolve_cache_report_window(
|
|
1117
|
+
args, now_utc=now_utc,
|
|
1118
|
+
tz_name=(tz.key if tz is not None else None),
|
|
884
1119
|
)
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1120
|
+
except ValueError as exc:
|
|
1121
|
+
# Centralized helper already eprinted on the dual-form path; for
|
|
1122
|
+
# the legacy parse_iso_datetime path (full-ISO mis-format) the
|
|
1123
|
+
# ValueError carries the message, so eprint it.
|
|
1124
|
+
msg = str(exc)
|
|
1125
|
+
if msg:
|
|
1126
|
+
eprint(f"Error: {msg}")
|
|
1127
|
+
return 1
|
|
1128
|
+
|
|
1129
|
+
# Issue #89 Pattern C: deferred loader scoped to the rendered window
|
|
1130
|
+
# (project filter mirrors what the cache-aggregator uses).
|
|
1131
|
+
c._emit_debug_samples_if_set(
|
|
1132
|
+
args,
|
|
1133
|
+
lambda: c.get_entries(since, until, project=args.project),
|
|
1134
|
+
command_label="cache-report",
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
mode = "session" if getattr(args, "by_session", False) else "day"
|
|
1138
|
+
top_key = "sessions" if mode == "session" else "days"
|
|
1139
|
+
|
|
1140
|
+
if since == until:
|
|
1141
|
+
if args.json:
|
|
1142
|
+
print(json.dumps(
|
|
1143
|
+
{top_key: [], "totals": None,
|
|
1144
|
+
"generatedAt": now_utc_iso(now_utc=now_utc)},
|
|
1145
|
+
indent=2,
|
|
1146
|
+
))
|
|
1147
|
+
else:
|
|
1148
|
+
print("(no cache activity in window)")
|
|
1149
|
+
return 0
|
|
1150
|
+
|
|
1151
|
+
if mode == "session":
|
|
1152
|
+
rows = _aggregate_cache_by_session(since, until, project=args.project)
|
|
894
1153
|
else:
|
|
895
|
-
|
|
1154
|
+
# Task A3: pass the resolved display_tz so day buckets match the
|
|
1155
|
+
# ``--tz`` flag (closes the pre-existing minor bug where the
|
|
1156
|
+
# window edges shifted but day buckets stayed on host-local —
|
|
1157
|
+
# spec §1.6 / plan A3).
|
|
1158
|
+
rows = _aggregate_cache_by_day(
|
|
1159
|
+
since, until, project=args.project, display_tz=tz,
|
|
1160
|
+
)
|
|
896
1161
|
|
|
897
|
-
|
|
1162
|
+
if not rows:
|
|
1163
|
+
if args.json:
|
|
1164
|
+
print(json.dumps(
|
|
1165
|
+
{top_key: [], "totals": None,
|
|
1166
|
+
"generatedAt": now_utc_iso(now_utc=now_utc)},
|
|
1167
|
+
indent=2,
|
|
1168
|
+
))
|
|
1169
|
+
else:
|
|
1170
|
+
print("(no cache activity in window)")
|
|
1171
|
+
return 0
|
|
1172
|
+
|
|
1173
|
+
_annotate_anomalies(
|
|
898
1174
|
rows,
|
|
899
|
-
threshold_pp=anomaly_threshold_pp,
|
|
900
|
-
window_days=anomaly_window_days,
|
|
901
|
-
enabled=
|
|
1175
|
+
threshold_pp=args.anomaly_threshold_pp,
|
|
1176
|
+
window_days=args.anomaly_window_days,
|
|
1177
|
+
enabled=not args.no_anomaly,
|
|
902
1178
|
)
|
|
903
1179
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
# construction mirrors the pre-EFF-3 adapter byte-for-byte —
|
|
909
|
-
# the strptime + astimezone(display_tz_or_UTC) pair treats the
|
|
910
|
-
# naive parsed datetime as host-local before shifting, which IS
|
|
911
|
-
# the prior contract; do not change without re-verifying the
|
|
912
|
-
# dashboard envelope's today.baseline_median_percent stays stable
|
|
913
|
-
# against the existing golden fixtures.
|
|
914
|
-
today_baseline_median: float | None = None
|
|
915
|
-
if mode == "day":
|
|
916
|
-
today_iso = now_utc.astimezone(
|
|
917
|
-
display_tz if display_tz is not None else dt.timezone.utc
|
|
918
|
-
).strftime("%Y-%m-%d")
|
|
919
|
-
today_anchor = dt.datetime.strptime(today_iso, "%Y-%m-%d").astimezone(
|
|
920
|
-
display_tz if display_tz is not None else dt.timezone.utc
|
|
921
|
-
)
|
|
922
|
-
other_rows = [r for r in rows if r.date != today_iso]
|
|
923
|
-
today_baseline_median = _compute_baseline_median(
|
|
924
|
-
other_rows,
|
|
925
|
-
anchor=today_anchor,
|
|
926
|
-
window_days=anomaly_window_days,
|
|
927
|
-
min_samples=CACHE_REPORT_MIN_BASELINE_DAYS,
|
|
928
|
-
)
|
|
1180
|
+
resolved_sort = args.sort
|
|
1181
|
+
if resolved_sort is None:
|
|
1182
|
+
resolved_sort = "net" if mode == "session" else "date"
|
|
1183
|
+
_sort_cache_rows(rows, resolved_sort, mode)
|
|
929
1184
|
|
|
930
|
-
|
|
931
|
-
rows=
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
today_baseline_median=today_baseline_median,
|
|
938
|
-
)
|
|
1185
|
+
if args.json:
|
|
1186
|
+
print(_emit_cache_report_json(rows, mode, now_utc=now_utc))
|
|
1187
|
+
return 0
|
|
1188
|
+
|
|
1189
|
+
title = _build_cache_report_title(args, mode)
|
|
1190
|
+
print(_render_cache_report_table(rows, title, mode=mode, tz=tz, compact=args.compact))
|
|
1191
|
+
return 0
|