@softspark/ai-toolkit 3.1.1 → 3.2.1

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,264 @@
1
+ #!/usr/bin/env python3
2
+ """Read Claude Code session JSONL files and report real token usage.
3
+
4
+ Claude Code writes one JSONL line per message to:
5
+ ~/.claude/projects/<sanitized-cwd>/<session-id>.jsonl
6
+
7
+ Each message contains a `usage` block with input_tokens, output_tokens,
8
+ cache_creation_input_tokens, and cache_read_input_tokens. This script
9
+ parses those blocks and emits aggregate or per-session reports.
10
+
11
+ Usage:
12
+ python3 scripts/session_token_stats.py [options]
13
+
14
+ Options:
15
+ --session PATH Path to a single session JSONL (default: latest)
16
+ --project-dir PATH Look only in this project's session dir
17
+ --claude-dir PATH Override ~/.claude (for testing)
18
+ --since DURATION Filter to messages newer than 7d|24h|30m
19
+ --json Emit JSON instead of human-readable text
20
+ --statusline One-line statusline-friendly output
21
+ --baseline FILE Compare current session vs a baseline JSON file
22
+
23
+ Exit codes:
24
+ 0 success
25
+ 1 no sessions found
26
+ 2 invalid arguments or filesystem error
27
+
28
+ Stdlib-only. Designed to run in the statusline hot path (<50ms target).
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import argparse
33
+ import json
34
+ import os
35
+ import re
36
+ import sys
37
+ from datetime import datetime, timedelta, timezone
38
+ from pathlib import Path
39
+ from typing import Any, Iterator
40
+
41
+
42
+ DEFAULT_CLAUDE_DIR = Path.home() / ".claude"
43
+ DURATION_RE = re.compile(r"^(\d+)([smhd])$")
44
+
45
+
46
+ def parse_duration(value: str) -> timedelta:
47
+ """Parse 7d, 24h, 30m, 90s into a timedelta. Raises ValueError on bad input."""
48
+ match = DURATION_RE.match(value)
49
+ if not match:
50
+ raise ValueError(f"invalid duration: {value!r}; expected e.g. 7d, 24h, 30m")
51
+ n = int(match.group(1))
52
+ unit = match.group(2)
53
+ if unit == "s":
54
+ return timedelta(seconds=n)
55
+ if unit == "m":
56
+ return timedelta(minutes=n)
57
+ if unit == "h":
58
+ return timedelta(hours=n)
59
+ return timedelta(days=n)
60
+
61
+
62
+ def sanitize_cwd(cwd: str) -> str:
63
+ """Claude Code mangles `/` to `-` and prefixes with `-` for project dir name."""
64
+ return "-" + cwd.replace("/", "-").lstrip("-")
65
+
66
+
67
+ def find_project_dir(claude_dir: Path, cwd: str | None = None) -> Path | None:
68
+ """Locate the project sessions directory for cwd, or None if missing."""
69
+ projects_root = claude_dir / "projects"
70
+ if not projects_root.is_dir():
71
+ return None
72
+ if cwd:
73
+ candidate = projects_root / sanitize_cwd(cwd)
74
+ return candidate if candidate.is_dir() else None
75
+ return projects_root
76
+
77
+
78
+ def find_latest_session(search_dir: Path) -> Path | None:
79
+ """Return the most-recently-modified .jsonl under search_dir, or None."""
80
+ if not search_dir.is_dir():
81
+ return None
82
+ candidates = list(search_dir.rglob("*.jsonl"))
83
+ if not candidates:
84
+ return None
85
+ return max(candidates, key=lambda p: p.stat().st_mtime)
86
+
87
+
88
+ def iter_messages(session_file: Path) -> Iterator[dict[str, Any]]:
89
+ """Yield parsed JSON messages from a session file. Skips malformed lines.
90
+
91
+ Lines without a `usage` field are skipped without parsing — most lines in a
92
+ Claude Code session JSONL are user inputs, tool results, etc. that have no
93
+ token cost. Filtering at the substring level avoids json.loads on ~80% of
94
+ the file and cuts parse time roughly in half on long sessions.
95
+ """
96
+ try:
97
+ with open(session_file, encoding="utf-8") as f:
98
+ for line in f:
99
+ line = line.strip()
100
+ if not line or '"usage"' not in line:
101
+ continue
102
+ try:
103
+ yield json.loads(line)
104
+ except json.JSONDecodeError:
105
+ continue
106
+ except OSError:
107
+ return
108
+
109
+
110
+ def extract_usage(message: dict[str, Any]) -> dict[str, int] | None:
111
+ """Pull usage block from a message, normalizing field names. Returns None if absent."""
112
+ usage = None
113
+ if isinstance(message.get("message"), dict):
114
+ usage = message["message"].get("usage")
115
+ if usage is None:
116
+ usage = message.get("usage")
117
+ if not isinstance(usage, dict):
118
+ return None
119
+ return {
120
+ "input": int(usage.get("input_tokens") or 0),
121
+ "output": int(usage.get("output_tokens") or 0),
122
+ "cache_create": int(usage.get("cache_creation_input_tokens") or 0),
123
+ "cache_read": int(usage.get("cache_read_input_tokens") or 0),
124
+ }
125
+
126
+
127
+ def message_timestamp(message: dict[str, Any]) -> datetime | None:
128
+ """Extract message timestamp. Tries common field names."""
129
+ for key in ("timestamp", "created_at", "time"):
130
+ value = message.get(key)
131
+ if isinstance(value, str):
132
+ try:
133
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
134
+ except ValueError:
135
+ continue
136
+ return None
137
+
138
+
139
+ def aggregate(
140
+ session_file: Path,
141
+ since: timedelta | None = None,
142
+ ) -> dict[str, Any]:
143
+ """Sum token usage across a session, optionally filtered by recency."""
144
+ cutoff = datetime.now(timezone.utc) - since if since else None
145
+ totals = {"input": 0, "output": 0, "cache_create": 0, "cache_read": 0}
146
+ message_count = 0
147
+ counted = 0
148
+
149
+ for message in iter_messages(session_file):
150
+ message_count += 1
151
+ if cutoff is not None:
152
+ ts = message_timestamp(message)
153
+ if ts is None or ts < cutoff:
154
+ continue
155
+ usage = extract_usage(message)
156
+ if usage is None:
157
+ continue
158
+ for key, value in usage.items():
159
+ totals[key] += value
160
+ counted += 1
161
+
162
+ totals["total"] = totals["input"] + totals["output"]
163
+ totals["total_with_cache"] = (
164
+ totals["total"] + totals["cache_create"] + totals["cache_read"]
165
+ )
166
+ # novel_input = tokens the model genuinely processed afresh (literal input
167
+ # + freshly cached prompt prefix). Excludes cache_read which is recycled.
168
+ # Used by statusline to render "i/o:novel_input/output".
169
+ totals["novel_input"] = totals["input"] + totals["cache_create"]
170
+ totals["displayed_total"] = totals["novel_input"] + totals["output"]
171
+ totals["messages_total"] = message_count
172
+ totals["messages_counted"] = counted
173
+ return totals
174
+
175
+
176
+ def format_human(totals: dict[str, Any], session_file: Path) -> str:
177
+ lines = [
178
+ f"session: {session_file.name}",
179
+ f" input {totals['input']:>10,}",
180
+ f" output {totals['output']:>10,}",
181
+ f" cache_create {totals['cache_create']:>10,}",
182
+ f" cache_read {totals['cache_read']:>10,}",
183
+ f" total {totals['total']:>10,}",
184
+ f" with cache {totals['total_with_cache']:>10,}",
185
+ f" messages {totals['messages_counted']}/{totals['messages_total']}",
186
+ ]
187
+ return "\n".join(lines)
188
+
189
+
190
+ def format_statusline(totals: dict[str, Any], baseline: dict[str, Any] | None) -> str:
191
+ total = totals["total"]
192
+ if total >= 1000:
193
+ rendered = f"{total / 1000:.1f}k"
194
+ else:
195
+ rendered = str(total)
196
+ output = f"[ai-toolkit] session: {rendered}"
197
+ if baseline:
198
+ baseline_total = baseline.get("total")
199
+ if isinstance(baseline_total, int) and baseline_total > 0:
200
+ delta = (total - baseline_total) / baseline_total
201
+ arrow = "↓" if delta < 0 else "↑" if delta > 0 else "·"
202
+ output += f" · trend: {arrow}{abs(delta) * 100:.0f}%"
203
+ return output
204
+
205
+
206
+ def main() -> int:
207
+ parser = argparse.ArgumentParser(description=__doc__)
208
+ parser.add_argument("--session", type=Path, help="Path to a session JSONL")
209
+ parser.add_argument("--project-dir", type=Path, help="Look only in this project's session dir")
210
+ parser.add_argument("--claude-dir", type=Path, default=DEFAULT_CLAUDE_DIR)
211
+ parser.add_argument("--cwd", type=str, default=os.getcwd())
212
+ parser.add_argument("--since", type=str)
213
+ parser.add_argument("--json", action="store_true")
214
+ parser.add_argument("--statusline", action="store_true")
215
+ parser.add_argument("--baseline", type=Path, help="JSON baseline for trend comparison")
216
+ args = parser.parse_args()
217
+
218
+ try:
219
+ since = parse_duration(args.since) if args.since else None
220
+ except ValueError as exc:
221
+ print(f"error: {exc}", file=sys.stderr)
222
+ return 2
223
+
224
+ if args.session:
225
+ session_file: Path | None = args.session
226
+ elif args.project_dir:
227
+ session_file = find_latest_session(args.project_dir)
228
+ else:
229
+ project_dir = find_project_dir(args.claude_dir, args.cwd)
230
+ if project_dir is None:
231
+ print("error: no Claude Code project directory found", file=sys.stderr)
232
+ return 1
233
+ session_file = find_latest_session(project_dir)
234
+
235
+ if not session_file or not session_file.is_file():
236
+ print("error: no session file found", file=sys.stderr)
237
+ return 1
238
+
239
+ totals = aggregate(session_file, since=since)
240
+
241
+ baseline = None
242
+ if args.baseline and args.baseline.is_file():
243
+ try:
244
+ baseline = json.loads(args.baseline.read_text())
245
+ except (json.JSONDecodeError, OSError):
246
+ baseline = None
247
+
248
+ if args.statusline:
249
+ print(format_statusline(totals, baseline))
250
+ return 0
251
+
252
+ if args.json:
253
+ payload = {"session": str(session_file), "totals": totals}
254
+ if baseline:
255
+ payload["baseline"] = baseline
256
+ print(json.dumps(payload, indent=2))
257
+ return 0
258
+
259
+ print(format_human(totals, session_file))
260
+ return 0
261
+
262
+
263
+ if __name__ == "__main__":
264
+ sys.exit(main())