cctally 1.22.2 → 1.22.4

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.
@@ -1,938 +1,1191 @@
1
- """Pure-function kernel for cctally cache-report.
2
-
3
- This module owns the day/session bucketing, financial computation, and
4
- anomaly classification logic that previously lived inline in
5
- ``bin/cctally``. The CLI command ``cctally cache-report`` and the
6
- dashboard sync builder both consume this kernel; the kernel itself is
7
- pure (no I/O, no logging, no environment reads, no SQLite connection).
8
-
9
- Display-tz threading: bucketing functions accept ``display_tz``
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
- from dataclasses import dataclass, field
20
- from typing import Any, Callable, Iterable, Literal, Optional
21
- from zoneinfo import ZoneInfo
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
- Used by ``_aggregate_cache_by_day``, ``_aggregate_cache_by_session``,
127
- and ``_aggregate_cache_breakdown`` so all three aggregators share one
128
- set of field names — typos become type errors, not silent runtime
129
- zero. The breakdown aggregator only populates the token + cache-$
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 _lookup_pricing(model: str, pricing: dict) -> dict | None:
160
- """Resolve pricing for a model. Strips ``anthropic/`` / ``anthropic.``
161
- aliases — same behavior as ``_lib_pricing._resolve_model_pricing`` but
162
- without the stderr warning side-effect (the kernel is pure).
163
- """
164
- p = pricing.get(model)
165
- if p is not None:
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
- pricing: dict,
182
- tiered_threshold: int = DEFAULT_TIERED_THRESHOLD,
183
- ) -> tuple[float, float, float]:
184
- """Return ``(saved_usd, wasted_usd, net_usd)`` for a single entry.
185
-
186
- ``saved_usd`` = ``cache_read_tokens × (base_rate read_rate)``
187
- what you'd have paid without caching.
188
- ``wasted_usd`` = ``cache_creation_tokens × (create_rate − base_rate)``
189
- premium paid to write cache.
190
- ``net_usd`` = ``saved_usd − wasted_usd``. Positive = caching helped.
191
-
192
- Applies Anthropic's per-call >200K-tokens tier (mirrors the
193
- ``_tiered`` helper in ``_calculate_entry_cost``). Aggregating tokens
194
- across multiple calls and then pricing would under-count savings on
195
- any single call that crossed the tier. Resolves ``anthropic/`` and
196
- ``anthropic.`` aliases via ``_lookup_pricing`` so cache-dollar
197
- numbers stay aligned with cost numbers.
198
-
199
- Unknown models (no pricing entry) ``(0.0, 0.0, 0.0)`` silently;
200
- the CLI's ``_calculate_entry_cost`` path emits the one-shot stderr
201
- warning for unknown models elsewhere.
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
- p = _lookup_pricing(model, pricing) or {}
204
- if not p:
205
- return (0.0, 0.0, 0.0)
206
-
207
- def _tiered_rate(tokens: int, base_key: str, tiered_key: str) -> float:
208
- """Blended $/token rate for a single-call token count under tiered pricing."""
209
- base_rate = p.get(base_key, 0.0)
210
- tiered_rate = p.get(tiered_key)
211
- if tokens <= 0:
212
- return 0.0
213
- if tokens > tiered_threshold and tiered_rate is not None:
214
- below = tiered_threshold
215
- above = tokens - tiered_threshold
216
- return (below * base_rate + above * tiered_rate) / tokens
217
- return base_rate
218
-
219
- base_for_read = _tiered_rate(
220
- cache_read_tokens,
221
- "input_cost_per_token",
222
- "input_cost_per_token_above_200k_tokens",
223
- )
224
- read_rate = _tiered_rate(
225
- cache_read_tokens,
226
- "cache_read_input_token_cost",
227
- "cache_read_input_token_cost_above_200k_tokens",
228
- )
229
- base_for_create = _tiered_rate(
230
- cache_creation_tokens,
231
- "input_cost_per_token",
232
- "input_cost_per_token_above_200k_tokens",
233
- )
234
- create_rate = _tiered_rate(
235
- cache_creation_tokens,
236
- "cache_creation_input_token_cost",
237
- "cache_creation_input_token_cost_above_200k_tokens",
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
- saved = cache_read_tokens * max(0.0, base_for_read - read_rate)
241
- wasted = cache_creation_tokens * max(0.0, create_rate - base_for_create)
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
- # Day-mode aggregator with explicit display_tz threading
248
- # ---------------------------------------------------------------------------
384
+ def _red(s: str) -> str:
385
+ return c._style_ansi(s, "31", color)
249
386
 
250
- def _resolve_bucket_tz(display_tz: ZoneInfo | None) -> dt.tzinfo:
251
- """Return the tz used to bucket entry timestamps into calendar days.
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
- ``display_tz`` is the caller's resolved IANA zone (from
254
- ``resolve_display_tz`` in the CLI / dashboard). ``None`` triggers the
255
- legacy host-local fallback preserves the pre-extraction contract
256
- for direct internal callers and matches the
257
- "internal fallback: host-local intentional" annotation in
258
- ``bin/cctally`` for the pre-extraction call site.
259
- """
260
- if display_tz is not None:
261
- return display_tz
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
- def _aggregate_cache_by_day(
267
- entries: Iterable,
268
- *,
269
- display_tz: ZoneInfo | None,
270
- pricing: dict,
271
- cost_calculator: Callable[[str, dict, str, Optional[float]], float],
272
- ) -> list[CacheRow]:
273
- """Group entries by display-tz local date.
274
-
275
- ``display_tz`` controls bucketing. ``None`` falls back to host-local —
276
- matches the legacy contract for direct callers (the pre-extraction
277
- site was annotated "internal fallback: host-local intentional"). The
278
- extraction closes a pre-existing minor bug where the CLI parsed
279
- ``--since`` / ``--until`` in display tz but bucketed by host-local
280
- (spec §1.6 / plan A3); callers pass the same resolved tz they used
281
- for window parsing.
282
-
283
- ``cost_calculator`` is the per-entry cost function (the CLI passes
284
- ``_calculate_entry_cost`` with embedded pricing; the dashboard
285
- snapshot builder injects the same). Required: the kernel does not
286
- fall back to a default so production callers can't accidentally
287
- bypass the embedded pricing tables.
288
-
289
- Overlaps with ``_lib_aggregators._aggregate_buckets`` but kept
290
- separate: the cache-report kernel is purity-contract (no internal
291
- imports per module docstring), and the day-bucket shape diverges
292
- (per-model breakdown children, cache-dollar tiered math). Cross-ref
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.model_breakdowns.append(mb)
345
- row.input_tokens += mb.input_tokens
346
- row.output_tokens += mb.output_tokens
347
- row.cache_creation_tokens += mb.cache_creation_tokens
348
- row.cache_read_tokens += mb.cache_read_tokens
349
- row.cost += mb.cost
350
- row.saved_usd += mb.saved_usd
351
- row.wasted_usd += mb.wasted_usd
352
- row.net_usd += mb.net_usd
353
- result.append(row)
354
- return result
355
-
356
-
357
- # ---------------------------------------------------------------------------
358
- # Session-mode aggregator (resume-merged across JSONL files)
359
- # ---------------------------------------------------------------------------
360
-
361
- def _filename_uuid_stem(path: str) -> str:
362
- """Extract the UUID stem from a JSONL filename.
363
-
364
- Claude JSONL files are named ``<uuid>.jsonl``; fall back to the full
365
- filename (without extension) if the stem isn't a valid UUID shape.
366
- Matches the ``session`` subcommand's convention for unresolved session
367
- IDs. Stays pure — uses only ``str.partition``, no ``os.path`` and no
368
- syscalls.
369
- """
370
- # The original lived in bin/cctally and used os.path.basename; this
371
- # rebuild matches that contract with pure-string slicing so the
372
- # kernel doesn't import os.
373
- last_slash = path.rfind("/")
374
- base = path[last_slash + 1:] if last_slash != -1 else path
375
- stem, _, _ = base.partition(".")
376
- return stem
377
-
378
-
379
- @dataclass
380
- class _SessionAggregationResult:
381
- """Bundles session rows + the fallback warning count.
382
-
383
- Returned by ``_aggregate_cache_by_session`` so callers can choose
384
- whether to emit the "N entries lacked session_files rows" one-shot
385
- warning. The CLI adapter consumes ``fallback_count`` to emit the
386
- legacy stderr line; the dashboard snapshot builder ignores it (the
387
- panel surfaces freshness via the doctor chip instead).
388
- """
389
- rows: list[CacheRow]
390
- fallback_count: int
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 _aggregate_cache_by_session(
394
- entries: Iterable,
395
- *,
396
- pricing: dict,
397
- cost_calculator: Callable[[str, dict, str, Optional[float]], float],
398
- project_decoder: Callable[[str], str],
399
- ) -> _SessionAggregationResult:
400
- """Group Claude entries by sessionId (resumed-merged).
401
-
402
- Resume-merging: entries from multiple JSONL files sharing a sessionId
403
- collapse into one row. ``project_path`` reflects the most-recent
404
- in-window entry's resolved project (with a per-session fallback to
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
- # buckets[sid] = {"entries": [...], "project_path": str|None,
425
- # "last_activity": dt|None, "source_paths": set[str]}
426
- buckets: dict[str, dict[str, Any]] = {}
427
- fallback_count = 0
428
- for entry in entries:
429
- if entry.model == "<synthetic>":
430
- continue
431
- sid = entry.session_id
432
- if sid is None:
433
- sid = _filename_uuid_stem(entry.source_path)
434
- fallback_count += 1
435
- b = buckets.setdefault(sid, {
436
- "entries": [],
437
- # Seed with decoded-cwd fallback so rows still resolve a
438
- # Project cell while session_files backfill is incomplete.
439
- # Real project_path from session_files (if present on any
440
- # joined row) overrides below.
441
- "project_path": project_decoder(entry.source_path),
442
- "last_activity": None,
443
- "source_paths": set(),
444
- })
445
- b["entries"].append(entry)
446
- b["source_paths"].add(entry.source_path)
447
- if b["last_activity"] is None or entry.timestamp > b["last_activity"]:
448
- b["last_activity"] = entry.timestamp
449
- # Project path from most-recent in-window entry that has it.
450
- if entry.project_path:
451
- b["project_path"] = entry.project_path
452
-
453
- result: list[CacheRow] = []
454
- for sid, b in buckets.items():
455
- # Per-model sub-buckets scoped to this session's entries.
456
- model_buckets: dict[str, _Bucket] = {}
457
- for entry in b["entries"]:
458
- mb_raw = model_buckets.setdefault(entry.model, _Bucket())
459
- mb_raw.input_tokens += entry.input_tokens
460
- mb_raw.output_tokens += entry.output_tokens
461
- mb_raw.cache_creation_tokens += entry.cache_creation_tokens
462
- mb_raw.cache_read_tokens += entry.cache_read_tokens
463
- mb_raw.cost += cost_calculator(
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
- for model_name in sorted(model_buckets.keys()):
491
- mb_raw = model_buckets[model_name]
492
- mb = CacheModelBreakdown(
493
- model_name=model_name,
494
- input_tokens=mb_raw.input_tokens,
495
- output_tokens=mb_raw.output_tokens,
496
- cache_creation_tokens=mb_raw.cache_creation_tokens,
497
- cache_read_tokens=mb_raw.cache_read_tokens,
498
- cache_hit_percent=_compute_cache_hit_percent(
499
- mb_raw.input_tokens,
500
- mb_raw.cache_creation_tokens,
501
- mb_raw.cache_read_tokens,
502
- ),
503
- cost=mb_raw.cost,
504
- saved_usd=mb_raw.saved_usd,
505
- wasted_usd=mb_raw.wasted_usd,
506
- net_usd=mb_raw.net_usd,
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.model_breakdowns.append(mb)
509
- row.input_tokens += mb.input_tokens
510
- row.output_tokens += mb.output_tokens
511
- row.cache_creation_tokens += mb.cache_creation_tokens
512
- row.cache_read_tokens += mb.cache_read_tokens
513
- row.cost += mb.cost
514
- row.saved_usd += mb.saved_usd
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
- upper_offset = (
576
- dt.timedelta(seconds=1) if is_session_mode else dt.timedelta(days=1)
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
- threshold_pp: int,
598
- window_days: int,
599
- enabled: bool = True,
600
- ) -> None:
601
- """Mutate each row's ``anomaly_triggered`` / ``anomaly_reasons`` in place.
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
- Trigger 1 (``net_negative``): ``net_usd < 0`` (strict). Skipped when the
604
- row has zero cache activity (no-op session, not a bug).
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
- Trigger 2 (``cache_drop``): ``cache_hit_percent`` is ``>= threshold_pp``
607
- below the trailing ``window_days`` median of OTHER rows. Requires
608
- a minimum of ``CACHE_REPORT_MIN_BASELINE_DAYS`` (daily) or
609
- ``CACHE_REPORT_MIN_BASELINE_SESSIONS`` (session) baseline samples;
610
- silently skipped otherwise.
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
- Mode is inferred from the first row: if it has a ``session_id``,
617
- session mode (window_days back to ``<= last_activity − 1s``);
618
- else daily mode (window_days back to ``<= date − 1 day``).
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
- if not enabled:
621
- for row in rows:
622
- row.anomaly_triggered = False
623
- row.anomaly_reasons = []
624
- return
625
- if not rows:
626
- return
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
- for i, row in enumerate(rows):
638
- reasons: list[CacheAnomalyReason] = []
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
- # Trigger 1: net_negative (no baseline needed; cache-activity guard).
641
- if row.cache_creation_tokens + row.cache_read_tokens > 0:
642
- if row.net_usd < 0:
643
- reasons.append("net_negative")
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
- row.anomaly_reasons = reasons
657
- row.anomaly_triggered = bool(reasons)
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 _aggregate_cache_breakdown(
665
- entries: Iterable,
770
+ def _resolve_cache_report_window(
771
+ args: argparse.Namespace,
666
772
  *,
667
- key_fn: Callable[[Any], str],
668
- pricing: dict,
669
- skip_synthetic: bool = True,
670
- top_n: int = 5,
671
- other_label: str = "(other)",
672
- ) -> tuple[CacheBreakdownRow, ...]:
673
- """Sum cache hit % + net $ per bucket; top ``top_n`` + ``(other)``.
674
-
675
- Single source of truth for the dashboard's by-project AND by-model
676
- breakdowns (spec §4.2). The caller injects ``key_fn`` to pick the
677
- bucket label per entry:
678
-
679
- - by-project: ``lambda e: getattr(e, "project_path", None) or "(unknown)"``
680
- - by-model: ``lambda e: e.model``
681
-
682
- ``skip_synthetic`` drops ``e.model == "<synthetic>"`` entries before
683
- bucketing Claude Code's internal markers aren't real model calls
684
- and would inflate token totals for whichever axis is keyed on
685
- something other than ``model``. Defaults to True so both axes agree
686
- on which entries contribute (closes the by-project / by-model
687
- drift previously caused by an inconsistent filter on the two
688
- dashboard-side helpers).
689
-
690
- Sorted by ``abs(net_usd)`` desc. When there are more than ``top_n``
691
- buckets, the tail collapses into a single ``(other)`` row whose
692
- ``cache_hit_percent`` is the TRUE aggregate hit % across the tail's
693
- token totals (not a placeholder zero, not the mean of the tail's
694
- per-bucket percentages) matches the by-project numbers users
695
- would see if they widened the top-N. The aggregate is computed by
696
- summing the head rows' token fields rather than re-walking the raw
697
- bucket map (EFF-4).
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
- buckets: dict[str, _Bucket] = {}
700
- for e in entries:
701
- if skip_synthetic and getattr(e, "model", None) == "<synthetic>":
702
- continue
703
- key = key_fn(e)
704
- b = buckets.setdefault(key, _Bucket())
705
- b.input_tokens += getattr(e, "input_tokens", 0)
706
- b.cache_creation_tokens += getattr(e, "cache_creation_tokens", 0)
707
- b.cache_read_tokens += getattr(e, "cache_read_tokens", 0)
708
- saved, wasted, net = _compute_entry_cache_dollars(
709
- getattr(e, "model", ""),
710
- getattr(e, "cache_creation_tokens", 0),
711
- getattr(e, "cache_read_tokens", 0),
712
- pricing=pricing,
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
- b.saved_usd += saved
715
- b.wasted_usd += wasted
716
- b.net_usd += net
717
-
718
- out: list[CacheBreakdownRow] = []
719
- for key, b in buckets.items():
720
- out.append(CacheBreakdownRow(
721
- key=key,
722
- cache_hit_percent=_compute_cache_hit_percent(
723
- b.input_tokens, b.cache_creation_tokens, b.cache_read_tokens,
724
- ),
725
- net_usd=b.net_usd,
726
- input_tokens=b.input_tokens,
727
- cache_creation_tokens=b.cache_creation_tokens,
728
- cache_read_tokens=b.cache_read_tokens,
729
- ))
730
- out.sort(key=lambda r: abs(r.net_usd), reverse=True)
731
- if len(out) <= top_n:
732
- return tuple(out)
733
- head = out[:top_n]
734
- tail = out[top_n:]
735
- other_net = sum(r.net_usd for r in tail)
736
- # True aggregate hit % over the tail buckets — sum directly from the
737
- # CacheBreakdownRow token fields (EFF-4 — avoids the previous triple
738
- # walk over ``buckets.items()``).
739
- tail_input = sum(r.input_tokens for r in tail)
740
- tail_creation = sum(r.cache_creation_tokens for r in tail)
741
- tail_read = sum(r.cache_read_tokens for r in tail)
742
- other_pct = _compute_cache_hit_percent(tail_input, tail_creation, tail_read)
743
- head.append(CacheBreakdownRow(
744
- key=other_label, cache_hit_percent=other_pct, net_usd=other_net,
745
- input_tokens=tail_input,
746
- cache_creation_tokens=tail_creation,
747
- cache_read_tokens=tail_read,
748
- ))
749
- return tuple(head)
750
-
751
-
752
- def _aggregate_cache_breakdown_from_rows(
753
- rows: Iterable["CacheRow"],
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
- skip_synthetic: bool = True,
756
- top_n: int = 5,
757
- other_label: str = "(other)",
758
- ) -> tuple[CacheBreakdownRow, ...]:
759
- """By-model breakdown folded from day-mode rows.
760
-
761
- Day-mode ``_aggregate_cache_by_day`` already buckets per-entry cache
762
- dollars by ``(date, model)``. Walking those pre-aggregated buckets is
763
- O(rows × distinct_models) orders of magnitude cheaper than calling
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
- buckets: dict[str, _Bucket] = {}
776
- for row in rows:
777
- for mb in row.model_breakdowns:
778
- if skip_synthetic and mb.model_name == "<synthetic>":
779
- continue
780
- b = buckets.setdefault(mb.model_name, _Bucket())
781
- b.input_tokens += mb.input_tokens
782
- b.cache_creation_tokens += mb.cache_creation_tokens
783
- b.cache_read_tokens += mb.cache_read_tokens
784
- b.net_usd += mb.net_usd
785
-
786
- out: list[CacheBreakdownRow] = []
787
- for key, b in buckets.items():
788
- out.append(CacheBreakdownRow(
789
- key=key,
790
- cache_hit_percent=_compute_cache_hit_percent(
791
- b.input_tokens, b.cache_creation_tokens, b.cache_read_tokens,
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
- net_usd=b.net_usd,
794
- input_tokens=b.input_tokens,
795
- cache_creation_tokens=b.cache_creation_tokens,
796
- cache_read_tokens=b.cache_read_tokens,
797
- ))
798
- out.sort(key=lambda r: abs(r.net_usd), reverse=True)
799
- if len(out) <= top_n:
800
- return tuple(out)
801
- head = out[:top_n]
802
- tail = out[top_n:]
803
- other_net = sum(r.net_usd for r in tail)
804
- tail_input = sum(r.input_tokens for r in tail)
805
- tail_creation = sum(r.cache_creation_tokens for r in tail)
806
- tail_read = sum(r.cache_read_tokens for r in tail)
807
- other_pct = _compute_cache_hit_percent(tail_input, tail_creation, tail_read)
808
- head.append(CacheBreakdownRow(
809
- key=other_label, cache_hit_percent=other_pct, net_usd=other_net,
810
- input_tokens=tail_input,
811
- cache_creation_tokens=tail_creation,
812
- cache_read_tokens=tail_read,
813
- ))
814
- return tuple(head)
815
-
816
-
817
- # ---------------------------------------------------------------------------
818
- # Top-level orchestrator
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 _build_cache_report(
848
- entries: Iterable,
849
- *,
850
- now_utc: dt.datetime,
851
- window_days: int,
852
- anomaly_threshold_pp: int,
853
- anomaly_window_days: int,
854
- display_tz: ZoneInfo | None,
855
- pricing: dict,
856
- cost_calculator: Callable[[str, dict, str, Optional[float]], float],
857
- mode: Literal["day", "session"] = "day",
858
- project_decoder: Callable[[str], str] | None = None,
859
- anomaly_enabled: bool = True,
860
- ) -> _CacheReportResult:
861
- """Top-level orchestrator: aggregate + classify anomalies.
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
- if mode == "day":
880
- rows = _aggregate_cache_by_day(
881
- entries,
882
- display_tz=display_tz, pricing=pricing,
883
- cost_calculator=cost_calculator,
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
- elif mode == "session":
886
- if project_decoder is None:
887
- raise ValueError("session mode requires project_decoder")
888
- rows = _aggregate_cache_by_session(
889
- entries,
890
- pricing=pricing,
891
- cost_calculator=cost_calculator,
892
- project_decoder=project_decoder,
893
- ).rows
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
- raise ValueError(f"unknown mode: {mode!r}")
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
- _classify_anomalies(
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=anomaly_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
- # EFF-3: surface today's baseline median directly on the result so
905
- # the dashboard snapshot builder doesn't have to re-run
906
- # _compute_baseline_median over the same row set. Day-mode only —
907
- # session mode has no equivalent "today" anchor concept. Anchor
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
- return _CacheReportResult(
931
- rows=rows,
932
- mode=mode,
933
- window_days=window_days,
934
- anomaly_threshold_pp=anomaly_threshold_pp,
935
- anomaly_window_days=anomaly_window_days,
936
- display_tz_key=display_tz.key if display_tz is not None else None,
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