cctally 1.15.0 → 1.16.0

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 CHANGED
@@ -5,6 +5,14 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.16.0] - 2026-05-26
9
+
10
+ ### Added
11
+ - **`-m/--mode {auto,calculate,display}` cost-source selector on `cctally daily`, `monthly`, `weekly`, `session`, and `blocks`** — a drop-in for `ccusage <cmd> --mode`. `auto` (the default) uses the recorded `costUSD` from JSONL when present and otherwise computes from embedded pricing; `calculate` always computes from pricing, ignoring any recorded `costUSD`; `display` shows the recorded `costUSD` only and renders `$0.00` when an entry has none (ccusage-faithful — and because most modern Claude Code JSONL omits `costUSD`, `display` reports `$0` for nearly everything). On `blocks` the mode is honored on both the main grouping and the active canonical-swapped block. `cctally five-hour-blocks` also accepts `--mode` but as a documented no-op, since its cost is the authoritative per-block value materialized at record-time. The default-`auto` output is byte-identical to before for `daily`, `monthly`, `weekly`, and `blocks`, and no `mode` key is added to any JSON shape (see the Changed note below for the one `session` exception). (#86)
12
+
13
+ ### Changed
14
+ - **`cctally session` (and the TUI session views) now prefer a session's recorded `costUSD` over a recomputed cost by default, for the historical sessions whose JSONL still carries it.** `session` was the one report that always recomputed cost from embedded pricing and ignored any recorded `costUSD`, even though `daily`, `monthly`, `weekly`, and the dashboard already preferred the recorded value — so on `costUSD`-bearing windows `cctally session` totals silently disagreed with `cctally daily`. Wiring the new `--mode auto` default through the session aggregator closes that gap: default totals now match the other reports (and the shipped reconcile invariant `Σ session.totalCost == Σ daily.totalCost`). Only the ~3.9% of historical files that still carry `costUSD` are affected (modern Claude Code omits it); everything else is unchanged. The TUI session panel/detail follow the same default and have no `--mode` flag; pass `cctally session --mode calculate` on the CLI to force the previous always-recompute behavior. (#86)
15
+
8
16
  ## [1.15.0] - 2026-05-26
9
17
 
10
18
  ### Added
@@ -608,6 +608,7 @@ def _aggregate_codex_sessions(entries: list[CodexEntry]) -> list[CodexSessionUsa
608
608
 
609
609
  def _aggregate_claude_sessions(
610
610
  entries: list["_JoinedClaudeEntry"],
611
+ mode: str = "auto",
611
612
  ) -> list[ClaudeSessionUsage]:
612
613
  """Group entries by session_id, collapsing resumed-across-files sessions.
613
614
 
@@ -666,7 +667,7 @@ def _aggregate_claude_sessions(
666
667
  "cache_creation_input_tokens": entry.cache_creation_tokens,
667
668
  "cache_read_input_tokens": entry.cache_read_tokens,
668
669
  }
669
- cost = _calculate_entry_cost(entry.model, usage)
670
+ cost = _calculate_entry_cost(entry.model, usage, mode=mode, cost_usd=entry.cost_usd)
670
671
 
671
672
  sess["input"] += entry.input_tokens
672
673
  sess["cache_create"] += entry.cache_creation_tokens
@@ -318,7 +318,7 @@ class DailyView:
318
318
  display_tz_label: str = ""
319
319
 
320
320
 
321
- def build_daily_view(entries, *, now_utc, display_tz=None):
321
+ def build_daily_view(entries, *, now_utc, display_tz=None, mode="auto"):
322
322
  """Build a ``DailyView`` from raw ``UsageEntry`` list (spec §5.1).
323
323
 
324
324
  Gap-free: only days with entries appear in ``view.rows`` /
@@ -336,7 +336,7 @@ def build_daily_view(entries, *, now_utc, display_tz=None):
336
336
  share consumers ignore them and read ``view.aggregated`` instead.
337
337
  """
338
338
  _agg = _load_lib("_lib_aggregators")
339
- buckets = _agg._aggregate_daily(entries, mode="auto", tz=display_tz)
339
+ buckets = _agg._aggregate_daily(entries, mode=mode, tz=display_tz)
340
340
  if not buckets:
341
341
  return DailyView(
342
342
  rows=(),
@@ -427,7 +427,7 @@ class MonthlyView:
427
427
  display_tz_label: str = ""
428
428
 
429
429
 
430
- def build_monthly_view(entries, *, now_utc, n=12, display_tz=None):
430
+ def build_monthly_view(entries, *, now_utc, n=12, display_tz=None, mode="auto"):
431
431
  """Build a ``MonthlyView`` for the trailing ``n`` calendar months
432
432
  (spec §5.2).
433
433
 
@@ -440,7 +440,7 @@ def build_monthly_view(entries, *, now_utc, n=12, display_tz=None):
440
440
  CLI table footer would.
441
441
  """
442
442
  _agg = _load_lib("_lib_aggregators")
443
- buckets = _agg._aggregate_monthly(entries, mode="auto", tz=display_tz)
443
+ buckets = _agg._aggregate_monthly(entries, mode=mode, tz=display_tz)
444
444
  if not buckets:
445
445
  return MonthlyView(
446
446
  rows=(), aggregated=(),
@@ -528,7 +528,7 @@ class WeeklyView:
528
528
 
529
529
 
530
530
  def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
531
- as_of_utc=None):
531
+ as_of_utc=None, mode="auto"):
532
532
  """Build a ``WeeklyView`` from subscription-week boundaries
533
533
  (spec §5.3).
534
534
 
@@ -548,7 +548,7 @@ def build_weekly_view(conn, entries, *, weeks, now_utc, display_tz=None,
548
548
  """
549
549
  _agg = _load_lib("_lib_aggregators")
550
550
  _cct_core = _load_lib("_cctally_core")
551
- buckets_asc = _agg._aggregate_weekly(entries, weeks)
551
+ buckets_asc = _agg._aggregate_weekly(entries, weeks, mode=mode)
552
552
  if not buckets_asc:
553
553
  return WeeklyView(
554
554
  rows=(), aggregated=(), overlay=(),
@@ -1022,7 +1022,7 @@ class SessionsView:
1022
1022
  display_tz_label: str = ""
1023
1023
 
1024
1024
 
1025
- def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None):
1025
+ def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None, mode="auto"):
1026
1026
  """Build a ``SessionsView`` from joined Claude session entries
1027
1027
  (spec §5.5).
1028
1028
 
@@ -1053,7 +1053,7 @@ def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None):
1053
1053
  """
1054
1054
  import os as _os # late: keep top-level imports lean.
1055
1055
  _agg = _load_lib("_lib_aggregators")
1056
- aggregated = _agg._aggregate_claude_sessions(entries)
1056
+ aggregated = _agg._aggregate_claude_sessions(entries, mode=mode)
1057
1057
  # Apply limit truncation up front so `rows` and `aggregated` stay
1058
1058
  # in lockstep (spec §4.3 invariant: `total_sessions == len(rows)
1059
1059
  # == len(aggregated)`). limit=None → keep everything.
package/bin/cctally CHANGED
@@ -4238,7 +4238,7 @@ def cmd_blocks(args: argparse.Namespace) -> int:
4238
4238
  range_start=range_start,
4239
4239
  range_end=range_end,
4240
4240
  display_tz=tz,
4241
- mode="auto",
4241
+ mode=args.mode,
4242
4242
  skip_rows=True,
4243
4243
  )
4244
4244
  blocks = list(view.aggregated)
@@ -4263,7 +4263,7 @@ def cmd_blocks(args: argparse.Namespace) -> int:
4263
4263
  # additional entries. Without re-aggregation the displayed window
4264
4264
  # said one thing and the cost said another (live data: window
4265
4265
  # 20:50→01:50 with $45 cost vs the real $128).
4266
- _maybe_swap_active_block_to_canonical(blocks, all_entries, now=now_utc)
4266
+ _maybe_swap_active_block_to_canonical(blocks, all_entries, now=now_utc, mode=args.mode)
4267
4267
 
4268
4268
  if args.json:
4269
4269
  print(_blocks_to_json(blocks))
@@ -4284,6 +4284,7 @@ def _maybe_swap_active_block_to_canonical(
4284
4284
  all_entries: list[Any],
4285
4285
  *,
4286
4286
  now: dt.datetime,
4287
+ mode: str = "auto",
4287
4288
  ) -> None:
4288
4289
  """In-place swap of an ACTIVE heuristic block to its API-anchored
4289
4290
  canonical window — timestamps AND token/cost totals.
@@ -4359,10 +4360,10 @@ def _maybe_swap_active_block_to_canonical(
4359
4360
  if block_end_utc <= now.astimezone(dt.timezone.utc):
4360
4361
  return
4361
4362
  # Re-aggregate entries over the canonical interval. Build a fresh
4362
- # Block via ``_build_activity_block`` (mode="auto" matches
4363
- # ``_group_entries_into_blocks``'s default) so every total stays in
4364
- # one code path no field-by-field assignment that could drift if
4365
- # the dataclass grows new fields.
4363
+ # Block via ``_build_activity_block`` so every total stays in one code
4364
+ # path no field-by-field assignment that could drift if the dataclass
4365
+ # grows new fields. Thread the caller's ``mode`` so the active block's
4366
+ # cost honors --mode like the main grouping (Session C / Codex F1).
4366
4367
  canonical_entries = [
4367
4368
  e for e in all_entries
4368
4369
  if block_start_utc <= e.timestamp < block_end_utc
@@ -4372,7 +4373,7 @@ def _maybe_swap_active_block_to_canonical(
4372
4373
  block_start_utc,
4373
4374
  block_end_utc,
4374
4375
  now.astimezone(dt.timezone.utc),
4375
- "auto",
4376
+ mode,
4376
4377
  anchor="recorded",
4377
4378
  )
4378
4379
  blocks[active_idx] = rebuilt
@@ -4541,7 +4542,7 @@ def cmd_daily(args: argparse.Namespace) -> int:
4541
4542
  # fields live on BucketUsage, not on DailyPanelRow. The builder's
4542
4543
  # `_aggregate_daily` call is the same one we used inline.
4543
4544
  view = build_daily_view(all_entries, now_utc=_command_as_of(),
4544
- display_tz=tz)
4545
+ display_tz=tz, mode=args.mode)
4545
4546
  # `_aggregate_daily` returned ascending order; build_daily_view stores
4546
4547
  # `aggregated` newest-first. CLI's default order is ascending, so
4547
4548
  # re-reverse to match the prior on-the-wire shape.
@@ -4628,7 +4629,7 @@ def cmd_monthly(args: argparse.Namespace) -> int:
4628
4629
  # Pass a large `n` so the CLI's `--since`/`--until` window controls
4629
4630
  # how many months render (the dashboard caps at n=12; CLI doesn't).
4630
4631
  view = build_monthly_view(all_entries, now_utc=_command_as_of(),
4631
- n=10**6, display_tz=tz)
4632
+ n=10**6, display_tz=tz, mode=args.mode)
4632
4633
  # The view stores `aggregated` newest-first; CLI default is asc.
4633
4634
  months = list(reversed(view.aggregated))
4634
4635
 
@@ -4745,7 +4746,7 @@ def cmd_weekly(args: argparse.Namespace) -> int:
4745
4746
  # assertions stay aligned.
4746
4747
  view = build_weekly_view(
4747
4748
  conn, all_entries, weeks=weeks, now_utc=now_utc,
4748
- display_tz=args._resolved_tz, as_of_utc=as_of_utc,
4749
+ display_tz=args._resolved_tz, as_of_utc=as_of_utc, mode=args.mode,
4749
4750
  )
4750
4751
  buckets = list(reversed(view.aggregated))
4751
4752
  overlay = list(reversed(view.overlay))
@@ -5973,6 +5974,7 @@ def cmd_session(args: argparse.Namespace) -> int:
5973
5974
  # files collapses to ONE entry in BOTH tuples).
5974
5975
  view = build_sessions_view(
5975
5976
  entries, now_utc=_command_as_of(), limit=None, display_tz=tz,
5977
+ mode=args.mode,
5976
5978
  )
5977
5979
  sessions = list(view.aggregated)
5978
5980
 
@@ -9783,6 +9785,36 @@ def _argparse_has_arg(parser, option_string: str) -> bool:
9783
9785
  return False
9784
9786
 
9785
9787
 
9788
+ def _add_mode_arg(parser, *, noop: bool = False) -> None:
9789
+ """Add ccusage's -m/--mode {auto,calculate,display} cost-source flag.
9790
+
9791
+ Standalone (not folded into _add_ccusage_alias_args) so it lands only
9792
+ on the six Session-C reporting commands and never collides with
9793
+ range-cost, which defines its own -m/--mode.
9794
+
9795
+ noop=True (five-hour-blocks only): the flag is accepted for surface
9796
+ parity with `blocks` but does not alter numbers — that command's cost
9797
+ is the authoritative materialized five_hour_blocks.total_cost_usd
9798
+ computed at record-time (always auto semantics).
9799
+ """
9800
+ help_real = (
9801
+ "Cost source: auto (recorded costUSD when present, else computed), "
9802
+ "calculate (always compute from embedded pricing), display "
9803
+ "(recorded costUSD only; $0 when absent). Default: auto."
9804
+ )
9805
+ help_noop = (
9806
+ "Accepted for ccusage drop-in compat; no-op here — five-hour-blocks "
9807
+ "cost is the authoritative materialized per-block value computed at "
9808
+ "record-time. Default: auto."
9809
+ )
9810
+ parser.add_argument(
9811
+ "-m", "--mode",
9812
+ default="auto",
9813
+ choices=["auto", "calculate", "display"],
9814
+ help=help_noop if noop else help_real,
9815
+ )
9816
+
9817
+
9786
9818
  def _add_ccusage_alias_args(parser, *, ansi_emit: bool) -> None:
9787
9819
  """Attach the Session A ccusage alias surface to a Claude-cmd subparser.
9788
9820
 
@@ -10137,6 +10169,7 @@ def _build_daily_parser(subparsers, name, *, help_text, xref):
10137
10169
  "Overrides config display.tz for this call.",
10138
10170
  )
10139
10171
  _add_ccusage_alias_args(p, ansi_emit=False)
10172
+ _add_mode_arg(p)
10140
10173
  _add_share_args(p)
10141
10174
  p.set_defaults(func=cmd_daily)
10142
10175
  return p
@@ -10195,6 +10228,7 @@ def _build_monthly_parser(subparsers, name, *, help_text, xref):
10195
10228
  "Overrides config display.tz for this call.",
10196
10229
  )
10197
10230
  _add_ccusage_alias_args(p, ansi_emit=False)
10231
+ _add_mode_arg(p)
10198
10232
  _add_share_args(p)
10199
10233
  p.set_defaults(func=cmd_monthly)
10200
10234
  return p
@@ -10235,6 +10269,7 @@ def _build_weekly_parser(subparsers, name, *, help_text, xref):
10235
10269
  help="Display timezone: local, utc, or IANA name. "
10236
10270
  "Overrides config display.tz for this call.")
10237
10271
  _add_ccusage_alias_args(p, ansi_emit=False)
10272
+ _add_mode_arg(p)
10238
10273
  _add_share_args(p)
10239
10274
  p.set_defaults(func=cmd_weekly)
10240
10275
  return p
@@ -10286,6 +10321,7 @@ def _build_session_parser(subparsers, name, *, help_text, xref):
10286
10321
  "Unknown id → exit 0 with the empty-render branch.",
10287
10322
  )
10288
10323
  _add_ccusage_alias_args(p, ansi_emit=False)
10324
+ _add_mode_arg(p)
10289
10325
  _add_share_args(p)
10290
10326
  p.set_defaults(func=cmd_session)
10291
10327
  return p
@@ -10340,6 +10376,7 @@ def _build_blocks_parser(subparsers, name, *, help_text, xref):
10340
10376
  "Overrides config display.tz for this call.",
10341
10377
  )
10342
10378
  _add_ccusage_alias_args(p, ansi_emit=False)
10379
+ _add_mode_arg(p)
10343
10380
  p.set_defaults(func=cmd_blocks)
10344
10381
  return p
10345
10382
 
@@ -11266,6 +11303,7 @@ def build_parser() -> argparse.ArgumentParser:
11266
11303
  # guard. The helper's --color add lands as a parsed-and-ignored
11267
11304
  # no-op (the renderer emits plain text).
11268
11305
  _add_ccusage_alias_args(fhb, ansi_emit=False)
11306
+ _add_mode_arg(fhb, noop=True)
11269
11307
  _add_share_args(fhb)
11270
11308
  fhb.set_defaults(func=cmd_five_hour_blocks)
11271
11309
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {