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.
- package/CHANGELOG.md +21 -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 +8 -5
- 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 +484 -0
- package/bin/_lib_cache_report.py +938 -0
- package/bin/_lib_diff_kernel.py +5 -8
- package/bin/_lib_fmt.py +325 -0
- package/bin/_lib_pricing_debug.py +182 -0
- package/bin/_lib_render.py +9 -24
- package/bin/_lib_subscription_weeks.py +2 -2
- package/bin/cctally +466 -9190
- package/package.json +15 -1
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""`cctally statusline` command — one-line status summary for CC hooks.
|
|
2
|
+
|
|
3
|
+
Holds `cmd_statusline` + its resolvers (`_resolve_statusline_tz`,
|
|
4
|
+
`_resolve_context_window`, `_read_last_assistant_usage`,
|
|
5
|
+
`_build_statusline_injections`) AND the two per-model context-window
|
|
6
|
+
constant dicts (`CLAUDE_MODEL_CONTEXT_WINDOWS`,
|
|
7
|
+
`CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY`), co-located with their sole
|
|
8
|
+
consumer `_resolve_context_window`.
|
|
9
|
+
|
|
10
|
+
Honest *name* imports are KERNEL-ONLY (`_cctally_core`: `eprint`, plus the
|
|
11
|
+
kernel symbols `open_db` / `_command_as_of` per the kernel-extraction
|
|
12
|
+
invariant in `tests/test_kernel_extraction_invariants.py`). `_lib_statusline`
|
|
13
|
+
is the eagerly-preloaded library kernel (bin/cctally:416), imported
|
|
14
|
+
qualified and referenced as a module object (`_lib_statusline.render_statusline`,
|
|
15
|
+
`_lib_statusline.ParseError`, …). Every other sibling/kernel-homed symbol
|
|
16
|
+
is reached via the call-time `_cctally()` accessor so test monkeypatches
|
|
17
|
+
through `cctally`'s namespace are preserved (spec §3.2). The 5h seam —
|
|
18
|
+
`_load_recorded_five_hour_windows`, `_maybe_swap_active_block_to_canonical`
|
|
19
|
+
— lives in `_cctally_five_hour.py` and is reached via `c.<name>` (spec §3.3).
|
|
20
|
+
|
|
21
|
+
bin/cctally re-exports `cmd_statusline` (parser `c.cmd_statusline`) plus the
|
|
22
|
+
resolvers/dicts; `_resolve_statusline_tz` is retrieved by `tests/test_statusline.py`
|
|
23
|
+
off the `cctally` namespace.
|
|
24
|
+
|
|
25
|
+
Spec: docs/superpowers/specs/2026-05-30-extract-five-hour-statusline-cmd-design.md
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import datetime as dt
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import pathlib
|
|
34
|
+
import sys
|
|
35
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
36
|
+
|
|
37
|
+
import _lib_statusline
|
|
38
|
+
from _cctally_core import _command_as_of, eprint, open_db
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _cctally():
|
|
42
|
+
"""Resolve the current `cctally` module at call-time (spec §3.2)."""
|
|
43
|
+
return sys.modules["cctally"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Per-model context window (used by `cctally statusline` segment 4).
|
|
47
|
+
# Keep in sync with Anthropic's docs:
|
|
48
|
+
# https://docs.anthropic.com/en/docs/about-claude/models
|
|
49
|
+
# Unknown model id → segment renders `🧠 N/A` + one-shot stderr warn.
|
|
50
|
+
CLAUDE_MODEL_CONTEXT_WINDOWS = {
|
|
51
|
+
# 1M-token variants (explicit IDs override the family default).
|
|
52
|
+
"claude-opus-4-8[1m]": 1_000_000,
|
|
53
|
+
"claude-opus-4-7[1m]": 1_000_000,
|
|
54
|
+
"claude-sonnet-4-5[1m]": 1_000_000,
|
|
55
|
+
# Default 200K for every other Sonnet/Opus/Haiku family member.
|
|
56
|
+
# The resolver does a substring match on the family token if the
|
|
57
|
+
# exact id is missing — see _resolve_context_window.
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY = {
|
|
61
|
+
# Substring (case-insensitive) → window. Order matters; first hit wins.
|
|
62
|
+
"sonnet": 200_000,
|
|
63
|
+
"opus": 200_000,
|
|
64
|
+
"haiku": 200_000,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _resolve_statusline_tz(cli_tz, cfg, warn_once):
|
|
69
|
+
"""Resolve the IANA tz_name for cmd_statusline using the same 3-rung
|
|
70
|
+
precedence as every other reporting command:
|
|
71
|
+
|
|
72
|
+
CLI ``--timezone`` > ``config.display.tz`` > DISPLAY_TZ_DEFAULT ("local")
|
|
73
|
+
|
|
74
|
+
"local" is converted to a real IANA via ``_local_tz_name()`` before
|
|
75
|
+
returning. Unknown IANA names emit a one-shot warning and fall back
|
|
76
|
+
to ``"UTC"``. Returns a real IANA name (or ``"UTC"``) — never the
|
|
77
|
+
literal sentinel ``"local"``.
|
|
78
|
+
|
|
79
|
+
Prior to #86 G follow-up, this defaulted to ``"UTC"`` when no config
|
|
80
|
+
was set, so ``today`` computed on the UTC calendar day while
|
|
81
|
+
``cctally daily`` (and every other reporting command) used the local
|
|
82
|
+
day — UTC-offset users saw a multi-hour lag between statusline and
|
|
83
|
+
daily. Regression: tests/test_statusline.py::TestTzResolution.
|
|
84
|
+
"""
|
|
85
|
+
c = _cctally()
|
|
86
|
+
tz_name = cli_tz
|
|
87
|
+
if not tz_name:
|
|
88
|
+
tz_name = c.get_display_tz_pref(cfg)
|
|
89
|
+
if tz_name in ("local", "LOCAL"):
|
|
90
|
+
try:
|
|
91
|
+
tz_name = c._local_tz_name() or "UTC"
|
|
92
|
+
except Exception:
|
|
93
|
+
tz_name = "UTC"
|
|
94
|
+
try:
|
|
95
|
+
ZoneInfo(tz_name)
|
|
96
|
+
except (ZoneInfoNotFoundError, Exception):
|
|
97
|
+
warn_once(
|
|
98
|
+
f"cctally statusline: invalid timezone {tz_name!r}; using 'UTC'"
|
|
99
|
+
)
|
|
100
|
+
tz_name = "UTC"
|
|
101
|
+
return tz_name
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_statusline(args: argparse.Namespace) -> int:
|
|
105
|
+
"""`cctally statusline` — one-line status summary for CC hooks.
|
|
106
|
+
|
|
107
|
+
See docs/superpowers/specs/2026-05-28-issue-86-session-g-statusline-design.md
|
|
108
|
+
for the full design.
|
|
109
|
+
|
|
110
|
+
Exit codes:
|
|
111
|
+
0 success (every absent stdin field degrades gracefully)
|
|
112
|
+
1 stdin is not parseable JSON OR root is not a JSON object
|
|
113
|
+
2 argparse rejected a flag (e.g. --cost-source ccusage), OR
|
|
114
|
+
--config PATH unreadable
|
|
115
|
+
"""
|
|
116
|
+
c = _cctally()
|
|
117
|
+
# NOTE: `--cost-source ccusage` is rejected at argparse-time by
|
|
118
|
+
# `_CostSourceAction` in `_build_statusline_parser`; it exits 2 with
|
|
119
|
+
# the rename hint before we get here, so no explicit re-check is
|
|
120
|
+
# needed in this function.
|
|
121
|
+
|
|
122
|
+
# Validate `--context-{low,medium}-threshold` BEFORE reading stdin
|
|
123
|
+
# so a misconfigured invocation fails fast without consuming the
|
|
124
|
+
# CC hook's stdin payload.
|
|
125
|
+
low = args.context_low_threshold
|
|
126
|
+
med = args.context_medium_threshold
|
|
127
|
+
if not isinstance(low, int) or low < 0 or low > 100:
|
|
128
|
+
eprint(
|
|
129
|
+
"cctally statusline: --context-low-threshold must be in [0, 100]"
|
|
130
|
+
)
|
|
131
|
+
return 2
|
|
132
|
+
if not isinstance(med, int) or med < 0 or med > 100:
|
|
133
|
+
eprint(
|
|
134
|
+
"cctally statusline: --context-medium-threshold must be in [0, 100]"
|
|
135
|
+
)
|
|
136
|
+
return 2
|
|
137
|
+
if low >= med:
|
|
138
|
+
eprint(
|
|
139
|
+
"cctally statusline: --context-low-threshold must be < "
|
|
140
|
+
"--context-medium-threshold"
|
|
141
|
+
)
|
|
142
|
+
return 2
|
|
143
|
+
|
|
144
|
+
# Silently clamp `--refresh-interval` to [0, 600]. The flag is a
|
|
145
|
+
# no-op alias for ccusage drop-in compat; users never observe the
|
|
146
|
+
# effect, but the spec mandates the clamp for forward-compat (when
|
|
147
|
+
# we promote it to a real flag, the clamped value should be the one
|
|
148
|
+
# propagated downstream).
|
|
149
|
+
try:
|
|
150
|
+
args.refresh_interval = max(0, min(600, int(args.refresh_interval)))
|
|
151
|
+
except (TypeError, ValueError):
|
|
152
|
+
args.refresh_interval = 1
|
|
153
|
+
|
|
154
|
+
# Read stdin once.
|
|
155
|
+
raw = sys.stdin.buffer.read()
|
|
156
|
+
parse_result = _lib_statusline.parse_statusline_stdin(raw)
|
|
157
|
+
if isinstance(parse_result, _lib_statusline.ParseError):
|
|
158
|
+
eprint(f"cctally statusline: {parse_result.message}")
|
|
159
|
+
return 1
|
|
160
|
+
inp = parse_result
|
|
161
|
+
|
|
162
|
+
# Resolve effective config: CLI > config.json > built-in default.
|
|
163
|
+
# `_load_claude_config_for_args` honors `--config PATH` (issue #88
|
|
164
|
+
# plumbing); a missing/invalid PATH raises SystemExit(2) inside
|
|
165
|
+
# `_load_config_from_explicit_path` so this call already enforces
|
|
166
|
+
# exit-2 on a bad --config.
|
|
167
|
+
cfg = c._load_claude_config_for_args(args)
|
|
168
|
+
sl_cfg = (cfg.get("statusline") or {}) if isinstance(cfg, dict) else {}
|
|
169
|
+
if not isinstance(sl_cfg, dict):
|
|
170
|
+
sl_cfg = {}
|
|
171
|
+
|
|
172
|
+
# Validate config values; on invalid, one-shot stderr warn + use default.
|
|
173
|
+
_warned: set = set()
|
|
174
|
+
|
|
175
|
+
def warn_once(msg: str) -> None:
|
|
176
|
+
if msg in _warned:
|
|
177
|
+
return
|
|
178
|
+
_warned.add(msg)
|
|
179
|
+
eprint(msg)
|
|
180
|
+
|
|
181
|
+
def _resolve(cli_val, cfg_key, default):
|
|
182
|
+
if cli_val is not None:
|
|
183
|
+
return cli_val
|
|
184
|
+
cv = sl_cfg.get(cfg_key)
|
|
185
|
+
if cv is None:
|
|
186
|
+
return default
|
|
187
|
+
return cv
|
|
188
|
+
|
|
189
|
+
vbr = _resolve(args.visual_burn_rate, "visual_burn_rate", "off")
|
|
190
|
+
if vbr not in ("off", "emoji", "text", "emoji-text"):
|
|
191
|
+
warn_once(
|
|
192
|
+
f"cctally statusline: invalid statusline.visual_burn_rate={vbr!r}; "
|
|
193
|
+
f"using 'off'"
|
|
194
|
+
)
|
|
195
|
+
vbr = "off"
|
|
196
|
+
|
|
197
|
+
cs = _resolve(args.cost_source, "cost_source", "auto")
|
|
198
|
+
if cs not in ("auto", "cctally", "cc", "both"):
|
|
199
|
+
warn_once(
|
|
200
|
+
f"cctally statusline: invalid statusline.cost_source={cs!r}; "
|
|
201
|
+
f"using 'auto'"
|
|
202
|
+
)
|
|
203
|
+
cs = "auto"
|
|
204
|
+
|
|
205
|
+
ext_on = _resolve(args.cctally_extensions, "cctally_extensions", True)
|
|
206
|
+
if not isinstance(ext_on, bool):
|
|
207
|
+
warn_once(
|
|
208
|
+
f"cctally statusline: invalid statusline.cctally_extensions="
|
|
209
|
+
f"{ext_on!r}; using True"
|
|
210
|
+
)
|
|
211
|
+
ext_on = True
|
|
212
|
+
|
|
213
|
+
tz_name = _resolve_statusline_tz(getattr(args, "timezone", None), cfg, warn_once)
|
|
214
|
+
|
|
215
|
+
# Color: explicit CLI > NO_COLOR env > TTY detect.
|
|
216
|
+
if args.color is True or args.color is False:
|
|
217
|
+
color = args.color
|
|
218
|
+
else:
|
|
219
|
+
color = (os.environ.get("NO_COLOR", "") == "") and sys.stdout.isatty()
|
|
220
|
+
|
|
221
|
+
sargs = _lib_statusline.StatuslineArgs(
|
|
222
|
+
visual_burn_rate=vbr,
|
|
223
|
+
cost_source=cs,
|
|
224
|
+
context_low_threshold=int(args.context_low_threshold),
|
|
225
|
+
context_medium_threshold=int(args.context_medium_threshold),
|
|
226
|
+
cctally_extensions=bool(ext_on),
|
|
227
|
+
color=bool(color),
|
|
228
|
+
display_tz_name=tz_name,
|
|
229
|
+
debug=bool(args.debug),
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Build injections (DB + transcript file IO).
|
|
233
|
+
inj = _build_statusline_injections(warn_once)
|
|
234
|
+
|
|
235
|
+
# `_command_as_of()` honors the `CCTALLY_AS_OF` testing hook so the
|
|
236
|
+
# golden harness can pin "now" for deterministic block-remaining and
|
|
237
|
+
# 5h/7d countdown numbers. Falls back to wall-clock UTC otherwise.
|
|
238
|
+
now = _command_as_of()
|
|
239
|
+
try:
|
|
240
|
+
line = _lib_statusline.render_statusline(inp, sargs, inj, now)
|
|
241
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
242
|
+
eprint(f"cctally statusline: render failed: {exc}")
|
|
243
|
+
return 1
|
|
244
|
+
print(line)
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _resolve_context_window(model_id, warn_once) -> "int | None":
|
|
249
|
+
"""Look up ``model_id`` in ``CLAUDE_MODEL_CONTEXT_WINDOWS``; fall back
|
|
250
|
+
to a family-substring match against
|
|
251
|
+
``CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY``. Unknown id → ``None`` +
|
|
252
|
+
one-shot stderr warning.
|
|
253
|
+
"""
|
|
254
|
+
if not model_id:
|
|
255
|
+
return None
|
|
256
|
+
if model_id in CLAUDE_MODEL_CONTEXT_WINDOWS:
|
|
257
|
+
return CLAUDE_MODEL_CONTEXT_WINDOWS[model_id]
|
|
258
|
+
mid_lower = model_id.lower()
|
|
259
|
+
for family, window in CLAUDE_MODEL_CONTEXT_WINDOW_DEFAULT_FAMILY.items():
|
|
260
|
+
if family in mid_lower:
|
|
261
|
+
return window
|
|
262
|
+
warn_once(
|
|
263
|
+
f"cctally statusline: unknown model {model_id!r}; context % unavailable"
|
|
264
|
+
)
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _read_last_assistant_usage(transcript_path):
|
|
269
|
+
"""Tail-walk the transcript JSONL backwards to the most recent
|
|
270
|
+
``type=assistant`` line carrying ``message.usage``. Returns the usage
|
|
271
|
+
dict or ``None``.
|
|
272
|
+
|
|
273
|
+
Reads in 64 KB chunks from the end so multi-MB transcripts don't
|
|
274
|
+
block the hot statusline path with a full-file parse.
|
|
275
|
+
"""
|
|
276
|
+
if not transcript_path:
|
|
277
|
+
return None
|
|
278
|
+
path = pathlib.Path(transcript_path)
|
|
279
|
+
if not path.exists():
|
|
280
|
+
return None
|
|
281
|
+
try:
|
|
282
|
+
with path.open("rb") as fh:
|
|
283
|
+
fh.seek(0, 2)
|
|
284
|
+
size = fh.tell()
|
|
285
|
+
tail = b""
|
|
286
|
+
chunk = 65536
|
|
287
|
+
# Read backwards in chunks until we have at least one full line
|
|
288
|
+
# and the tail starts with a newline (so the first line is whole).
|
|
289
|
+
while size > 0 and tail.count(b"\n") < 2:
|
|
290
|
+
read_at = max(0, size - chunk)
|
|
291
|
+
fh.seek(read_at)
|
|
292
|
+
tail = fh.read(size - read_at) + tail
|
|
293
|
+
size = read_at
|
|
294
|
+
lines = tail.split(b"\n")
|
|
295
|
+
except OSError:
|
|
296
|
+
return None
|
|
297
|
+
for line in reversed(lines):
|
|
298
|
+
line = line.strip()
|
|
299
|
+
if not line:
|
|
300
|
+
continue
|
|
301
|
+
try:
|
|
302
|
+
obj = json.loads(line)
|
|
303
|
+
except Exception:
|
|
304
|
+
continue
|
|
305
|
+
if not isinstance(obj, dict):
|
|
306
|
+
continue
|
|
307
|
+
if obj.get("type") != "assistant":
|
|
308
|
+
continue
|
|
309
|
+
msg = obj.get("message") or {}
|
|
310
|
+
usage = msg.get("usage") if isinstance(msg, dict) else None
|
|
311
|
+
if isinstance(usage, dict):
|
|
312
|
+
return usage
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _build_statusline_injections(warn_once):
|
|
317
|
+
"""Wire DB- and FS-backed implementations for the kernel's injection ports.
|
|
318
|
+
|
|
319
|
+
See ``_lib_statusline.StatuslineInjections`` for the contract. All
|
|
320
|
+
callables fast-fail to "no data" on any exception — statusline must
|
|
321
|
+
NEVER block the Claude Code hook tick.
|
|
322
|
+
"""
|
|
323
|
+
def _cctally_session_cost(sid):
|
|
324
|
+
c = _cctally()
|
|
325
|
+
if not sid:
|
|
326
|
+
return None
|
|
327
|
+
try:
|
|
328
|
+
conn = c.open_cache_db()
|
|
329
|
+
except Exception:
|
|
330
|
+
return None
|
|
331
|
+
try:
|
|
332
|
+
# Walk all entries via session_files join; sum costs whose
|
|
333
|
+
# session_id matches. Stays read-only — does NOT call
|
|
334
|
+
# sync_cache (too heavy for the hot statusline path; the
|
|
335
|
+
# record-usage + hook-tick paths keep the cache warm).
|
|
336
|
+
sql = (
|
|
337
|
+
"SELECT se.timestamp_utc, se.model, "
|
|
338
|
+
" se.input_tokens, se.output_tokens, "
|
|
339
|
+
" se.cache_create_tokens, se.cache_read_tokens, "
|
|
340
|
+
" se.cost_usd_raw, se.usage_extra_json "
|
|
341
|
+
"FROM session_entries se "
|
|
342
|
+
"LEFT JOIN session_files sf ON sf.path = se.source_path "
|
|
343
|
+
"WHERE sf.session_id = ?"
|
|
344
|
+
)
|
|
345
|
+
rows = list(conn.execute(sql, (sid,)))
|
|
346
|
+
except Exception:
|
|
347
|
+
return None
|
|
348
|
+
finally:
|
|
349
|
+
try:
|
|
350
|
+
conn.close()
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
if not rows:
|
|
354
|
+
return None
|
|
355
|
+
total = 0.0
|
|
356
|
+
for r in rows:
|
|
357
|
+
usage = {
|
|
358
|
+
"input_tokens": r[2] or 0,
|
|
359
|
+
"output_tokens": r[3] or 0,
|
|
360
|
+
"cache_creation_input_tokens": r[4] or 0,
|
|
361
|
+
"cache_read_input_tokens": r[5] or 0,
|
|
362
|
+
}
|
|
363
|
+
try:
|
|
364
|
+
if r[7]:
|
|
365
|
+
extras = json.loads(r[7])
|
|
366
|
+
if isinstance(extras, dict):
|
|
367
|
+
usage.update(extras)
|
|
368
|
+
except Exception:
|
|
369
|
+
pass
|
|
370
|
+
try:
|
|
371
|
+
total += c._calculate_entry_cost(
|
|
372
|
+
r[1], usage, mode="auto", cost_usd=r[6],
|
|
373
|
+
)
|
|
374
|
+
except Exception:
|
|
375
|
+
continue
|
|
376
|
+
return total
|
|
377
|
+
|
|
378
|
+
def _today_cost(tz_name, now):
|
|
379
|
+
c = _cctally()
|
|
380
|
+
try:
|
|
381
|
+
tz = ZoneInfo(tz_name) if tz_name and tz_name != "UTC" else dt.timezone.utc
|
|
382
|
+
except Exception:
|
|
383
|
+
tz = dt.timezone.utc
|
|
384
|
+
local_now = now.astimezone(tz)
|
|
385
|
+
day_start_local = local_now.replace(
|
|
386
|
+
hour=0, minute=0, second=0, microsecond=0
|
|
387
|
+
)
|
|
388
|
+
day_end_local = day_start_local + dt.timedelta(days=1)
|
|
389
|
+
range_start = day_start_local.astimezone(dt.timezone.utc)
|
|
390
|
+
range_end = day_end_local.astimezone(dt.timezone.utc)
|
|
391
|
+
# Two-filter pattern (UTC half-open + display-tz date check):
|
|
392
|
+
# the UTC range fetches the candidate window cheaply via the
|
|
393
|
+
# cache's indexed `timestamp` column; the display-tz date check
|
|
394
|
+
# then trims any entries that fall outside today's local
|
|
395
|
+
# calendar day (the UTC window straddles two local dates when
|
|
396
|
+
# the display tz has any UTC offset, so the SQL range alone is
|
|
397
|
+
# slightly wider than the local day).
|
|
398
|
+
try:
|
|
399
|
+
entries = c.get_entries(range_start, range_end, skip_sync=True)
|
|
400
|
+
except Exception:
|
|
401
|
+
return 0.0
|
|
402
|
+
total = 0.0
|
|
403
|
+
for e in entries:
|
|
404
|
+
try:
|
|
405
|
+
# Filter to today in display tz (second-pass trim).
|
|
406
|
+
ts_local = e.timestamp.astimezone(tz)
|
|
407
|
+
if ts_local.date() != local_now.date():
|
|
408
|
+
continue
|
|
409
|
+
total += c._calculate_entry_cost(
|
|
410
|
+
e.model, e.usage, mode="auto", cost_usd=e.cost_usd,
|
|
411
|
+
)
|
|
412
|
+
except Exception:
|
|
413
|
+
continue
|
|
414
|
+
return total
|
|
415
|
+
|
|
416
|
+
def _active_block(now):
|
|
417
|
+
c = _cctally()
|
|
418
|
+
try:
|
|
419
|
+
# Look at last 24h — captures the full active 5h window.
|
|
420
|
+
range_start = now - dt.timedelta(hours=24)
|
|
421
|
+
entries = c.get_entries(range_start, now, skip_sync=True)
|
|
422
|
+
except Exception:
|
|
423
|
+
return None
|
|
424
|
+
if not entries:
|
|
425
|
+
return None
|
|
426
|
+
try:
|
|
427
|
+
recorded_windows, block_start_overrides, canonical_intervals = (
|
|
428
|
+
c._load_recorded_five_hour_windows(
|
|
429
|
+
range_start - c.BLOCK_DURATION, now + c.BLOCK_DURATION,
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
except Exception:
|
|
433
|
+
recorded_windows, block_start_overrides, canonical_intervals = (
|
|
434
|
+
[], {}, {},
|
|
435
|
+
)
|
|
436
|
+
try:
|
|
437
|
+
blocks = c._group_entries_into_blocks(
|
|
438
|
+
entries,
|
|
439
|
+
mode="auto",
|
|
440
|
+
recorded_windows=recorded_windows,
|
|
441
|
+
block_start_overrides=block_start_overrides,
|
|
442
|
+
canonical_intervals=canonical_intervals,
|
|
443
|
+
now=now,
|
|
444
|
+
)
|
|
445
|
+
except Exception:
|
|
446
|
+
return None
|
|
447
|
+
for b in blocks:
|
|
448
|
+
if not b.is_gap and b.is_active:
|
|
449
|
+
remaining_s = int((b.end_time - now).total_seconds())
|
|
450
|
+
elapsed_s = int((now - b.start_time).total_seconds())
|
|
451
|
+
return (float(b.cost_usd or 0.0), remaining_s, elapsed_s)
|
|
452
|
+
return None
|
|
453
|
+
|
|
454
|
+
def _hwm_clamp(five_resets, seven_resets):
|
|
455
|
+
c = _cctally()
|
|
456
|
+
five_hwm = None
|
|
457
|
+
seven_hwm = None
|
|
458
|
+
try:
|
|
459
|
+
conn = open_db()
|
|
460
|
+
except Exception:
|
|
461
|
+
return (None, None)
|
|
462
|
+
try:
|
|
463
|
+
if five_resets is not None:
|
|
464
|
+
try:
|
|
465
|
+
key = c._canonical_5h_window_key(int(five_resets))
|
|
466
|
+
row = conn.execute(
|
|
467
|
+
"SELECT MAX(five_hour_percent) "
|
|
468
|
+
"FROM weekly_usage_snapshots "
|
|
469
|
+
"WHERE five_hour_window_key = ?",
|
|
470
|
+
(key,),
|
|
471
|
+
).fetchone()
|
|
472
|
+
if row and row[0] is not None:
|
|
473
|
+
five_hwm = float(row[0])
|
|
474
|
+
except Exception:
|
|
475
|
+
pass
|
|
476
|
+
if seven_resets is not None:
|
|
477
|
+
try:
|
|
478
|
+
# Seven-day window bounds from the resets_at epoch:
|
|
479
|
+
# week_end = reset; week_start = reset - 7 days. The
|
|
480
|
+
# date form is the snapshot lookup key (week_start_date
|
|
481
|
+
# is deliberately NOT re-anchored across a mid-week
|
|
482
|
+
# reset — see _apply_reset_events_to_subweeks).
|
|
483
|
+
week_end_dt = dt.datetime.fromtimestamp(
|
|
484
|
+
int(seven_resets), tz=dt.timezone.utc,
|
|
485
|
+
)
|
|
486
|
+
week_start_dt = week_end_dt - dt.timedelta(days=7)
|
|
487
|
+
week_start_date = week_start_dt.date().isoformat()
|
|
488
|
+
# Reset-aware floor. An Anthropic mid-week reset / in-
|
|
489
|
+
# place credit leaves the pre-reset peak snapshots in
|
|
490
|
+
# this SAME week_start_date bucket (the boundary the
|
|
491
|
+
# snapshots carry does not change). A naive bucket-wide
|
|
492
|
+
# MAX(weekly_percent) would clamp the post-reset value
|
|
493
|
+
# UP to that stale peak — the statusline would show the
|
|
494
|
+
# pre-reset 7d %. Mirror the CLI/dashboard segmentation
|
|
495
|
+
# (_apply_reset_events_to_subweeks: post-reset window
|
|
496
|
+
# start_ts := effective_reset_at_utc) by flooring the
|
|
497
|
+
# MAX to snapshots captured at/after the latest reset
|
|
498
|
+
# effective WITHIN this window. unixepoch() on both
|
|
499
|
+
# sides — reset rows carry mixed offset spellings
|
|
500
|
+
# (+00:00 / +03:00) while captured_at_utc uses 'Z', so a
|
|
501
|
+
# lexical compare would misorder them (same rule as the
|
|
502
|
+
# 5h-block cross-reset flag).
|
|
503
|
+
floor_row = conn.execute(
|
|
504
|
+
"SELECT MAX(effective_reset_at_utc) "
|
|
505
|
+
"FROM week_reset_events "
|
|
506
|
+
"WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?) "
|
|
507
|
+
" AND unixepoch(effective_reset_at_utc) < unixepoch(?)",
|
|
508
|
+
(week_start_dt.isoformat(), week_end_dt.isoformat()),
|
|
509
|
+
).fetchone()
|
|
510
|
+
floor_iso = (
|
|
511
|
+
floor_row[0] if floor_row and floor_row[0] else None
|
|
512
|
+
)
|
|
513
|
+
if floor_iso is not None:
|
|
514
|
+
row = conn.execute(
|
|
515
|
+
"SELECT MAX(weekly_percent) "
|
|
516
|
+
"FROM weekly_usage_snapshots "
|
|
517
|
+
"WHERE week_start_date = ? "
|
|
518
|
+
" AND unixepoch(captured_at_utc) >= unixepoch(?)",
|
|
519
|
+
(week_start_date, floor_iso),
|
|
520
|
+
).fetchone()
|
|
521
|
+
else:
|
|
522
|
+
row = conn.execute(
|
|
523
|
+
"SELECT MAX(weekly_percent) "
|
|
524
|
+
"FROM weekly_usage_snapshots "
|
|
525
|
+
"WHERE week_start_date = ?",
|
|
526
|
+
(week_start_date,),
|
|
527
|
+
).fetchone()
|
|
528
|
+
if row and row[0] is not None:
|
|
529
|
+
seven_hwm = float(row[0])
|
|
530
|
+
except Exception:
|
|
531
|
+
pass
|
|
532
|
+
finally:
|
|
533
|
+
try:
|
|
534
|
+
conn.close()
|
|
535
|
+
except Exception:
|
|
536
|
+
pass
|
|
537
|
+
return (five_hwm, seven_hwm)
|
|
538
|
+
|
|
539
|
+
def _db_latest_rate_limits():
|
|
540
|
+
try:
|
|
541
|
+
conn = open_db()
|
|
542
|
+
except Exception:
|
|
543
|
+
return None
|
|
544
|
+
try:
|
|
545
|
+
# Prefer `week_end_at` (ISO timestamp; sub-day precision) over
|
|
546
|
+
# `week_end_date` (date-only; UTC-midnight). Older snapshots
|
|
547
|
+
# may have `week_end_at` NULL — fall back to the date column
|
|
548
|
+
# in that case. See the neighbor query in `pick_week_selection`
|
|
549
|
+
# (bin/cctally:3849) for the precedent.
|
|
550
|
+
row = conn.execute(
|
|
551
|
+
"SELECT five_hour_percent, five_hour_window_key, "
|
|
552
|
+
" weekly_percent, week_end_at, week_end_date "
|
|
553
|
+
"FROM weekly_usage_snapshots "
|
|
554
|
+
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
|
|
555
|
+
).fetchone()
|
|
556
|
+
if not row:
|
|
557
|
+
return None
|
|
558
|
+
five_pct = float(row[0]) if row[0] is not None else None
|
|
559
|
+
five_resets = int(row[1]) if row[1] is not None else None
|
|
560
|
+
seven_pct = float(row[2]) if row[2] is not None else None
|
|
561
|
+
seven_resets = None
|
|
562
|
+
week_end_at = row[3]
|
|
563
|
+
week_end_date = row[4]
|
|
564
|
+
if week_end_at:
|
|
565
|
+
try:
|
|
566
|
+
# `datetime.fromisoformat` accepts the trailing `Z`
|
|
567
|
+
# only on Python 3.11+; normalize to `+00:00` so 3.10
|
|
568
|
+
# checkouts (and any odd Z-suffixed snapshot) parse.
|
|
569
|
+
raw_iso = str(week_end_at)
|
|
570
|
+
if raw_iso.endswith("Z"):
|
|
571
|
+
raw_iso = raw_iso[:-1] + "+00:00"
|
|
572
|
+
end_dt = dt.datetime.fromisoformat(raw_iso)
|
|
573
|
+
if end_dt.tzinfo is None:
|
|
574
|
+
end_dt = end_dt.replace(tzinfo=dt.timezone.utc)
|
|
575
|
+
seven_resets = int(end_dt.timestamp())
|
|
576
|
+
except Exception:
|
|
577
|
+
seven_resets = None
|
|
578
|
+
if seven_resets is None and week_end_date:
|
|
579
|
+
try:
|
|
580
|
+
end_dt = dt.datetime.fromisoformat(str(week_end_date))
|
|
581
|
+
if end_dt.tzinfo is None:
|
|
582
|
+
end_dt = end_dt.replace(tzinfo=dt.timezone.utc)
|
|
583
|
+
# week_end_date is exclusive — that's the reset moment.
|
|
584
|
+
seven_resets = int(end_dt.timestamp())
|
|
585
|
+
except Exception:
|
|
586
|
+
seven_resets = None
|
|
587
|
+
return (five_pct, five_resets, seven_pct, seven_resets)
|
|
588
|
+
except Exception:
|
|
589
|
+
return None
|
|
590
|
+
finally:
|
|
591
|
+
try:
|
|
592
|
+
conn.close()
|
|
593
|
+
except Exception:
|
|
594
|
+
pass
|
|
595
|
+
|
|
596
|
+
def _context_pct(transcript_path, model_id):
|
|
597
|
+
if not transcript_path or not model_id:
|
|
598
|
+
return None
|
|
599
|
+
window = _resolve_context_window(model_id, warn_once)
|
|
600
|
+
if window is None:
|
|
601
|
+
return None
|
|
602
|
+
try:
|
|
603
|
+
usage = _read_last_assistant_usage(transcript_path)
|
|
604
|
+
except Exception:
|
|
605
|
+
return None
|
|
606
|
+
if not isinstance(usage, dict):
|
|
607
|
+
return None
|
|
608
|
+
try:
|
|
609
|
+
ctx_tokens = (
|
|
610
|
+
int(usage.get("input_tokens", 0) or 0)
|
|
611
|
+
+ int(usage.get("cache_read_input_tokens", 0) or 0)
|
|
612
|
+
+ int(usage.get("cache_creation_input_tokens", 0) or 0)
|
|
613
|
+
)
|
|
614
|
+
except (TypeError, ValueError):
|
|
615
|
+
return None
|
|
616
|
+
if window <= 0:
|
|
617
|
+
return None
|
|
618
|
+
return ctx_tokens / window * 100.0
|
|
619
|
+
|
|
620
|
+
return _lib_statusline.StatuslineInjections(
|
|
621
|
+
cctally_session_cost=_cctally_session_cost,
|
|
622
|
+
today_cost=_today_cost,
|
|
623
|
+
active_block=_active_block,
|
|
624
|
+
hwm_clamp=_hwm_clamp,
|
|
625
|
+
db_latest_rate_limits=_db_latest_rate_limits,
|
|
626
|
+
context_pct=_context_pct,
|
|
627
|
+
warn_once=warn_once,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
@@ -6,11 +6,12 @@ JSONL-aggregates the week's cost + inserts a `weekly_cost_snapshots`
|
|
|
6
6
|
row + emits the success line (or `--json` envelope).
|
|
7
7
|
|
|
8
8
|
Every helper this command calls — `load_config`, `get_week_start_name`,
|
|
9
|
-
`open_db`, `pick_week_selection`, `
|
|
10
|
-
`format_local_iso`, `insert_cost_snapshot`, `make_week_ref`,
|
|
9
|
+
`open_db`, `pick_week_selection`, `format_local_iso`, `make_week_ref`,
|
|
11
10
|
`get_latest_usage_for_week` — stays in `bin/cctally` (they're shared
|
|
12
|
-
with the rest of the subcommand surface)
|
|
13
|
-
|
|
11
|
+
with the rest of the subcommand surface); `compute_week_cost` /
|
|
12
|
+
`insert_cost_snapshot` now live in `_cctally_milestones.py` (re-exported on
|
|
13
|
+
the ns). All are reached via the `_cctally()` call-time accessor (spec
|
|
14
|
+
§5.2 / §5.5 pattern).
|
|
14
15
|
|
|
15
16
|
bin/cctally re-exports `cmd_sync_week` so the two non-extracted internal
|
|
16
17
|
callers (`cmd_record_usage`'s milestone-cost-sync path and the
|