tokenol 0.1.0__py3-none-any.whl

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.
Files changed (47) hide show
  1. tokenol/__init__.py +3 -0
  2. tokenol/assumptions.py +51 -0
  3. tokenol/cli.py +408 -0
  4. tokenol/enums.py +26 -0
  5. tokenol/ingest/__init__.py +0 -0
  6. tokenol/ingest/builder.py +88 -0
  7. tokenol/ingest/discovery.py +43 -0
  8. tokenol/ingest/parser.py +162 -0
  9. tokenol/ingest/schema.py +10 -0
  10. tokenol/metrics/__init__.py +0 -0
  11. tokenol/metrics/context.py +99 -0
  12. tokenol/metrics/cost.py +145 -0
  13. tokenol/metrics/history.py +80 -0
  14. tokenol/metrics/patterns.py +246 -0
  15. tokenol/metrics/rollups.py +342 -0
  16. tokenol/metrics/thresholds.py +87 -0
  17. tokenol/metrics/verdicts.py +37 -0
  18. tokenol/metrics/windows.py +103 -0
  19. tokenol/model/__init__.py +0 -0
  20. tokenol/model/events.py +116 -0
  21. tokenol/model/pricing.py +112 -0
  22. tokenol/model/registry.py +57 -0
  23. tokenol/report/__init__.py +0 -0
  24. tokenol/report/text.py +352 -0
  25. tokenol/serve/__init__.py +0 -0
  26. tokenol/serve/app.py +312 -0
  27. tokenol/serve/prefs.py +68 -0
  28. tokenol/serve/session_detail.py +222 -0
  29. tokenol/serve/state.py +1370 -0
  30. tokenol/serve/static/app.js +1205 -0
  31. tokenol/serve/static/chart.js +230 -0
  32. tokenol/serve/static/components.js +159 -0
  33. tokenol/serve/static/day.html +102 -0
  34. tokenol/serve/static/day.js +108 -0
  35. tokenol/serve/static/index.html +332 -0
  36. tokenol/serve/static/model.html +51 -0
  37. tokenol/serve/static/model.js +57 -0
  38. tokenol/serve/static/project.html +137 -0
  39. tokenol/serve/static/project.js +240 -0
  40. tokenol/serve/static/session.html +297 -0
  41. tokenol/serve/static/session.js +848 -0
  42. tokenol/serve/static/styles.css +797 -0
  43. tokenol/serve/streaming.py +63 -0
  44. tokenol-0.1.0.dist-info/METADATA +253 -0
  45. tokenol-0.1.0.dist-info/RECORD +47 -0
  46. tokenol-0.1.0.dist-info/WHEEL +4 -0
  47. tokenol-0.1.0.dist-info/entry_points.txt +2 -0
tokenol/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """tokenol — Claude Code usage & efficiency audit tool."""
2
+
3
+ __version__ = "0.1.0.dev0"
tokenol/assumptions.py ADDED
@@ -0,0 +1,51 @@
1
+ """AssumptionTag recorder — collects fired heuristics for report footer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+
7
+ from tokenol.enums import AssumptionTag
8
+
9
+
10
+ class AssumptionRecorder:
11
+ """Thread-unsafe (single-threaded CLI use only) assumption counter."""
12
+
13
+ def __init__(self) -> None:
14
+ self._counts: Counter[AssumptionTag] = Counter()
15
+
16
+ def record(self, tags: list[AssumptionTag]) -> None:
17
+ for t in tags:
18
+ self._counts[t] += 1
19
+
20
+ def fired(self) -> dict[AssumptionTag, int]:
21
+ return dict(self._counts)
22
+
23
+ def summary_lines(self) -> list[str]:
24
+ if not self._counts:
25
+ return []
26
+ lines = ["Assumptions fired:"]
27
+ for tag, count in sorted(self._counts.items(), key=lambda x: x[0].value):
28
+ lines.append(f" {tag.value}: {count:,} event(s)")
29
+ return lines
30
+
31
+ def reset(self) -> None:
32
+ self._counts.clear()
33
+
34
+
35
+ _recorder = AssumptionRecorder()
36
+
37
+
38
+ def record(tags: list[AssumptionTag]) -> None:
39
+ _recorder.record(tags)
40
+
41
+
42
+ def fired() -> dict[AssumptionTag, int]:
43
+ return _recorder.fired()
44
+
45
+
46
+ def summary_lines() -> list[str]:
47
+ return _recorder.summary_lines()
48
+
49
+
50
+ def reset() -> None:
51
+ _recorder.reset()
tokenol/cli.py ADDED
@@ -0,0 +1,408 @@
1
+ """tokenol — Claude Code usage & efficiency audit CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ import subprocess
8
+ from datetime import date, datetime, timedelta, timezone
9
+ from enum import Enum
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from tokenol import assumptions as assumption_recorder
15
+ from tokenol.ingest.builder import build_sessions, build_turns
16
+ from tokenol.ingest.discovery import find_jsonl_files, get_config_dirs
17
+ from tokenol.metrics.cost import rollup_by_date, rollup_by_hour
18
+ from tokenol.metrics.rollups import (
19
+ build_model_rollups,
20
+ build_project_rollups,
21
+ build_session_rollup,
22
+ )
23
+ from tokenol.metrics.verdicts import compute_verdict
24
+ from tokenol.metrics.windows import align_windows, project_window
25
+ from tokenol.report.text import (
26
+ print_daily,
27
+ print_hourly,
28
+ print_live_full,
29
+ print_models,
30
+ print_projects,
31
+ print_sessions,
32
+ )
33
+
34
+ app = typer.Typer(
35
+ name="tokenol",
36
+ help="Audit Claude Code JSONL logs: cost, cache health, blow-up detection.",
37
+ add_completion=False,
38
+ )
39
+
40
+ # Single process-wide flag — set by the --all-projects option on each command.
41
+ # Simpler than threading it through every helper signature.
42
+ _SCAN_ALL: bool = False
43
+
44
+ console = Console(stderr=False)
45
+ err = Console(stderr=True)
46
+
47
+
48
+ class LogLevel(str, Enum):
49
+ debug = "debug"
50
+ info = "info"
51
+ warning = "warning"
52
+
53
+
54
+ class SortKey(str, Enum):
55
+ cost = "cost"
56
+ input = "input"
57
+ output = "output"
58
+ cache_read = "cache_read"
59
+ turns = "turns"
60
+ max_input = "max_input"
61
+ duration = "duration"
62
+
63
+
64
+ def _parse_since(since: str) -> date:
65
+ """Parse e.g. '14d', '30d', '2026-04-01' into a date."""
66
+ since = since.strip()
67
+ if since.endswith("d"):
68
+ days = int(since[:-1])
69
+ return (datetime.now(tz=timezone.utc) - timedelta(days=days)).date()
70
+ return date.fromisoformat(since)
71
+
72
+
73
+ def _parse_last(last: str) -> timedelta:
74
+ """Parse lookback duration like '20m', '2h', '30s'. Rejects bare numbers and zero."""
75
+ m = re.fullmatch(r"([1-9]\d*)([mhs])", last.strip())
76
+ if not m:
77
+ raise typer.BadParameter(
78
+ f"Invalid duration '{last}'. Use a positive number with unit: '20m', '2h', '30s'."
79
+ )
80
+ value = int(m.group(1))
81
+ unit = m.group(2)
82
+ if unit == "m":
83
+ return timedelta(minutes=value)
84
+ if unit == "h":
85
+ return timedelta(hours=value)
86
+ # unit == "s"
87
+ return timedelta(seconds=value)
88
+
89
+
90
+ def _configure_logging(log_level: LogLevel) -> None:
91
+ level = getattr(logging, log_level.value.upper(), logging.INFO)
92
+ logging.basicConfig(level=level)
93
+
94
+
95
+ def _set_scan_scope(all_projects: bool) -> None:
96
+ global _SCAN_ALL
97
+ _SCAN_ALL = all_projects
98
+
99
+
100
+ _ALL_PROJECTS_OPT = typer.Option(
101
+ False,
102
+ "--all-projects",
103
+ "-A",
104
+ help="Scan every ~/.claude* directory, ignoring CLAUDE_CONFIG_DIR.",
105
+ )
106
+
107
+
108
+ def _timedelta_label(td: timedelta) -> str:
109
+ """Convert a timedelta to a short human label: '20min', '2hr', '30sec'."""
110
+ total = int(td.total_seconds())
111
+ if total % 3600 == 0:
112
+ return f"{total // 3600}hr"
113
+ if total % 60 == 0:
114
+ return f"{total // 60}min"
115
+ return f"{total}sec"
116
+
117
+
118
+ def _load_turns(since: date | None = None):
119
+ assumption_recorder.reset()
120
+ dirs = get_config_dirs(all_projects=_SCAN_ALL)
121
+ paths = find_jsonl_files(dirs)
122
+ turns = build_turns(paths)
123
+ if since:
124
+ turns = [t for t in turns if t.timestamp.date() >= since]
125
+ return turns, paths
126
+
127
+
128
+ def _load_turns_and_sessions(since: date | None = None):
129
+ assumption_recorder.reset()
130
+ dirs = get_config_dirs(all_projects=_SCAN_ALL)
131
+ paths = find_jsonl_files(dirs)
132
+ turns = build_turns(paths)
133
+ sessions = build_sessions(turns, paths=paths)
134
+ if since:
135
+ turns = [t for t in turns if t.timestamp.date() >= since]
136
+ sessions = [
137
+ s for s in sessions
138
+ if s.turns and s.turns[-1].timestamp.date() >= since
139
+ ]
140
+ return turns, sessions, paths
141
+
142
+
143
+ @app.command()
144
+ def daily(
145
+ since: str = typer.Option("14d", help="Start date, e.g. '14d' or '2026-04-01'"),
146
+ strict: bool = typer.Option(False, "--strict", help="Error on any assumption fallback"),
147
+ show_assumptions: bool = typer.Option(False, "--show-assumptions", help="Always print assumption footer"),
148
+ log_level: LogLevel = typer.Option(LogLevel.info, "--log-level"), # noqa: B008
149
+ all_projects: bool = _ALL_PROJECTS_OPT, # noqa: B008
150
+ ) -> None:
151
+ """Daily token and cost aggregates."""
152
+ _set_scan_scope(all_projects)
153
+ _configure_logging(log_level)
154
+ since_date = _parse_since(since)
155
+ turns, paths = _load_turns(since=since_date)
156
+ if strict and assumption_recorder.fired():
157
+ raise typer.BadParameter("Assumptions fired and --strict is set.")
158
+ rollups = rollup_by_date(turns, since=since_date)
159
+ print_daily(rollups, console=console, show_assumptions=show_assumptions)
160
+
161
+
162
+ @app.command()
163
+ def hourly(
164
+ day: str | None = typer.Argument(None, help="Date as YYYY-MM-DD (default: today)"),
165
+ strict: bool = typer.Option(False, "--strict"),
166
+ show_assumptions: bool = typer.Option(False, "--show-assumptions"),
167
+ log_level: LogLevel = typer.Option(LogLevel.info, "--log-level"), # noqa: B008
168
+ all_projects: bool = _ALL_PROJECTS_OPT, # noqa: B008
169
+ ) -> None:
170
+ """Hourly token and cost breakdown for one day."""
171
+ _set_scan_scope(all_projects)
172
+ _configure_logging(log_level)
173
+ target = date.fromisoformat(day) if day else date.today()
174
+ turns, paths = _load_turns()
175
+ if strict and assumption_recorder.fired():
176
+ raise typer.BadParameter("Assumptions fired and --strict is set.")
177
+ rollups = rollup_by_hour(turns, target_date=target)
178
+ print_hourly(rollups, console=console, show_assumptions=show_assumptions)
179
+
180
+
181
+ @app.command()
182
+ def live(
183
+ last: str = typer.Option(..., "--last", help="Lookback duration, e.g. '20m', '2h', '30s'"),
184
+ strict: bool = typer.Option(False, "--strict"),
185
+ show_assumptions: bool = typer.Option(False, "--show-assumptions"),
186
+ log_level: LogLevel = typer.Option(LogLevel.info, "--log-level"), # noqa: B008
187
+ all_projects: bool = _ALL_PROJECTS_OPT, # noqa: B008
188
+ ) -> None:
189
+ """Live burn-rate view for the active 5h window."""
190
+ _set_scan_scope(all_projects)
191
+ _configure_logging(log_level)
192
+ lookback = _parse_last(last)
193
+
194
+ turns, paths = _load_turns()
195
+ if strict and assumption_recorder.fired():
196
+ raise typer.BadParameter("Assumptions fired and --strict is set.")
197
+
198
+ now = datetime.now(tz=timezone.utc)
199
+
200
+ # Only consider turns within a reasonable past window (last 10h) to keep it fast
201
+ recent_all = [t for t in turns if t.timestamp >= now - timedelta(hours=10)]
202
+ windows = align_windows(recent_all)
203
+
204
+ if not windows:
205
+ console.print("[yellow]No active window found.[/yellow]")
206
+ raise typer.Exit(code=0)
207
+
208
+ active = windows[-1]
209
+
210
+ # Check if active window is actually current (within 5h of now)
211
+ if active.end < now:
212
+ console.print("[yellow]No active window found (all windows have ended).[/yellow]")
213
+ raise typer.Exit(code=0)
214
+
215
+ proj = project_window(active, now=now, lookback=lookback)
216
+
217
+ cutoff = now - lookback
218
+ recent_turns_count = sum(1 for t in active.turns if t.timestamp >= cutoff)
219
+
220
+ print_live_full(
221
+ active_window=active,
222
+ projection=proj,
223
+ recent_turns_count=recent_turns_count,
224
+ last_label=_timedelta_label(lookback),
225
+ console=console,
226
+ )
227
+
228
+ if proj["over_reference"]:
229
+ raise typer.Exit(code=1)
230
+
231
+
232
+ @app.command()
233
+ def sessions(
234
+ top: int = typer.Option(10, "--top", help="Number of sessions to show"),
235
+ sort: SortKey = typer.Option(SortKey.cost, "--sort", help="Sort key"), # noqa: B008
236
+ since: str = typer.Option("14d", help="Start date, e.g. '14d' or '2026-04-01'"),
237
+ strict: bool = typer.Option(False, "--strict"),
238
+ show_assumptions: bool = typer.Option(False, "--show-assumptions"),
239
+ log_level: LogLevel = typer.Option(LogLevel.info, "--log-level"), # noqa: B008
240
+ all_projects: bool = _ALL_PROJECTS_OPT, # noqa: B008
241
+ ) -> None:
242
+ """Per-session detail table sorted by a chosen metric."""
243
+ _set_scan_scope(all_projects)
244
+ _configure_logging(log_level)
245
+ since_date = _parse_since(since)
246
+ turns, session_list, paths = _load_turns_and_sessions(since=since_date)
247
+
248
+ if strict and assumption_recorder.fired():
249
+ raise typer.BadParameter("Assumptions fired and --strict is set.")
250
+
251
+ # Build rollups and compute verdicts
252
+ rollups = []
253
+ for session in session_list:
254
+ sr = build_session_rollup(session)
255
+ sr.verdict = compute_verdict(sr)
256
+ rollups.append(sr)
257
+
258
+ _sort_attrs = {
259
+ SortKey.cost: lambda sr: sr.cost_usd,
260
+ SortKey.input: lambda sr: float(sr.input_tokens),
261
+ SortKey.output: lambda sr: float(sr.output_tokens),
262
+ SortKey.cache_read: lambda sr: float(sr.cache_read_tokens),
263
+ SortKey.turns: lambda sr: float(sr.turns),
264
+ SortKey.max_input: lambda sr: float(sr.max_turn_input),
265
+ SortKey.duration: lambda sr: (sr.last_ts - sr.first_ts).total_seconds(),
266
+ }
267
+ rollups.sort(key=_sort_attrs[sort], reverse=True)
268
+ rollups = rollups[:top]
269
+
270
+ print_sessions(rollups, console=console, show_assumptions=show_assumptions)
271
+
272
+
273
+ @app.command()
274
+ def projects(
275
+ since: str = typer.Option("14d", help="Start date, e.g. '14d' or '2026-04-01'"),
276
+ strict: bool = typer.Option(False, "--strict"),
277
+ show_assumptions: bool = typer.Option(False, "--show-assumptions"),
278
+ log_level: LogLevel = typer.Option(LogLevel.info, "--log-level"), # noqa: B008
279
+ all_projects: bool = _ALL_PROJECTS_OPT, # noqa: B008
280
+ ) -> None:
281
+ """Per-project rollup (grouped by cwd)."""
282
+ _set_scan_scope(all_projects)
283
+ _configure_logging(log_level)
284
+ since_date = _parse_since(since)
285
+ turns, session_list, paths = _load_turns_and_sessions(since=since_date)
286
+
287
+ if strict and assumption_recorder.fired():
288
+ raise typer.BadParameter("Assumptions fired and --strict is set.")
289
+
290
+ session_rollups = [build_session_rollup(s) for s in session_list]
291
+ project_rollups = build_project_rollups(session_rollups)
292
+ total_cost = sum(pr.cost_usd for pr in project_rollups)
293
+
294
+ print_projects(project_rollups, total_cost=total_cost, console=console, show_assumptions=show_assumptions)
295
+
296
+
297
+ @app.command()
298
+ def models(
299
+ since: str = typer.Option("14d", help="Start date, e.g. '14d' or '2026-04-01'"),
300
+ strict: bool = typer.Option(False, "--strict"),
301
+ show_assumptions: bool = typer.Option(False, "--show-assumptions"),
302
+ log_level: LogLevel = typer.Option(LogLevel.info, "--log-level"), # noqa: B008
303
+ all_projects: bool = _ALL_PROJECTS_OPT, # noqa: B008
304
+ ) -> None:
305
+ """Per-model rollup."""
306
+ _set_scan_scope(all_projects)
307
+ _configure_logging(log_level)
308
+ since_date = _parse_since(since)
309
+ turns, paths = _load_turns(since=since_date)
310
+
311
+ if strict and assumption_recorder.fired():
312
+ raise typer.BadParameter("Assumptions fired and --strict is set.")
313
+
314
+ model_rollups = build_model_rollups(turns)
315
+ print_models(model_rollups, console=console, show_assumptions=show_assumptions)
316
+
317
+
318
+ @app.command()
319
+ def verify(
320
+ since: str = typer.Option("14d"),
321
+ tolerance: float = typer.Option(0.02, help="Max acceptable fractional diff vs ccusage"),
322
+ all_projects: bool = _ALL_PROJECTS_OPT, # noqa: B008
323
+ ) -> None:
324
+ """Cross-check tokenol totals against ccusage (if installed)."""
325
+ _set_scan_scope(all_projects)
326
+ since_date = _parse_since(since)
327
+ turns, paths = _load_turns(since=since_date)
328
+
329
+ our_cost = sum(t.cost_usd for t in turns)
330
+
331
+ # Try ccusage
332
+ try:
333
+ result = subprocess.run(
334
+ ["ccusage", "--json"],
335
+ capture_output=True, text=True, timeout=30,
336
+ )
337
+ if result.returncode != 0:
338
+ err.print("[yellow]ccusage returned non-zero exit — skipping comparison[/yellow]")
339
+ console.print(f"tokenol total: ${our_cost:.4f}")
340
+ return
341
+ import json
342
+ data = json.loads(result.stdout)
343
+ # ccusage JSON structure: list of day objects or a totals key
344
+ ccusage_cost = 0.0
345
+ if isinstance(data, list):
346
+ for row in data:
347
+ ccusage_cost += row.get("cost", 0) or row.get("totalCost", 0)
348
+ elif isinstance(data, dict):
349
+ ccusage_cost = data.get("totalCost", data.get("cost", 0))
350
+
351
+ diff = abs(our_cost - ccusage_cost) / max(ccusage_cost, 1e-9)
352
+ status = "OK" if diff <= tolerance else "FAIL"
353
+ console.print(f"tokenol: ${our_cost:.4f} ccusage: ${ccusage_cost:.4f} diff: {diff:.1%} [{status}]")
354
+ if status == "FAIL":
355
+ raise typer.Exit(code=1)
356
+
357
+ except FileNotFoundError:
358
+ err.print("[dim]ccusage not on PATH — showing tokenol total only[/dim]")
359
+ console.print(f"tokenol total: ${our_cost:.4f}")
360
+
361
+
362
+ @app.command()
363
+ def serve(
364
+ port: int = typer.Option(8787, "--port", help="TCP port to bind."),
365
+ tick: str = typer.Option("5s", "--tick", help="SSE tick interval, e.g. '2s', '5s'."),
366
+ reference: float = typer.Option(50.0, "--reference", help="$/window alarm threshold."),
367
+ open_browser: bool = typer.Option(False, "--open", help="Open dashboard in default browser."),
368
+ all_projects: bool = _ALL_PROJECTS_OPT, # noqa: B008
369
+ log_level: LogLevel = typer.Option(LogLevel.info, "--log-level"), # noqa: B008
370
+ ) -> None:
371
+ """Start the live dashboard server."""
372
+ _configure_logging(log_level)
373
+ tick_td = _parse_last(tick)
374
+ tick_seconds = max(1, int(tick_td.total_seconds()))
375
+
376
+ try:
377
+ import uvicorn
378
+
379
+ from tokenol.serve.app import ServerConfig, create_app
380
+ except ImportError:
381
+ err.print(
382
+ "[red]tokenol[serve] extras not installed.[/red] "
383
+ "Run: pip install 'tokenol[serve]'"
384
+ )
385
+ raise typer.Exit(code=1) from None
386
+
387
+ config = ServerConfig(
388
+ all_projects=all_projects,
389
+ reference_usd=reference,
390
+ tick_seconds=tick_seconds,
391
+ )
392
+ application = create_app(config)
393
+ url = f"http://127.0.0.1:{port}"
394
+ err.print(f"tokenol dashboard → {url}")
395
+
396
+ if open_browser:
397
+ import webbrowser
398
+ webbrowser.open(url)
399
+
400
+ uvicorn.run(application, host="127.0.0.1", port=port, log_level=log_level.value.lower())
401
+
402
+
403
+ def main() -> None:
404
+ app()
405
+
406
+
407
+ if __name__ == "__main__":
408
+ main()
tokenol/enums.py ADDED
@@ -0,0 +1,26 @@
1
+ from enum import Enum
2
+
3
+
4
+ class BlockType(str, Enum):
5
+ TEXT = "text"
6
+ THINKING = "thinking"
7
+ TOOL_USE = "tool_use"
8
+ TOOL_RESULT = "tool_result"
9
+ REDACTED_THINKING = "redacted_thinking"
10
+ OTHER = "other"
11
+
12
+
13
+ class BlowUpVerdict(str, Enum):
14
+ OK = "OK"
15
+ CONTEXT_CREEP = "CONTEXT_CREEP"
16
+ SIDECHAIN_HEAVY = "SIDECHAIN_HEAVY"
17
+ TOOL_ERROR_STORM = "TOOL_ERROR_STORM"
18
+ RUNAWAY_WINDOW = "RUNAWAY_WINDOW"
19
+
20
+
21
+ class AssumptionTag(str, Enum):
22
+ WINDOW_BOUNDARY_HEURISTIC = "WINDOW_BOUNDARY_HEURISTIC"
23
+ UNKNOWN_MODEL_FALLBACK = "UNKNOWN_MODEL_FALLBACK"
24
+ DEDUP_PASSTHROUGH = "DEDUP_PASSTHROUGH"
25
+ INTERRUPTED_TURN_SKIPPED = "INTERRUPTED_TURN_SKIPPED"
26
+ GEMINI_UNPRICED = "GEMINI_UNPRICED"
File without changes
@@ -0,0 +1,88 @@
1
+ """Build Turn objects from deduplicated RawEvents, applying cost computation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from pathlib import Path
7
+
8
+ from tokenol import assumptions as assumption_recorder
9
+ from tokenol.ingest.parser import dedup_key, iter_assistant_events, parse_file
10
+ from tokenol.metrics.cost import cost_for_turn
11
+ from tokenol.model.events import Session, Turn, Usage
12
+
13
+
14
+ def build_turns(paths: list[Path]) -> list[Turn]:
15
+ """Parse *paths*, deduplicate, compute cost, record assumptions."""
16
+ turns: list[Turn] = []
17
+
18
+ for ev, tags in iter_assistant_events(paths):
19
+ is_interrupted = ev.usage is None
20
+ usage = ev.usage if ev.usage is not None else Usage()
21
+
22
+ tc = cost_for_turn(ev.model, usage)
23
+ tags.extend(t for t in tc.assumptions if t not in tags)
24
+ assumption_recorder.record(tags)
25
+
26
+ key = dedup_key(ev) or ev.uuid or str(id(ev))
27
+
28
+ turns.append(
29
+ Turn(
30
+ dedup_key=key,
31
+ timestamp=ev.timestamp,
32
+ session_id=ev.session_id,
33
+ model=ev.model,
34
+ usage=usage,
35
+ is_sidechain=ev.is_sidechain,
36
+ stop_reason=ev.stop_reason,
37
+ assumptions=tags,
38
+ cost_usd=tc.total_usd,
39
+ is_interrupted=is_interrupted,
40
+ tool_use_count=ev.tool_use_count,
41
+ tool_error_count=ev.tool_error_count,
42
+ )
43
+ )
44
+
45
+ return turns
46
+
47
+
48
+ def build_sessions(turns: list[Turn], paths: list[Path] | None = None) -> list[Session]:
49
+ """Group turns by session_id. One Session per JSONL file.
50
+
51
+ If *paths* is provided, scans each file once to extract cwd and source path.
52
+ """
53
+ cwd_by_session: dict[str, str] = {}
54
+ session_source: dict[str, str] = {}
55
+
56
+ if paths:
57
+ for path in paths:
58
+ sid = path.stem
59
+ session_source[sid] = str(path)
60
+ for raw_ev in parse_file(path):
61
+ if raw_ev.cwd and sid not in cwd_by_session:
62
+ cwd_by_session[sid] = raw_ev.cwd
63
+ break
64
+
65
+ session_turns: dict[str, list[Turn]] = defaultdict(list)
66
+ session_sidechain: dict[str, bool] = {}
67
+
68
+ for turn in turns:
69
+ sid = turn.session_id
70
+ session_turns[sid].append(turn)
71
+ if sid not in session_sidechain:
72
+ session_sidechain[sid] = turn.is_sidechain
73
+
74
+ sessions: list[Session] = []
75
+ for sid, t_list in session_turns.items():
76
+ t_list.sort(key=lambda t: t.timestamp)
77
+ sessions.append(
78
+ Session(
79
+ session_id=sid,
80
+ source_file=session_source.get(sid, ""),
81
+ is_sidechain=session_sidechain.get(sid, False),
82
+ cwd=cwd_by_session.get(sid),
83
+ turns=t_list,
84
+ )
85
+ )
86
+
87
+ sessions.sort(key=lambda s: s.turns[0].timestamp if s.turns else s.session_id)
88
+ return sessions
@@ -0,0 +1,43 @@
1
+ """Discover JSONL files from ~/.claude* config directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import os
7
+ from pathlib import Path
8
+
9
+
10
+ def get_config_dirs(all_projects: bool = False) -> list[Path]:
11
+ """Return config directories to scan.
12
+
13
+ - If *all_projects* is True, scan every ~/.claude* directory (ignoring
14
+ CLAUDE_CONFIG_DIR). Useful when workspace isolation points the env var
15
+ at a single project but you want a cross-project view.
16
+ - Otherwise, if CLAUDE_CONFIG_DIR is set, honour it (single path, or
17
+ colon- or comma-separated list of paths). Matches ccusage behaviour.
18
+ - Otherwise, scan all ~/.claude* directories.
19
+ """
20
+ home = Path.home()
21
+ all_dirs = sorted(p for p in home.glob(".claude*") if p.is_dir())
22
+
23
+ if all_projects:
24
+ return all_dirs
25
+
26
+ env = os.environ.get("CLAUDE_CONFIG_DIR")
27
+ if env:
28
+ parts = [p for chunk in env.split(",") for p in chunk.split(":") if p]
29
+ return [Path(p) for p in parts]
30
+
31
+ return all_dirs
32
+
33
+
34
+ def find_jsonl_files(dirs: list[Path] | None = None) -> list[Path]:
35
+ """Return all JSONL files under the given (or auto-discovered) dirs."""
36
+ if dirs is None:
37
+ dirs = get_config_dirs()
38
+
39
+ files: list[Path] = []
40
+ for d in dirs:
41
+ pattern = str(d / "projects" / "**" / "*.jsonl")
42
+ files.extend(Path(p) for p in glob.glob(pattern, recursive=True))
43
+ return sorted(files)