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 +3 -0
- weekburn/analyze.py +414 -0
- weekburn/cli.py +129 -0
- weekburn/parse.py +300 -0
- weekburn/render.py +372 -0
- weekburn/timeutil.py +49 -0
- weekburn-0.1.0.dist-info/METADATA +169 -0
- weekburn-0.1.0.dist-info/RECORD +12 -0
- weekburn-0.1.0.dist-info/WHEEL +5 -0
- weekburn-0.1.0.dist-info/entry_points.txt +2 -0
- weekburn-0.1.0.dist-info/licenses/LICENSE +21 -0
- weekburn-0.1.0.dist-info/top_level.txt +1 -0
weekburn/__init__.py
ADDED
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()
|