pi-multi-agent-skill 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Report token usage and cost for multi-agent pi sessions.
|
|
3
|
+
|
|
4
|
+
The multi-agent skill keeps its pi sessions in a directory of its own, away
|
|
5
|
+
from pi's normal session list. This script reads those files, joins the job
|
|
6
|
+
metadata next to them when it exists, and prints tables like ccusage.
|
|
7
|
+
|
|
8
|
+
Sessions and their usage records are the source of truth. Costs are the ones
|
|
9
|
+
pi recorded for each request; this script never computes prices itself.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import date, datetime, timedelta, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
23
|
+
|
|
24
|
+
# Tables use a few Unicode characters, so write UTF-8 whatever the locale.
|
|
25
|
+
for _stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
26
|
+
try:
|
|
27
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
28
|
+
except (AttributeError, ValueError, OSError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
UTC = timezone.utc # noqa: UP017 - Python 3.9 has no datetime.UTC.
|
|
32
|
+
|
|
33
|
+
SESSION_FILE_RE = re.compile(r"^[^_]+_(.+)\.jsonl$")
|
|
34
|
+
VIEWS = (
|
|
35
|
+
"overview",
|
|
36
|
+
"daily",
|
|
37
|
+
"weekly",
|
|
38
|
+
"monthly",
|
|
39
|
+
"project",
|
|
40
|
+
"session",
|
|
41
|
+
"model",
|
|
42
|
+
"profile",
|
|
43
|
+
)
|
|
44
|
+
VIEW_ALIASES = {"summary": "overview", "jobs": "session"}
|
|
45
|
+
TIME_VIEWS = {"daily", "weekly", "monthly"}
|
|
46
|
+
GROUP_VIEWS = {"project", "session", "model", "profile"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --- reading files -----------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def pi_agent_dir() -> Path:
|
|
53
|
+
return Path(os.environ.get("PI_CODING_AGENT_DIR") or "~/.pi/agent").expanduser()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def sessions_root() -> Path:
|
|
57
|
+
return pi_agent_dir() / "multi-agent-sessions"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def state_root() -> Path:
|
|
61
|
+
return (
|
|
62
|
+
Path(os.environ.get("XDG_STATE_HOME") or "~/.local/state").expanduser()
|
|
63
|
+
/ "pi-multi-agent"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def load_jobs(root: Path) -> dict:
|
|
68
|
+
"""Map each (project slug, job name) to its job.json content."""
|
|
69
|
+
jobs = {}
|
|
70
|
+
if not root.is_dir():
|
|
71
|
+
return jobs
|
|
72
|
+
for path in sorted(root.glob("*/jobs/*/job.json")):
|
|
73
|
+
try:
|
|
74
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
75
|
+
except (OSError, ValueError):
|
|
76
|
+
continue
|
|
77
|
+
if not isinstance(data, dict):
|
|
78
|
+
continue
|
|
79
|
+
name = path.parent.name
|
|
80
|
+
slug = path.parent.parent.parent.name
|
|
81
|
+
jobs[(slug, name)] = data
|
|
82
|
+
return jobs
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_timestamp(value):
|
|
86
|
+
"""Turn an ISO string or a millisecond epoch number into an aware datetime."""
|
|
87
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
88
|
+
try:
|
|
89
|
+
return datetime.fromtimestamp(value / 1000.0, UTC)
|
|
90
|
+
except (OSError, OverflowError, ValueError):
|
|
91
|
+
return None
|
|
92
|
+
if not isinstance(value, str):
|
|
93
|
+
return None
|
|
94
|
+
text = value.strip()
|
|
95
|
+
if not text:
|
|
96
|
+
return None
|
|
97
|
+
if text.endswith(("Z", "z")):
|
|
98
|
+
text = text[:-1] + "+00:00"
|
|
99
|
+
try:
|
|
100
|
+
parsed = datetime.fromisoformat(text)
|
|
101
|
+
except ValueError:
|
|
102
|
+
return None
|
|
103
|
+
if parsed.tzinfo is None:
|
|
104
|
+
parsed = parsed.replace(tzinfo=UTC)
|
|
105
|
+
return parsed
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def entry_timestamp(entry: dict, message: dict | None = None):
|
|
109
|
+
timestamp = parse_timestamp(entry.get("timestamp"))
|
|
110
|
+
if timestamp is not None:
|
|
111
|
+
return timestamp
|
|
112
|
+
if message is not None:
|
|
113
|
+
return parse_timestamp(message.get("timestamp"))
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def session_name(path: Path) -> str:
|
|
118
|
+
match = SESSION_FILE_RE.match(path.name)
|
|
119
|
+
return match.group(1) if match else path.stem
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def as_number(value) -> int:
|
|
123
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
124
|
+
return 0
|
|
125
|
+
return int(value)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def usage_cost(usage: dict):
|
|
129
|
+
cost = usage.get("cost")
|
|
130
|
+
if isinstance(cost, dict):
|
|
131
|
+
total = cost.get("total")
|
|
132
|
+
if isinstance(total, (int, float)) and not isinstance(total, bool):
|
|
133
|
+
return float(total)
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class UsageRecord:
|
|
139
|
+
timestamp: datetime
|
|
140
|
+
provider: str
|
|
141
|
+
model: str
|
|
142
|
+
input: int
|
|
143
|
+
output: int
|
|
144
|
+
cache_read: int
|
|
145
|
+
cache_write: int
|
|
146
|
+
total_tokens: int
|
|
147
|
+
cost: float | None
|
|
148
|
+
turn: bool
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def model_label(self) -> str:
|
|
152
|
+
if self.provider and self.model:
|
|
153
|
+
return f"{self.provider}/{self.model}"
|
|
154
|
+
return self.model or self.provider or "(unknown)"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def make_record(
|
|
158
|
+
timestamp: datetime | None,
|
|
159
|
+
usage: dict,
|
|
160
|
+
provider: str,
|
|
161
|
+
model: str,
|
|
162
|
+
turn: bool,
|
|
163
|
+
):
|
|
164
|
+
if timestamp is None:
|
|
165
|
+
return None
|
|
166
|
+
input_tokens = as_number(usage.get("input"))
|
|
167
|
+
output_tokens = as_number(usage.get("output"))
|
|
168
|
+
cache_read = as_number(usage.get("cacheRead"))
|
|
169
|
+
cache_write = as_number(usage.get("cacheWrite"))
|
|
170
|
+
if isinstance(usage.get("totalTokens"), (int, float)) and not isinstance(
|
|
171
|
+
usage.get("totalTokens"), bool
|
|
172
|
+
):
|
|
173
|
+
total_tokens = as_number(usage.get("totalTokens"))
|
|
174
|
+
else:
|
|
175
|
+
total_tokens = input_tokens + output_tokens + cache_read + cache_write
|
|
176
|
+
return UsageRecord(
|
|
177
|
+
timestamp=timestamp,
|
|
178
|
+
provider=provider,
|
|
179
|
+
model=model,
|
|
180
|
+
input=input_tokens,
|
|
181
|
+
output=output_tokens,
|
|
182
|
+
cache_read=cache_read,
|
|
183
|
+
cache_write=cache_write,
|
|
184
|
+
total_tokens=total_tokens,
|
|
185
|
+
cost=usage_cost(usage),
|
|
186
|
+
turn=turn,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def read_session_file(path: Path):
|
|
191
|
+
"""Return the header, usage records, bad line count and header timestamp."""
|
|
192
|
+
try:
|
|
193
|
+
text = path.read_text(encoding="utf-8-sig", errors="replace")
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
raise OSError(str(exc)) from exc
|
|
196
|
+
lines = text.split("\n")
|
|
197
|
+
header = {}
|
|
198
|
+
header_time = None
|
|
199
|
+
records = []
|
|
200
|
+
bad_lines = 0
|
|
201
|
+
current_provider = ""
|
|
202
|
+
current_model = ""
|
|
203
|
+
for index, line in enumerate(lines):
|
|
204
|
+
if not line.strip():
|
|
205
|
+
continue
|
|
206
|
+
try:
|
|
207
|
+
entry = json.loads(line)
|
|
208
|
+
except json.JSONDecodeError:
|
|
209
|
+
# The last line of a running session may still be half written.
|
|
210
|
+
if index == len(lines) - 1 and not text.endswith(("\n", "\r")):
|
|
211
|
+
continue
|
|
212
|
+
bad_lines += 1
|
|
213
|
+
continue
|
|
214
|
+
if not isinstance(entry, dict):
|
|
215
|
+
bad_lines += 1
|
|
216
|
+
continue
|
|
217
|
+
kind = entry.get("type")
|
|
218
|
+
message = (
|
|
219
|
+
entry.get("message") if isinstance(entry.get("message"), dict) else None
|
|
220
|
+
)
|
|
221
|
+
timestamp = entry_timestamp(entry, message)
|
|
222
|
+
if kind == "session":
|
|
223
|
+
if not header:
|
|
224
|
+
header = entry
|
|
225
|
+
header_time = timestamp
|
|
226
|
+
continue
|
|
227
|
+
if kind == "model_change":
|
|
228
|
+
provider = entry.get("provider")
|
|
229
|
+
model = entry.get("modelId") or entry.get("model")
|
|
230
|
+
if provider or model:
|
|
231
|
+
current_provider = str(provider or "")
|
|
232
|
+
current_model = str(model or "")
|
|
233
|
+
continue
|
|
234
|
+
if kind == "message":
|
|
235
|
+
if message is None or message.get("role") != "assistant":
|
|
236
|
+
continue
|
|
237
|
+
usage = message.get("usage")
|
|
238
|
+
if not isinstance(usage, dict):
|
|
239
|
+
continue
|
|
240
|
+
provider = str(message.get("provider") or current_provider or "")
|
|
241
|
+
model = str(
|
|
242
|
+
message.get("responseModel")
|
|
243
|
+
or message.get("model")
|
|
244
|
+
or current_model
|
|
245
|
+
or ""
|
|
246
|
+
)
|
|
247
|
+
record = make_record(timestamp or header_time, usage, provider, model, True)
|
|
248
|
+
if record is not None:
|
|
249
|
+
records.append(record)
|
|
250
|
+
continue
|
|
251
|
+
if kind in ("usage", "compaction", "branch_summary"):
|
|
252
|
+
usage = entry.get("usage")
|
|
253
|
+
if not isinstance(usage, dict):
|
|
254
|
+
continue
|
|
255
|
+
provider = str(entry.get("provider") or current_provider or "")
|
|
256
|
+
model = str(entry.get("model") or current_model or "")
|
|
257
|
+
record = make_record(
|
|
258
|
+
timestamp or header_time, usage, provider, model, False
|
|
259
|
+
)
|
|
260
|
+
if record is not None:
|
|
261
|
+
records.append(record)
|
|
262
|
+
if header.get("parentSession") and header_time is not None:
|
|
263
|
+
# Forks copy older usage timestamps; keep only usage from this session.
|
|
264
|
+
records = [record for record in records if record.timestamp >= header_time]
|
|
265
|
+
return header, records, bad_lines, header_time
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass
|
|
269
|
+
class Session:
|
|
270
|
+
slug: str
|
|
271
|
+
name: str
|
|
272
|
+
cwd: str = ""
|
|
273
|
+
job_project: str = ""
|
|
274
|
+
profile: str = "-"
|
|
275
|
+
parent: str | None = None
|
|
276
|
+
depth: int = 0
|
|
277
|
+
mode: str = ""
|
|
278
|
+
start: datetime | None = None
|
|
279
|
+
last: datetime | None = None
|
|
280
|
+
records: list = field(default_factory=list)
|
|
281
|
+
files: int = 0
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def key(self):
|
|
285
|
+
return (self.slug, self.name)
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def project(self) -> str:
|
|
289
|
+
return self.job_project or self.cwd or self.slug
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def load_sessions(root: Path, jobs: dict):
|
|
293
|
+
sessions = {}
|
|
294
|
+
warnings = []
|
|
295
|
+
if not root.is_dir():
|
|
296
|
+
return [], warnings
|
|
297
|
+
for path in sorted(root.glob("*/*.jsonl")):
|
|
298
|
+
slug = path.parent.name
|
|
299
|
+
name = session_name(path)
|
|
300
|
+
try:
|
|
301
|
+
header, records, bad_lines, header_time = read_session_file(path)
|
|
302
|
+
except OSError as exc:
|
|
303
|
+
warnings.append(f"warning: cannot read {path}: {exc}")
|
|
304
|
+
continue
|
|
305
|
+
if bad_lines:
|
|
306
|
+
warnings.append(f"warning: skipped {bad_lines} malformed line(s) in {path}")
|
|
307
|
+
key = (slug, name)
|
|
308
|
+
session = sessions.get(key)
|
|
309
|
+
if session is None:
|
|
310
|
+
session = Session(slug=slug, name=name)
|
|
311
|
+
sessions[key] = session
|
|
312
|
+
session.files += 1
|
|
313
|
+
cwd = header.get("cwd")
|
|
314
|
+
if not session.cwd and isinstance(cwd, str) and cwd:
|
|
315
|
+
session.cwd = cwd
|
|
316
|
+
if header_time is not None:
|
|
317
|
+
session.start = (
|
|
318
|
+
header_time
|
|
319
|
+
if session.start is None
|
|
320
|
+
else min(session.start, header_time)
|
|
321
|
+
)
|
|
322
|
+
for record in records:
|
|
323
|
+
session.records.append(record)
|
|
324
|
+
session.last = (
|
|
325
|
+
record.timestamp
|
|
326
|
+
if session.last is None
|
|
327
|
+
else max(session.last, record.timestamp)
|
|
328
|
+
)
|
|
329
|
+
for key, session in sessions.items():
|
|
330
|
+
job = jobs.get(key)
|
|
331
|
+
if job:
|
|
332
|
+
project = job.get("project")
|
|
333
|
+
if isinstance(project, str) and project:
|
|
334
|
+
session.job_project = project
|
|
335
|
+
if not session.cwd:
|
|
336
|
+
cwd = job.get("cwd") or project
|
|
337
|
+
if isinstance(cwd, str):
|
|
338
|
+
session.cwd = cwd
|
|
339
|
+
profile = job.get("profile")
|
|
340
|
+
session.profile = profile if isinstance(profile, str) and profile else "-"
|
|
341
|
+
parent = job.get("parent")
|
|
342
|
+
session.parent = parent if isinstance(parent, str) else None
|
|
343
|
+
depth = job.get("depth")
|
|
344
|
+
if isinstance(depth, int) and not isinstance(depth, bool):
|
|
345
|
+
session.depth = depth
|
|
346
|
+
mode = job.get("mode")
|
|
347
|
+
session.mode = mode if isinstance(mode, str) else ""
|
|
348
|
+
if session.records:
|
|
349
|
+
first = min(record.timestamp for record in session.records)
|
|
350
|
+
last = max(record.timestamp for record in session.records)
|
|
351
|
+
session.start = (
|
|
352
|
+
first if session.start is None else min(session.start, first)
|
|
353
|
+
)
|
|
354
|
+
session.last = last if session.last is None else max(session.last, last)
|
|
355
|
+
if session.start is None:
|
|
356
|
+
session.start = session.last
|
|
357
|
+
if session.last is None:
|
|
358
|
+
session.last = session.start
|
|
359
|
+
return list(sessions.values()), warnings
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# --- dates and periods -------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def local_datetime(moment: datetime, tz: ZoneInfo | None) -> datetime:
|
|
366
|
+
return moment.astimezone(tz) if tz is not None else moment.astimezone()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def local_date(moment: datetime, tz: ZoneInfo | None) -> date:
|
|
370
|
+
return local_datetime(moment, tz).date()
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def current_date(tz: ZoneInfo | None) -> date:
|
|
374
|
+
return local_datetime(datetime.now(UTC), tz).date()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def timezone_name(tz: ZoneInfo | None) -> str:
|
|
378
|
+
if tz is None:
|
|
379
|
+
return "local"
|
|
380
|
+
return getattr(tz, "key", str(tz))
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def week_start(day: date) -> date:
|
|
384
|
+
return day - timedelta(days=day.weekday())
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def month_start(day: date) -> date:
|
|
388
|
+
return day.replace(day=1)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def add_months(day: date, months: int) -> date:
|
|
392
|
+
total = day.year * 12 + (day.month - 1) + months
|
|
393
|
+
return date(total // 12, total % 12 + 1, 1)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@dataclass(frozen=True)
|
|
397
|
+
class Period:
|
|
398
|
+
key: str
|
|
399
|
+
label: str
|
|
400
|
+
start: date
|
|
401
|
+
end: date
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def make_periods(view: str, start: date, end: date) -> list:
|
|
405
|
+
periods = []
|
|
406
|
+
if view == "daily":
|
|
407
|
+
cursor = start
|
|
408
|
+
while cursor <= end:
|
|
409
|
+
label = cursor.isoformat()
|
|
410
|
+
periods.append(Period(label, label, cursor, cursor))
|
|
411
|
+
cursor += timedelta(days=1)
|
|
412
|
+
return periods
|
|
413
|
+
if view == "weekly":
|
|
414
|
+
cursor = week_start(start)
|
|
415
|
+
last = week_start(end)
|
|
416
|
+
while cursor <= last:
|
|
417
|
+
iso = cursor.isocalendar()
|
|
418
|
+
label = f"{iso[0]:04d}-W{iso[1]:02d}"
|
|
419
|
+
periods.append(
|
|
420
|
+
Period(cursor.isoformat(), label, cursor, cursor + timedelta(days=6))
|
|
421
|
+
)
|
|
422
|
+
cursor += timedelta(days=7)
|
|
423
|
+
return periods
|
|
424
|
+
cursor = month_start(start)
|
|
425
|
+
last = month_start(end)
|
|
426
|
+
while cursor <= last:
|
|
427
|
+
label = f"{cursor.year:04d}-{cursor.month:02d}"
|
|
428
|
+
periods.append(
|
|
429
|
+
Period(label, label, cursor, add_months(cursor, 1) - timedelta(days=1))
|
|
430
|
+
)
|
|
431
|
+
cursor = add_months(cursor, 1)
|
|
432
|
+
return periods
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def period_for(periods: list, day: date) -> str | None:
|
|
436
|
+
for period in periods:
|
|
437
|
+
if period.start <= day <= period.end:
|
|
438
|
+
return period.key
|
|
439
|
+
return None
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# --- grouping ----------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@dataclass
|
|
446
|
+
class Bucket:
|
|
447
|
+
sessions: set = field(default_factory=set)
|
|
448
|
+
models: set = field(default_factory=set)
|
|
449
|
+
turns: int = 0
|
|
450
|
+
input: int = 0
|
|
451
|
+
output: int = 0
|
|
452
|
+
cache_read: int = 0
|
|
453
|
+
cache_write: int = 0
|
|
454
|
+
total_tokens: int = 0
|
|
455
|
+
cost: float = 0.0
|
|
456
|
+
unpriced: int = 0
|
|
457
|
+
last: datetime | None = None
|
|
458
|
+
|
|
459
|
+
def add_session(self, key, moment: datetime | None = None):
|
|
460
|
+
self.sessions.add(key)
|
|
461
|
+
if moment is not None and (self.last is None or moment > self.last):
|
|
462
|
+
self.last = moment
|
|
463
|
+
|
|
464
|
+
def add(self, key, record: UsageRecord):
|
|
465
|
+
self.add_session(key, record.timestamp)
|
|
466
|
+
self.models.add(record.model_label)
|
|
467
|
+
if record.turn:
|
|
468
|
+
self.turns += 1
|
|
469
|
+
self.input += record.input
|
|
470
|
+
self.output += record.output
|
|
471
|
+
self.cache_read += record.cache_read
|
|
472
|
+
self.cache_write += record.cache_write
|
|
473
|
+
self.total_tokens += record.total_tokens
|
|
474
|
+
if record.cost is None:
|
|
475
|
+
self.unpriced += 1
|
|
476
|
+
else:
|
|
477
|
+
self.cost += record.cost
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@dataclass
|
|
481
|
+
class Row:
|
|
482
|
+
key: str
|
|
483
|
+
label: str
|
|
484
|
+
bucket: Bucket
|
|
485
|
+
extra: dict = field(default_factory=dict)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def bucket_data(bucket: Bucket) -> dict:
|
|
489
|
+
return {
|
|
490
|
+
"sessions": len(bucket.sessions),
|
|
491
|
+
"turns": bucket.turns,
|
|
492
|
+
"input": bucket.input,
|
|
493
|
+
"output": bucket.output,
|
|
494
|
+
"cache_read": bucket.cache_read,
|
|
495
|
+
"cache_write": bucket.cache_write,
|
|
496
|
+
"total_tokens": bucket.total_tokens,
|
|
497
|
+
"cost": bucket.cost,
|
|
498
|
+
"unpriced_records": bucket.unpriced,
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def row_data(row: Row) -> dict:
|
|
503
|
+
data = {"key": row.key, "label": row.label}
|
|
504
|
+
data.update(bucket_data(row.bucket))
|
|
505
|
+
if row.bucket.last is not None:
|
|
506
|
+
data["last_active_at"] = row.bucket.last.isoformat()
|
|
507
|
+
data.update(row.extra)
|
|
508
|
+
return data
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# --- report ------------------------------------------------------------------
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def project_matches(project: str, filters: list) -> bool:
|
|
515
|
+
if not filters:
|
|
516
|
+
return True
|
|
517
|
+
text = project.casefold()
|
|
518
|
+
return any(item.casefold() in text for item in filters)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def dataset_bounds(sessions: list, tz: ZoneInfo | None):
|
|
522
|
+
dates = []
|
|
523
|
+
for session in sessions:
|
|
524
|
+
for record in session.records:
|
|
525
|
+
dates.append(local_date(record.timestamp, tz))
|
|
526
|
+
if not session.records and session.start is not None:
|
|
527
|
+
dates.append(local_date(session.start, tz))
|
|
528
|
+
return (min(dates), max(dates)) if dates else (None, None)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def session_in_range(
|
|
532
|
+
session: Session,
|
|
533
|
+
start: date,
|
|
534
|
+
end: date,
|
|
535
|
+
tz: ZoneInfo | None,
|
|
536
|
+
require_date: bool,
|
|
537
|
+
) -> bool:
|
|
538
|
+
if session.records:
|
|
539
|
+
return any(
|
|
540
|
+
start <= local_date(record.timestamp, tz) <= end
|
|
541
|
+
for record in session.records
|
|
542
|
+
)
|
|
543
|
+
if session.start is not None:
|
|
544
|
+
return start <= local_date(session.start, tz) <= end
|
|
545
|
+
return not require_date
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def records_in_range(
|
|
549
|
+
session: Session, start: date, end: date, tz: ZoneInfo | None
|
|
550
|
+
) -> list:
|
|
551
|
+
return [
|
|
552
|
+
record
|
|
553
|
+
for record in session.records
|
|
554
|
+
if start <= local_date(record.timestamp, tz) <= end
|
|
555
|
+
]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def determine_range(view: str, args, sessions: list, tz: ZoneInfo | None):
|
|
559
|
+
first, last = dataset_bounds(sessions, tz)
|
|
560
|
+
today = current_date(tz)
|
|
561
|
+
end = args.until or today
|
|
562
|
+
if args.all:
|
|
563
|
+
if args.until is None and last is not None:
|
|
564
|
+
end = max(end, last)
|
|
565
|
+
start = args.since or (min(first, end) if first is not None else end)
|
|
566
|
+
return start, end, view in TIME_VIEWS or bool(args.since or args.until)
|
|
567
|
+
if view in TIME_VIEWS:
|
|
568
|
+
if args.since is not None:
|
|
569
|
+
start = args.since
|
|
570
|
+
elif view == "daily":
|
|
571
|
+
start = end - timedelta(days=args.days - 1)
|
|
572
|
+
elif view == "weekly":
|
|
573
|
+
start = week_start(end) - timedelta(weeks=args.weeks - 1)
|
|
574
|
+
else:
|
|
575
|
+
start = add_months(month_start(end), -(args.months - 1))
|
|
576
|
+
return start, end, True
|
|
577
|
+
if args.until is None and last is not None:
|
|
578
|
+
end = max(end, last)
|
|
579
|
+
start = args.since or (min(first, end) if first is not None else end)
|
|
580
|
+
return start, end, bool(args.since or args.until)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def project_sessions(sessions: list, args, tz: ZoneInfo | None):
|
|
584
|
+
filtered = [s for s in sessions if project_matches(s.project, args.project)]
|
|
585
|
+
start, end, require_date = determine_range(args.view, args, filtered, tz)
|
|
586
|
+
filtered = [
|
|
587
|
+
s for s in filtered if session_in_range(s, start, end, tz, require_date)
|
|
588
|
+
]
|
|
589
|
+
return filtered, start, end
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def total_bucket(sessions: list, start: date, end: date, tz: ZoneInfo | None) -> Bucket:
|
|
593
|
+
total = Bucket()
|
|
594
|
+
for session in sessions:
|
|
595
|
+
total.add_session(session.key, session.last)
|
|
596
|
+
for record in records_in_range(session, start, end, tz):
|
|
597
|
+
total.add(session.key, record)
|
|
598
|
+
return total
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def time_rows(
|
|
602
|
+
view: str,
|
|
603
|
+
sessions: list,
|
|
604
|
+
start: date,
|
|
605
|
+
end: date,
|
|
606
|
+
tz: ZoneInfo | None,
|
|
607
|
+
) -> list:
|
|
608
|
+
periods = make_periods(view, start, end)
|
|
609
|
+
buckets = {period.key: Bucket() for period in periods}
|
|
610
|
+
for session in sessions:
|
|
611
|
+
records = records_in_range(session, start, end, tz)
|
|
612
|
+
if records:
|
|
613
|
+
for record in records:
|
|
614
|
+
key = period_for(periods, local_date(record.timestamp, tz))
|
|
615
|
+
if key is not None:
|
|
616
|
+
buckets[key].add(session.key, record)
|
|
617
|
+
elif session.start is not None:
|
|
618
|
+
day = local_date(session.start, tz)
|
|
619
|
+
if start <= day <= end:
|
|
620
|
+
key = period_for(periods, day)
|
|
621
|
+
if key is not None:
|
|
622
|
+
buckets[key].add_session(session.key, session.last)
|
|
623
|
+
return [Row(period.key, period.label, buckets[period.key]) for period in periods]
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def project_rows(sessions: list, start: date, end: date, tz: ZoneInfo | None) -> list:
|
|
627
|
+
buckets = {}
|
|
628
|
+
for session in sessions:
|
|
629
|
+
key = session.project
|
|
630
|
+
bucket = buckets.setdefault(key, Bucket())
|
|
631
|
+
bucket.add_session(session.key, session.last)
|
|
632
|
+
for record in records_in_range(session, start, end, tz):
|
|
633
|
+
bucket.add(session.key, record)
|
|
634
|
+
return [
|
|
635
|
+
Row(key, key, bucket)
|
|
636
|
+
for key, bucket in sorted(
|
|
637
|
+
buckets.items(), key=lambda item: (-item[1].cost, item[0])
|
|
638
|
+
)
|
|
639
|
+
]
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def session_rows(sessions: list, start: date, end: date, tz: ZoneInfo | None) -> list:
|
|
643
|
+
rows = []
|
|
644
|
+
for session in sessions:
|
|
645
|
+
bucket = Bucket()
|
|
646
|
+
bucket.add_session(session.key, session.last)
|
|
647
|
+
for record in records_in_range(session, start, end, tz):
|
|
648
|
+
bucket.add(session.key, record)
|
|
649
|
+
extra = {
|
|
650
|
+
"job": session.name,
|
|
651
|
+
"project": session.project,
|
|
652
|
+
"cwd": session.cwd or None,
|
|
653
|
+
"profile": session.profile,
|
|
654
|
+
"models": sorted(bucket.models),
|
|
655
|
+
"files": session.files,
|
|
656
|
+
"depth": session.depth,
|
|
657
|
+
"parent": session.parent,
|
|
658
|
+
"mode": session.mode,
|
|
659
|
+
"started_at": session.start.isoformat() if session.start else None,
|
|
660
|
+
}
|
|
661
|
+
rows.append(
|
|
662
|
+
Row(
|
|
663
|
+
f"{session.slug}/{session.name}",
|
|
664
|
+
session.name,
|
|
665
|
+
bucket,
|
|
666
|
+
extra,
|
|
667
|
+
)
|
|
668
|
+
)
|
|
669
|
+
rows.sort(key=lambda row: (-row.bucket.cost, row.label))
|
|
670
|
+
return rows
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def model_rows(sessions: list, start: date, end: date, tz: ZoneInfo | None) -> list:
|
|
674
|
+
buckets = {}
|
|
675
|
+
for session in sessions:
|
|
676
|
+
for record in records_in_range(session, start, end, tz):
|
|
677
|
+
key = record.model_label
|
|
678
|
+
bucket = buckets.setdefault(key, Bucket())
|
|
679
|
+
bucket.add(session.key, record)
|
|
680
|
+
return [
|
|
681
|
+
Row(key, key, bucket)
|
|
682
|
+
for key, bucket in sorted(
|
|
683
|
+
buckets.items(), key=lambda item: (-item[1].cost, item[0])
|
|
684
|
+
)
|
|
685
|
+
]
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def profile_rows(sessions: list, start: date, end: date, tz: ZoneInfo | None) -> list:
|
|
689
|
+
buckets = {}
|
|
690
|
+
for session in sessions:
|
|
691
|
+
key = session.profile
|
|
692
|
+
bucket = buckets.setdefault(key, Bucket())
|
|
693
|
+
bucket.add_session(session.key, session.last)
|
|
694
|
+
for record in records_in_range(session, start, end, tz):
|
|
695
|
+
bucket.add(session.key, record)
|
|
696
|
+
return [
|
|
697
|
+
Row(key, key, bucket)
|
|
698
|
+
for key, bucket in sorted(
|
|
699
|
+
buckets.items(), key=lambda item: (-item[1].cost, item[0])
|
|
700
|
+
)
|
|
701
|
+
]
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def make_rows(view: str, sessions: list, start: date, end: date, tz) -> list:
|
|
705
|
+
if view in TIME_VIEWS:
|
|
706
|
+
return time_rows(view, sessions, start, end, tz)
|
|
707
|
+
if view == "project":
|
|
708
|
+
return project_rows(sessions, start, end, tz)
|
|
709
|
+
if view == "session":
|
|
710
|
+
return session_rows(sessions, start, end, tz)
|
|
711
|
+
if view == "model":
|
|
712
|
+
return model_rows(sessions, start, end, tz)
|
|
713
|
+
return profile_rows(sessions, start, end, tz)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
@dataclass
|
|
717
|
+
class Report:
|
|
718
|
+
data: dict
|
|
719
|
+
rows: list
|
|
720
|
+
total_rows: int
|
|
721
|
+
sessions: list
|
|
722
|
+
warnings: list
|
|
723
|
+
tz: ZoneInfo | None
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def recent_rows(
|
|
727
|
+
sessions: list, args, tz: ZoneInfo | None, today: date, days: int = 7
|
|
728
|
+
) -> list:
|
|
729
|
+
end = args.until or today
|
|
730
|
+
start = end - timedelta(days=days - 1)
|
|
731
|
+
if args.since is not None and args.since > start:
|
|
732
|
+
start = args.since
|
|
733
|
+
start = min(start, end)
|
|
734
|
+
selected = [
|
|
735
|
+
session
|
|
736
|
+
for session in sessions
|
|
737
|
+
if session_in_range(session, start, end, tz, True)
|
|
738
|
+
]
|
|
739
|
+
return time_rows("daily", selected, start, end, tz)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def generate_report(sessions: list, args, tz: ZoneInfo | None) -> Report:
|
|
743
|
+
warnings = []
|
|
744
|
+
selected, start, end = project_sessions(sessions, args, tz)
|
|
745
|
+
if start > end:
|
|
746
|
+
raise ValueError(
|
|
747
|
+
f"the start date {start.isoformat()} is after the end date {end.isoformat()}"
|
|
748
|
+
)
|
|
749
|
+
totals = total_bucket(selected, start, end, tz)
|
|
750
|
+
if args.view == "overview":
|
|
751
|
+
top_projects = project_rows(selected, start, end, tz)[:5]
|
|
752
|
+
top_models = model_rows(selected, start, end, tz)[:5]
|
|
753
|
+
all_rows = top_projects
|
|
754
|
+
else:
|
|
755
|
+
top_projects = []
|
|
756
|
+
top_models = []
|
|
757
|
+
all_rows = make_rows(args.view, selected, start, end, tz)
|
|
758
|
+
total_rows = len(all_rows)
|
|
759
|
+
rows = all_rows
|
|
760
|
+
if args.limit is not None and args.limit > 0 and args.view in GROUP_VIEWS:
|
|
761
|
+
rows = all_rows[: args.limit]
|
|
762
|
+
model_count = len(
|
|
763
|
+
{
|
|
764
|
+
record.model_label
|
|
765
|
+
for session in selected
|
|
766
|
+
for record in records_in_range(session, start, end, tz)
|
|
767
|
+
}
|
|
768
|
+
)
|
|
769
|
+
data = {
|
|
770
|
+
"generated_at": local_datetime(datetime.now(UTC), tz).isoformat(
|
|
771
|
+
timespec="seconds"
|
|
772
|
+
),
|
|
773
|
+
"view": args.view,
|
|
774
|
+
"timezone": timezone_name(tz),
|
|
775
|
+
"period": {"since": start.isoformat(), "until": end.isoformat()},
|
|
776
|
+
"filters": {"projects": args.project or []},
|
|
777
|
+
"totals": bucket_data(totals),
|
|
778
|
+
"project_count": len({session.project for session in selected}),
|
|
779
|
+
"model_count": model_count,
|
|
780
|
+
"row_count": len(rows),
|
|
781
|
+
"total_rows": total_rows,
|
|
782
|
+
"rows": [row_data(row) for row in rows],
|
|
783
|
+
}
|
|
784
|
+
if args.view == "overview":
|
|
785
|
+
data["top_projects"] = [row_data(row) for row in top_projects]
|
|
786
|
+
data["top_models"] = [row_data(row) for row in top_models]
|
|
787
|
+
data["last_7_days"] = [
|
|
788
|
+
row_data(row) for row in recent_rows(selected, args, tz, current_date(tz))
|
|
789
|
+
]
|
|
790
|
+
return Report(data, rows, total_rows, selected, warnings, tz)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
# --- output ------------------------------------------------------------------
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
class Palette:
|
|
797
|
+
def __init__(self, enabled: bool):
|
|
798
|
+
self.enabled = enabled
|
|
799
|
+
|
|
800
|
+
def _wrap(self, code: str, text: str) -> str:
|
|
801
|
+
if not self.enabled:
|
|
802
|
+
return text
|
|
803
|
+
return f"\033[{code}m{text}\033[0m"
|
|
804
|
+
|
|
805
|
+
def bold(self, text: str) -> str:
|
|
806
|
+
return self._wrap("1", text)
|
|
807
|
+
|
|
808
|
+
def dim(self, text: str) -> str:
|
|
809
|
+
return self._wrap("2", text)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def pad(text: str, width: int, align: str) -> str:
|
|
813
|
+
return text.rjust(width) if align == "right" else text.ljust(width)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def render_table(
|
|
817
|
+
headers: list,
|
|
818
|
+
rows: list,
|
|
819
|
+
aligns: list,
|
|
820
|
+
palette: Palette,
|
|
821
|
+
bold_last: bool = True,
|
|
822
|
+
) -> str:
|
|
823
|
+
widths = [len(header) for header in headers]
|
|
824
|
+
for row in rows:
|
|
825
|
+
for index, cell in enumerate(row):
|
|
826
|
+
widths[index] = max(widths[index], len(str(cell)))
|
|
827
|
+
header_line = " ".join(
|
|
828
|
+
palette.bold(pad(str(header), widths[index], aligns[index]))
|
|
829
|
+
for index, header in enumerate(headers)
|
|
830
|
+
)
|
|
831
|
+
rule = " ".join("-" * width for width in widths)
|
|
832
|
+
lines = [header_line, rule]
|
|
833
|
+
for row_index, row in enumerate(rows):
|
|
834
|
+
line = " ".join(
|
|
835
|
+
pad(str(cell), widths[index], aligns[index])
|
|
836
|
+
for index, cell in enumerate(row)
|
|
837
|
+
)
|
|
838
|
+
if bold_last and row_index == len(rows) - 1:
|
|
839
|
+
line = palette.bold(line)
|
|
840
|
+
lines.append(line)
|
|
841
|
+
return "\n".join(lines)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def format_tokens(value: int) -> str:
|
|
845
|
+
return f"{value:,}"
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def format_compact(value: int) -> str:
|
|
849
|
+
if value >= 1_000_000_000:
|
|
850
|
+
return f"{value / 1_000_000_000:.1f}B"
|
|
851
|
+
if value >= 1_000_000:
|
|
852
|
+
return f"{value / 1_000_000:.1f}M"
|
|
853
|
+
if value >= 1_000:
|
|
854
|
+
return f"{value / 1_000:.1f}k"
|
|
855
|
+
return str(value)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def format_cost(value: float) -> str:
|
|
859
|
+
if value == 0:
|
|
860
|
+
return "$0.00"
|
|
861
|
+
if abs(value) < 0.01:
|
|
862
|
+
return f"${value:.4f}"
|
|
863
|
+
if abs(value) < 1:
|
|
864
|
+
return f"${value:.3f}"
|
|
865
|
+
return f"${value:,.2f}"
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def format_share(part: float, total: float) -> str:
|
|
869
|
+
if total == 0:
|
|
870
|
+
return "-"
|
|
871
|
+
return f"{part / total * 100:.1f}%"
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def format_date(moment: datetime | None, tz: ZoneInfo | None) -> str:
|
|
875
|
+
if moment is None:
|
|
876
|
+
return "-"
|
|
877
|
+
return local_date(moment, tz).isoformat()
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def display_path(path: str) -> str:
|
|
881
|
+
home = str(Path.home())
|
|
882
|
+
if path == home:
|
|
883
|
+
return "~"
|
|
884
|
+
if path.startswith(home + os.sep):
|
|
885
|
+
return "~" + path[len(home) :]
|
|
886
|
+
return path
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def short_project(path: str) -> str:
|
|
890
|
+
parts = Path(path).parts
|
|
891
|
+
if len(parts) >= 2:
|
|
892
|
+
return os.path.join(*parts[-2:])
|
|
893
|
+
return path
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def middle(text: str, limit: int) -> str:
|
|
897
|
+
text = str(text)
|
|
898
|
+
if len(text) <= limit:
|
|
899
|
+
return text
|
|
900
|
+
if limit <= 3:
|
|
901
|
+
return text[:limit]
|
|
902
|
+
left = (limit - 1) // 2
|
|
903
|
+
right = limit - 1 - left
|
|
904
|
+
return text[:left] + "…" + text[-right:]
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def models_text(models: set) -> str:
|
|
908
|
+
ordered = sorted(models)
|
|
909
|
+
if not ordered:
|
|
910
|
+
return "-"
|
|
911
|
+
if len(ordered) == 1:
|
|
912
|
+
return middle(ordered[0], 28)
|
|
913
|
+
first = middle(ordered[0], 13)
|
|
914
|
+
second = middle(ordered[1], 13)
|
|
915
|
+
suffix = "" if len(ordered) == 2 else f" +{len(ordered) - 2}"
|
|
916
|
+
return f"{first}, {second}{suffix}"
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def total_line(totals: dict) -> list:
|
|
920
|
+
return [
|
|
921
|
+
"Total",
|
|
922
|
+
format_tokens(totals["sessions"]),
|
|
923
|
+
format_tokens(totals["turns"]),
|
|
924
|
+
format_tokens(totals["input"]),
|
|
925
|
+
format_tokens(totals["output"]),
|
|
926
|
+
format_tokens(totals["cache_read"]),
|
|
927
|
+
format_tokens(totals["cache_write"]),
|
|
928
|
+
format_tokens(totals["total_tokens"]),
|
|
929
|
+
format_cost(totals["cost"]),
|
|
930
|
+
]
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def render_time_table(report: Report, palette: Palette) -> str:
|
|
934
|
+
headers = [
|
|
935
|
+
"Period",
|
|
936
|
+
"Sessions",
|
|
937
|
+
"Turns",
|
|
938
|
+
"Input",
|
|
939
|
+
"Output",
|
|
940
|
+
"Cache R",
|
|
941
|
+
"Cache W",
|
|
942
|
+
"Total tokens",
|
|
943
|
+
"Cost",
|
|
944
|
+
]
|
|
945
|
+
aligns = ["left"] + ["right"] * 8
|
|
946
|
+
rows = []
|
|
947
|
+
for row in report.rows:
|
|
948
|
+
bucket = row.bucket
|
|
949
|
+
rows.append(
|
|
950
|
+
[
|
|
951
|
+
row.label,
|
|
952
|
+
format_tokens(len(bucket.sessions)),
|
|
953
|
+
format_tokens(bucket.turns),
|
|
954
|
+
format_tokens(bucket.input),
|
|
955
|
+
format_tokens(bucket.output),
|
|
956
|
+
format_tokens(bucket.cache_read),
|
|
957
|
+
format_tokens(bucket.cache_write),
|
|
958
|
+
format_tokens(bucket.total_tokens),
|
|
959
|
+
format_cost(bucket.cost),
|
|
960
|
+
]
|
|
961
|
+
)
|
|
962
|
+
rows.append(total_line(report.data["totals"]))
|
|
963
|
+
return render_table(headers, rows, aligns, palette)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def render_project_table(report: Report, palette: Palette) -> str:
|
|
967
|
+
headers = ["Project", "Sessions", "Turns", "Total tokens", "Cost", "Last active"]
|
|
968
|
+
aligns = ["left", "right", "right", "right", "right", "left"]
|
|
969
|
+
rows = []
|
|
970
|
+
for row in report.rows:
|
|
971
|
+
bucket = row.bucket
|
|
972
|
+
rows.append(
|
|
973
|
+
[
|
|
974
|
+
middle(display_path(row.label), 48),
|
|
975
|
+
format_tokens(len(bucket.sessions)),
|
|
976
|
+
format_tokens(bucket.turns),
|
|
977
|
+
format_tokens(bucket.total_tokens),
|
|
978
|
+
format_cost(bucket.cost),
|
|
979
|
+
format_date(bucket.last, report.tz),
|
|
980
|
+
]
|
|
981
|
+
)
|
|
982
|
+
totals = report.data["totals"]
|
|
983
|
+
rows.append(
|
|
984
|
+
[
|
|
985
|
+
"Total",
|
|
986
|
+
format_tokens(totals["sessions"]),
|
|
987
|
+
format_tokens(totals["turns"]),
|
|
988
|
+
format_tokens(totals["total_tokens"]),
|
|
989
|
+
format_cost(totals["cost"]),
|
|
990
|
+
"-",
|
|
991
|
+
]
|
|
992
|
+
)
|
|
993
|
+
return render_table(headers, rows, aligns, palette)
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def render_session_table(report: Report, palette: Palette) -> str:
|
|
997
|
+
headers = [
|
|
998
|
+
"Agent",
|
|
999
|
+
"Project",
|
|
1000
|
+
"Profile",
|
|
1001
|
+
"Models",
|
|
1002
|
+
"Turns",
|
|
1003
|
+
"Total tokens",
|
|
1004
|
+
"Cost",
|
|
1005
|
+
"Last active",
|
|
1006
|
+
]
|
|
1007
|
+
aligns = ["left", "left", "left", "left", "right", "right", "right", "left"]
|
|
1008
|
+
rows = []
|
|
1009
|
+
for row in report.rows:
|
|
1010
|
+
bucket = row.bucket
|
|
1011
|
+
rows.append(
|
|
1012
|
+
[
|
|
1013
|
+
middle(row.label, 24),
|
|
1014
|
+
middle(short_project(row.extra["project"]), 28),
|
|
1015
|
+
middle(row.extra["profile"], 18),
|
|
1016
|
+
models_text(bucket.models),
|
|
1017
|
+
format_tokens(bucket.turns),
|
|
1018
|
+
format_tokens(bucket.total_tokens),
|
|
1019
|
+
format_cost(bucket.cost),
|
|
1020
|
+
format_date(bucket.last, report.tz),
|
|
1021
|
+
]
|
|
1022
|
+
)
|
|
1023
|
+
totals = report.data["totals"]
|
|
1024
|
+
rows.append(
|
|
1025
|
+
[
|
|
1026
|
+
"Total",
|
|
1027
|
+
"-",
|
|
1028
|
+
"-",
|
|
1029
|
+
"-",
|
|
1030
|
+
format_tokens(totals["turns"]),
|
|
1031
|
+
format_tokens(totals["total_tokens"]),
|
|
1032
|
+
format_cost(totals["cost"]),
|
|
1033
|
+
"-",
|
|
1034
|
+
]
|
|
1035
|
+
)
|
|
1036
|
+
return render_table(headers, rows, aligns, palette)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def render_ranking_table(report: Report, label: str, palette: Palette) -> str:
|
|
1040
|
+
headers = [label, "Sessions", "Turns", "Total tokens", "Cost", "Share"]
|
|
1041
|
+
aligns = ["left", "right", "right", "right", "right", "right"]
|
|
1042
|
+
rows = []
|
|
1043
|
+
total_cost = report.data["totals"]["cost"]
|
|
1044
|
+
for row in report.rows:
|
|
1045
|
+
bucket = row.bucket
|
|
1046
|
+
rows.append(
|
|
1047
|
+
[
|
|
1048
|
+
middle(row.label, 36),
|
|
1049
|
+
format_tokens(len(bucket.sessions)),
|
|
1050
|
+
format_tokens(bucket.turns),
|
|
1051
|
+
format_tokens(bucket.total_tokens),
|
|
1052
|
+
format_cost(bucket.cost),
|
|
1053
|
+
format_share(bucket.cost, total_cost),
|
|
1054
|
+
]
|
|
1055
|
+
)
|
|
1056
|
+
totals = report.data["totals"]
|
|
1057
|
+
rows.append(
|
|
1058
|
+
[
|
|
1059
|
+
"Total",
|
|
1060
|
+
format_tokens(totals["sessions"]),
|
|
1061
|
+
format_tokens(totals["turns"]),
|
|
1062
|
+
format_tokens(totals["total_tokens"]),
|
|
1063
|
+
format_cost(totals["cost"]),
|
|
1064
|
+
format_share(totals["cost"], total_cost),
|
|
1065
|
+
]
|
|
1066
|
+
)
|
|
1067
|
+
return render_table(headers, rows, aligns, palette)
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def render_overview(report: Report, palette: Palette) -> str:
|
|
1071
|
+
totals = report.data["totals"]
|
|
1072
|
+
lines = []
|
|
1073
|
+
period = "{} to {}".format(
|
|
1074
|
+
report.data["period"]["since"], report.data["period"]["until"]
|
|
1075
|
+
)
|
|
1076
|
+
lines.append(palette.bold("pi multi-agent usage") + " " + period)
|
|
1077
|
+
lines.append(
|
|
1078
|
+
"{} sessions · {} projects · {} turns · {} models".format(
|
|
1079
|
+
format_tokens(totals["sessions"]),
|
|
1080
|
+
format_tokens(report.data["project_count"]),
|
|
1081
|
+
format_tokens(totals["turns"]),
|
|
1082
|
+
format_tokens(report.data["model_count"]),
|
|
1083
|
+
)
|
|
1084
|
+
)
|
|
1085
|
+
lines.append("")
|
|
1086
|
+
lines.append("Total cost " + palette.bold(format_cost(totals["cost"])))
|
|
1087
|
+
lines.append(
|
|
1088
|
+
"Tokens {} input {} · output {} · cache read {} · cache write {}".format(
|
|
1089
|
+
format_compact(totals["total_tokens"]),
|
|
1090
|
+
format_compact(totals["input"]),
|
|
1091
|
+
format_compact(totals["output"]),
|
|
1092
|
+
format_compact(totals["cache_read"]),
|
|
1093
|
+
format_compact(totals["cache_write"]),
|
|
1094
|
+
)
|
|
1095
|
+
)
|
|
1096
|
+
if totals["unpriced_records"]:
|
|
1097
|
+
lines.append(
|
|
1098
|
+
"Unpriced {} usage record(s) had no cost".format(
|
|
1099
|
+
totals["unpriced_records"]
|
|
1100
|
+
)
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
lines.append("")
|
|
1104
|
+
lines.append(palette.bold("Top projects"))
|
|
1105
|
+
lines.append(
|
|
1106
|
+
render_ranking_subset(
|
|
1107
|
+
report.data["top_projects"], "Project", palette, full_path=True
|
|
1108
|
+
)
|
|
1109
|
+
)
|
|
1110
|
+
lines.append("")
|
|
1111
|
+
lines.append(palette.bold("Top models"))
|
|
1112
|
+
lines.append(render_ranking_subset(report.data["top_models"], "Model", palette))
|
|
1113
|
+
lines.append("")
|
|
1114
|
+
lines.append(palette.bold("Last 7 days"))
|
|
1115
|
+
lines.append(render_recent_subset(report.data["last_7_days"], palette))
|
|
1116
|
+
lines.append("")
|
|
1117
|
+
lines.append("Views: " + " ".join(VIEWS))
|
|
1118
|
+
return "\n".join(lines)
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def render_ranking_subset(
|
|
1122
|
+
rows: list, label: str, palette: Palette, full_path: bool = False
|
|
1123
|
+
) -> str:
|
|
1124
|
+
headers = [label, "Sessions", "Turns", "Tokens", "Cost"]
|
|
1125
|
+
aligns = ["left", "right", "right", "right", "right"]
|
|
1126
|
+
data = []
|
|
1127
|
+
for row in rows:
|
|
1128
|
+
name = display_path(row["label"]) if full_path else row["label"]
|
|
1129
|
+
data.append(
|
|
1130
|
+
[
|
|
1131
|
+
middle(name, 48),
|
|
1132
|
+
format_tokens(row["sessions"]),
|
|
1133
|
+
format_tokens(row["turns"]),
|
|
1134
|
+
format_compact(row["total_tokens"]),
|
|
1135
|
+
format_cost(row["cost"]),
|
|
1136
|
+
]
|
|
1137
|
+
)
|
|
1138
|
+
return render_table(headers, data, aligns, palette, bold_last=False)
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def render_recent_subset(rows: list, palette: Palette) -> str:
|
|
1142
|
+
headers = ["Date", "Sessions", "Turns", "Tokens", "Cost"]
|
|
1143
|
+
aligns = ["left", "right", "right", "right", "right"]
|
|
1144
|
+
data = []
|
|
1145
|
+
for row in rows:
|
|
1146
|
+
data.append(
|
|
1147
|
+
[
|
|
1148
|
+
row["label"],
|
|
1149
|
+
format_tokens(row["sessions"]),
|
|
1150
|
+
format_tokens(row["turns"]),
|
|
1151
|
+
format_compact(row["total_tokens"]),
|
|
1152
|
+
format_cost(row["cost"]),
|
|
1153
|
+
]
|
|
1154
|
+
)
|
|
1155
|
+
return render_table(headers, data, aligns, palette, bold_last=False)
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def render_text(report: Report, palette: Palette) -> str:
|
|
1159
|
+
if report.data["view"] == "overview":
|
|
1160
|
+
return render_overview(report, palette)
|
|
1161
|
+
if report.data["view"] in TIME_VIEWS:
|
|
1162
|
+
return render_time_table(report, palette)
|
|
1163
|
+
if report.data["view"] == "project":
|
|
1164
|
+
return render_project_table(report, palette)
|
|
1165
|
+
if report.data["view"] == "session":
|
|
1166
|
+
return render_session_table(report, palette)
|
|
1167
|
+
if report.data["view"] == "model":
|
|
1168
|
+
return render_ranking_table(report, "Model", palette)
|
|
1169
|
+
return render_ranking_table(report, "Profile", palette)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def color_enabled(args) -> bool:
|
|
1173
|
+
return not args.no_color and "NO_COLOR" not in os.environ and sys.stdout.isatty()
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1177
|
+
parser = argparse.ArgumentParser(
|
|
1178
|
+
description=(
|
|
1179
|
+
"Show token usage and cost from the pi sessions the multi-agent "
|
|
1180
|
+
"skill stores under its own session directory."
|
|
1181
|
+
),
|
|
1182
|
+
epilog=(
|
|
1183
|
+
"Examples:\n"
|
|
1184
|
+
" usage.py daily --days 14\n"
|
|
1185
|
+
" usage.py project --since 2026-10-01\n"
|
|
1186
|
+
" usage.py session --json\n"
|
|
1187
|
+
" usage.py weekly --all\n"
|
|
1188
|
+
),
|
|
1189
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
1190
|
+
)
|
|
1191
|
+
parser.add_argument(
|
|
1192
|
+
"view",
|
|
1193
|
+
nargs="?",
|
|
1194
|
+
default="overview",
|
|
1195
|
+
choices=VIEWS + tuple(VIEW_ALIASES),
|
|
1196
|
+
help="report view (default: overview)",
|
|
1197
|
+
)
|
|
1198
|
+
parser.add_argument(
|
|
1199
|
+
"--days", type=positive_int, default=30, help="days in daily view (default: 30)"
|
|
1200
|
+
)
|
|
1201
|
+
parser.add_argument(
|
|
1202
|
+
"--weeks",
|
|
1203
|
+
type=positive_int,
|
|
1204
|
+
default=12,
|
|
1205
|
+
help="weeks in weekly view (default: 12)",
|
|
1206
|
+
)
|
|
1207
|
+
parser.add_argument(
|
|
1208
|
+
"--months",
|
|
1209
|
+
type=positive_int,
|
|
1210
|
+
default=12,
|
|
1211
|
+
help="months in monthly view (default: 12)",
|
|
1212
|
+
)
|
|
1213
|
+
parser.add_argument("--since", type=date_arg, metavar="YYYY-MM-DD")
|
|
1214
|
+
parser.add_argument("--until", type=date_arg, metavar="YYYY-MM-DD")
|
|
1215
|
+
parser.add_argument(
|
|
1216
|
+
"--project",
|
|
1217
|
+
action="append",
|
|
1218
|
+
metavar="TEXT",
|
|
1219
|
+
help="only projects whose path contains TEXT; repeatable",
|
|
1220
|
+
)
|
|
1221
|
+
parser.add_argument(
|
|
1222
|
+
"--all", action="store_true", help="show all time instead of the default window"
|
|
1223
|
+
)
|
|
1224
|
+
parser.add_argument(
|
|
1225
|
+
"--timezone",
|
|
1226
|
+
type=timezone_arg,
|
|
1227
|
+
metavar="NAME",
|
|
1228
|
+
help="IANA timezone for day boundaries (default: local)",
|
|
1229
|
+
)
|
|
1230
|
+
parser.add_argument(
|
|
1231
|
+
"--limit",
|
|
1232
|
+
type=nonnegative_int,
|
|
1233
|
+
metavar="N",
|
|
1234
|
+
help="limit rows in group views; 0 means all",
|
|
1235
|
+
)
|
|
1236
|
+
parser.add_argument("--json", action="store_true", help="print one JSON object")
|
|
1237
|
+
parser.add_argument("--no-color", action="store_true", help="disable ANSI color")
|
|
1238
|
+
return parser
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def positive_int(value: str) -> int:
|
|
1242
|
+
try:
|
|
1243
|
+
number = int(value)
|
|
1244
|
+
except ValueError:
|
|
1245
|
+
raise argparse.ArgumentTypeError("expected a positive whole number")
|
|
1246
|
+
if number < 1:
|
|
1247
|
+
raise argparse.ArgumentTypeError("expected a positive whole number")
|
|
1248
|
+
return number
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def nonnegative_int(value: str) -> int:
|
|
1252
|
+
try:
|
|
1253
|
+
number = int(value)
|
|
1254
|
+
except ValueError:
|
|
1255
|
+
raise argparse.ArgumentTypeError("expected a whole number")
|
|
1256
|
+
if number < 0:
|
|
1257
|
+
raise argparse.ArgumentTypeError("expected a whole number")
|
|
1258
|
+
return number
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def date_arg(value: str) -> date:
|
|
1262
|
+
try:
|
|
1263
|
+
return date.fromisoformat(value)
|
|
1264
|
+
except ValueError:
|
|
1265
|
+
raise argparse.ArgumentTypeError("expected a date such as 2026-10-01")
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def timezone_arg(value: str) -> ZoneInfo | None:
|
|
1269
|
+
if value.lower() in ("local", ""):
|
|
1270
|
+
return None
|
|
1271
|
+
try:
|
|
1272
|
+
return ZoneInfo(value)
|
|
1273
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
1274
|
+
raise argparse.ArgumentTypeError(f"unknown timezone: {value}")
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def main(argv=None) -> int:
|
|
1278
|
+
parser = build_parser()
|
|
1279
|
+
args = parser.parse_args(argv)
|
|
1280
|
+
args.view = VIEW_ALIASES.get(args.view, args.view)
|
|
1281
|
+
if args.since is not None and args.until is not None and args.since > args.until:
|
|
1282
|
+
parser.error("--since must not be after --until")
|
|
1283
|
+
root = sessions_root()
|
|
1284
|
+
jobs = load_jobs(state_root())
|
|
1285
|
+
sessions, warnings = load_sessions(root, jobs)
|
|
1286
|
+
try:
|
|
1287
|
+
report = generate_report(sessions, args, args.timezone)
|
|
1288
|
+
except ValueError as exc:
|
|
1289
|
+
print(f"usage.py: error: {exc}", file=sys.stderr)
|
|
1290
|
+
return 2
|
|
1291
|
+
report.warnings.extend(warnings)
|
|
1292
|
+
for warning in report.warnings:
|
|
1293
|
+
print(warning, file=sys.stderr)
|
|
1294
|
+
|
|
1295
|
+
if args.json:
|
|
1296
|
+
print(json.dumps(report.data, indent=2, ensure_ascii=False))
|
|
1297
|
+
return 0
|
|
1298
|
+
|
|
1299
|
+
if not sessions:
|
|
1300
|
+
print(f"No multi-agent sessions found in {root}.")
|
|
1301
|
+
return 0
|
|
1302
|
+
if not report.sessions:
|
|
1303
|
+
print("No sessions match the filters.")
|
|
1304
|
+
return 0
|
|
1305
|
+
|
|
1306
|
+
palette = Palette(color_enabled(args))
|
|
1307
|
+
print(render_text(report, palette))
|
|
1308
|
+
totals = report.data["totals"]
|
|
1309
|
+
if totals["unpriced_records"] and report.data["view"] != "overview":
|
|
1310
|
+
print(
|
|
1311
|
+
palette.dim(
|
|
1312
|
+
"{} usage record(s) had no cost.".format(totals["unpriced_records"])
|
|
1313
|
+
)
|
|
1314
|
+
)
|
|
1315
|
+
if report.total_rows > len(report.rows):
|
|
1316
|
+
print(
|
|
1317
|
+
palette.dim(
|
|
1318
|
+
f"Showing {len(report.rows)} of {report.total_rows} rows; use --limit 0 for all."
|
|
1319
|
+
)
|
|
1320
|
+
)
|
|
1321
|
+
return 0
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
if __name__ == "__main__":
|
|
1325
|
+
sys.exit(main())
|