cctally 1.22.2 → 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 +13 -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_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 +419 -8891
- package/package.json +14 -1
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
# bin/_cctally_codex.py
|
|
2
|
+
"""Codex (OpenAI) parity command family.
|
|
3
|
+
|
|
4
|
+
Holds the four codex commands — `cmd_codex_daily`, `cmd_codex_monthly`,
|
|
5
|
+
`cmd_codex_weekly`, `cmd_codex_session` — their speed/tz resolvers
|
|
6
|
+
(`_detect_codex_fast_service_tier`, `_resolve_codex_speed`,
|
|
7
|
+
`_resolve_codex_tz_name`) and the cost-stats/debug cluster
|
|
8
|
+
(`_CodexCostSample`, `_CodexCostStats`, `_compute_codex_cost_stats`,
|
|
9
|
+
`_render_codex_cost_report`, `_emit_codex_debug_samples_if_set`).
|
|
10
|
+
|
|
11
|
+
Honest *name* imports are KERNEL-ONLY (`_cctally_core`). This module
|
|
12
|
+
references the bin/cctally RE-EXPORTED names of every library kernel it
|
|
13
|
+
needs (`build_codex_daily_view`, `_calculate_codex_entry_cost`,
|
|
14
|
+
`_render_codex_session_table`, …) — NOT the `_lib_*` module objects — so
|
|
15
|
+
NO qualified `_lib_*` import is required; every such name is reached via
|
|
16
|
+
the call-time `_cctally()` accessor so test monkeypatches through
|
|
17
|
+
`cctally`'s namespace are preserved (spec §3.1). The codex path-resolvers
|
|
18
|
+
`_codex_home_roots`/`_codex_session_roots` STAY in bin/cctally (shared
|
|
19
|
+
with cache/doctor/aggregators) and are reached via `c.`.
|
|
20
|
+
|
|
21
|
+
THE SHARED DEBUG GUARD: `_DEBUG_REPORT_EMITTED` STAYS in bin/cctally
|
|
22
|
+
(module-global); `_emit_codex_debug_samples_if_set` reaches it via
|
|
23
|
+
`c._DEBUG_REPORT_EMITTED` for BOTH read and write — there is NO `global`
|
|
24
|
+
declaration here (spec §3.3).
|
|
25
|
+
|
|
26
|
+
bin/cctally re-exports EVERY moved symbol (eager): the parser resolves
|
|
27
|
+
`c.cmd_codex_*`; tests reach `mod._compute_codex_cost_stats` /
|
|
28
|
+
`mod._render_codex_cost_report` / `cc._resolve_codex_speed` /
|
|
29
|
+
`cc._detect_codex_fast_service_tier` off the `cctally` namespace.
|
|
30
|
+
|
|
31
|
+
Spec: docs/superpowers/specs/2026-05-31-extract-codex-reporting-cmd-design.md
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import sys
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
|
|
41
|
+
from _cctally_core import WEEKDAY_MAP, _command_as_of, eprint, get_week_start_name
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _cctally():
|
|
45
|
+
"""Resolve the current `cctally` module at call-time (spec §3.1)."""
|
|
46
|
+
return sys.modules["cctally"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# === moved verbatim from bin/cctally (Regions A–C) ===
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class _CodexCostSample:
|
|
54
|
+
file: str
|
|
55
|
+
timestamp: str
|
|
56
|
+
model: str
|
|
57
|
+
calculated_cost: float
|
|
58
|
+
usage: dict
|
|
59
|
+
is_fallback: bool
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class _CodexCostStats:
|
|
64
|
+
command_label: str | None = None
|
|
65
|
+
total_entries: int = 0
|
|
66
|
+
total_cost: float = 0.0
|
|
67
|
+
model_counts: dict = field(default_factory=dict)
|
|
68
|
+
fallback_models: set = field(default_factory=set)
|
|
69
|
+
samples: list = field(default_factory=list)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _compute_codex_cost_stats(entries, speed: str = "standard"):
|
|
73
|
+
"""Walk ``entries: Iterable[CodexEntry]`` and compute the totals +
|
|
74
|
+
per-entry computed-cost samples that ``_render_codex_cost_report``
|
|
75
|
+
consumes (issue #92).
|
|
76
|
+
|
|
77
|
+
Unlike the Claude ``_compute_pricing_mismatch_stats`` there is no
|
|
78
|
+
recorded cost to diff against, so every entry contributes a sample.
|
|
79
|
+
Samples are collected for all entries and sorted descending by
|
|
80
|
+
computed cost; the renderer slices to ``--debug-samples``. (Memory is
|
|
81
|
+
O(entries); acceptable for typical codex histories and symmetric with
|
|
82
|
+
the Claude helper, which retains its full discrepancy list.)
|
|
83
|
+
|
|
84
|
+
Cost + fallback resolution mirror the live aggregation path:
|
|
85
|
+
``_calculate_codex_entry_cost`` (LiteLLM token semantics) and
|
|
86
|
+
``_resolve_codex_pricing`` (unknown model → ``gpt-5`` fallback).
|
|
87
|
+
"""
|
|
88
|
+
c = _cctally()
|
|
89
|
+
stats = _CodexCostStats()
|
|
90
|
+
for entry in entries:
|
|
91
|
+
stats.total_entries += 1
|
|
92
|
+
stats.model_counts[entry.model] = (
|
|
93
|
+
stats.model_counts.get(entry.model, 0) + 1
|
|
94
|
+
)
|
|
95
|
+
_, is_fallback = c._resolve_codex_pricing(entry.model)
|
|
96
|
+
if is_fallback:
|
|
97
|
+
stats.fallback_models.add(entry.model)
|
|
98
|
+
cost = c._calculate_codex_entry_cost(
|
|
99
|
+
entry.model,
|
|
100
|
+
entry.input_tokens,
|
|
101
|
+
entry.cached_input_tokens,
|
|
102
|
+
entry.output_tokens,
|
|
103
|
+
entry.reasoning_output_tokens,
|
|
104
|
+
speed=speed,
|
|
105
|
+
)
|
|
106
|
+
stats.total_cost += cost
|
|
107
|
+
stats.samples.append(_CodexCostSample(
|
|
108
|
+
file=os.path.basename(entry.source_path),
|
|
109
|
+
timestamp=entry.timestamp.isoformat(),
|
|
110
|
+
model=entry.model,
|
|
111
|
+
calculated_cost=cost,
|
|
112
|
+
usage={
|
|
113
|
+
"input_tokens": entry.input_tokens,
|
|
114
|
+
"cached_input_tokens": entry.cached_input_tokens,
|
|
115
|
+
"output_tokens": entry.output_tokens,
|
|
116
|
+
"reasoning_output_tokens": entry.reasoning_output_tokens,
|
|
117
|
+
"total_tokens": entry.total_tokens,
|
|
118
|
+
},
|
|
119
|
+
is_fallback=is_fallback,
|
|
120
|
+
))
|
|
121
|
+
# Stable sort: equal-cost samples keep iteration order (mirrors the
|
|
122
|
+
# Claude helper's iteration-order discrepancy list).
|
|
123
|
+
stats.samples.sort(key=lambda s: -s.calculated_cost)
|
|
124
|
+
return stats
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _render_codex_cost_report(stats, sample_limit):
|
|
128
|
+
"""Return the codex --debug report as a list of stderr lines (issue #92).
|
|
129
|
+
|
|
130
|
+
Structurally parallel to ``_render_pricing_mismatch_report`` but with
|
|
131
|
+
no match/mismatch framing (codex has no recorded cost):
|
|
132
|
+
|
|
133
|
+
- Early-return ``"No Codex usage data found to analyze."`` when
|
|
134
|
+
``total_entries == 0``.
|
|
135
|
+
- Totals header: entries processed, models seen (count desc, ties
|
|
136
|
+
by name asc; fallback models tagged ``(N, fallback→gpt-5)``),
|
|
137
|
+
total computed cost.
|
|
138
|
+
- ``Command: cctally <label>`` self-identifier when set (parity
|
|
139
|
+
with the Claude report's one non-upstream line).
|
|
140
|
+
- Sample block omitted when ``sample_limit == 0`` or no samples;
|
|
141
|
+
header prints the requested ``sample_limit`` (upstream parity).
|
|
142
|
+
Each sample carries ``Recorded cost: (none)`` and a
|
|
143
|
+
``(fallback→gpt-5)`` model-line marker when applicable.
|
|
144
|
+
"""
|
|
145
|
+
c = _cctally()
|
|
146
|
+
out = []
|
|
147
|
+
if stats.total_entries == 0:
|
|
148
|
+
out.append("No Codex usage data found to analyze.")
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
fallback = c.CODEX_LEGACY_FALLBACK_MODEL
|
|
152
|
+
parts = []
|
|
153
|
+
for model, count in sorted(
|
|
154
|
+
stats.model_counts.items(), key=lambda kv: (-kv[1], kv[0]),
|
|
155
|
+
):
|
|
156
|
+
if model in stats.fallback_models:
|
|
157
|
+
parts.append(f"{model} ({count:,}, fallback→{fallback})")
|
|
158
|
+
else:
|
|
159
|
+
parts.append(f"{model} ({count:,})")
|
|
160
|
+
|
|
161
|
+
out.append("")
|
|
162
|
+
out.append("=== Codex Pricing Debug Report ===")
|
|
163
|
+
if stats.command_label:
|
|
164
|
+
out.append(f"Command: cctally {stats.command_label}")
|
|
165
|
+
out.append(f"Total entries processed: {stats.total_entries:,}")
|
|
166
|
+
out.append(f"Models seen: {', '.join(parts)}")
|
|
167
|
+
out.append(f"Total computed cost: ${stats.total_cost:.6f}")
|
|
168
|
+
|
|
169
|
+
if stats.samples and sample_limit > 0:
|
|
170
|
+
out.append("")
|
|
171
|
+
out.append(f"=== Sample Top Entries (first {sample_limit}) ===")
|
|
172
|
+
for s in stats.samples[:sample_limit]:
|
|
173
|
+
model_line = (
|
|
174
|
+
f"{s.model} (fallback→{fallback})"
|
|
175
|
+
if s.is_fallback else s.model
|
|
176
|
+
)
|
|
177
|
+
out.append(f"File: {s.file}")
|
|
178
|
+
out.append(f"Timestamp: {s.timestamp}")
|
|
179
|
+
out.append(f"Model: {model_line}")
|
|
180
|
+
out.append("Recorded cost: (none)")
|
|
181
|
+
out.append(f"Calculated cost: ${s.calculated_cost:.6f}")
|
|
182
|
+
out.append(f"Tokens: {json.dumps(s.usage)}")
|
|
183
|
+
out.append("---")
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _emit_codex_debug_samples_if_set(
|
|
188
|
+
args,
|
|
189
|
+
entries,
|
|
190
|
+
*,
|
|
191
|
+
command_label: str,
|
|
192
|
+
speed: str = "standard",
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Emit the codex --debug report once per process when ``args.debug``
|
|
195
|
+
is True (issue #92).
|
|
196
|
+
|
|
197
|
+
``entries`` is an eager ``list[CodexEntry]`` — each ``cmd_codex_*`` body
|
|
198
|
+
already loads them via ``get_codex_entries`` before this call, so unlike
|
|
199
|
+
the Claude helper there is no deferred-loader variant. Shares the
|
|
200
|
+
process-wide ``_DEBUG_REPORT_EMITTED`` guard with
|
|
201
|
+
``_emit_debug_samples_if_set`` so a single CLI invocation emits one
|
|
202
|
+
report regardless of family.
|
|
203
|
+
"""
|
|
204
|
+
c = _cctally()
|
|
205
|
+
if c._DEBUG_REPORT_EMITTED:
|
|
206
|
+
return
|
|
207
|
+
if not getattr(args, "debug", False):
|
|
208
|
+
return
|
|
209
|
+
sample_limit = int(getattr(args, "debug_samples", 5))
|
|
210
|
+
stats = _compute_codex_cost_stats(entries, speed=speed)
|
|
211
|
+
stats.command_label = command_label
|
|
212
|
+
for line in _render_codex_cost_report(stats, sample_limit):
|
|
213
|
+
eprint(line)
|
|
214
|
+
c._DEBUG_REPORT_EMITTED = True
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _resolve_codex_tz_name(args: argparse.Namespace,
|
|
218
|
+
config: "dict | None") -> "str | None":
|
|
219
|
+
"""Resolve the IANA tz NAME (or None for host-local) used by Codex
|
|
220
|
+
aggregators (`codex-{daily,monthly,weekly,session}`).
|
|
221
|
+
|
|
222
|
+
Precedence (F2 fix):
|
|
223
|
+
1. Explicit `--tz <anything>` flag → use it (None on canonical "local").
|
|
224
|
+
2. Explicit `display.tz` set in config → use it (None on "local").
|
|
225
|
+
3. Else fall back to upstream's `--timezone` (drop-in parity).
|
|
226
|
+
4. Else None (host local).
|
|
227
|
+
|
|
228
|
+
Steps 1+2 funnel through `resolve_display_tz`; step 3+4 are the
|
|
229
|
+
pre-existing fallback path. The bug `resolve_display_tz` could not
|
|
230
|
+
fix on its own: it returns None for both "explicit local" AND
|
|
231
|
+
"implicit local fallback when no config exists", which collapsed the
|
|
232
|
+
two semantically distinct cases. We disambiguate by inspecting
|
|
233
|
+
`args.tz` and `config["display"]["tz"]` directly.
|
|
234
|
+
"""
|
|
235
|
+
c = _cctally()
|
|
236
|
+
flag_set = (
|
|
237
|
+
getattr(args, "tz", None) is not None
|
|
238
|
+
and str(getattr(args, "tz")).strip() != ""
|
|
239
|
+
)
|
|
240
|
+
if flag_set or c._config_has_explicit_display_tz(config):
|
|
241
|
+
tz_obj = c.resolve_display_tz(args, config)
|
|
242
|
+
return tz_obj.key if tz_obj is not None else None
|
|
243
|
+
# No explicit display tz pin → defer to upstream's --timezone, then
|
|
244
|
+
# host-local as the final default.
|
|
245
|
+
return getattr(args, "timezone", None)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _detect_codex_fast_service_tier() -> bool:
|
|
249
|
+
"""True iff any $CODEX_HOME root's config.toml requests fast/priority tier.
|
|
250
|
+
|
|
251
|
+
Reads <root>/config.toml for EVERY entry in _codex_home_roots() (comma-
|
|
252
|
+
separated $CODEX_HOME, else ~/.codex) — including direct-JSONL entries,
|
|
253
|
+
which usually have no config.toml (read → absent → skipped) but DO count
|
|
254
|
+
if one is present. Returns on the first root that requests it (any-root
|
|
255
|
+
semantics, matching upstream ccusage). Tolerates absent/unreadable config
|
|
256
|
+
(→ that root contributes nothing).
|
|
257
|
+
"""
|
|
258
|
+
c = _cctally()
|
|
259
|
+
for root in c._codex_home_roots():
|
|
260
|
+
cfg = root / "config.toml"
|
|
261
|
+
try:
|
|
262
|
+
content = cfg.read_text(encoding="utf-8", errors="replace")
|
|
263
|
+
except OSError:
|
|
264
|
+
continue
|
|
265
|
+
if c._codex_config_requests_fast_service_tier(content):
|
|
266
|
+
return True
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _resolve_codex_speed(requested: str) -> str:
|
|
271
|
+
"""Resolve a ``--speed`` value to an effective tier.
|
|
272
|
+
|
|
273
|
+
``auto`` → ``fast`` iff any ``$CODEX_HOME`` root's ``config.toml``
|
|
274
|
+
requests it, else ``standard``. ``fast``/``standard`` pass through
|
|
275
|
+
unchanged.
|
|
276
|
+
"""
|
|
277
|
+
if requested == "auto":
|
|
278
|
+
return "fast" if _detect_codex_fast_service_tier() else "standard"
|
|
279
|
+
return requested
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def cmd_codex_daily(args: argparse.Namespace) -> int:
|
|
283
|
+
"""Show Codex usage report grouped by date (display tz, --tz, or --timezone)."""
|
|
284
|
+
c = _cctally()
|
|
285
|
+
config = c.load_config()
|
|
286
|
+
tz_obj = c.resolve_display_tz(args, config)
|
|
287
|
+
args._resolved_tz = tz_obj
|
|
288
|
+
# Codex aggregators take a tz_name string. F2 fix: precedence is
|
|
289
|
+
# `--tz` flag > config.display.tz > `--timezone` > host-local. Without
|
|
290
|
+
# this, an explicit "--tz local" silently falls through to --timezone
|
|
291
|
+
# (because resolve_display_tz returns None for canonical "local").
|
|
292
|
+
tz_name = _resolve_codex_tz_name(args, config)
|
|
293
|
+
force_compact = bool(getattr(args, "compact", False))
|
|
294
|
+
range = c._parse_cli_date_range(
|
|
295
|
+
args, tz_name=tz_name, now_utc=_command_as_of(),
|
|
296
|
+
)
|
|
297
|
+
if isinstance(range, int):
|
|
298
|
+
return range
|
|
299
|
+
range_start, range_end = range
|
|
300
|
+
|
|
301
|
+
entries = c.get_codex_entries(range_start, range_end)
|
|
302
|
+
speed = _resolve_codex_speed(args.speed)
|
|
303
|
+
_emit_codex_debug_samples_if_set(args, entries, command_label="codex-daily", speed=speed)
|
|
304
|
+
# Route through ``build_codex_daily_view`` (issue #58). The View
|
|
305
|
+
# wraps ``_aggregate_codex_daily`` without changing it — preserves
|
|
306
|
+
# LiteLLM token semantics, intentional dedup vs upstream, and
|
|
307
|
+
# ``CODEX_LEGACY_FALLBACK_MODEL`` warning end-to-end.
|
|
308
|
+
view = c.build_codex_daily_view(
|
|
309
|
+
entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
|
|
310
|
+
)
|
|
311
|
+
days = list(view.rows) # asc — matches aggregator default
|
|
312
|
+
if args.order == "desc":
|
|
313
|
+
days = list(reversed(days))
|
|
314
|
+
|
|
315
|
+
if not days:
|
|
316
|
+
# Match upstream's no-data sentinel (see _emit_codex_no_data docstring).
|
|
317
|
+
c._emit_codex_no_data(args, "daily")
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
if args.json:
|
|
321
|
+
# Upstream daily --json uses "Dec 25, 2025" style for the date key.
|
|
322
|
+
print(c._codex_bucket_to_json(
|
|
323
|
+
days, list_key="daily", date_key="date",
|
|
324
|
+
display_fn=c._codex_daily_bucket_display,
|
|
325
|
+
))
|
|
326
|
+
return 0
|
|
327
|
+
|
|
328
|
+
# Wide-mode table Date cell: two-line "Dec 25,\n2025"
|
|
329
|
+
def daily_table_display(bucket: str) -> str:
|
|
330
|
+
y, m, d = bucket.split("-")
|
|
331
|
+
return f"{c._CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
|
|
332
|
+
|
|
333
|
+
tz_label = view.display_tz_label
|
|
334
|
+
title = f"Codex Token Usage Report - Daily (Timezone: {tz_label})"
|
|
335
|
+
print(c._render_codex_bucket_table(
|
|
336
|
+
days,
|
|
337
|
+
first_col_name="Date",
|
|
338
|
+
title=title,
|
|
339
|
+
compact_split_fn=c._daily_compact_split, # reuse existing helper (YYYY-MM-DD split)
|
|
340
|
+
bucket_display_fn=daily_table_display,
|
|
341
|
+
breakdown=args.breakdown,
|
|
342
|
+
force_compact=force_compact,
|
|
343
|
+
))
|
|
344
|
+
return 0
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def cmd_codex_monthly(args: argparse.Namespace) -> int:
|
|
348
|
+
"""Show Codex usage report grouped by calendar month (display tz, --tz, or --timezone)."""
|
|
349
|
+
c = _cctally()
|
|
350
|
+
config = c.load_config()
|
|
351
|
+
tz_obj = c.resolve_display_tz(args, config)
|
|
352
|
+
args._resolved_tz = tz_obj
|
|
353
|
+
# F2 fix: see cmd_codex_daily.
|
|
354
|
+
tz_name = _resolve_codex_tz_name(args, config)
|
|
355
|
+
force_compact = bool(getattr(args, "compact", False))
|
|
356
|
+
range = c._parse_cli_date_range(
|
|
357
|
+
args, tz_name=tz_name, now_utc=_command_as_of(),
|
|
358
|
+
)
|
|
359
|
+
if isinstance(range, int):
|
|
360
|
+
return range
|
|
361
|
+
range_start, range_end = range
|
|
362
|
+
|
|
363
|
+
entries = c.get_codex_entries(range_start, range_end)
|
|
364
|
+
speed = _resolve_codex_speed(args.speed)
|
|
365
|
+
_emit_codex_debug_samples_if_set(args, entries, command_label="codex-monthly", speed=speed)
|
|
366
|
+
# Route through ``build_codex_monthly_view`` (issue #58).
|
|
367
|
+
view = c.build_codex_monthly_view(
|
|
368
|
+
entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
|
|
369
|
+
)
|
|
370
|
+
months = list(view.rows)
|
|
371
|
+
if args.order == "desc":
|
|
372
|
+
months = list(reversed(months))
|
|
373
|
+
|
|
374
|
+
if not months:
|
|
375
|
+
# Match upstream's no-data sentinel (see _emit_codex_no_data docstring).
|
|
376
|
+
c._emit_codex_no_data(args, "monthly")
|
|
377
|
+
return 0
|
|
378
|
+
|
|
379
|
+
if args.json:
|
|
380
|
+
# Upstream monthly --json uses "Dec 2025" style for the month key.
|
|
381
|
+
print(c._codex_bucket_to_json(
|
|
382
|
+
months, list_key="monthly", date_key="month",
|
|
383
|
+
display_fn=c._codex_monthly_bucket_display,
|
|
384
|
+
))
|
|
385
|
+
return 0
|
|
386
|
+
|
|
387
|
+
# Wide-mode table Month cell: two-line "Dec\n2025"
|
|
388
|
+
def monthly_table_display(bucket: str) -> str:
|
|
389
|
+
y, m = bucket.split("-")
|
|
390
|
+
return f"{c._CODEX_MONTHS[int(m) - 1]}\n{y}"
|
|
391
|
+
|
|
392
|
+
tz_label = view.display_tz_label
|
|
393
|
+
title = f"Codex Token Usage Report - Monthly (Timezone: {tz_label})"
|
|
394
|
+
print(c._render_codex_bucket_table(
|
|
395
|
+
months,
|
|
396
|
+
first_col_name="Month",
|
|
397
|
+
title=title,
|
|
398
|
+
compact_split_fn=c._monthly_compact_split, # reuse existing Claude helper (YYYY-MM split)
|
|
399
|
+
bucket_display_fn=monthly_table_display,
|
|
400
|
+
breakdown=args.breakdown,
|
|
401
|
+
force_compact=force_compact,
|
|
402
|
+
))
|
|
403
|
+
return 0
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def cmd_codex_weekly(args: argparse.Namespace) -> int:
|
|
407
|
+
"""Show Codex usage grouped by week (display tz, --tz, or --timezone)."""
|
|
408
|
+
c = _cctally()
|
|
409
|
+
now_utc = _command_as_of()
|
|
410
|
+
config = c.load_config()
|
|
411
|
+
tz_obj = c.resolve_display_tz(args, config)
|
|
412
|
+
args._resolved_tz = tz_obj
|
|
413
|
+
# F2 fix: see cmd_codex_daily.
|
|
414
|
+
tz_name = _resolve_codex_tz_name(args, config)
|
|
415
|
+
force_compact = bool(getattr(args, "compact", False))
|
|
416
|
+
range = c._parse_cli_date_range(args, tz_name=tz_name, now_utc=now_utc)
|
|
417
|
+
if isinstance(range, int):
|
|
418
|
+
return range
|
|
419
|
+
range_start, range_end = range
|
|
420
|
+
|
|
421
|
+
# Resolve week-start from config (Monday default; reuse already-loaded config).
|
|
422
|
+
week_start_name = get_week_start_name(config)
|
|
423
|
+
week_start_idx = WEEKDAY_MAP[week_start_name]
|
|
424
|
+
|
|
425
|
+
entries = c.get_codex_entries(range_start, range_end)
|
|
426
|
+
speed = _resolve_codex_speed(args.speed)
|
|
427
|
+
_emit_codex_debug_samples_if_set(args, entries, command_label="codex-weekly", speed=speed)
|
|
428
|
+
# Route through ``build_codex_weekly_view`` (issue #58).
|
|
429
|
+
view = c.build_codex_weekly_view(
|
|
430
|
+
entries, now_utc=now_utc, tz_name=tz_name,
|
|
431
|
+
week_start_idx=week_start_idx, speed=speed,
|
|
432
|
+
)
|
|
433
|
+
weeks = list(view.rows)
|
|
434
|
+
if args.order == "desc":
|
|
435
|
+
weeks = list(reversed(weeks))
|
|
436
|
+
|
|
437
|
+
if not weeks:
|
|
438
|
+
# Match upstream's no-data sentinel (same string daily/monthly use).
|
|
439
|
+
c._emit_codex_no_data(args, "weekly")
|
|
440
|
+
return 0
|
|
441
|
+
|
|
442
|
+
if args.json:
|
|
443
|
+
# No upstream codex weekly JSON exists — use MMM DD, YYYY style matching codex-daily.
|
|
444
|
+
def weekly_bucket_display(bucket: str) -> str:
|
|
445
|
+
y, m, d = bucket.split("-")
|
|
446
|
+
return f"{c._CODEX_MONTHS[int(m) - 1]} {int(d):02d}, {y}"
|
|
447
|
+
print(c._codex_bucket_to_json(
|
|
448
|
+
weeks, list_key="weekly", date_key="week",
|
|
449
|
+
display_fn=weekly_bucket_display,
|
|
450
|
+
))
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
# Wide-mode table Week cell: two-line "Apr 13,\n2026"
|
|
454
|
+
def weekly_table_display(bucket: str) -> str:
|
|
455
|
+
y, m, d = bucket.split("-")
|
|
456
|
+
return f"{c._CODEX_MONTHS[int(m) - 1]} {int(d):02d},\n{y}"
|
|
457
|
+
|
|
458
|
+
tz_label = view.display_tz_label
|
|
459
|
+
title = f"Codex Token Usage Report - Weekly (Timezone: {tz_label})"
|
|
460
|
+
print(c._render_codex_bucket_table(
|
|
461
|
+
weeks,
|
|
462
|
+
first_col_name="Week",
|
|
463
|
+
title=title,
|
|
464
|
+
compact_split_fn=c._daily_compact_split, # two-line split of "YYYY-MM-DD" — same shape as daily
|
|
465
|
+
bucket_display_fn=weekly_table_display,
|
|
466
|
+
breakdown=args.breakdown,
|
|
467
|
+
force_compact=force_compact,
|
|
468
|
+
))
|
|
469
|
+
return 0
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def cmd_codex_session(args: argparse.Namespace) -> int:
|
|
473
|
+
"""Show Codex usage report grouped by session (sorted by last activity)."""
|
|
474
|
+
c = _cctally()
|
|
475
|
+
config = c.load_config()
|
|
476
|
+
tz_obj = c.resolve_display_tz(args, config)
|
|
477
|
+
args._resolved_tz = tz_obj
|
|
478
|
+
# F2 fix: see cmd_codex_daily.
|
|
479
|
+
tz_name = _resolve_codex_tz_name(args, config)
|
|
480
|
+
force_compact = bool(getattr(args, "compact", False))
|
|
481
|
+
range = c._parse_cli_date_range(
|
|
482
|
+
args, tz_name=tz_name, now_utc=_command_as_of(),
|
|
483
|
+
)
|
|
484
|
+
if isinstance(range, int):
|
|
485
|
+
return range
|
|
486
|
+
range_start, range_end = range
|
|
487
|
+
|
|
488
|
+
entries = c.get_codex_entries(range_start, range_end)
|
|
489
|
+
speed = _resolve_codex_speed(args.speed)
|
|
490
|
+
_emit_codex_debug_samples_if_set(args, entries, command_label="codex-session", speed=speed)
|
|
491
|
+
# Route through ``build_codex_session_view`` (issue #58). View rows
|
|
492
|
+
# come descending by last_activity (aggregator default + upstream
|
|
493
|
+
# parity); --order asc reverses.
|
|
494
|
+
view = c.build_codex_session_view(
|
|
495
|
+
entries, now_utc=_command_as_of(), tz_name=tz_name, speed=speed,
|
|
496
|
+
)
|
|
497
|
+
sessions = list(view.rows)
|
|
498
|
+
if args.order == "asc":
|
|
499
|
+
sessions = list(reversed(sessions))
|
|
500
|
+
|
|
501
|
+
if not sessions:
|
|
502
|
+
# Match upstream's no-data sentinel (plural "sessions" matches upstream
|
|
503
|
+
# — confirmed in @ccusage/codex@18.0.8 dist/index.js around line 7962).
|
|
504
|
+
c._emit_codex_no_data(args, "sessions")
|
|
505
|
+
return 0
|
|
506
|
+
|
|
507
|
+
if args.json:
|
|
508
|
+
print(c._codex_sessions_to_json(sessions))
|
|
509
|
+
return 0
|
|
510
|
+
|
|
511
|
+
tz_label = view.display_tz_label
|
|
512
|
+
# Upstream uses "Sessions" (plural) in the session banner title.
|
|
513
|
+
title = f"Codex Token Usage Report - Sessions (Timezone: {tz_label})"
|
|
514
|
+
print(c._render_codex_session_table(
|
|
515
|
+
sessions, title=title,
|
|
516
|
+
force_compact=force_compact, tz_name=tz_name,
|
|
517
|
+
))
|
|
518
|
+
return 0
|
|
@@ -1916,11 +1916,11 @@ class CacheReportSnapshot:
|
|
|
1916
1916
|
# synthetic entries on the same project.
|
|
1917
1917
|
|
|
1918
1918
|
def _cache_report_load_kernel():
|
|
1919
|
-
"""Lazy-load ``
|
|
1919
|
+
"""Lazy-load ``_lib_cache_report`` via the cctally ``_load_sibling``
|
|
1920
1920
|
bridge so monkeypatch-driven test reloads of cctally see the same
|
|
1921
1921
|
kernel module instance (matches the late-load pattern used by share /
|
|
1922
1922
|
doctor helpers in this file)."""
|
|
1923
|
-
return sys.modules["cctally"]._load_sibling("
|
|
1923
|
+
return sys.modules["cctally"]._load_sibling("_lib_cache_report")
|
|
1924
1924
|
|
|
1925
1925
|
|
|
1926
1926
|
def build_cache_report_snapshot(
|
|
@@ -1936,7 +1936,7 @@ def build_cache_report_snapshot(
|
|
|
1936
1936
|
Pulls entries via ``get_claude_session_entries`` (uses the cache when
|
|
1937
1937
|
warm, falls back to direct-JSONL parse on cache miss / lock
|
|
1938
1938
|
contention — same chain the CLI uses). Delegates aggregation +
|
|
1939
|
-
anomaly classification to ``
|
|
1939
|
+
anomaly classification to ``_lib_cache_report._build_cache_report``;
|
|
1940
1940
|
shapes the result into a frozen ``CacheReportSnapshot``.
|
|
1941
1941
|
|
|
1942
1942
|
``window_days`` is hardcoded at 14 in v1 (spec §6.1 hardcodes
|