weekburn 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.
weekburn/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """WeekBurn: see which Claude Code sessions burned your weekly limit."""
2
+
3
+ __version__ = "0.1.0"
weekburn/analyze.py ADDED
@@ -0,0 +1,414 @@
1
+ """Session totals, cause tags, the weekly report and the two-period comparison."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import statistics
7
+ from collections import Counter
8
+ from dataclasses import dataclass, field
9
+ from datetime import date, datetime
10
+
11
+ import numpy as np
12
+
13
+ from .parse import Dataset, Request, UserMessage
14
+
15
+ # Cause tag rules. Each tag is only given when the data supports it.
16
+ HEAVY_TOP_FRACTION = 0.05 # top 5% of sessions by tokens
17
+ SUBAGENT_SHARE = 0.50 # more than half the session's tokens came from subagents
18
+ IDLE_MINUTES = 60.0 # a request after more than an hour of silence ...
19
+ IDLE_UNCACHED_TOKENS = 100_000 # ... that sent over 100k tokens not served from cache
20
+ LONG_CONTEXT_MEDIAN = 150_000 # median prompt size of the main conversation
21
+ HIGH_EFFORT_LEVELS = frozenset({"xhigh", "max"})
22
+ HIGH_EFFORT_SHARE = 0.50 # more than half the session's tokens at xhigh or max effort
23
+
24
+ TAG_HEAVY = "heavy session"
25
+ TAG_SUBAGENT = "subagent heavy"
26
+ TAG_IDLE = "idle resume"
27
+ TAG_LONG = "long context"
28
+ TAG_EFFORT = "high effort"
29
+ TAG_ORDER = [TAG_HEAVY, TAG_SUBAGENT, TAG_IDLE, TAG_LONG, TAG_EFFORT]
30
+
31
+ TAG_RULES = {
32
+ TAG_HEAVY: "top 5% of sessions by total tokens in the period",
33
+ TAG_SUBAGENT: "over 50% of the session's tokens came from subagents",
34
+ TAG_IDLE: "a request after more than 60 minutes idle that sent over 100k uncached tokens",
35
+ TAG_LONG: "median prompt size of the main conversation over 150k tokens",
36
+ TAG_EFFORT: "over 50% of the session's tokens at xhigh or max effort (only when effort is recorded)",
37
+ }
38
+
39
+
40
+ def top_count(n: int, fraction: float = HEAVY_TOP_FRACTION) -> int:
41
+ """How many sessions make up the top ``fraction``. At least one when n > 0."""
42
+ return math.ceil(n * fraction) if n > 0 else 0
43
+
44
+
45
+ @dataclass
46
+ class SessionStats:
47
+ session_id: str
48
+ project: str = ""
49
+ start: datetime | None = None
50
+ end: datetime | None = None
51
+ requests: int = 0
52
+ user_messages: int = 0
53
+ input_tokens: int = 0
54
+ output_tokens: int = 0
55
+ cache_write_tokens: int = 0
56
+ cache_read_tokens: int = 0
57
+ subagent_tokens: int = 0
58
+ high_effort_tokens: int = 0
59
+ idle_resumes: int = 0
60
+ median_context: float = 0.0
61
+ models: Counter = field(default_factory=Counter)
62
+ tags: list[str] = field(default_factory=list)
63
+
64
+ @property
65
+ def total(self) -> int:
66
+ return self.input_tokens + self.output_tokens + self.cache_write_tokens + self.cache_read_tokens
67
+
68
+ @property
69
+ def subagent_share(self) -> float:
70
+ return self.subagent_tokens / self.total if self.total else 0.0
71
+
72
+ @property
73
+ def cache_read_share(self) -> float:
74
+ return self.cache_read_tokens / self.total if self.total else 0.0
75
+
76
+ @property
77
+ def main_model(self) -> str:
78
+ return self.models.most_common(1)[0][0] if self.models else "unknown"
79
+
80
+
81
+ def in_window(items, start: datetime | None, end: datetime | None):
82
+ return [x for x in items if (start is None or x.timestamp >= start) and (end is None or x.timestamp < end)]
83
+
84
+
85
+ def session_stats(requests: list[Request], user_messages: list[UserMessage]) -> list[SessionStats]:
86
+ """Aggregate requests and user messages into one row per session."""
87
+ rows: dict[str, SessionStats] = {}
88
+ contexts: dict[str, list[int]] = {}
89
+ sub_contexts: dict[str, list[int]] = {}
90
+ for r in requests:
91
+ s = rows.get(r.session_id)
92
+ if s is None:
93
+ s = rows[r.session_id] = SessionStats(session_id=r.session_id)
94
+ s.requests += 1
95
+ s.input_tokens += r.input_tokens
96
+ s.output_tokens += r.output_tokens
97
+ s.cache_write_tokens += r.cache_write_tokens
98
+ s.cache_read_tokens += r.cache_read_tokens
99
+ s.models[r.model] += r.total
100
+ if r.is_subagent:
101
+ s.subagent_tokens += r.total
102
+ sub_contexts.setdefault(r.session_id, []).append(r.context)
103
+ else:
104
+ contexts.setdefault(r.session_id, []).append(r.context)
105
+ if r.project and not s.project:
106
+ s.project = r.project
107
+ if r.effort in HIGH_EFFORT_LEVELS:
108
+ s.high_effort_tokens += r.total
109
+ if (
110
+ r.gap_minutes is not None
111
+ and r.gap_minutes > IDLE_MINUTES
112
+ and r.uncached > IDLE_UNCACHED_TOKENS
113
+ ):
114
+ s.idle_resumes += 1
115
+ if s.start is None or r.timestamp < s.start:
116
+ s.start = r.timestamp
117
+ if s.end is None or r.timestamp > s.end:
118
+ s.end = r.timestamp
119
+ for u in user_messages:
120
+ s = rows.get(u.session_id)
121
+ if s is None:
122
+ s = rows[u.session_id] = SessionStats(session_id=u.session_id, start=u.timestamp, end=u.timestamp)
123
+ s.user_messages += 1
124
+ for sid, s in rows.items():
125
+ ctx = contexts.get(sid) or sub_contexts.get(sid) or []
126
+ s.median_context = float(statistics.median(ctx)) if ctx else 0.0
127
+ return list(rows.values())
128
+
129
+
130
+ def apply_tags(sessions: list[SessionStats], effort_recorded: bool) -> None:
131
+ ranked = sorted((s for s in sessions if s.total > 0), key=lambda s: s.total, reverse=True)
132
+ heavy = {s.session_id for s in ranked[: top_count(len(ranked))]}
133
+ for s in sessions:
134
+ tags: list[str] = []
135
+ if s.session_id in heavy:
136
+ tags.append(TAG_HEAVY)
137
+ if s.subagent_share > SUBAGENT_SHARE:
138
+ tags.append(TAG_SUBAGENT)
139
+ if s.idle_resumes > 0:
140
+ tags.append(TAG_IDLE)
141
+ if s.median_context > LONG_CONTEXT_MEDIAN:
142
+ tags.append(TAG_LONG)
143
+ if effort_recorded and s.total and s.high_effort_tokens / s.total > HIGH_EFFORT_SHARE:
144
+ tags.append(TAG_EFFORT)
145
+ s.tags = tags
146
+
147
+
148
+ # --------------------------------------------------------------------------- report
149
+
150
+
151
+ @dataclass
152
+ class CauseRow:
153
+ cause: str
154
+ sessions: int
155
+ tokens: int
156
+ share: float
157
+
158
+
159
+ @dataclass
160
+ class Report:
161
+ start: datetime
162
+ end: datetime
163
+ sessions: list[SessionStats] # all sessions in the window, heaviest first
164
+ top: list[SessionStats]
165
+ causes: list[CauseRow]
166
+ total_tokens: int
167
+ input_tokens: int
168
+ output_tokens: int
169
+ cache_write_tokens: int
170
+ cache_read_tokens: int
171
+ subagent_tokens: int
172
+ requests: int
173
+ user_messages: int
174
+ idle_resume_requests: int
175
+ effort_recorded: bool
176
+
177
+
178
+ def build_report(data: Dataset, start: datetime, end: datetime, top: int = 10) -> Report:
179
+ reqs = in_window(data.requests, start, end)
180
+ msgs = in_window(data.user_messages, start, end)
181
+ sessions = session_stats(reqs, msgs)
182
+ effort_recorded = any(r.effort for r in reqs)
183
+ apply_tags(sessions, effort_recorded)
184
+ sessions.sort(key=lambda s: s.total, reverse=True)
185
+ total = sum(s.total for s in sessions)
186
+
187
+ causes: list[CauseRow] = []
188
+ for tag in TAG_ORDER:
189
+ if tag == TAG_EFFORT and not effort_recorded:
190
+ continue
191
+ tagged = [s for s in sessions if tag in s.tags]
192
+ tok = sum(s.total for s in tagged)
193
+ causes.append(CauseRow(tag, len(tagged), tok, tok / total if total else 0.0))
194
+ untagged = [s for s in sessions if not s.tags and s.total > 0]
195
+ tok = sum(s.total for s in untagged)
196
+ causes.append(CauseRow("no tag", len(untagged), tok, tok / total if total else 0.0))
197
+
198
+ return Report(
199
+ start=start,
200
+ end=end,
201
+ sessions=sessions,
202
+ top=sessions[:top],
203
+ causes=causes,
204
+ total_tokens=total,
205
+ input_tokens=sum(s.input_tokens for s in sessions),
206
+ output_tokens=sum(s.output_tokens for s in sessions),
207
+ cache_write_tokens=sum(s.cache_write_tokens for s in sessions),
208
+ cache_read_tokens=sum(s.cache_read_tokens for s in sessions),
209
+ subagent_tokens=sum(s.subagent_tokens for s in sessions),
210
+ requests=len(reqs),
211
+ user_messages=len(msgs),
212
+ idle_resume_requests=sum(s.idle_resumes for s in sessions),
213
+ effort_recorded=effort_recorded,
214
+ )
215
+
216
+
217
+ # -------------------------------------------------------------------------- compare
218
+
219
+ METRIC_TPM = "Tokens per user message"
220
+ METRIC_CR = "Cache read share"
221
+ METRIC_CW = "Cache write share"
222
+ MIN_SESSIONS = 5
223
+
224
+
225
+ @dataclass
226
+ class Metric:
227
+ name: str
228
+ kind: str # "ratio" (change shown in %) or "share" (change shown in points)
229
+ before: float
230
+ before_ci: tuple[float, float]
231
+ after: float
232
+ after_ci: tuple[float, float]
233
+ change: float # % change for ratio metrics, percentage points for shares
234
+ change_ci: tuple[float, float]
235
+
236
+ @property
237
+ def clear_change(self) -> bool:
238
+ lo, hi = self.change_ci
239
+ if math.isnan(lo) or math.isnan(hi):
240
+ return False
241
+ return lo > 0 or hi < 0
242
+
243
+
244
+ @dataclass
245
+ class Period:
246
+ label: str
247
+ first_day: date
248
+ last_day: date
249
+ sessions: int
250
+ user_messages: int
251
+ tokens: int
252
+ removed: int = 0 # sessions dropped by the heavy-session check
253
+
254
+
255
+ @dataclass
256
+ class Comparison:
257
+ before: Period
258
+ after: Period
259
+ metrics: list[Metric]
260
+ trimmed_before: Period
261
+ trimmed_after: Period
262
+ trimmed_metrics: list[Metric]
263
+ resamples: int
264
+ seed: int
265
+ warnings: list[str]
266
+
267
+ @property
268
+ def changed(self) -> bool:
269
+ return self.metrics[0].clear_change
270
+
271
+ @property
272
+ def trimmed_changed(self) -> bool:
273
+ return self.trimmed_metrics[0].clear_change
274
+
275
+
276
+ def _session_matrix(sessions: list[SessionStats]) -> np.ndarray:
277
+ """Columns: total tokens, user messages, cache read tokens, cache write tokens."""
278
+ if not sessions:
279
+ return np.zeros((0, 4), dtype=float)
280
+ return np.array(
281
+ [[s.total, s.user_messages, s.cache_read_tokens, s.cache_write_tokens] for s in sessions],
282
+ dtype=float,
283
+ )
284
+
285
+
286
+ def _metrics_from_sums(sums: np.ndarray) -> np.ndarray:
287
+ """sums has shape (..., 4); returns (..., 3): tokens/msg, cache read share, cache write share."""
288
+ tok, msg, cr, cw = (sums[..., i] for i in range(4))
289
+ with np.errstate(divide="ignore", invalid="ignore"):
290
+ tpm = np.where(msg > 0, tok / np.where(msg > 0, msg, 1), np.nan)
291
+ crs = np.where(tok > 0, cr / np.where(tok > 0, tok, 1), np.nan)
292
+ cws = np.where(tok > 0, cw / np.where(tok > 0, tok, 1), np.nan)
293
+ return np.stack([tpm, crs, cws], axis=-1)
294
+
295
+
296
+ def _bootstrap_sums(mat: np.ndarray, resamples: int, rng: np.random.Generator, chunk: int = 250) -> np.ndarray:
297
+ """Resample sessions with replacement and return the column sums of each resample."""
298
+ n = mat.shape[0]
299
+ if n == 0:
300
+ return np.full((resamples, mat.shape[1]), np.nan)
301
+ out = np.empty((resamples, mat.shape[1]))
302
+ done = 0
303
+ while done < resamples:
304
+ size = min(chunk, resamples - done)
305
+ counts = rng.multinomial(n, np.full(n, 1.0 / n), size=size)
306
+ out[done : done + size] = counts @ mat
307
+ done += size
308
+ return out
309
+
310
+
311
+ def _ci(values: np.ndarray) -> tuple[float, float]:
312
+ v = values[~np.isnan(values)]
313
+ if v.size < 10:
314
+ return (math.nan, math.nan)
315
+ lo, hi = np.percentile(v, [2.5, 97.5])
316
+ return (float(lo), float(hi))
317
+
318
+
319
+ def _compare_sessions(
320
+ before: list[SessionStats], after: list[SessionStats], resamples: int, seed: int
321
+ ) -> list[Metric]:
322
+ rng = np.random.default_rng(seed)
323
+ mb, ma = _session_matrix(before), _session_matrix(after)
324
+ point_b = _metrics_from_sums(mb.sum(axis=0))
325
+ point_a = _metrics_from_sums(ma.sum(axis=0))
326
+ boot_b = _metrics_from_sums(_bootstrap_sums(mb, resamples, rng))
327
+ boot_a = _metrics_from_sums(_bootstrap_sums(ma, resamples, rng))
328
+
329
+ metrics: list[Metric] = []
330
+ for i, (name, kind) in enumerate([(METRIC_TPM, "ratio"), (METRIC_CR, "share"), (METRIC_CW, "share")]):
331
+ b, a = float(point_b[i]), float(point_a[i])
332
+ with np.errstate(divide="ignore", invalid="ignore"):
333
+ if kind == "ratio":
334
+ change = (a / b - 1.0) * 100.0 if b and not math.isnan(b) else math.nan
335
+ change_draws = (boot_a[:, i] / boot_b[:, i] - 1.0) * 100.0
336
+ else:
337
+ change = (a - b) * 100.0
338
+ change_draws = (boot_a[:, i] - boot_b[:, i]) * 100.0
339
+ change_draws = np.where(np.isfinite(change_draws), change_draws, np.nan)
340
+ metrics.append(
341
+ Metric(
342
+ name=name,
343
+ kind=kind,
344
+ before=b,
345
+ before_ci=_ci(boot_b[:, i]),
346
+ after=a,
347
+ after_ci=_ci(boot_a[:, i]),
348
+ change=change,
349
+ change_ci=_ci(change_draws),
350
+ )
351
+ )
352
+ return metrics
353
+
354
+
355
+ def _drop_heaviest(sessions: list[SessionStats]) -> tuple[list[SessionStats], int]:
356
+ if len(sessions) < 2:
357
+ return list(sessions), 0
358
+ k = top_count(len(sessions))
359
+ ranked = sorted(sessions, key=lambda s: s.total, reverse=True)
360
+ return ranked[k:], k
361
+
362
+
363
+ def _period(label: str, days: tuple[date, date], sessions: list[SessionStats], removed: int = 0) -> Period:
364
+ return Period(
365
+ label=label,
366
+ first_day=days[0],
367
+ last_day=days[1],
368
+ sessions=len(sessions),
369
+ user_messages=sum(s.user_messages for s in sessions),
370
+ tokens=sum(s.total for s in sessions),
371
+ removed=removed,
372
+ )
373
+
374
+
375
+ def build_comparison(
376
+ data: Dataset,
377
+ before_days: tuple[date, date],
378
+ after_days: tuple[date, date],
379
+ before_window: tuple[datetime, datetime],
380
+ after_window: tuple[datetime, datetime],
381
+ resamples: int = 2000,
382
+ seed: int = 0,
383
+ ) -> Comparison:
384
+ def sessions_in(window: tuple[datetime, datetime]) -> list[SessionStats]:
385
+ reqs = in_window(data.requests, *window)
386
+ msgs = in_window(data.user_messages, *window)
387
+ return [s for s in session_stats(reqs, msgs) if s.total > 0 or s.user_messages > 0]
388
+
389
+ sb, sa = sessions_in(before_window), sessions_in(after_window)
390
+ tb, removed_b = _drop_heaviest(sb)
391
+ ta, removed_a = _drop_heaviest(sa)
392
+
393
+ warnings: list[str] = []
394
+ for label, sess in (("before", sb), ("after", sa)):
395
+ if not sess:
396
+ warnings.append(f"The {label} period has no sessions.")
397
+ elif len(sess) < MIN_SESSIONS:
398
+ warnings.append(
399
+ f"The {label} period has only {len(sess)} session(s); intervals will be wide or missing."
400
+ )
401
+ if sess and sum(s.user_messages for s in sess) == 0:
402
+ warnings.append(f"The {label} period has no user messages, so tokens per message cannot be computed.")
403
+
404
+ return Comparison(
405
+ before=_period("before", before_days, sb),
406
+ after=_period("after", after_days, sa),
407
+ metrics=_compare_sessions(sb, sa, resamples, seed),
408
+ trimmed_before=_period("before", before_days, tb, removed_b),
409
+ trimmed_after=_period("after", after_days, ta, removed_a),
410
+ trimmed_metrics=_compare_sessions(tb, ta, resamples, seed),
411
+ resamples=resamples,
412
+ seed=seed,
413
+ warnings=warnings,
414
+ )
weekburn/cli.py ADDED
@@ -0,0 +1,129 @@
1
+ """Command line interface: `weekburn report` and `weekburn compare`."""
2
+
3
+ import sys
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import List, Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from . import __version__
12
+ from .analyze import build_comparison, build_report
13
+ from .parse import Dataset, default_project_dirs, load
14
+ from .render import fmt_day, markdown_compare, markdown_report, print_compare, print_report
15
+ from .timeutil import parse_range, parse_since, range_window
16
+
17
+ app = typer.Typer(
18
+ add_completion=False,
19
+ no_args_is_help=True,
20
+ help=(
21
+ "See which Claude Code sessions burned your weekly limit, which known causes explain it, "
22
+ "and whether your usage per message really changed. Reads local transcripts only; "
23
+ "makes no network calls."
24
+ ),
25
+ )
26
+
27
+ _PROJECTS_HELP = (
28
+ "Folder with Claude Code transcripts. Can be given more than once. "
29
+ "Default: ~/.claude/projects and ~/.config/claude/projects (or $CLAUDE_CONFIG_DIR/projects)."
30
+ )
31
+ _MARKDOWN_HELP = "Print Markdown to paste into a GitHub issue (counts only: no prompts, paths or code)."
32
+
33
+
34
+ def _version(value: bool) -> None:
35
+ if value:
36
+ typer.echo(f"weekburn {__version__}")
37
+ raise typer.Exit()
38
+
39
+
40
+ @app.callback()
41
+ def _root(
42
+ version: bool = typer.Option(
43
+ False, "--version", callback=_version, is_eager=True, help="Show the version and exit."
44
+ ),
45
+ ) -> None:
46
+ """WeekBurn reads ~/.claude/projects and reports token use per session."""
47
+
48
+
49
+ def _fail(message: str) -> None:
50
+ Console(stderr=True).print(f"[red]Error:[/red] {message}", highlight=False)
51
+ raise typer.Exit(code=1)
52
+
53
+
54
+ def _dirs(projects_dir: Optional[List[Path]]) -> List[Path]:
55
+ if projects_dir:
56
+ dirs = [p.expanduser() for p in projects_dir]
57
+ missing = [str(d) for d in dirs if not d.is_dir()]
58
+ if missing:
59
+ _fail(f"folder not found: {', '.join(missing)}")
60
+ return dirs
61
+ dirs = default_project_dirs()
62
+ if not dirs:
63
+ _fail("no Claude Code transcripts found in ~/.claude/projects. Use --projects-dir to point at them.")
64
+ return dirs
65
+
66
+
67
+ def _load(dirs: List[Path], since: datetime) -> Dataset:
68
+ with Console(stderr=True).status("Reading transcripts...", spinner="dots"):
69
+ return load(dirs, modified_since=since)
70
+
71
+
72
+ @app.command()
73
+ def report(
74
+ since: str = typer.Option("7d", "--since", help="How far back: 7d, 48h, 2w, or a date like 2026-10-20."),
75
+ top: int = typer.Option(10, "--top", min=1, help="How many sessions to list."),
76
+ markdown: bool = typer.Option(False, "--markdown", help=_MARKDOWN_HELP),
77
+ projects_dir: Optional[List[Path]] = typer.Option(None, "--projects-dir", help=_PROJECTS_HELP, show_default=False),
78
+ ) -> None:
79
+ """Top sessions by tokens, their cause tags, and the share of tokens by cause."""
80
+ now = datetime.now(timezone.utc)
81
+ try:
82
+ start = parse_since(since, now)
83
+ except ValueError as e:
84
+ raise typer.BadParameter(str(e), param_hint="--since") from None
85
+ data = _load(_dirs(projects_dir), start)
86
+ rep = build_report(data, start, now, top=top)
87
+ if not any(s.total for s in rep.sessions):
88
+ _fail(f"no Claude Code usage found between {fmt_day(start)} and {fmt_day(now)}.")
89
+ if markdown:
90
+ sys.stdout.write(markdown_report(rep))
91
+ else:
92
+ print_report(rep, Console())
93
+
94
+
95
+ @app.command()
96
+ def compare(
97
+ before: str = typer.Option(..., "--before", help="First period as FIRST:LAST, e.g. 2026-10-10:2026-10-16."),
98
+ after: str = typer.Option(..., "--after", help="Second period as FIRST:LAST, e.g. 2026-10-24:2026-10-30."),
99
+ markdown: bool = typer.Option(False, "--markdown", help=_MARKDOWN_HELP),
100
+ resamples: int = typer.Option(2000, "--resamples", min=200, help="Bootstrap resamples."),
101
+ seed: int = typer.Option(0, "--seed", help="Random seed, so the same data gives the same intervals."),
102
+ projects_dir: Optional[List[Path]] = typer.Option(None, "--projects-dir", help=_PROJECTS_HELP, show_default=False),
103
+ ) -> None:
104
+ """Did tokens per user message really change between two periods? Bootstrap 95% intervals."""
105
+ try:
106
+ before_days = parse_range(before)
107
+ except ValueError as e:
108
+ raise typer.BadParameter(str(e), param_hint="--before") from None
109
+ try:
110
+ after_days = parse_range(after)
111
+ except ValueError as e:
112
+ raise typer.BadParameter(str(e), param_hint="--after") from None
113
+ bw, aw = range_window(before_days), range_window(after_days)
114
+ data = _load(_dirs(projects_dir), min(bw[0], aw[0]))
115
+ result = build_comparison(data, before_days, after_days, bw, aw, resamples=resamples, seed=seed)
116
+ if result.before.sessions == 0 and result.after.sessions == 0:
117
+ _fail("no Claude Code usage found in either period.")
118
+ if markdown:
119
+ sys.stdout.write(markdown_compare(result))
120
+ else:
121
+ print_compare(result, Console())
122
+
123
+
124
+ def main() -> None:
125
+ app()
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()