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.
@@ -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`, `compute_week_cost`,
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), reached via the `_cctally()`
13
- call-time accessor (spec §5.2 / §5.5 pattern).
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