cctrack 0.6.0__tar.gz → 0.7.0__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.
- {cctrack-0.6.0 → cctrack-0.7.0}/CLAUDE.md +3 -2
- {cctrack-0.6.0 → cctrack-0.7.0}/PKG-INFO +27 -1
- {cctrack-0.6.0 → cctrack-0.7.0}/README.md +26 -0
- {cctrack-0.6.0 → cctrack-0.7.0}/pyproject.toml +1 -1
- cctrack-0.7.0/scripts/diagnose_session_cost.py +172 -0
- cctrack-0.7.0/scripts/trace_project.py +140 -0
- {cctrack-0.6.0 → cctrack-0.7.0}/src/cctrack/__init__.py +363 -41
- {cctrack-0.6.0 → cctrack-0.7.0}/tests/test_cctrack.py +238 -0
- {cctrack-0.6.0 → cctrack-0.7.0}/tests/test_hook_reporting.py +111 -46
- {cctrack-0.6.0 → cctrack-0.7.0}/.github/workflows/ci.yml +0 -0
- {cctrack-0.6.0 → cctrack-0.7.0}/.github/workflows/publish.yml +0 -0
- {cctrack-0.6.0 → cctrack-0.7.0}/.gitignore +0 -0
- {cctrack-0.6.0 → cctrack-0.7.0}/LICENSE +0 -0
- {cctrack-0.6.0 → cctrack-0.7.0}/src/cctrack/__main__.py +0 -0
- {cctrack-0.6.0 → cctrack-0.7.0}/tests/test_hook.py +0 -0
|
@@ -35,10 +35,11 @@ This is a single-module CLI tool. All logic lives in `src/cctrack/__init__.py`:
|
|
|
35
35
|
1a. **Energy model** (`ENERGY_WH_PER_MTOK`, `estimate_energy_wh`) - Opt-in (`--energy`) order-of-magnitude estimate keyed off each rate entry's `tier`. Deliberately NOT a function of price: both cache-write TTLs use one figure because a 1h write costs more money but the same compute.
|
|
36
36
|
2. **File discovery** (`default_dirs`, `discover_files`) - Walks `~/.claude/projects/` and `~/.sandy/sandboxes/` for `.jsonl` files.
|
|
37
37
|
3. **Remote aggregation** (`fetch_remote_jsonl`, `parse_remote_events`, `fetch_remote_hook_data`, `parse_remote_hook_data`) - SSHs to hosts, cats all JSONL logs plus statusline hook data (statusline files, monthly summaries, session baselines) with file separators, parses locally.
|
|
38
|
-
3a. **Hook parsing** (`parse_hook_rows`, `compute_hook_deltas
|
|
38
|
+
3a. **Hook parsing** (`parse_hook_rows`, `compute_hook_deltas`) - The hook gives **cost only**. Its `input_tokens`/`output_tokens`/`cache_*` fields are context-window SNAPSHOTS (`input_tokens` ≈ `cache_read + cache_write`; `output_tokens` is just the last message's), not cumulative — never use them for token counts. `cost_usd` is a session-lifetime cumulative that is unreliable in two ways handled by `compute_hook_deltas`: (1) it can already be large the first time a session is observed (a long-running/unattended session that only rendered its statusline partway through), so the **first observation is a baseline that is NOT counted** — booking a session's pre-existing cumulative onto its first-seen day is what produced a bogus $3,370 day; (2) it **drops** on resume/compaction (or when parallel processes share one session id), so a drop **rebaselines without counting the new level** — otherwise a day's booked spend can exceed the counter's own peak, which is impossible for real money. Net rule: **book only observed upward increments**; a rolled-up baseline seeds `prev` so already-counted days aren't recounted.
|
|
39
39
|
4. **JSONL parsing** (`parse_lines`, `parse_events`, `dedup_events`) - Extracts `assistant` events with token usage. Deduplicates by `requestId` across all of a host's files (last event wins) — resumed sessions (`--resume`/`--continue`) repeat events with the same `requestId` in new files. Events without `requestId` are never deduped.
|
|
40
40
|
5. **Aggregation** (`aggregate`) - Buckets events into daily and monthly summaries with token counts and costs.
|
|
41
41
|
6. **Output** (`print_report`) - Prints current month-to-date summary with projections, daily breakdown, and previous month summaries. Cost is composed **per source** (one source per machine): hook cost where a source has statusline coverage, accuracy-factor-adjusted JSONL cost otherwise, summed per date. The accuracy factor is measured per source over dates strictly after that source's hook install day.
|
|
42
|
+
6a. **Explore** (`print_explore`, `--explore DATE`) - Drills into one day: dollar decomposition by token class, then by model / project / session / machine. Each JSONL event carries its `project` (from `cwd`), `session_id`, `source` (host) and `git_branch` for attribution. Per-session JSONL estimate is shown against the hook's authoritative per-session cost; a >15% day-total divergence is flagged so an inflated estimate (incomplete logs, unknown model) is distinguishable from real spend.
|
|
42
43
|
|
|
43
44
|
Entry point: `cctrack:main` (registered in `pyproject.toml` as a console script).
|
|
44
45
|
|
|
@@ -48,7 +49,7 @@ Entry point: `cctrack:main` (registered in `pyproject.toml` as a console script)
|
|
|
48
49
|
- Deduplication is global per host by `requestId` (last wins) - resumed sessions replay history into new files and must not double count. Cross-host `requestId` collisions can't occur (distinct API calls).
|
|
49
50
|
- Hook data is authoritative for **cost only**; every token count comes from the JSONL logs. Never mix the two in one figure - the hook's token fields are snapshots and would double count against JSONL cache columns.
|
|
50
51
|
- Never let one machine's hook coverage suppress another machine's JSONL cost - compose per source.
|
|
51
|
-
- Cumulative counters from the hook are not monotonic
|
|
52
|
+
- Cumulative counters from the hook are not monotonic and don't start at zero when first observed. `compute_hook_deltas` books only observed upward increments (first observation and post-drop levels are baselines, never counted), so a day's hook cost can never exceed the counter's peak and a session's pre-observation history is never mis-dated onto its first-seen day. Test fixtures must start sessions at ~$0 to mirror real statusline behavior.
|
|
52
53
|
- `session-baselines.json` carries each session's rolled-up cumulative across the 90-day retention boundary so straddling sessions aren't re-counted from zero.
|
|
53
54
|
- Build system is Hatch (`hatchling`)
|
|
54
55
|
- Requires Python 3.10+
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cctrack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
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
|
|
@@ -84,6 +84,32 @@ Daily breakdown:
|
|
|
84
84
|
4. Calculates cost using Anthropic's published rates per model
|
|
85
85
|
5. Prints daily breakdown with input/output/cache token splits and monthly summaries
|
|
86
86
|
|
|
87
|
+
## Explore a day
|
|
88
|
+
|
|
89
|
+
When a day's cost looks wrong or surprisingly high, `--explore` breaks it down:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
cctrack --explore 2026-08-03 # a specific day
|
|
93
|
+
cctrack --explore yesterday # or 'today'
|
|
94
|
+
cctrack --explore yesterday --remote air dgx # across machines
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
It reports, for that day:
|
|
98
|
+
|
|
99
|
+
- **Where it went** — the day's cost decomposed by token class (output, 1h/5m cache writes, input, cache reads), so a cache-write blowup or a runaway output loop is obvious at a glance.
|
|
100
|
+
- **By model / by project / by machine** — cost and share for each, so you can see which model, which repo, and which host drove it.
|
|
101
|
+
- **Top sessions** — ranked by cost, each with its project and model, and — where the statusline hook covered that session — its **authoritative** cost next to the JSONL estimate.
|
|
102
|
+
|
|
103
|
+
The day's JSONL-estimated total is shown next to the hook-authoritative total. If they diverge by more than ~15% the report says so: a big gap means the estimate is off (incomplete logs, or a model the rate card doesn't know), not that you actually spent that much. This is the fastest way to tell a real expensive day from an accounting artifact.
|
|
104
|
+
|
|
105
|
+
The project label is the basename of each session's working directory (`cwd`). When that's an unfamiliar name — a container path like `/workspace/team-inbox`, or a directory you don't recognize — trace it to its real location:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
cctrack --explore 2026-08-03 --project team-inbox
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
This restricts the view to that project and prints each session's full `cwd`, git branch, and the log file on disk (which reveals the host directory or the specific Sandy sandbox it came from). The unfiltered view flags this automatically when a `cwd` looks like a container path. `scripts/trace_project.py` and `scripts/diagnose_session_cost.py` are standalone versions of the trace and per-session cost diagnostics.
|
|
112
|
+
|
|
87
113
|
## Remote aggregation
|
|
88
114
|
|
|
89
115
|
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.
|
|
@@ -59,6 +59,32 @@ Daily breakdown:
|
|
|
59
59
|
4. Calculates cost using Anthropic's published rates per model
|
|
60
60
|
5. Prints daily breakdown with input/output/cache token splits and monthly summaries
|
|
61
61
|
|
|
62
|
+
## Explore a day
|
|
63
|
+
|
|
64
|
+
When a day's cost looks wrong or surprisingly high, `--explore` breaks it down:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
cctrack --explore 2026-08-03 # a specific day
|
|
68
|
+
cctrack --explore yesterday # or 'today'
|
|
69
|
+
cctrack --explore yesterday --remote air dgx # across machines
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
It reports, for that day:
|
|
73
|
+
|
|
74
|
+
- **Where it went** — the day's cost decomposed by token class (output, 1h/5m cache writes, input, cache reads), so a cache-write blowup or a runaway output loop is obvious at a glance.
|
|
75
|
+
- **By model / by project / by machine** — cost and share for each, so you can see which model, which repo, and which host drove it.
|
|
76
|
+
- **Top sessions** — ranked by cost, each with its project and model, and — where the statusline hook covered that session — its **authoritative** cost next to the JSONL estimate.
|
|
77
|
+
|
|
78
|
+
The day's JSONL-estimated total is shown next to the hook-authoritative total. If they diverge by more than ~15% the report says so: a big gap means the estimate is off (incomplete logs, or a model the rate card doesn't know), not that you actually spent that much. This is the fastest way to tell a real expensive day from an accounting artifact.
|
|
79
|
+
|
|
80
|
+
The project label is the basename of each session's working directory (`cwd`). When that's an unfamiliar name — a container path like `/workspace/team-inbox`, or a directory you don't recognize — trace it to its real location:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
cctrack --explore 2026-08-03 --project team-inbox
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
This restricts the view to that project and prints each session's full `cwd`, git branch, and the log file on disk (which reveals the host directory or the specific Sandy sandbox it came from). The unfiltered view flags this automatically when a `cwd` looks like a container path. `scripts/trace_project.py` and `scripts/diagnose_session_cost.py` are standalone versions of the trace and per-session cost diagnostics.
|
|
87
|
+
|
|
62
88
|
## Remote aggregation
|
|
63
89
|
|
|
64
90
|
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.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Diagnose a cctrack --explore day and show what each session really spent.
|
|
3
|
+
|
|
4
|
+
The hook's `total_cost_usd` is a session-lifetime cumulative that (a) can be
|
|
5
|
+
large the moment cctrack first sees a long-running session, and (b) drops on
|
|
6
|
+
resume/compaction. cctrack 0.6.0 mis-attributed both: it booked a session's
|
|
7
|
+
whole pre-existing cumulative onto the first day it saw the session, and it
|
|
8
|
+
re-added post-drop levels as fresh spend — so a session whose counter never
|
|
9
|
+
passed $1,086 could be reported at $2,083, and a day's total could balloon far
|
|
10
|
+
above real spend.
|
|
11
|
+
|
|
12
|
+
This script reads the raw statusline data for a date and, per session, prints:
|
|
13
|
+
|
|
14
|
+
* the cost_usd trajectory,
|
|
15
|
+
* BUGGY: what cctrack ≤0.6.0 booked (first level + every post-drop level),
|
|
16
|
+
* FIXED: what cctrack now books — only observed increments, never the
|
|
17
|
+
standing level (== the honest spend cctrack can see on that day).
|
|
18
|
+
|
|
19
|
+
If FIXED is far below BUGGY, the old report was inflated. FIXED is what the
|
|
20
|
+
corrected cctrack reports; any spend from earlier days the hook didn't capture
|
|
21
|
+
is genuinely unattributable and isn't booked on this day.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
python3 scripts/diagnose_session_cost.py <YYYY-MM-DD> [session_prefix ...]
|
|
25
|
+
|
|
26
|
+
# every session that has data that day:
|
|
27
|
+
python3 scripts/diagnose_session_cost.py 2026-08-03
|
|
28
|
+
|
|
29
|
+
# just specific ones (any unique prefix of the session id works):
|
|
30
|
+
python3 scripts/diagnose_session_cost.py 2026-08-03 34dfdf14 63fd9dae 7c112e0a
|
|
31
|
+
|
|
32
|
+
Reads ~/.claude/cctrack and any ~/.sandy/sandboxes/*/claude/cctrack. Stdlib only.
|
|
33
|
+
"""
|
|
34
|
+
import glob
|
|
35
|
+
import json
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def buggy_increment(cur: float, prev: float) -> float:
|
|
41
|
+
"""What cctrack <= 0.6.0 did: a meaningful drop counted the new value in
|
|
42
|
+
full (and prev started at 0, so the first reading counted in full too)."""
|
|
43
|
+
if cur < prev - max(1e-6, abs(prev) * 0.001):
|
|
44
|
+
return max(0.0, cur)
|
|
45
|
+
return max(0.0, cur - prev)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def hook_dirs() -> list[Path]:
|
|
49
|
+
dirs = [Path.home() / ".claude" / "cctrack"]
|
|
50
|
+
dirs += [Path(p) for p in glob.glob(
|
|
51
|
+
str(Path.home() / ".sandy" / "sandboxes" / "*" / "claude" / "cctrack"))]
|
|
52
|
+
return [d for d in dirs if d.exists()]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_rows(date_str: str) -> dict[str, list[tuple[str, float]]]:
|
|
56
|
+
"""Return {session_id: [(ts, cost_usd), ...]} for the given date."""
|
|
57
|
+
rows: dict[str, list[tuple[str, float]]] = {}
|
|
58
|
+
for d in hook_dirs():
|
|
59
|
+
for f in glob.glob(str(d / f"statusline-{date_str}.jsonl")):
|
|
60
|
+
try:
|
|
61
|
+
lines = Path(f).read_text(errors="replace").splitlines()
|
|
62
|
+
except OSError:
|
|
63
|
+
continue
|
|
64
|
+
for line in lines:
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if not line:
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
e = json.loads(line)
|
|
70
|
+
except (json.JSONDecodeError, ValueError):
|
|
71
|
+
continue
|
|
72
|
+
sid = e.get("session_id", "")
|
|
73
|
+
ts = e.get("_ts", "")
|
|
74
|
+
if not sid or not ts:
|
|
75
|
+
continue
|
|
76
|
+
rows.setdefault(sid, []).append((ts, float(e.get("cost_usd", 0) or 0)))
|
|
77
|
+
return rows
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def analyze(sid: str, seq: list[tuple[str, float]]) -> dict:
|
|
81
|
+
seq.sort()
|
|
82
|
+
costs = [c for _, c in seq]
|
|
83
|
+
|
|
84
|
+
# BUGGY (<= 0.6.0): prev starts at 0; drops re-add the full new level.
|
|
85
|
+
buggy = prev = 0.0
|
|
86
|
+
drops = 0
|
|
87
|
+
for c in costs:
|
|
88
|
+
if c < prev - max(1e-6, abs(prev) * 0.001):
|
|
89
|
+
drops += 1
|
|
90
|
+
buggy += buggy_increment(c, prev)
|
|
91
|
+
prev = c
|
|
92
|
+
|
|
93
|
+
# FIXED: first reading is a baseline (not counted); a drop rebaselines
|
|
94
|
+
# (not counted); only genuine upward increments are booked.
|
|
95
|
+
fixed = 0.0
|
|
96
|
+
prev = None
|
|
97
|
+
for c in costs:
|
|
98
|
+
if prev is None:
|
|
99
|
+
prev = c
|
|
100
|
+
elif c < prev - max(1e-6, abs(prev) * 0.001):
|
|
101
|
+
prev = c
|
|
102
|
+
else:
|
|
103
|
+
fixed += c - prev
|
|
104
|
+
prev = c
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"sid": sid,
|
|
108
|
+
"renders": len(costs),
|
|
109
|
+
"min": min(costs),
|
|
110
|
+
"max": max(costs),
|
|
111
|
+
"first": costs[0],
|
|
112
|
+
"last": costs[-1],
|
|
113
|
+
"drops": drops,
|
|
114
|
+
"buggy": buggy,
|
|
115
|
+
"fixed": fixed,
|
|
116
|
+
"costs": costs,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def main() -> int:
|
|
121
|
+
if len(sys.argv) < 2:
|
|
122
|
+
print(__doc__)
|
|
123
|
+
return 2
|
|
124
|
+
|
|
125
|
+
date_str = sys.argv[1]
|
|
126
|
+
prefixes = sys.argv[2:]
|
|
127
|
+
|
|
128
|
+
rows = load_rows(date_str)
|
|
129
|
+
if not rows:
|
|
130
|
+
print(f"No statusline data found for {date_str} in:")
|
|
131
|
+
for d in hook_dirs() or ["(no hook directories exist)"]:
|
|
132
|
+
print(f" {d}")
|
|
133
|
+
return 1
|
|
134
|
+
|
|
135
|
+
selected = {sid: seq for sid, seq in rows.items()
|
|
136
|
+
if not prefixes or any(sid.startswith(p) for p in prefixes)}
|
|
137
|
+
if not selected:
|
|
138
|
+
print(f"No sessions matching {prefixes} on {date_str}. "
|
|
139
|
+
f"Sessions present: {', '.join(s[:12] for s in rows)}")
|
|
140
|
+
return 1
|
|
141
|
+
|
|
142
|
+
results = [analyze(sid, seq) for sid, seq in selected.items()]
|
|
143
|
+
results.sort(key=lambda r: -r["buggy"])
|
|
144
|
+
|
|
145
|
+
grand_buggy = grand_fixed = 0.0
|
|
146
|
+
for r in results:
|
|
147
|
+
grand_buggy += r["buggy"]
|
|
148
|
+
grand_fixed += r["fixed"]
|
|
149
|
+
inflation = (r["buggy"] / r["fixed"]) if r["fixed"] > 0.005 else float("inf")
|
|
150
|
+
tag = "" if inflation < 1.15 else f" ← inflated {inflation:.0f}×" if inflation != float("inf") else " ← booked from nothing"
|
|
151
|
+
print(f"\n=== {r['sid'][:12]} ({r['renders']} renders) ===")
|
|
152
|
+
print(f" cost_usd first={r['first']:.2f} min={r['min']:.2f} "
|
|
153
|
+
f"max={r['max']:.2f} last={r['last']:.2f} drops={r['drops']}")
|
|
154
|
+
print(f" BUGGY (cctrack ≤0.6.0 booked on this day): ${r['buggy']:.2f}{tag}")
|
|
155
|
+
print(f" FIXED (real observed spend this day): ${r['fixed']:.2f}")
|
|
156
|
+
shown = " ".join(f"{c:.1f}" for c in r["costs"][:40])
|
|
157
|
+
print(f" trajectory: {shown}" + (" …" if len(r["costs"]) > 40 else ""))
|
|
158
|
+
|
|
159
|
+
print(f"\n{'─' * 60}")
|
|
160
|
+
print(f"selected sessions — buggy total: ${grand_buggy:.2f} fixed total: ${grand_fixed:.2f}")
|
|
161
|
+
if grand_fixed > 0.005 and grand_buggy / grand_fixed > 1.15:
|
|
162
|
+
print(f"The old report over-stated these sessions by "
|
|
163
|
+
f"{grand_buggy / grand_fixed:.1f}×. The FIXED column is the honest")
|
|
164
|
+
print("spend cctrack can see for this day. Any earlier-day spend the hook")
|
|
165
|
+
print("never captured is unattributable and is not booked here.")
|
|
166
|
+
else:
|
|
167
|
+
print("Buggy and fixed agree — these sessions were attributed correctly.")
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
sys.exit(main())
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Trace where a cctrack --explore project label actually comes from.
|
|
3
|
+
|
|
4
|
+
`--explore` labels each session by the basename of the `cwd` recorded on its
|
|
5
|
+
JSONL events (e.g. a cwd of /workspace/team-inbox shows as "team-inbox"). That
|
|
6
|
+
basename can be a container/automation path that doesn't match any sandbox or
|
|
7
|
+
directory you recognize. This script shows the FULL cwd, the git branch, the
|
|
8
|
+
session id, and the exact log file on disk for a project or session, so you can
|
|
9
|
+
see where the events physically are.
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
python3 scripts/trace_project.py [YYYY-MM-DD] [name_or_prefix ...]
|
|
13
|
+
|
|
14
|
+
# everything logged yesterday, grouped by project:
|
|
15
|
+
python3 scripts/trace_project.py 2026-08-03
|
|
16
|
+
|
|
17
|
+
# just the mystery project (matches project basename OR session-id prefix):
|
|
18
|
+
python3 scripts/trace_project.py 2026-08-03 team-inbox
|
|
19
|
+
|
|
20
|
+
# a whole date range is not supported; omit the date to scan all logs:
|
|
21
|
+
python3 scripts/trace_project.py team-inbox
|
|
22
|
+
|
|
23
|
+
Scans ~/.claude/projects and ~/.sandy/sandboxes/*/... for *.jsonl. Stdlib only.
|
|
24
|
+
"""
|
|
25
|
+
import glob
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
from collections import defaultdict
|
|
29
|
+
from datetime import datetime
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_date(tok: str) -> bool:
|
|
34
|
+
try:
|
|
35
|
+
datetime.strptime(tok, "%Y-%m-%d")
|
|
36
|
+
return True
|
|
37
|
+
except ValueError:
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def scan_dirs() -> list[Path]:
|
|
42
|
+
home = Path.home()
|
|
43
|
+
roots = [home / ".claude" / "projects"]
|
|
44
|
+
sandy = home / ".sandy" / "sandboxes"
|
|
45
|
+
if sandy.exists():
|
|
46
|
+
roots.append(sandy)
|
|
47
|
+
files: list[Path] = []
|
|
48
|
+
for root in roots:
|
|
49
|
+
if root.exists():
|
|
50
|
+
files += [Path(p) for p in glob.glob(str(root / "**" / "*.jsonl"), recursive=True)]
|
|
51
|
+
return files
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main() -> int:
|
|
55
|
+
args = sys.argv[1:]
|
|
56
|
+
date_str = None
|
|
57
|
+
if args and is_date(args[0]):
|
|
58
|
+
date_str = args[0]
|
|
59
|
+
args = args[1:]
|
|
60
|
+
filters = args # project basenames or session-id prefixes
|
|
61
|
+
|
|
62
|
+
def matches(project: str, sid: str) -> bool:
|
|
63
|
+
if not filters:
|
|
64
|
+
return True
|
|
65
|
+
return any(project == f or project.endswith(f) or sid.startswith(f) for f in filters)
|
|
66
|
+
|
|
67
|
+
# key: (cwd, sessionId, file) -> {count, models, branches, first, last}
|
|
68
|
+
groups: dict[tuple, dict] = defaultdict(
|
|
69
|
+
lambda: {"count": 0, "models": set(), "branches": set(), "first": "", "last": ""})
|
|
70
|
+
|
|
71
|
+
files = scan_dirs()
|
|
72
|
+
for f in files:
|
|
73
|
+
try:
|
|
74
|
+
lines = f.read_text(errors="replace").splitlines()
|
|
75
|
+
except OSError:
|
|
76
|
+
continue
|
|
77
|
+
for line in lines:
|
|
78
|
+
line = line.strip()
|
|
79
|
+
if not line:
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
e = json.loads(line)
|
|
83
|
+
except (json.JSONDecodeError, ValueError):
|
|
84
|
+
continue
|
|
85
|
+
if e.get("type") != "assistant":
|
|
86
|
+
continue
|
|
87
|
+
ts = e.get("timestamp", "")
|
|
88
|
+
if date_str and not ts.startswith(date_str):
|
|
89
|
+
continue
|
|
90
|
+
cwd = e.get("cwd", "")
|
|
91
|
+
project = Path(cwd).name if cwd else "(unknown)"
|
|
92
|
+
sid = e.get("sessionId", "")
|
|
93
|
+
if not matches(project, sid):
|
|
94
|
+
continue
|
|
95
|
+
g = groups[(cwd, sid, str(f))]
|
|
96
|
+
g["count"] += 1
|
|
97
|
+
model = e.get("message", {}).get("model", "")
|
|
98
|
+
if model:
|
|
99
|
+
g["models"].add(model)
|
|
100
|
+
if e.get("gitBranch"):
|
|
101
|
+
g["branches"].add(e["gitBranch"])
|
|
102
|
+
if ts:
|
|
103
|
+
g["first"] = ts if not g["first"] else min(g["first"], ts)
|
|
104
|
+
g["last"] = max(g["last"], ts)
|
|
105
|
+
|
|
106
|
+
if not groups:
|
|
107
|
+
where = f" on {date_str}" if date_str else ""
|
|
108
|
+
filt = f" matching {filters}" if filters else ""
|
|
109
|
+
print(f"No assistant events{filt}{where} found in ~/.claude/projects or ~/.sandy/sandboxes.")
|
|
110
|
+
return 1
|
|
111
|
+
|
|
112
|
+
# Group the display by project basename.
|
|
113
|
+
by_project: dict[str, list[tuple]] = defaultdict(list)
|
|
114
|
+
for (cwd, sid, f), g in groups.items():
|
|
115
|
+
by_project[Path(cwd).name if cwd else "(unknown)"].append((cwd, sid, f, g))
|
|
116
|
+
|
|
117
|
+
for project in sorted(by_project):
|
|
118
|
+
rows = by_project[project]
|
|
119
|
+
total = sum(g["count"] for *_ , g in rows)
|
|
120
|
+
print(f"\n=== project '{project}' ({total} assistant events, "
|
|
121
|
+
f"{len(set(s for _, s, _, _ in rows))} sessions) ===")
|
|
122
|
+
# distinct cwds under this basename (reveals collisions / container paths)
|
|
123
|
+
for cwd in sorted(set(c for c, _, _, _ in rows)):
|
|
124
|
+
print(f" cwd: {cwd or '(empty)'}")
|
|
125
|
+
for cwd, sid, f, g in sorted(rows, key=lambda r: -r[3]["count"]):
|
|
126
|
+
branch = ",".join(sorted(g["branches"])) or "-"
|
|
127
|
+
models = ",".join(sorted(m.replace("claude-", "") for m in g["models"])) or "-"
|
|
128
|
+
print(f" session {sid[:12]} {g['count']:>4} ev branch={branch} model={models}")
|
|
129
|
+
print(f" {g['first'][:19]} … {g['last'][:19]}")
|
|
130
|
+
print(f" file: {f}")
|
|
131
|
+
print()
|
|
132
|
+
print("The `cwd` line is the working directory Claude Code recorded — that is")
|
|
133
|
+
print("the real source of the project label. If it's a container/automation")
|
|
134
|
+
print("path (e.g. /workspace/...), the run happened inside that environment,")
|
|
135
|
+
print("not a host directory or sandbox of that name.")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
sys.exit(main())
|