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.
- tokenol/__init__.py +3 -0
- tokenol/assumptions.py +51 -0
- tokenol/cli.py +408 -0
- tokenol/enums.py +26 -0
- tokenol/ingest/__init__.py +0 -0
- tokenol/ingest/builder.py +88 -0
- tokenol/ingest/discovery.py +43 -0
- tokenol/ingest/parser.py +162 -0
- tokenol/ingest/schema.py +10 -0
- tokenol/metrics/__init__.py +0 -0
- tokenol/metrics/context.py +99 -0
- tokenol/metrics/cost.py +145 -0
- tokenol/metrics/history.py +80 -0
- tokenol/metrics/patterns.py +246 -0
- tokenol/metrics/rollups.py +342 -0
- tokenol/metrics/thresholds.py +87 -0
- tokenol/metrics/verdicts.py +37 -0
- tokenol/metrics/windows.py +103 -0
- tokenol/model/__init__.py +0 -0
- tokenol/model/events.py +116 -0
- tokenol/model/pricing.py +112 -0
- tokenol/model/registry.py +57 -0
- tokenol/report/__init__.py +0 -0
- tokenol/report/text.py +352 -0
- tokenol/serve/__init__.py +0 -0
- tokenol/serve/app.py +312 -0
- tokenol/serve/prefs.py +68 -0
- tokenol/serve/session_detail.py +222 -0
- tokenol/serve/state.py +1370 -0
- tokenol/serve/static/app.js +1205 -0
- tokenol/serve/static/chart.js +230 -0
- tokenol/serve/static/components.js +159 -0
- tokenol/serve/static/day.html +102 -0
- tokenol/serve/static/day.js +108 -0
- tokenol/serve/static/index.html +332 -0
- tokenol/serve/static/model.html +51 -0
- tokenol/serve/static/model.js +57 -0
- tokenol/serve/static/project.html +137 -0
- tokenol/serve/static/project.js +240 -0
- tokenol/serve/static/session.html +297 -0
- tokenol/serve/static/session.js +848 -0
- tokenol/serve/static/styles.css +797 -0
- tokenol/serve/streaming.py +63 -0
- tokenol-0.1.0.dist-info/METADATA +253 -0
- tokenol-0.1.0.dist-info/RECORD +47 -0
- tokenol-0.1.0.dist-info/WHEEL +4 -0
- tokenol-0.1.0.dist-info/entry_points.txt +2 -0
tokenol/__init__.py
ADDED
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)
|