cctrack 0.3.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cctrack
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost
5
5
  Project-URL: Homepage, https://github.com/rappdw/cctrack
6
6
  Project-URL: Repository, https://github.com/rappdw/cctrack
@@ -86,11 +86,14 @@ Daily breakdown:
86
86
 
87
87
  ## Remote aggregation
88
88
 
89
- With `--remote`, cctrack SSHs to each host (one call per host) and streams back all JSONL content for local parsing. Requires SSH key auth.
89
+ With `--remote`, cctrack SSHs to each host and streams back both the JSONL logs and the host's statusline hook data (`~/.claude/cctrack/`) for local parsing, so authoritative hook costs from every machine are summed per day. Requires SSH key auth.
90
90
 
91
91
  ```bash
92
92
  # Aggregate this machine + DGX server + laptop
93
93
  cctrack --remote dgx macbook-air
94
+
95
+ # For accurate aggregate costs, install the hook on each host too
96
+ cctrack --install-hook --remote dgx macbook-air
94
97
  ```
95
98
 
96
99
  ## Rate card
@@ -61,11 +61,14 @@ Daily breakdown:
61
61
 
62
62
  ## Remote aggregation
63
63
 
64
- With `--remote`, cctrack SSHs to each host (one call per host) and streams back all JSONL content for local parsing. Requires SSH key auth.
64
+ With `--remote`, cctrack SSHs to each host and streams back both the JSONL logs and the host's statusline hook data (`~/.claude/cctrack/`) for local parsing, so authoritative hook costs from every machine are summed per day. Requires SSH key auth.
65
65
 
66
66
  ```bash
67
67
  # Aggregate this machine + DGX server + laptop
68
68
  cctrack --remote dgx macbook-air
69
+
70
+ # For accurate aggregate costs, install the hook on each host too
71
+ cctrack --install-hook --remote dgx macbook-air
69
72
  ```
70
73
 
71
74
  ## Rate card
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cctrack"
7
- version = "0.3.0"
7
+ version = "0.4.1"
8
8
  description = "Claude Code cost tracker — scans JSONL logs and reports token usage + estimated cost"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -26,16 +26,18 @@ from pathlib import Path
26
26
 
27
27
  # Longer/more-specific prefixes MUST come first — "claude-opus-4-6" would
28
28
  # otherwise match "claude-opus-4" and get the wrong rate.
29
+ # cache_write is the 5-minute TTL rate (1.25× input); cache_write_1h is the
30
+ # 1-hour TTL rate (2× input). Claude Code hits the 1h tier heavily in practice.
29
31
  RATES = [
30
- {"family": "claude-opus-4-7", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25},
31
- {"family": "claude-opus-4-6", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25},
32
- {"family": "claude-opus-4-5", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25},
33
- {"family": "claude-opus-4", "input": 15.0, "output": 75.0, "cache_read": 1.50, "cache_write": 18.75},
34
- {"family": "claude-sonnet-4", "input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_write": 3.75},
35
- {"family": "claude-sonnet-3", "input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_write": 3.75},
36
- {"family": "claude-haiku-4", "input": 1.0, "output": 5.0, "cache_read": 0.10, "cache_write": 1.25},
37
- {"family": "claude-haiku-3-5", "input": 0.80, "output": 4.0, "cache_read": 0.08, "cache_write": 1.00},
38
- {"family": "claude-haiku-3", "input": 0.25, "output": 1.25, "cache_read": 0.03, "cache_write": 0.30},
32
+ {"family": "claude-opus-4-7", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25, "cache_write_1h": 10.0},
33
+ {"family": "claude-opus-4-6", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25, "cache_write_1h": 10.0},
34
+ {"family": "claude-opus-4-5", "input": 5.0, "output": 25.0, "cache_read": 0.50, "cache_write": 6.25, "cache_write_1h": 10.0},
35
+ {"family": "claude-opus-4", "input": 15.0, "output": 75.0, "cache_read": 1.50, "cache_write": 18.75, "cache_write_1h": 30.0},
36
+ {"family": "claude-sonnet-4", "input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_write": 3.75, "cache_write_1h": 6.0},
37
+ {"family": "claude-sonnet-3", "input": 3.0, "output": 15.0, "cache_read": 0.30, "cache_write": 3.75, "cache_write_1h": 6.0},
38
+ {"family": "claude-haiku-4", "input": 1.0, "output": 5.0, "cache_read": 0.10, "cache_write": 1.25, "cache_write_1h": 2.0},
39
+ {"family": "claude-haiku-3-5", "input": 0.80, "output": 4.0, "cache_read": 0.08, "cache_write": 1.00, "cache_write_1h": 1.60},
40
+ {"family": "claude-haiku-3", "input": 0.25, "output": 1.25, "cache_read": 0.03, "cache_write": 0.30, "cache_write_1h": 0.50},
39
41
  ]
40
42
 
41
43
  SONNET_RATES = RATES[4] # fallback
@@ -49,13 +51,15 @@ def get_rates(model: str) -> dict:
49
51
  return SONNET_RATES
50
52
 
51
53
 
52
- def calculate_cost(model: str, input_t: int, output_t: int, cache_read: int, cache_write: int) -> float:
54
+ def calculate_cost(model: str, input_t: int, output_t: int, cache_read: int,
55
+ cache_write: int, cache_write_1h: int = 0) -> float:
53
56
  r = get_rates(model)
54
57
  return (
55
58
  input_t / 1_000_000 * r["input"]
56
59
  + output_t / 1_000_000 * r["output"]
57
60
  + cache_read / 1_000_000 * r["cache_read"]
58
61
  + cache_write / 1_000_000 * r["cache_write"]
62
+ + cache_write_1h / 1_000_000 * r["cache_write_1h"]
59
63
  )
60
64
 
61
65
 
@@ -122,6 +126,84 @@ def parse_remote_events(host: str) -> list[dict]:
122
126
  return all_events
123
127
 
124
128
 
129
+ HOOK_FILE_SEP = "___CCTRACK_HOOK_FILE___"
130
+ MONTHLY_FILE_SEP = "___CCTRACK_MONTHLY_FILE___"
131
+
132
+ # Hook data locations on remote hosts: the host's own cctrack dir plus any
133
+ # Sandy sandbox equivalents (mirrors discover_hook_data_dirs).
134
+ REMOTE_HOOK_DIRS = "~/.claude/cctrack ~/.sandy/sandboxes"
135
+
136
+
137
+ def fetch_remote_hook_data(host: str) -> str:
138
+ """SSH to host and cat all statusline JSONL + monthly summary files.
139
+ Returns raw output with file-type separators."""
140
+ remote_cmd = (
141
+ f"find {REMOTE_HOOK_DIRS} -name 'statusline*.jsonl' "
142
+ f"-exec echo '{HOOK_FILE_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
143
+ f"find {REMOTE_HOOK_DIRS} -name 'monthly-*.json' "
144
+ f"-exec echo '{MONTHLY_FILE_SEP}' \\; -exec cat {{}} \\; 2>/dev/null; "
145
+ f"true"
146
+ )
147
+ cmd = ["ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", host, remote_cmd]
148
+ try:
149
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
150
+ if result.returncode != 0 and not result.stdout:
151
+ print(f" Warning: SSH to {host} failed: {result.stderr.strip()}", file=sys.stderr)
152
+ return ""
153
+ return result.stdout
154
+ except subprocess.TimeoutExpired:
155
+ print(f" Warning: SSH to {host} timed out", file=sys.stderr)
156
+ return ""
157
+ except FileNotFoundError:
158
+ print(f" Warning: ssh command not found", file=sys.stderr)
159
+ return ""
160
+
161
+
162
+ def parse_remote_hook_data(host: str) -> tuple[list[dict], dict]:
163
+ """Fetch and parse hook data from a remote host.
164
+ Returns (hook_events, monthly_summaries) in the same shapes as
165
+ parse_hook_events and load_monthly_hook_summaries."""
166
+ raw = fetch_remote_hook_data(host)
167
+ if not raw:
168
+ return [], {}
169
+
170
+ # Statusline chunks come first, monthly chunks after (find order above).
171
+ head, _, monthly_raw = raw.partition(MONTHLY_FILE_SEP)
172
+
173
+ lines: list[str] = []
174
+ for chunk in head.split(HOOK_FILE_SEP):
175
+ lines.extend(chunk.splitlines())
176
+ events = _hook_events_from_lines(lines)
177
+
178
+ monthly: dict[str, dict] = {}
179
+ for chunk in monthly_raw.split(MONTHLY_FILE_SEP):
180
+ chunk = chunk.strip()
181
+ if not chunk:
182
+ continue
183
+ try:
184
+ data = json.loads(chunk)
185
+ except (json.JSONDecodeError, ValueError):
186
+ continue
187
+ for date_str, day_data in data.get("days", {}).items():
188
+ entry = {
189
+ "cost": float(day_data.get("cost", 0.0)),
190
+ "sessions": int(day_data.get("sessions", 0)),
191
+ }
192
+ merge_monthly_summaries(monthly, {date_str: entry})
193
+ return events, monthly
194
+
195
+
196
+ def merge_monthly_summaries(target: dict, source: dict) -> None:
197
+ """Merge per-date {cost, sessions} summaries from source into target,
198
+ summing where dates overlap (e.g. same day on multiple hosts)."""
199
+ for date_str, data in source.items():
200
+ if date_str in target:
201
+ target[date_str]["cost"] += data["cost"]
202
+ target[date_str]["sessions"] += data["sessions"]
203
+ else:
204
+ target[date_str] = dict(data)
205
+
206
+
125
207
  # ── Hook infrastructure ───────────────────────────────────────────────
126
208
 
127
209
  HOOK_SCRIPT = """\
@@ -432,9 +514,19 @@ def parse_lines(lines: list[str]) -> list[dict]:
432
514
  input_t = usage.get("input_tokens", 0)
433
515
  output_t = usage.get("output_tokens", 0)
434
516
  cache_read = usage.get("cache_read_input_tokens", 0)
435
- cache_write = usage.get("cache_creation_input_tokens", 0)
436
517
 
437
- if input_t == 0 and output_t == 0 and cache_read == 0 and cache_write == 0:
518
+ # Prefer nested cache_creation.{ephemeral_5m,ephemeral_1h}_input_tokens
519
+ # — 1h cache writes cost 2× input vs 1.25× for 5m, and Claude Code
520
+ # commonly uses the 1h tier. Fall back to flat cache_creation_input_tokens
521
+ # as all-5m for older logs that didn't emit the split.
522
+ cc = usage.get("cache_creation") or {}
523
+ cache_write = cc.get("ephemeral_5m_input_tokens", 0)
524
+ cache_write_1h = cc.get("ephemeral_1h_input_tokens", 0)
525
+ if not cc:
526
+ cache_write = usage.get("cache_creation_input_tokens", 0)
527
+
528
+ if (input_t == 0 and output_t == 0 and cache_read == 0
529
+ and cache_write == 0 and cache_write_1h == 0):
438
530
  continue
439
531
 
440
532
  entry = {
@@ -444,6 +536,7 @@ def parse_lines(lines: list[str]) -> list[dict]:
444
536
  "output": output_t,
445
537
  "cache_read": cache_read,
446
538
  "cache_write": cache_write,
539
+ "cache_write_1h": cache_write_1h,
447
540
  }
448
541
 
449
542
  req_id = event.get("requestId", "")
@@ -470,61 +563,47 @@ def parse_events(files: list[str]) -> list[dict]:
470
563
 
471
564
  # ── Hook data parsing ──────────────────────────────────────────────────
472
565
 
473
- def parse_hook_events(cctrack_dir: Path | None = None) -> list[dict]:
474
- """Parse statusline-*.jsonl files. Dedup by session_id (last entry by _ts wins).
475
- Also reads legacy statusline.jsonl for migration.
476
- Returns list of {date_str, cost_usd, input_tokens, output_tokens, cache_read, cache_write, model, session_id}."""
477
- if cctrack_dir is None:
478
- cctrack_dir = CCTRACK_DIR
479
- if not cctrack_dir.exists():
480
- return []
566
+ def _hook_events_from_lines(lines) -> list[dict]:
567
+ """Parse statusline JSONL lines. For each (session_id, date) pair, keeps the
568
+ entry with the latest _ts — the session's cumulative values as of end-of-day
569
+ for that date. Returns list of {date_str, session_id, cost_usd, input_tokens,
570
+ output_tokens, cache_read, cache_write, model}."""
571
+ by_session_day: dict[tuple[str, str], dict] = {}
481
572
 
482
- by_session: dict[str, dict] = {}
483
-
484
- files = sorted(cctrack_dir.glob("statusline-*.jsonl"))
485
- legacy = cctrack_dir / "statusline.jsonl"
486
- if legacy.exists():
487
- files.append(legacy)
488
-
489
- for filepath in files:
573
+ for line in lines:
574
+ line = line.strip()
575
+ if not line:
576
+ continue
490
577
  try:
491
- with open(filepath, "r", errors="replace") as f:
492
- for line in f:
493
- line = line.strip()
494
- if not line:
495
- continue
496
- try:
497
- entry = json.loads(line)
498
- except (json.JSONDecodeError, ValueError):
499
- continue
500
-
501
- session_id = entry.get("session_id", "")
502
- if not session_id:
503
- continue
504
-
505
- ts = entry.get("_ts", "")
506
- if not ts:
507
- continue
508
-
509
- if session_id in by_session:
510
- existing_ts = by_session[session_id].get("_ts", "")
511
- if ts <= existing_ts:
512
- continue
578
+ entry = json.loads(line)
579
+ except (json.JSONDecodeError, ValueError):
580
+ continue
513
581
 
514
- by_session[session_id] = entry
515
- except (OSError, IOError):
582
+ session_id = entry.get("session_id", "")
583
+ if not session_id:
516
584
  continue
517
585
 
518
- results = []
519
- for session_id, entry in by_session.items():
520
586
  ts = entry.get("_ts", "")
587
+ if not ts:
588
+ continue
589
+
521
590
  try:
522
591
  dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
523
592
  except (ValueError, AttributeError):
524
593
  continue
594
+ date_str = dt.strftime("%Y-%m-%d")
525
595
 
596
+ key = (session_id, date_str)
597
+ if key in by_session_day:
598
+ if ts <= by_session_day[key].get("_ts", ""):
599
+ continue
600
+
601
+ by_session_day[key] = entry
602
+
603
+ results = []
604
+ for (session_id, date_str), entry in by_session_day.items():
526
605
  results.append({
527
- "date_str": dt.strftime("%Y-%m-%d"),
606
+ "date_str": date_str,
528
607
  "cost_usd": float(entry.get("cost_usd", 0.0)),
529
608
  "input_tokens": int(entry.get("input_tokens", 0)),
530
609
  "output_tokens": int(entry.get("output_tokens", 0)),
@@ -537,6 +616,75 @@ def parse_hook_events(cctrack_dir: Path | None = None) -> list[dict]:
537
616
  return results
538
617
 
539
618
 
619
+ def parse_hook_events(cctrack_dir: Path | None = None) -> list[dict]:
620
+ """Parse statusline-*.jsonl files from a local hook data directory.
621
+ Also reads legacy statusline.jsonl for migration. See _hook_events_from_lines
622
+ for the per-(session, date) dedup semantics."""
623
+ if cctrack_dir is None:
624
+ cctrack_dir = CCTRACK_DIR
625
+ if not cctrack_dir.exists():
626
+ return []
627
+
628
+ files = sorted(cctrack_dir.glob("statusline-*.jsonl"))
629
+ legacy = cctrack_dir / "statusline.jsonl"
630
+ if legacy.exists():
631
+ files.append(legacy)
632
+
633
+ lines: list[str] = []
634
+ for filepath in files:
635
+ try:
636
+ with open(filepath, "r", errors="replace") as f:
637
+ lines.extend(f.readlines())
638
+ except (OSError, IOError):
639
+ continue
640
+
641
+ return _hook_events_from_lines(lines)
642
+
643
+
644
+ def compute_hook_deltas(events: list[dict]) -> list[dict]:
645
+ """Convert per-(session, date) cumulative records into per-(session, date)
646
+ daily deltas. For each session, sorts its records by date and computes
647
+ day-over-day deltas for cost_usd, input_tokens, output_tokens. The first
648
+ visible day for a session uses 0 as the prior cumulative. Cache fields are
649
+ snapshot (not cumulative), so they're passed through unchanged. Records
650
+ with zero cost delta and zero token delta are dropped."""
651
+ by_session: dict[str, list[dict]] = defaultdict(list)
652
+ for e in events:
653
+ by_session[e["session_id"]].append(e)
654
+
655
+ result = []
656
+ for sid, session_events in by_session.items():
657
+ session_events.sort(key=lambda e: e["date_str"])
658
+ prev_cost = 0.0
659
+ prev_in = 0
660
+ prev_out = 0
661
+ for e in session_events:
662
+ cost = float(e.get("cost_usd", 0.0))
663
+ in_t = int(e.get("input_tokens", 0))
664
+ out_t = int(e.get("output_tokens", 0))
665
+
666
+ d_cost = max(0.0, cost - prev_cost)
667
+ d_in = max(0, in_t - prev_in)
668
+ d_out = max(0, out_t - prev_out)
669
+
670
+ prev_cost, prev_in, prev_out = cost, in_t, out_t
671
+
672
+ if d_cost == 0.0 and d_in == 0 and d_out == 0:
673
+ continue
674
+
675
+ result.append({
676
+ "date_str": e["date_str"],
677
+ "session_id": sid,
678
+ "cost_usd": d_cost,
679
+ "input_tokens": d_in,
680
+ "output_tokens": d_out,
681
+ "cache_read": int(e.get("cache_read", 0)),
682
+ "cache_write": int(e.get("cache_write", 0)),
683
+ "model": e.get("model", ""),
684
+ })
685
+ return result
686
+
687
+
540
688
  def load_monthly_hook_summaries(cctrack_dir: Path | None = None) -> dict:
541
689
  """Load pre-aggregated monthly-YYYY-MM.json summary files.
542
690
  Returns {date_str: {cost: float, sessions: int}}."""
@@ -561,7 +709,10 @@ def load_monthly_hook_summaries(cctrack_dir: Path | None = None) -> dict:
561
709
 
562
710
  def rollup_old_hook_files(cctrack_dir: Path | None = None,
563
711
  retention_days: int = HOOK_RETENTION_DAYS) -> None:
564
- """Roll up daily JSONL files older than retention_days into monthly summaries, then delete them."""
712
+ """Roll up daily JSONL files older than retention_days into monthly summaries,
713
+ then delete them. Computes per-session daily deltas within the rollup pass so
714
+ a session spanning multiple days contributes correctly to each day (rather
715
+ than double-counting cumulative values across days)."""
565
716
  if cctrack_dir is None:
566
717
  cctrack_dir = CCTRACK_DIR
567
718
  if not cctrack_dir.exists():
@@ -569,12 +720,21 @@ def rollup_old_hook_files(cctrack_dir: Path | None = None,
569
720
 
570
721
  cutoff = (datetime.now(timezone.utc) - timedelta(days=retention_days)).strftime("%Y-%m-%d")
571
722
 
723
+ old_paths: list[tuple[str, Path]] = []
572
724
  for path in sorted(cctrack_dir.glob("statusline-*.jsonl")):
573
725
  date_str = path.stem.replace("statusline-", "")
574
726
  if not date_str or date_str >= cutoff:
575
727
  continue
728
+ old_paths.append((date_str, path))
729
+
730
+ if not old_paths:
731
+ return
576
732
 
577
- by_session: dict[str, dict] = {}
733
+ # Parse all old files into per-(session, date) latest cumulative snapshots.
734
+ by_session_day: dict[tuple[str, str], dict] = {}
735
+ empty_paths: list[Path] = []
736
+ for date_str, path in old_paths:
737
+ had_any_row = False
578
738
  try:
579
739
  with open(path, "r", errors="replace") as f:
580
740
  for line in f:
@@ -589,35 +749,68 @@ def rollup_old_hook_files(cctrack_dir: Path | None = None,
589
749
  ts = entry.get("_ts", "")
590
750
  if not sid or not ts:
591
751
  continue
592
- if sid in by_session and ts <= by_session[sid].get("_ts", ""):
752
+ had_any_row = True
753
+ key = (sid, date_str)
754
+ if key in by_session_day and ts <= by_session_day[key].get("_ts", ""):
593
755
  continue
594
- by_session[sid] = entry
756
+ by_session_day[key] = entry
595
757
  except (OSError, IOError):
596
758
  continue
597
759
 
598
- if not by_session:
599
- try:
600
- path.unlink()
601
- except OSError:
602
- pass
603
- continue
604
-
605
- day_cost = sum(float(e.get("cost_usd", 0.0)) for e in by_session.values())
606
- day_sessions = len(by_session)
607
-
760
+ if not had_any_row:
761
+ empty_paths.append(path)
762
+
763
+ # Compute per-session per-day deltas by walking each session chronologically.
764
+ by_session: dict[str, list[tuple[str, dict]]] = defaultdict(list)
765
+ for (sid, date_str), entry in by_session_day.items():
766
+ by_session[sid].append((date_str, entry))
767
+
768
+ day_costs: dict[str, float] = defaultdict(float)
769
+ day_session_ids: dict[str, set] = defaultdict(set)
770
+
771
+ for sid, records in by_session.items():
772
+ records.sort(key=lambda r: r[0])
773
+ prev_cost = 0.0
774
+ for date_str, entry in records:
775
+ cost = float(entry.get("cost_usd", 0.0))
776
+ delta = max(0.0, cost - prev_cost)
777
+ prev_cost = cost
778
+ day_costs[date_str] += delta
779
+ day_session_ids[date_str].add(sid)
780
+
781
+ # Write per-day deltas into the appropriate monthly file and delete dailies.
782
+ by_month: dict[str, dict] = {}
783
+ for date_str in sorted(day_costs.keys()):
608
784
  month_str = date_str[:7]
609
- monthly_path = cctrack_dir / f"monthly-{month_str}.json"
610
- monthly_data: dict = {"month": month_str, "days": {}}
611
- if monthly_path.exists():
612
- try:
613
- monthly_data = json.loads(monthly_path.read_text())
614
- except (json.JSONDecodeError, OSError):
615
- monthly_data = {"month": month_str, "days": {}}
785
+ if month_str not in by_month:
786
+ monthly_path = cctrack_dir / f"monthly-{month_str}.json"
787
+ if monthly_path.exists():
788
+ try:
789
+ by_month[month_str] = json.loads(monthly_path.read_text())
790
+ except (json.JSONDecodeError, OSError):
791
+ by_month[month_str] = {"month": month_str, "days": {}}
792
+ else:
793
+ by_month[month_str] = {"month": month_str, "days": {}}
794
+ by_month[month_str].setdefault("days", {})
795
+ by_month[month_str]["days"][date_str] = {
796
+ "cost": day_costs[date_str],
797
+ "sessions": len(day_session_ids[date_str]),
798
+ }
616
799
 
617
- monthly_data["days"][date_str] = {"cost": day_cost, "sessions": day_sessions}
800
+ for month_str, data in by_month.items():
801
+ monthly_path = cctrack_dir / f"monthly-{month_str}.json"
802
+ try:
803
+ monthly_path.write_text(json.dumps(data, indent=2) + "\n")
804
+ except OSError:
805
+ pass
618
806
 
807
+ for _, path in old_paths:
808
+ try:
809
+ path.unlink()
810
+ except OSError:
811
+ pass
812
+ for path in empty_paths:
619
813
  try:
620
- monthly_path.write_text(json.dumps(monthly_data, indent=2) + "\n")
621
814
  path.unlink()
622
815
  except OSError:
623
816
  pass
@@ -625,21 +818,38 @@ def rollup_old_hook_files(cctrack_dir: Path | None = None,
625
818
 
626
819
  def aggregate_hook_data(events: list[dict], monthly_summaries: dict | None = None) -> dict:
627
820
  """Aggregate hook events by date, merging with pre-aggregated monthly data.
628
- Returns {date_str: {cost: float, sessions: int}}."""
821
+ Returns {date_str: {cost, sessions, input_tokens, output_tokens, cache_read, cache_write, models}}.
822
+ Note: cache_read/cache_write come from current_usage (snapshot of the last message's
823
+ context), so aggregating them is not strictly meaningful — treat as indicative only."""
824
+ def _empty() -> dict:
825
+ return {"cost": 0.0, "sessions": 0, "input_tokens": 0, "output_tokens": 0,
826
+ "cache_read": 0, "cache_write": 0, "models": {}}
827
+
629
828
  by_date: dict[str, dict] = {}
630
829
 
631
830
  if monthly_summaries:
632
831
  for date_str, data in monthly_summaries.items():
633
- by_date[date_str] = {"cost": data["cost"], "sessions": data["sessions"]}
832
+ bucket = _empty()
833
+ bucket["cost"] = data["cost"]
834
+ bucket["sessions"] = data["sessions"]
835
+ by_date[date_str] = bucket
634
836
 
635
837
  recent_dates: set[str] = set()
636
838
  for e in events:
637
839
  date_str = e["date_str"]
638
840
  if date_str not in recent_dates:
639
- by_date[date_str] = {"cost": 0.0, "sessions": 0}
841
+ by_date[date_str] = _empty()
640
842
  recent_dates.add(date_str)
641
- by_date[date_str]["cost"] += e["cost_usd"]
642
- by_date[date_str]["sessions"] += 1
843
+ bucket = by_date[date_str]
844
+ bucket["cost"] += e["cost_usd"]
845
+ bucket["sessions"] += 1
846
+ bucket["input_tokens"] += e.get("input_tokens", 0)
847
+ bucket["output_tokens"] += e.get("output_tokens", 0)
848
+ bucket["cache_read"] += e.get("cache_read", 0)
849
+ bucket["cache_write"] += e.get("cache_write", 0)
850
+ model = e.get("model", "")
851
+ if model:
852
+ bucket["models"][model] = bucket["models"].get(model, 0) + 1
643
853
 
644
854
  return by_date
645
855
 
@@ -647,14 +857,15 @@ def aggregate_hook_data(events: list[dict], monthly_summaries: dict | None = Non
647
857
  # ── Aggregation ─────────────────────────────────────────────────────────
648
858
 
649
859
  def new_bucket() -> dict:
650
- return {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0, "cost": 0.0}
860
+ return {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0,
861
+ "cache_write_1h": 0, "cost": 0.0, "models": {}}
651
862
 
652
863
 
653
864
  def aggregate(events: list[dict]) -> tuple[dict, dict]:
654
865
  """
655
866
  Returns:
656
- daily: {date_str: {input, output, cache_read, cache_write, cost}}
657
- monthly: {month_str: {input, output, cache_read, cache_write, cost}}
867
+ daily: {date_str: {input, output, cache_read, cache_write, cache_write_1h, cost, models}}
868
+ monthly: {month_str: {input, output, cache_read, cache_write, cache_write_1h, cost, models}}
658
869
  """
659
870
  daily = defaultdict(new_bucket)
660
871
  monthly = defaultdict(new_bucket)
@@ -672,14 +883,20 @@ def aggregate(events: list[dict]) -> tuple[dict, dict]:
672
883
  date_str = dt.strftime("%Y-%m-%d")
673
884
  month_str = dt.strftime("%Y-%m")
674
885
 
675
- cost = calculate_cost(e["model"], e["input"], e["output"], e["cache_read"], e["cache_write"])
886
+ cache_1h = e.get("cache_write_1h", 0)
887
+ cost = calculate_cost(e["model"], e["input"], e["output"],
888
+ e["cache_read"], e["cache_write"], cache_1h)
889
+ model = e.get("model", "")
676
890
 
677
891
  for bucket in (daily[date_str], monthly[month_str]):
678
892
  bucket["input"] += e["input"]
679
893
  bucket["output"] += e["output"]
680
894
  bucket["cache_read"] += e["cache_read"]
681
895
  bucket["cache_write"] += e["cache_write"]
896
+ bucket["cache_write_1h"] += cache_1h
682
897
  bucket["cost"] += cost
898
+ if model:
899
+ bucket["models"][model] = bucket["models"].get(model, 0) + 1
683
900
 
684
901
  return dict(daily), dict(monthly)
685
902
 
@@ -690,8 +907,26 @@ def format_tokens(n: int) -> str:
690
907
  return f"{n:,}"
691
908
 
692
909
 
910
+ def format_model(models: dict) -> str:
911
+ """Format a model counter dict as 'short-name' or 'short-name+N' when
912
+ multiple models appear. Returns empty string for no models.
913
+ Strips the 'claude-' prefix and any trailing YYYYMMDD date suffix."""
914
+ if not models:
915
+ return ""
916
+ ranked = sorted(models.items(), key=lambda kv: (-kv[1], kv[0]))
917
+ primary = ranked[0][0]
918
+ short = primary[len("claude-"):] if primary.startswith("claude-") else primary
919
+ parts = short.split("-")
920
+ if len(parts) > 1 and parts[-1].isdigit() and len(parts[-1]) == 8:
921
+ short = "-".join(parts[:-1])
922
+ if len(ranked) > 1:
923
+ short += f"+{len(ranked) - 1}"
924
+ return short
925
+
926
+
693
927
  def total_tokens(b: dict) -> int:
694
- return b["input"] + b["output"] + b["cache_read"] + b["cache_write"]
928
+ return (b["input"] + b["output"] + b["cache_read"]
929
+ + b["cache_write"] + b.get("cache_write_1h", 0))
695
930
 
696
931
 
697
932
  def _compute_accuracy_factor(daily: dict, hook_daily: dict, hook_install_date: str | None) -> tuple[float, bool]:
@@ -796,12 +1031,25 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
796
1031
  print(f"{current_month_name} — month to date (day {day_of_month}, {days_active} active)")
797
1032
  print("───────────────────────────────────────")
798
1033
  if m:
799
- print(f" Input tokens: {format_tokens(m['input']):>15}")
800
- print(f" Output tokens: {format_tokens(m['output']):>15}")
801
- if m['cache_read'] or m['cache_write']:
1034
+ hook_in_mtd = sum(hook_daily.get(d, {}).get("input_tokens", 0) for d in current_days_all)
1035
+ hook_out_mtd = sum(hook_daily.get(d, {}).get("output_tokens", 0) for d in current_days_all)
1036
+ input_t = hook_in_mtd if hook_in_mtd else m["input"]
1037
+ output_t = hook_out_mtd if hook_out_mtd else m["output"]
1038
+ print(f" Input tokens: {format_tokens(input_t):>15}")
1039
+ print(f" Output tokens: {format_tokens(output_t):>15}")
1040
+ cache_w_total = m['cache_write'] + m.get('cache_write_1h', 0)
1041
+ if m['cache_read'] or cache_w_total:
802
1042
  print(f" Cache read: {format_tokens(m['cache_read']):>15}")
803
- print(f" Cache write: {format_tokens(m['cache_write']):>15}")
804
- print(f" Total tokens: {format_tokens(total_tokens(m)):>15}")
1043
+ print(f" Cache write: {format_tokens(cache_w_total):>15}")
1044
+ total = input_t + output_t + m['cache_read'] + cache_w_total
1045
+ print(f" Total tokens: {format_tokens(total):>15}")
1046
+ mtd_model = format_model(m.get("models", {}))
1047
+ if mtd_model:
1048
+ print(f" Model: {mtd_model:>15}")
1049
+
1050
+ mtd_sessions = sum(hook_daily.get(d, {}).get("sessions", 0) for d in current_days_all)
1051
+ if mtd_sessions:
1052
+ print(f" Sessions: {mtd_sessions:>15}")
805
1053
 
806
1054
  if hook_install_date and est_days > 0:
807
1055
  factor_source = "measured" if factor_is_measured else "estimated ~20-25%"
@@ -829,26 +1077,32 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
829
1077
  if current_month_days:
830
1078
  print("Daily breakdown:")
831
1079
  if hook_install_date:
832
- print(f" {'Date':<12} {'Input':>12} {'Output':>12} {'Cache R':>12} {'Cache W':>12} {'Cost':>10} {'':>3}")
833
- print(f" {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*10} {'─'*3}")
1080
+ print(f" {'Date':<12} {'Input':>11} {'Output':>11} {'Cache R':>11} {'Cache W':>11} {'Sess':>5} {'Model':<12} {'Cost':>9} {'':>2}")
1081
+ print(f" {'─'*12} {'─'*11} {'─'*11} {'─'*11} {'─'*11} {'─'*5} {'─'*12} {'─'*9} {'─'*2}")
834
1082
  else:
835
- print(f" {'Date':<12} {'Input':>12} {'Output':>12} {'Cache R':>12} {'Cache W':>12} {'Cost':>10}")
836
- print(f" {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*10}")
1083
+ print(f" {'Date':<12} {'Input':>11} {'Output':>11} {'Cache R':>11} {'Cache W':>11} {'Model':<12} {'Cost':>9}")
1084
+ print(f" {'─'*12} {'─'*11} {'─'*11} {'─'*11} {'─'*11} {'─'*12} {'─'*9}")
837
1085
  display_days = current_month_days
838
1086
  if days is not None:
839
1087
  display_days = display_days[:days]
840
1088
  for date_str in display_days:
841
1089
  cost, is_auth = _date_cost(date_str)
842
1090
  d = daily.get(date_str)
843
- input_t = d["input"] if d else 0
844
- output_t = d["output"] if d else 0
1091
+ hook = hook_daily.get(date_str, {})
1092
+ hook_in = hook.get("input_tokens", 0)
1093
+ hook_out = hook.get("output_tokens", 0)
1094
+ input_t = hook_in if hook_in else (d["input"] if d else 0)
1095
+ output_t = hook_out if hook_out else (d["output"] if d else 0)
845
1096
  cache_r = d["cache_read"] if d else 0
846
- cache_w = d["cache_write"] if d else 0
1097
+ cache_w = (d["cache_write"] + d.get("cache_write_1h", 0)) if d else 0
1098
+ model_str = format_model(d.get("models", {})) if d else format_model(hook.get("models", {}))
847
1099
  if hook_install_date:
848
1100
  marker = "" if is_auth else "~"
849
- print(f" {date_str:<12} {format_tokens(input_t):>12} {format_tokens(output_t):>12} {format_tokens(cache_r):>12} {format_tokens(cache_w):>12} ${cost:>8.2f} {marker:>3}")
1101
+ sessions = hook.get("sessions", 0)
1102
+ sess_str = str(sessions) if sessions else "-"
1103
+ print(f" {date_str:<12} {format_tokens(input_t):>11} {format_tokens(output_t):>11} {format_tokens(cache_r):>11} {format_tokens(cache_w):>11} {sess_str:>5} {model_str:<12} ${cost:>7.2f} {marker:>2}")
850
1104
  else:
851
- print(f" {date_str:<12} {format_tokens(input_t):>12} {format_tokens(output_t):>12} {format_tokens(cache_r):>12} {format_tokens(cache_w):>12} ${cost:>8.2f}")
1105
+ print(f" {date_str:<12} {format_tokens(input_t):>11} {format_tokens(output_t):>11} {format_tokens(cache_r):>11} {format_tokens(cache_w):>11} {model_str:<12} ${cost:>7.2f}")
852
1106
 
853
1107
  if hook_install_date and current_month_days:
854
1108
  has_estimated = any(not _date_cost(d)[1] for d in current_month_days)
@@ -863,8 +1117,8 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
863
1117
  if prev_months:
864
1118
  print()
865
1119
  print("Previous months:")
866
- print(f" {'Month':<12} {'Input':>12} {'Output':>12} {'Cache R':>12} {'Cache W':>12} {'Cost':>10}")
867
- print(f" {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*12} {'─'*10}")
1120
+ print(f" {'Month':<12} {'Input':>11} {'Output':>11} {'Cache R':>11} {'Cache W':>11} {'Model':<12} {'Cost':>9}")
1121
+ print(f" {'─'*12} {'─'*11} {'─'*11} {'─'*11} {'─'*11} {'─'*12} {'─'*9}")
868
1122
  for month_str in prev_months:
869
1123
  m = monthly.get(month_str)
870
1124
  month_days_list = [d for d in prev_month_days if d.startswith(month_str)]
@@ -890,12 +1144,13 @@ def print_report(daily: dict, monthly: dict, days: int | None = None,
890
1144
  input_t = m["input"] if m else 0
891
1145
  output_t = m["output"] if m else 0
892
1146
  cache_r = m["cache_read"] if m else 0
893
- cache_w = m["cache_write"] if m else 0
1147
+ cache_w = (m["cache_write"] + m.get("cache_write_1h", 0)) if m else 0
1148
+ model_str = format_model(m.get("models", {})) if m else ""
894
1149
 
895
- cost_str = f"${month_cost:>8.2f}"
1150
+ cost_str = f"${month_cost:>7.2f}"
896
1151
  if hook_install_date and month_has_est and not month_has_hook:
897
1152
  cost_str += " ~"
898
- print(f" {month_str + days_str:<12} {format_tokens(input_t):>12} {format_tokens(output_t):>12} {format_tokens(cache_r):>12} {format_tokens(cache_w):>12} {cost_str}")
1153
+ print(f" {month_str + days_str:<12} {format_tokens(input_t):>11} {format_tokens(output_t):>11} {format_tokens(cache_r):>11} {format_tokens(cache_w):>11} {model_str:<12} {cost_str}")
899
1154
 
900
1155
  print()
901
1156
 
@@ -969,21 +1224,33 @@ def main():
969
1224
  except (ValueError, AttributeError):
970
1225
  pass
971
1226
 
972
- hook_dirs = discover_hook_data_dirs()
973
- if hook_dirs:
974
- all_hook_events: list[dict] = []
975
- all_monthly: dict[str, dict] = {}
976
- for hdir in hook_dirs:
977
- rollup_old_hook_files(hdir)
978
- all_hook_events.extend(parse_hook_events(hdir))
979
- for date_str, data in load_monthly_hook_summaries(hdir).items():
980
- if date_str in all_monthly:
981
- all_monthly[date_str]["cost"] += data["cost"]
982
- all_monthly[date_str]["sessions"] += data["sessions"]
983
- else:
984
- all_monthly[date_str] = dict(data)
985
- if all_hook_events or all_monthly:
986
- hook_daily = aggregate_hook_data(all_hook_events, all_monthly)
1227
+ all_hook_events: list[dict] = []
1228
+ all_monthly: dict[str, dict] = {}
1229
+
1230
+ for hdir in discover_hook_data_dirs():
1231
+ rollup_old_hook_files(hdir)
1232
+ all_hook_events.extend(compute_hook_deltas(parse_hook_events(hdir)))
1233
+ merge_monthly_summaries(all_monthly, load_monthly_hook_summaries(hdir))
1234
+
1235
+ # Remote hook data: each host's deltas are computed independently so a
1236
+ # session's cumulative history never mixes across hosts.
1237
+ if args.remote:
1238
+ for host in args.remote:
1239
+ remote_hook_events, remote_monthly = parse_remote_hook_data(host)
1240
+ all_hook_events.extend(compute_hook_deltas(remote_hook_events))
1241
+ merge_monthly_summaries(all_monthly, remote_monthly)
1242
+
1243
+ if all_hook_events or all_monthly:
1244
+ hook_daily = aggregate_hook_data(all_hook_events, all_monthly)
1245
+
1246
+ # Hook coverage may start earlier than the local install date (e.g. a
1247
+ # remote host installed the hook first). Treat the earliest observed
1248
+ # hook date as the effective install date so those days count as
1249
+ # authoritative rather than being scaled by the accuracy factor.
1250
+ if hook_daily:
1251
+ earliest_hook_date = min(hook_daily.keys())
1252
+ if hook_install_date is None or earliest_hook_date < hook_install_date:
1253
+ hook_install_date = earliest_hook_date
987
1254
 
988
1255
  # Suggest hook if not installed
989
1256
  if not is_hook_installed():
@@ -172,6 +172,62 @@ def test_calculate_cost_zero_tokens():
172
172
  assert cost == 0.0
173
173
 
174
174
 
175
+ def test_calculate_cost_1h_cache_write():
176
+ # Opus 4.7: 1M 1h-cache-write tokens at $10/M = $10.00 (2× input rate)
177
+ cost = cctrack.calculate_cost("claude-opus-4-7", 0, 0, 0, 0, 1_000_000)
178
+ assert abs(cost - 10.0) < 0.001
179
+
180
+
181
+ def test_calculate_cost_1h_vs_5m_cache_write_differ():
182
+ # Same 1M tokens priced differently depending on TTL.
183
+ cost_5m = cctrack.calculate_cost("claude-opus-4-7", 0, 0, 0, 1_000_000, 0)
184
+ cost_1h = cctrack.calculate_cost("claude-opus-4-7", 0, 0, 0, 0, 1_000_000)
185
+ assert abs(cost_5m - 6.25) < 0.001
186
+ assert abs(cost_1h - 10.0) < 0.001
187
+
188
+
189
+ def test_get_rates_include_1h_cache_write():
190
+ # Every family should expose a cache_write_1h rate at 2× input.
191
+ for r in cctrack.RATES:
192
+ assert "cache_write_1h" in r
193
+ assert abs(r["cache_write_1h"] - 2.0 * r["input"]) < 0.001
194
+
195
+
196
+ def test_parse_lines_extracts_1h_cache_split():
197
+ # Nested cache_creation.ephemeral_{5m,1h}_input_tokens should be picked up.
198
+ event = make_event()
199
+ event["message"]["usage"]["cache_creation_input_tokens"] = 1000
200
+ event["message"]["usage"]["cache_creation"] = {
201
+ "ephemeral_5m_input_tokens": 300,
202
+ "ephemeral_1h_input_tokens": 700,
203
+ }
204
+ events = cctrack.parse_lines([json.dumps(event)])
205
+ assert len(events) == 1
206
+ assert events[0]["cache_write"] == 300
207
+ assert events[0]["cache_write_1h"] == 700
208
+
209
+
210
+ def test_parse_lines_flat_cache_creation_treated_as_5m():
211
+ # Legacy logs without the nested object treat the flat field as 5m.
212
+ event = make_event(cache_write=1000)
213
+ events = cctrack.parse_lines([json.dumps(event)])
214
+ assert len(events) == 1
215
+ assert events[0]["cache_write"] == 1000
216
+ assert events[0]["cache_write_1h"] == 0
217
+
218
+
219
+ def test_aggregate_1h_cache_cost():
220
+ # 1M 1h-cache-write tokens on opus-4-7 → $10 via aggregation.
221
+ events = [
222
+ {"model": "claude-opus-4-7", "timestamp": "2026-03-15T10:00:00Z",
223
+ "input": 0, "output": 0, "cache_read": 0,
224
+ "cache_write": 0, "cache_write_1h": 1_000_000},
225
+ ]
226
+ daily, monthly = cctrack.aggregate(events)
227
+ assert abs(daily["2026-03-15"]["cost"] - 10.0) < 0.001
228
+ assert daily["2026-03-15"]["cache_write_1h"] == 1_000_000
229
+
230
+
175
231
  # ── File discovery tests ───────────────────────────────────────────────
176
232
 
177
233
  def test_discover_files_finds_jsonl():
@@ -159,6 +159,207 @@ def test_parse_hook_events_reads_legacy_file():
159
159
  assert events[0]["session_id"] == "legacy_1"
160
160
 
161
161
 
162
+ def test_parse_hook_events_session_spans_multiple_days():
163
+ """A session active on multiple days returns one record per (session, day)
164
+ with that day's maximum cumulative values."""
165
+ with tempfile.TemporaryDirectory() as d:
166
+ write_hook_jsonl(d, [
167
+ make_hook_entry(session_id="sA", ts="2026-04-14T10:00:00+00:00", cost_usd=2.00, input_tokens=100, output_tokens=50),
168
+ make_hook_entry(session_id="sA", ts="2026-04-14T18:00:00+00:00", cost_usd=5.00, input_tokens=300, output_tokens=150),
169
+ ], date="2026-04-14")
170
+ write_hook_jsonl(d, [
171
+ make_hook_entry(session_id="sA", ts="2026-04-15T09:00:00+00:00", cost_usd=8.00, input_tokens=500, output_tokens=250),
172
+ make_hook_entry(session_id="sA", ts="2026-04-15T16:00:00+00:00", cost_usd=12.00, input_tokens=800, output_tokens=400),
173
+ ], date="2026-04-15")
174
+ events = cctrack.parse_hook_events(cctrack_dir=Path(d))
175
+ by_date = {e["date_str"]: e for e in events}
176
+ assert len(events) == 2
177
+ assert by_date["2026-04-14"]["cost_usd"] == 5.00
178
+ assert by_date["2026-04-14"]["input_tokens"] == 300
179
+ assert by_date["2026-04-15"]["cost_usd"] == 12.00
180
+ assert by_date["2026-04-15"]["input_tokens"] == 800
181
+
182
+
183
+ # ── compute_hook_deltas tests ──────────────────────────────────────────
184
+
185
+ def test_compute_hook_deltas_single_day_session():
186
+ """Session visible on one day produces one delta equal to its cumulative."""
187
+ events = [
188
+ {"date_str": "2026-04-15", "session_id": "s1", "cost_usd": 3.0,
189
+ "input_tokens": 100, "output_tokens": 50, "cache_read": 0, "cache_write": 0, "model": "m"},
190
+ ]
191
+ deltas = cctrack.compute_hook_deltas(events)
192
+ assert len(deltas) == 1
193
+ assert deltas[0]["cost_usd"] == 3.0
194
+ assert deltas[0]["input_tokens"] == 100
195
+ assert deltas[0]["output_tokens"] == 50
196
+
197
+
198
+ def test_compute_hook_deltas_session_spans_days():
199
+ """A session spanning days produces one delta per day, each being the
200
+ day-over-day increment rather than the cumulative value."""
201
+ events = [
202
+ {"date_str": "2026-04-14", "session_id": "sA", "cost_usd": 5.00,
203
+ "input_tokens": 300, "output_tokens": 150, "cache_read": 0, "cache_write": 0, "model": "m"},
204
+ {"date_str": "2026-04-15", "session_id": "sA", "cost_usd": 12.00,
205
+ "input_tokens": 800, "output_tokens": 400, "cache_read": 0, "cache_write": 0, "model": "m"},
206
+ ]
207
+ deltas = cctrack.compute_hook_deltas(events)
208
+ by_date = {d["date_str"]: d for d in deltas}
209
+ assert abs(by_date["2026-04-14"]["cost_usd"] - 5.00) < 0.001
210
+ assert abs(by_date["2026-04-15"]["cost_usd"] - 7.00) < 0.001
211
+ assert by_date["2026-04-14"]["input_tokens"] == 300
212
+ assert by_date["2026-04-15"]["input_tokens"] == 500
213
+ assert by_date["2026-04-14"]["output_tokens"] == 150
214
+ assert by_date["2026-04-15"]["output_tokens"] == 250
215
+
216
+
217
+ def test_compute_hook_deltas_drops_zero_records():
218
+ """A record whose cumulative didn't advance from the prior day is dropped."""
219
+ events = [
220
+ {"date_str": "2026-04-14", "session_id": "sA", "cost_usd": 5.0,
221
+ "input_tokens": 100, "output_tokens": 50, "cache_read": 0, "cache_write": 0, "model": "m"},
222
+ {"date_str": "2026-04-15", "session_id": "sA", "cost_usd": 5.0,
223
+ "input_tokens": 100, "output_tokens": 50, "cache_read": 0, "cache_write": 0, "model": "m"},
224
+ ]
225
+ deltas = cctrack.compute_hook_deltas(events)
226
+ assert len(deltas) == 1
227
+ assert deltas[0]["date_str"] == "2026-04-14"
228
+
229
+
230
+ def test_compute_hook_deltas_clamps_negatives():
231
+ """Cumulative values should never decrease, but if they do (bad data),
232
+ deltas clamp to zero rather than produce negative costs."""
233
+ events = [
234
+ {"date_str": "2026-04-14", "session_id": "sA", "cost_usd": 5.0,
235
+ "input_tokens": 100, "output_tokens": 50, "cache_read": 0, "cache_write": 0, "model": "m"},
236
+ {"date_str": "2026-04-15", "session_id": "sA", "cost_usd": 3.0,
237
+ "input_tokens": 80, "output_tokens": 40, "cache_read": 0, "cache_write": 0, "model": "m"},
238
+ ]
239
+ deltas = cctrack.compute_hook_deltas(events)
240
+ # Day 2 delta is negative, so it's dropped entirely (all-zero after clamping).
241
+ assert len(deltas) == 1
242
+ assert deltas[0]["date_str"] == "2026-04-14"
243
+
244
+
245
+ def test_compute_hook_deltas_multiple_sessions_independent():
246
+ """Each session's deltas are computed independently."""
247
+ events = [
248
+ {"date_str": "2026-04-14", "session_id": "sA", "cost_usd": 10.0,
249
+ "input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
250
+ {"date_str": "2026-04-15", "session_id": "sA", "cost_usd": 15.0,
251
+ "input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
252
+ {"date_str": "2026-04-15", "session_id": "sB", "cost_usd": 4.0,
253
+ "input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
254
+ ]
255
+ deltas = cctrack.compute_hook_deltas(events)
256
+ by_key = {(d["date_str"], d["session_id"]): d["cost_usd"] for d in deltas}
257
+ assert by_key[("2026-04-14", "sA")] == 10.0
258
+ assert by_key[("2026-04-15", "sA")] == 5.0
259
+ assert by_key[("2026-04-15", "sB")] == 4.0
260
+
261
+
262
+ # ── Remote hook data tests ─────────────────────────────────────────────
263
+
264
+ def _remote_raw(hook_chunks: list[list[dict]], monthly_chunks: list[dict]) -> str:
265
+ """Build a raw SSH output stream the way fetch_remote_hook_data returns it."""
266
+ parts = []
267
+ for entries in hook_chunks:
268
+ parts.append(cctrack.HOOK_FILE_SEP)
269
+ for e in entries:
270
+ parts.append(json.dumps(e))
271
+ for doc in monthly_chunks:
272
+ parts.append(cctrack.MONTHLY_FILE_SEP)
273
+ parts.append(json.dumps(doc))
274
+ return "\n".join(parts) + "\n"
275
+
276
+
277
+ def test_parse_remote_hook_data_basic():
278
+ """Statusline entries and monthly summaries are both parsed from the stream."""
279
+ raw = _remote_raw(
280
+ hook_chunks=[
281
+ [make_hook_entry(session_id="r1", ts="2026-04-15T10:00:00+00:00", cost_usd=3.00)],
282
+ [make_hook_entry(session_id="r2", ts="2026-04-16T10:00:00+00:00", cost_usd=7.00)],
283
+ ],
284
+ monthly_chunks=[
285
+ {"month": "2026-01", "days": {"2026-01-10": {"cost": 10.0, "sessions": 2}}},
286
+ ],
287
+ )
288
+ with patch("cctrack.fetch_remote_hook_data", return_value=raw):
289
+ events, monthly = cctrack.parse_remote_hook_data("fakehost")
290
+ assert len(events) == 2
291
+ by_session = {e["session_id"]: e for e in events}
292
+ assert by_session["r1"]["cost_usd"] == 3.00
293
+ assert by_session["r2"]["date_str"] == "2026-04-16"
294
+ assert monthly == {"2026-01-10": {"cost": 10.0, "sessions": 2}}
295
+
296
+
297
+ def test_parse_remote_hook_data_dedup_across_files():
298
+ """Same (session, date) in multiple remote files: latest _ts wins."""
299
+ raw = _remote_raw(
300
+ hook_chunks=[
301
+ [make_hook_entry(session_id="rA", ts="2026-04-15T10:00:00+00:00", cost_usd=2.00)],
302
+ [make_hook_entry(session_id="rA", ts="2026-04-15T18:00:00+00:00", cost_usd=6.00)],
303
+ ],
304
+ monthly_chunks=[],
305
+ )
306
+ with patch("cctrack.fetch_remote_hook_data", return_value=raw):
307
+ events, monthly = cctrack.parse_remote_hook_data("fakehost")
308
+ assert len(events) == 1
309
+ assert events[0]["cost_usd"] == 6.00
310
+
311
+
312
+ def test_parse_remote_hook_data_empty():
313
+ with patch("cctrack.fetch_remote_hook_data", return_value=""):
314
+ events, monthly = cctrack.parse_remote_hook_data("fakehost")
315
+ assert events == []
316
+ assert monthly == {}
317
+
318
+
319
+ def test_parse_remote_hook_data_malformed_monthly_skipped():
320
+ raw = (cctrack.HOOK_FILE_SEP + "\n"
321
+ + json.dumps(make_hook_entry(session_id="ok", cost_usd=1.0)) + "\n"
322
+ + cctrack.MONTHLY_FILE_SEP + "\n"
323
+ + "not json\n")
324
+ with patch("cctrack.fetch_remote_hook_data", return_value=raw):
325
+ events, monthly = cctrack.parse_remote_hook_data("fakehost")
326
+ assert len(events) == 1
327
+ assert monthly == {}
328
+
329
+
330
+ def test_fetch_remote_hook_data_timeout():
331
+ with patch("subprocess.run", side_effect=cctrack.subprocess.TimeoutExpired("ssh", 60)):
332
+ result = cctrack.fetch_remote_hook_data("fakehost")
333
+ assert result == ""
334
+
335
+
336
+ def test_merge_monthly_summaries_sums_overlapping_dates():
337
+ """Same date on two hosts sums cost and sessions."""
338
+ target = {"2026-01-10": {"cost": 10.0, "sessions": 2}}
339
+ cctrack.merge_monthly_summaries(target, {
340
+ "2026-01-10": {"cost": 5.0, "sessions": 1},
341
+ "2026-01-11": {"cost": 3.0, "sessions": 1},
342
+ })
343
+ assert target["2026-01-10"] == {"cost": 15.0, "sessions": 3}
344
+ assert target["2026-01-11"] == {"cost": 3.0, "sessions": 1}
345
+
346
+
347
+ def test_remote_and_local_hook_costs_sum_per_date():
348
+ """Regression: a date covered by hooks on two hosts must sum both hosts'
349
+ costs, not keep just one host's value."""
350
+ local_events = cctrack.compute_hook_deltas([
351
+ {"date_str": "2026-06-11", "session_id": "local_s", "cost_usd": 9.05,
352
+ "input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
353
+ ])
354
+ remote_events = cctrack.compute_hook_deltas([
355
+ {"date_str": "2026-06-11", "session_id": "air_s", "cost_usd": 340.90,
356
+ "input_tokens": 0, "output_tokens": 0, "cache_read": 0, "cache_write": 0, "model": "m"},
357
+ ])
358
+ hook_daily = cctrack.aggregate_hook_data(local_events + remote_events)
359
+ assert abs(hook_daily["2026-06-11"]["cost"] - 349.95) < 0.001
360
+ assert hook_daily["2026-06-11"]["sessions"] == 2
361
+
362
+
162
363
  # ── aggregate_hook_data tests ──────────────────────────────────────────
163
364
 
164
365
  def test_aggregate_hook_data_single_day():
@@ -316,6 +517,35 @@ def test_rollup_appends_to_existing_monthly():
316
517
  assert data["days"]["2025-12-02"]["cost"] == 7.00
317
518
 
318
519
 
520
+ def test_rollup_cross_day_session_attributes_deltas():
521
+ """A session active across multiple rolled-up days should have its
522
+ cumulative cost attributed as per-day deltas, not double-counted."""
523
+ with tempfile.TemporaryDirectory() as d:
524
+ # Session sA: cumulative rises 0 → 5 on day 1, 5 → 12 on day 2.
525
+ # Session sB: only active on day 2, cumulative rises 0 → 3.
526
+ write_hook_jsonl(d, [
527
+ make_hook_entry(session_id="sA", ts="2025-12-01T10:00:00+00:00", cost_usd=2.00),
528
+ make_hook_entry(session_id="sA", ts="2025-12-01T18:00:00+00:00", cost_usd=5.00),
529
+ ], date="2025-12-01")
530
+ write_hook_jsonl(d, [
531
+ make_hook_entry(session_id="sA", ts="2025-12-02T10:00:00+00:00", cost_usd=12.00),
532
+ make_hook_entry(session_id="sB", ts="2025-12-02T11:00:00+00:00", cost_usd=3.00),
533
+ ], date="2025-12-02")
534
+
535
+ cctrack.rollup_old_hook_files(cctrack_dir=Path(d), retention_days=0)
536
+
537
+ data = json.loads(Path(d, "monthly-2025-12.json").read_text())
538
+ # Day 1: sA delta = 5.00. Total = 5.00, 1 session.
539
+ assert abs(data["days"]["2025-12-01"]["cost"] - 5.00) < 0.001
540
+ assert data["days"]["2025-12-01"]["sessions"] == 1
541
+ # Day 2: sA delta = 12 - 5 = 7.00, sB delta = 3.00. Total = 10.00, 2 sessions.
542
+ assert abs(data["days"]["2025-12-02"]["cost"] - 10.00) < 0.001
543
+ assert data["days"]["2025-12-02"]["sessions"] == 2
544
+ # Sum equals actual session totals: sA $12 + sB $3 = $15.
545
+ total = sum(day["cost"] for day in data["days"].values())
546
+ assert abs(total - 15.00) < 0.001
547
+
548
+
319
549
  def test_rollup_deletes_empty_daily():
320
550
  """Daily file with no valid sessions is just deleted."""
321
551
  with tempfile.TemporaryDirectory() as d:
File without changes
File without changes
File without changes
File without changes
File without changes