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())