usage-cli 0.29.32__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
analyzer/reporter.py ADDED
@@ -0,0 +1,989 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ from collections import defaultdict
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from dataclasses import dataclass
13
+ from datetime import date, datetime, timedelta, timezone
14
+ import json
15
+ import logging
16
+ import os
17
+ from pathlib import Path
18
+ import tempfile
19
+ import time
20
+ from typing import Any, NotRequired, TypedDict, cast
21
+
22
+ import codex_loader
23
+ from analyzer import persona_loader
24
+ from analyzer import subscription
25
+ from adapters import agy, claude, codex
26
+ from adapters.types import AgentInfo, UsageEntry
27
+ from pricing import calculate_cost, is_model_priced
28
+
29
+ from .aggregator import aggregate_sessions
30
+ from . import diagnoser
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ AGENT_LOADERS = {"claude-code": claude, "codex": codex, "antigravity": agy}
35
+ AGENT_NAMES = {"claude-code": "Claude Code", "codex": "Codex"}
36
+ _YEAR_WEEKS = 53
37
+ YEAR_CACHE_PATH = Path(os.path.expanduser("~/.usage/year_cache.json"))
38
+ YEAR_LEDGER_PATH = YEAR_CACHE_PATH.with_name("year_ledger.json")
39
+ YEAR_CACHE_TTL_SECONDS = 6 * 3600
40
+ _YEAR_CACHE_SCHEMA = 1
41
+ _YEAR_LEDGER_SCHEMA = 1
42
+ _YEAR_LEDGER_TRIM_BUFFER_DAYS = 60
43
+
44
+
45
+ class _YearDay(TypedDict):
46
+ total_tokens: int
47
+ cost: float
48
+ model_tokens: dict[str, int]
49
+ project_tokens: dict[str, int]
50
+ agent_tokens: dict[str, int]
51
+ sessions: int
52
+
53
+
54
+ class _YearLedger(TypedDict):
55
+ schema_version: int
56
+ days: dict[str, _YearDay]
57
+
58
+
59
+ class SummaryReportData(TypedDict):
60
+ total_tokens: int
61
+ cost_usd: float
62
+ sessions: int
63
+ messages: int
64
+ active_days: int
65
+ total_days: int
66
+
67
+
68
+ class AgentReportRow(TypedDict):
69
+ id: str
70
+ name: str
71
+ tokens: int
72
+ cost: float
73
+ sessions: int
74
+ messages: int
75
+ pct: float
76
+
77
+
78
+ class ProjectReportRow(TypedDict):
79
+ project: str
80
+ tokens: int
81
+ cost: float
82
+ sessions: int
83
+ pct: float
84
+
85
+
86
+ class ModelReportRow(TypedDict):
87
+ model: str
88
+ tokens: int
89
+ cost: float
90
+ cost_known: bool
91
+ pct: float
92
+ top_project: str | None
93
+
94
+
95
+ class DailyTrendPoint(TypedDict):
96
+ date: str
97
+ tokens: int
98
+ cost: float
99
+
100
+
101
+ class TopSessionReportRow(TypedDict):
102
+ start_time: str
103
+ project: str
104
+ model: str
105
+ duration_min: float
106
+ tokens: int
107
+ cost: float
108
+
109
+
110
+ class ComparisonReportData(TypedDict):
111
+ period: str
112
+ has_prev: bool
113
+ prev_tokens: int
114
+ prev_cost: float
115
+ prev_projects: list[str]
116
+ prev_model_share: dict[str, float]
117
+
118
+
119
+ class PersonaReportData(TypedDict):
120
+ hour_histogram: list[int]
121
+ recent_titles: list[str]
122
+ top_projects: NotRequired[list[tuple[str, int]]]
123
+
124
+
125
+ class ContributionDay(TypedDict):
126
+ date: str
127
+ tokens: int
128
+ level: int
129
+
130
+
131
+ class BusiestDay(TypedDict):
132
+ date: str
133
+ tokens: int
134
+
135
+
136
+ class ContributionReportData(TypedDict):
137
+ weeks: list[list[ContributionDay]]
138
+ start: str
139
+ end: str
140
+ max_tokens: int
141
+ total_tokens: int
142
+ active_days: int
143
+ current_streak: int
144
+ longest_streak: int
145
+ busiest_day: BusiestDay | None
146
+
147
+
148
+ class WrappedReportData(TypedDict):
149
+ year_label: str
150
+ total_tokens: int
151
+ total_cost: float
152
+ active_days: int
153
+ total_sessions: int
154
+ top_model: str | None
155
+ top_project: str | None
156
+ busiest_day: BusiestDay | None
157
+ longest_streak: int
158
+ claude_tokens: int
159
+ codex_tokens: int
160
+ beast: str | None
161
+
162
+
163
+ class YearReportData(TypedDict):
164
+ contribution: ContributionReportData
165
+ wrapped: WrappedReportData
166
+
167
+
168
+ class ReportData(TypedDict):
169
+ period: str
170
+ period_label: str
171
+ date_from: str
172
+ date_to: str
173
+ summary: SummaryReportData
174
+ by_agent: list[AgentReportRow]
175
+ by_project: list[ProjectReportRow]
176
+ by_model: list[ModelReportRow]
177
+ daily_trend: list[DailyTrendPoint]
178
+ top_sessions: list[TopSessionReportRow]
179
+ comparison: ComparisonReportData
180
+ subscriptions: list[dict[str, str | None]]
181
+ persona: PersonaReportData | None
182
+ contribution: ContributionReportData
183
+ wrapped: WrappedReportData
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class _PeriodSpec:
188
+ persona_days: int
189
+ has_comparison: bool
190
+
191
+
192
+ # 每個時間範圍的所有屬性集中在這一張表 —— 加新範圍只改這裡,
193
+ # 不再散落到多個函式各維護一份名單(那正是 last30 漏掉前期比較的根因)。
194
+ PERIOD_SPECS: dict[str, _PeriodSpec] = {
195
+ "today": _PeriodSpec(persona_days=1, has_comparison=False),
196
+ "week": _PeriodSpec(persona_days=7, has_comparison=True),
197
+ "last7": _PeriodSpec(persona_days=7, has_comparison=True),
198
+ "month": _PeriodSpec(persona_days=30, has_comparison=True),
199
+ # last30 刻意不做前期比較:它是預設/最常開的報告,做比較要多載一倍歷史
200
+ # (v0.11.6「faster Codex reports」的效能取捨,由 test_report_last30_uses_expected_codex_hours_back 守護)。
201
+ "last30": _PeriodSpec(persona_days=30, has_comparison=False),
202
+ "all": _PeriodSpec(persona_days=3650, has_comparison=False),
203
+ }
204
+ # 未知 period 的保底:與收斂前各名單對未列出 period 的行為一致。
205
+ _DEFAULT_PERIOD_SPEC = _PeriodSpec(persona_days=30, has_comparison=False)
206
+
207
+
208
+ def _period_spec(period: str) -> _PeriodSpec:
209
+ return PERIOD_SPECS.get(period, _DEFAULT_PERIOD_SPEC)
210
+
211
+
212
+ def _entry_date(entry: UsageEntry) -> date:
213
+ ts = entry.timestamp
214
+ if ts.tzinfo:
215
+ ts = ts.astimezone()
216
+ return ts.date()
217
+
218
+
219
+ def _period_bounds(period: str, today: date) -> tuple[date | None, date]:
220
+ if period == "today":
221
+ return today, today
222
+ if period == "week":
223
+ return today - timedelta(days=today.weekday()), today
224
+ if period == "last7":
225
+ return today - timedelta(days=6), today
226
+ if period == "month":
227
+ return today.replace(day=1), today
228
+ if period == "all":
229
+ return None, today
230
+ if period == "last30":
231
+ return today - timedelta(days=29), today
232
+ return today.replace(day=1), today
233
+
234
+
235
+ def _load_agent_entries(
236
+ agent: AgentInfo,
237
+ hours_back: int = 0,
238
+ ) -> list[UsageEntry]:
239
+ if hours_back > 0 and agent.id == "claude-code":
240
+ return _load_recent_claude_entries(hours_back)
241
+ if agent.id == "codex":
242
+ return _load_codex_entries(hours_back)
243
+ loader = AGENT_LOADERS.get(agent.id)
244
+ if loader is None:
245
+ return []
246
+ entries: list[UsageEntry] = loader.load_entries(hours_back=hours_back)
247
+ for entry in entries:
248
+ entry.agent_id = agent.id
249
+ return entries
250
+
251
+
252
+ def _load_recent_claude_entries(hours_back: int) -> list[UsageEntry]:
253
+ entries: list[UsageEntry] = []
254
+ seen: set[str] = set()
255
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=hours_back)
256
+ cutoff_ts = cutoff.timestamp()
257
+ jobs: list[tuple[Path, Path]] = []
258
+ for base_dir in claude.get_claude_dirs():
259
+ base = Path(base_dir)
260
+ if not base.is_dir():
261
+ continue
262
+ for jsonl_path in base.rglob("*.jsonl"):
263
+ try:
264
+ if jsonl_path.stat().st_mtime < cutoff_ts:
265
+ continue
266
+ except OSError:
267
+ continue
268
+ jobs.append((jsonl_path, base))
269
+ with ThreadPoolExecutor(max_workers=8) as executor:
270
+ results = executor.map(lambda job: _parse_claude_file(job[0], job[1], cutoff), jobs)
271
+ for parsed in results:
272
+ for entry in parsed:
273
+ if entry.dedup_key in seen:
274
+ continue
275
+ seen.add(entry.dedup_key)
276
+ entries.append(entry)
277
+ entries.sort(key=lambda entry: entry.timestamp)
278
+ return entries
279
+
280
+
281
+ def _parse_claude_file(path: Path, base: Path, cutoff: datetime) -> list[UsageEntry]:
282
+ parsed: list[UsageEntry] = []
283
+ local_seen: set[str] = set()
284
+ fallback_project = claude.extract_project_from_dir(path, base)
285
+ claude.parse_jsonl(path, fallback_project, parsed, local_seen, cutoff)
286
+ return parsed
287
+
288
+
289
+ def _load_codex_entries(hours_back: int) -> list[UsageEntry]:
290
+ return [
291
+ UsageEntry(
292
+ timestamp=entry.timestamp,
293
+ session_id=entry.session_id,
294
+ message_id=entry.message_id,
295
+ request_id=entry.request_id,
296
+ model=entry.model,
297
+ input_tokens=entry.input_tokens,
298
+ output_tokens=entry.output_tokens,
299
+ cache_creation_tokens=entry.cache_creation_tokens,
300
+ cache_read_tokens=entry.cache_read_tokens,
301
+ cost_usd=entry.cost_usd,
302
+ project=entry.project,
303
+ agent_id="codex",
304
+ message_count=getattr(entry, "message_count", 1),
305
+ )
306
+ for entry in codex_loader.load_entries(hours_back=hours_back)
307
+ ]
308
+
309
+
310
+ def _pct(value: int, total: int) -> float:
311
+ return round((value / total * 100), 1) if total else 0.0
312
+
313
+
314
+ def _round_cost(value: float) -> float:
315
+ return round(value, 4)
316
+
317
+
318
+ def _load_persona_for_period(period: str) -> PersonaReportData | None:
319
+ days_back = _period_spec(period).persona_days
320
+ try:
321
+ profile = persona_loader.load_profile(days_back)
322
+ except Exception:
323
+ return None
324
+ return {
325
+ "hour_histogram": list(profile.hour_histogram),
326
+ "recent_titles": list(profile.recent_titles),
327
+ }
328
+
329
+
330
+ def _empty_comparison(period: str) -> ComparisonReportData:
331
+ return {
332
+ "period": period,
333
+ "has_prev": False,
334
+ "prev_tokens": 0,
335
+ "prev_cost": 0.0,
336
+ "prev_projects": [],
337
+ "prev_model_share": {},
338
+ }
339
+
340
+
341
+ def _build_comparison(
342
+ raw_entries: list[UsageEntry],
343
+ entry_dates: dict[int, date],
344
+ period: str,
345
+ date_from: date,
346
+ date_to: date,
347
+ ) -> ComparisonReportData:
348
+ if not _period_spec(period).has_comparison:
349
+ return _empty_comparison(period)
350
+
351
+ total_days = (date_to - date_from).days + 1
352
+ prev_date_to = date_from - timedelta(days=1)
353
+ prev_date_from = prev_date_to - timedelta(days=total_days - 1)
354
+ prev_entries = [
355
+ entry
356
+ for entry in raw_entries
357
+ if prev_date_from <= entry_dates[id(entry)] <= prev_date_to
358
+ ]
359
+
360
+ prev_tokens = sum(entry.total_tokens for entry in prev_entries)
361
+ prev_cost = sum(calculate_cost(entry) for entry in prev_entries)
362
+ prev_projects = sorted(
363
+ {entry.project or "unknown" for entry in prev_entries}
364
+ )
365
+ model_tokens: dict[str, int] = defaultdict(int)
366
+ for entry in prev_entries:
367
+ model_tokens[entry.model or "unknown"] += entry.total_tokens
368
+ prev_model_share = {
369
+ model: _pct(tokens, prev_tokens)
370
+ for model, tokens in sorted(model_tokens.items())
371
+ }
372
+
373
+ return {
374
+ "period": period,
375
+ "has_prev": bool(prev_entries),
376
+ "prev_tokens": prev_tokens,
377
+ "prev_cost": _round_cost(prev_cost),
378
+ "prev_projects": prev_projects,
379
+ "prev_model_share": prev_model_share,
380
+ }
381
+
382
+
383
+ def _top_project(project_tokens: dict[str, int]) -> str | None:
384
+ if not project_tokens:
385
+ return None
386
+ return max(project_tokens.items(), key=lambda item: item[1])[0]
387
+
388
+
389
+ def _top_name(bucket: dict[str, int]) -> str | None:
390
+ if not bucket:
391
+ return None
392
+ return sorted(bucket.items(), key=lambda item: (-item[1], item[0]))[0][0]
393
+
394
+
395
+ def _streaks(
396
+ daily_tokens: dict[date, int],
397
+ start: date,
398
+ end: date,
399
+ ) -> tuple[int, int]:
400
+ current_streak = 0
401
+ cursor = end
402
+ while cursor >= start and daily_tokens.get(cursor, 0) > 0:
403
+ current_streak += 1
404
+ cursor -= timedelta(days=1)
405
+
406
+ longest_streak = 0
407
+ streak = 0
408
+ cursor = start
409
+ while cursor <= end:
410
+ if daily_tokens.get(cursor, 0) > 0:
411
+ streak += 1
412
+ longest_streak = max(longest_streak, streak)
413
+ else:
414
+ streak = 0
415
+ cursor += timedelta(days=1)
416
+ return current_streak, longest_streak
417
+
418
+
419
+ def _contribution_thresholds(active_tokens: list[int]) -> list[int]:
420
+ sorted_tokens = sorted(tokens for tokens in active_tokens if tokens > 0)
421
+ if not sorted_tokens:
422
+ return []
423
+
424
+ last_index = len(sorted_tokens) - 1
425
+ return [
426
+ sorted_tokens[min(last_index, (len(sorted_tokens) * quartile - 1) // 4)]
427
+ for quartile in range(1, 5)
428
+ ]
429
+
430
+
431
+ def _contribution_level(tokens: int, thresholds: list[int]) -> int:
432
+ if tokens <= 0 or not thresholds:
433
+ return 0
434
+ for level, threshold in enumerate(thresholds, start=1):
435
+ if tokens <= threshold:
436
+ return level
437
+ return 4
438
+
439
+
440
+ def _empty_year_ledger() -> _YearLedger:
441
+ return {
442
+ "schema_version": _YEAR_LEDGER_SCHEMA,
443
+ "days": {},
444
+ }
445
+
446
+
447
+ def _token_map(value: object) -> dict[str, int]:
448
+ if not isinstance(value, dict):
449
+ return {}
450
+ tokens: dict[str, int] = {}
451
+ for key, raw_tokens in value.items():
452
+ if (
453
+ isinstance(key, str)
454
+ and isinstance(raw_tokens, int)
455
+ and not isinstance(raw_tokens, bool)
456
+ ):
457
+ tokens[key] = raw_tokens
458
+ return tokens
459
+
460
+
461
+ def _year_day_from_json(value: object) -> _YearDay | None:
462
+ if not isinstance(value, dict):
463
+ return None
464
+ total_tokens = value.get("total_tokens")
465
+ cost = value.get("cost")
466
+ sessions = value.get("sessions")
467
+ if (
468
+ not isinstance(total_tokens, int)
469
+ or isinstance(total_tokens, bool)
470
+ or not isinstance(cost, int | float)
471
+ or isinstance(cost, bool)
472
+ or not isinstance(sessions, int)
473
+ or isinstance(sessions, bool)
474
+ ):
475
+ return None
476
+ return {
477
+ "total_tokens": total_tokens,
478
+ "cost": float(cost),
479
+ "model_tokens": _token_map(value.get("model_tokens")),
480
+ "project_tokens": _token_map(value.get("project_tokens")),
481
+ "agent_tokens": _token_map(value.get("agent_tokens")),
482
+ "sessions": sessions,
483
+ }
484
+
485
+
486
+ def _read_year_ledger() -> _YearLedger:
487
+ try:
488
+ with YEAR_LEDGER_PATH.open(encoding="utf-8") as file:
489
+ ledger = json.load(file)
490
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
491
+ if os.environ.get("USAGE_DEBUG") == "1":
492
+ logger.warning(
493
+ "failed to read year ledger %s",
494
+ YEAR_LEDGER_PATH,
495
+ exc_info=True,
496
+ )
497
+ return _empty_year_ledger()
498
+
499
+ if not isinstance(ledger, dict):
500
+ return _empty_year_ledger()
501
+ if ledger.get("schema_version") != _YEAR_LEDGER_SCHEMA:
502
+ return _empty_year_ledger()
503
+ raw_days = ledger.get("days")
504
+ if not isinstance(raw_days, dict):
505
+ return _empty_year_ledger()
506
+
507
+ days: dict[str, _YearDay] = {}
508
+ for day_key, raw_day in raw_days.items():
509
+ if not isinstance(day_key, str):
510
+ continue
511
+ try:
512
+ date.fromisoformat(day_key)
513
+ except ValueError:
514
+ continue
515
+ day = _year_day_from_json(raw_day)
516
+ if day is not None:
517
+ days[day_key] = day
518
+ return {
519
+ "schema_version": _YEAR_LEDGER_SCHEMA,
520
+ "days": days,
521
+ }
522
+
523
+
524
+ def _write_year_ledger(ledger: _YearLedger) -> None:
525
+ tmp_path: str | None = None
526
+ try:
527
+ YEAR_LEDGER_PATH.parent.mkdir(parents=True, exist_ok=True)
528
+ fd, tmp_path = tempfile.mkstemp(dir=YEAR_LEDGER_PATH.parent, suffix=".tmp")
529
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
530
+ json.dump(ledger, file, ensure_ascii=False, indent=2, sort_keys=True)
531
+ os.replace(tmp_path, YEAR_LEDGER_PATH)
532
+ tmp_path = None
533
+ except Exception as exc:
534
+ if os.environ.get("USAGE_DEBUG") == "1":
535
+ logger.warning("failed to write year ledger %s: %s", YEAR_LEDGER_PATH, exc)
536
+ finally:
537
+ if tmp_path and os.path.exists(tmp_path):
538
+ with contextlib.suppress(OSError):
539
+ os.unlink(tmp_path)
540
+
541
+
542
+ def _new_year_day() -> _YearDay:
543
+ return {
544
+ "total_tokens": 0,
545
+ "cost": 0.0,
546
+ "model_tokens": {},
547
+ "project_tokens": {},
548
+ "agent_tokens": {},
549
+ "sessions": 0,
550
+ }
551
+
552
+
553
+ def _aggregate_year_days(entries: list[UsageEntry]) -> dict[str, _YearDay]:
554
+ days: dict[str, _YearDay] = {}
555
+ session_ids: dict[str, set[str]] = defaultdict(set)
556
+ for entry in entries:
557
+ day_key = _entry_date(entry).isoformat()
558
+ day = days.setdefault(day_key, _new_year_day())
559
+ entry_tokens = entry.total_tokens
560
+ day["total_tokens"] += entry_tokens
561
+ day["cost"] += calculate_cost(entry)
562
+ day["model_tokens"][entry.model or "unknown"] = (
563
+ day["model_tokens"].get(entry.model or "unknown", 0) + entry_tokens
564
+ )
565
+ day["project_tokens"][entry.project or "unknown"] = (
566
+ day["project_tokens"].get(entry.project or "unknown", 0) + entry_tokens
567
+ )
568
+ day["agent_tokens"][entry.agent_id or "unknown"] = (
569
+ day["agent_tokens"].get(entry.agent_id or "unknown", 0) + entry_tokens
570
+ )
571
+ session_ids[day_key].add(entry.session_id)
572
+
573
+ for day_key, ids in session_ids.items():
574
+ days[day_key]["sessions"] = len(ids)
575
+ return days
576
+
577
+
578
+ def _merge_year_ledger(
579
+ current_days: dict[str, _YearDay],
580
+ *,
581
+ trim_before: date,
582
+ ) -> _YearLedger:
583
+ ledger = _read_year_ledger()
584
+ days = ledger["days"]
585
+ for day_key, current_day in current_days.items():
586
+ ledger_day = days.get(day_key)
587
+ if ledger_day is None or current_day["total_tokens"] >= ledger_day["total_tokens"]:
588
+ days[day_key] = current_day
589
+
590
+ ledger["days"] = {
591
+ day_key: day
592
+ for day_key, day in days.items()
593
+ if date.fromisoformat(day_key) >= trim_before
594
+ }
595
+ _write_year_ledger(ledger)
596
+ return ledger
597
+
598
+
599
+ def _build_year_output_from_ledger(
600
+ ledger: _YearLedger,
601
+ *,
602
+ grid_start: date,
603
+ grid_end: date,
604
+ today: date,
605
+ ) -> YearReportData:
606
+ daily_tokens: dict[date, int] = {}
607
+ model_tokens: dict[str, int] = defaultdict(int)
608
+ project_tokens: dict[str, int] = defaultdict(int)
609
+ agent_tokens: dict[str, int] = defaultdict(int)
610
+ total_tokens = 0
611
+ total_cost = 0.0
612
+ total_sessions = 0
613
+
614
+ for day_key, day in ledger["days"].items():
615
+ day_date = date.fromisoformat(day_key)
616
+ if not grid_start <= day_date <= today:
617
+ continue
618
+ daily_tokens[day_date] = day["total_tokens"]
619
+ total_tokens += day["total_tokens"]
620
+ total_cost += day["cost"]
621
+ total_sessions += day["sessions"]
622
+ for model, tokens in day["model_tokens"].items():
623
+ model_tokens[model] += tokens
624
+ for project, tokens in day["project_tokens"].items():
625
+ project_tokens[project] += tokens
626
+ for agent_id, tokens in day["agent_tokens"].items():
627
+ agent_tokens[agent_id] += tokens
628
+
629
+ active_days = sum(1 for tokens in daily_tokens.values() if tokens > 0)
630
+ contribution_thresholds = _contribution_thresholds(list(daily_tokens.values()))
631
+ max_tokens = max(daily_tokens.values(), default=0)
632
+ busiest_day: BusiestDay | None = None
633
+ if max_tokens > 0:
634
+ busiest_date = min(
635
+ day for day, tokens in daily_tokens.items() if tokens == max_tokens
636
+ )
637
+ busiest_day = {
638
+ "date": busiest_date.isoformat(),
639
+ "tokens": max_tokens,
640
+ }
641
+
642
+ current_streak, longest_streak = _streaks(daily_tokens, grid_start, today)
643
+
644
+ weeks: list[list[ContributionDay]] = []
645
+ cursor = grid_start
646
+ while cursor <= grid_end:
647
+ week: list[ContributionDay] = []
648
+ for _ in range(7):
649
+ tokens = daily_tokens.get(cursor, 0) if cursor <= today else 0
650
+ week.append(
651
+ {
652
+ "date": cursor.isoformat(),
653
+ "tokens": tokens,
654
+ "level": _contribution_level(tokens, contribution_thresholds),
655
+ }
656
+ )
657
+ cursor += timedelta(days=1)
658
+ weeks.append(week)
659
+
660
+ claude_tokens = agent_tokens.get("claude-code", 0)
661
+ codex_tokens = agent_tokens.get("codex", 0)
662
+ beast = None
663
+ if total_tokens > 0:
664
+ beast = "phoenix" if claude_tokens >= codex_tokens else "dragon"
665
+
666
+ contribution: ContributionReportData = {
667
+ "weeks": weeks,
668
+ "start": grid_start.isoformat(),
669
+ "end": today.isoformat(),
670
+ "max_tokens": max_tokens,
671
+ "total_tokens": total_tokens,
672
+ "active_days": active_days,
673
+ "current_streak": current_streak,
674
+ "longest_streak": longest_streak,
675
+ "busiest_day": busiest_day,
676
+ }
677
+ wrapped: WrappedReportData = {
678
+ "year_label": str(today.year),
679
+ "total_tokens": total_tokens,
680
+ "total_cost": _round_cost(total_cost),
681
+ "active_days": active_days,
682
+ "total_sessions": total_sessions,
683
+ "top_model": _top_name(model_tokens),
684
+ "top_project": _top_name(project_tokens),
685
+ "busiest_day": busiest_day,
686
+ "longest_streak": longest_streak,
687
+ "claude_tokens": claude_tokens,
688
+ "codex_tokens": codex_tokens,
689
+ "beast": beast,
690
+ }
691
+ return {
692
+ "contribution": contribution,
693
+ "wrapped": wrapped,
694
+ }
695
+
696
+
697
+ def build_year_data(agents: list[AgentInfo]) -> YearReportData:
698
+ today = datetime.now().astimezone().date()
699
+ grid_end = today + timedelta(days=(5 - today.weekday()) % 7)
700
+ grid_start = grid_end - timedelta(days=_YEAR_WEEKS * 7 - 1)
701
+ hours_back = ((today - grid_start).days + 2) * 24
702
+
703
+ raw_entries: list[UsageEntry] = []
704
+ for agent in agents:
705
+ raw_entries.extend(_load_agent_entries(agent, hours_back))
706
+
707
+ entries = [
708
+ entry
709
+ for entry in raw_entries
710
+ if grid_start <= _entry_date(entry) <= today
711
+ ]
712
+
713
+ current_days = _aggregate_year_days(entries)
714
+ ledger = _merge_year_ledger(
715
+ current_days,
716
+ trim_before=grid_start - timedelta(days=_YEAR_LEDGER_TRIM_BUFFER_DAYS),
717
+ )
718
+ return _build_year_output_from_ledger(
719
+ ledger,
720
+ grid_start=grid_start,
721
+ grid_end=grid_end,
722
+ today=today,
723
+ )
724
+
725
+
726
+ def _load_year_data_cached(agents: list[AgentInfo]) -> YearReportData:
727
+ cached = _read_year_cache()
728
+ if cached is not None:
729
+ return cast(YearReportData, cached)
730
+
731
+ data = build_year_data(agents)
732
+ _write_year_cache(data)
733
+ return data
734
+
735
+
736
+ def _read_year_cache() -> dict[str, Any] | None:
737
+ try:
738
+ with YEAR_CACHE_PATH.open(encoding="utf-8") as file:
739
+ cache = json.load(file)
740
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
741
+ if os.environ.get("USAGE_DEBUG") == "1":
742
+ logger.warning("failed to read year cache %s", YEAR_CACHE_PATH, exc_info=True)
743
+ return None
744
+
745
+ if not isinstance(cache, dict):
746
+ return None
747
+ if cache.get("schema_version") != _YEAR_CACHE_SCHEMA:
748
+ return None
749
+
750
+ cached_at = cache.get("cached_at")
751
+ if not isinstance(cached_at, int | float):
752
+ return None
753
+ if (time.time() - float(cached_at)) > YEAR_CACHE_TTL_SECONDS:
754
+ return None
755
+
756
+ data = cache.get("data")
757
+ return data if isinstance(data, dict) else None
758
+
759
+
760
+ def _write_year_cache(data: YearReportData) -> None:
761
+ tmp_path: str | None = None
762
+ try:
763
+ YEAR_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
764
+ fd, tmp_path = tempfile.mkstemp(dir=YEAR_CACHE_PATH.parent, suffix=".tmp")
765
+ payload = {
766
+ "schema_version": _YEAR_CACHE_SCHEMA,
767
+ "cached_at": time.time(),
768
+ "data": data,
769
+ }
770
+ with os.fdopen(fd, "w", encoding="utf-8") as file:
771
+ json.dump(payload, file, ensure_ascii=False, indent=2, sort_keys=True)
772
+ os.replace(tmp_path, YEAR_CACHE_PATH)
773
+ tmp_path = None
774
+ except Exception as exc:
775
+ if os.environ.get("USAGE_DEBUG") == "1":
776
+ logger.warning("failed to write year cache %s: %s", YEAR_CACHE_PATH, exc)
777
+ finally:
778
+ if tmp_path and os.path.exists(tmp_path):
779
+ with contextlib.suppress(OSError):
780
+ os.unlink(tmp_path)
781
+
782
+
783
+ def serialize_diagnosis(
784
+ result: diagnoser.DiagnosisResult,
785
+ *,
786
+ total_corpus_tokens: int,
787
+ ) -> dict[str, Any]:
788
+ waste_pct = (
789
+ result.total_waste_tokens / total_corpus_tokens * 100
790
+ if total_corpus_tokens
791
+ else 0.0
792
+ )
793
+ fixable_pct = (
794
+ result.fixable_waste_tokens / total_corpus_tokens * 100
795
+ if total_corpus_tokens
796
+ else 0.0
797
+ )
798
+ return {
799
+ "has_data": result.has_data,
800
+ "total_waste_usd": _round_cost(result.total_waste_usd),
801
+ "monthly_savings_estimate_usd": _round_cost(
802
+ result.monthly_savings_estimate_usd
803
+ ),
804
+ "total_waste_tokens": int(result.total_waste_tokens),
805
+ "fixable_waste_tokens": int(result.fixable_waste_tokens),
806
+ "total_corpus_tokens": int(total_corpus_tokens),
807
+ "waste_pct": round(waste_pct, 1),
808
+ "fixable_pct": round(fixable_pct, 1),
809
+ "findings": [
810
+ {
811
+ "severity": finding.severity,
812
+ "kind": finding.kind,
813
+ "headline_plain": finding.headline_plain,
814
+ "headline_detail": finding.headline_detail,
815
+ "estimated_waste_usd": _round_cost(finding.estimated_waste_usd),
816
+ "estimated_waste_tokens": int(finding.estimated_waste_tokens),
817
+ "items": finding.items,
818
+ }
819
+ for finding in result.findings
820
+ ],
821
+ "suggested_claudeignore": result.suggested_claudeignore,
822
+ }
823
+
824
+
825
+ def build_report_data(agents: list[AgentInfo], period: str = "month") -> ReportData:
826
+ """
827
+ period: "today" | "week" | "last7" | "month" | "all"
828
+ 回傳 dict,包含:
829
+ period_label: str
830
+ date_from: str
831
+ date_to: str
832
+ summary: dict
833
+ by_agent: list[dict]
834
+ by_project: list[dict]
835
+ by_model: list[dict]
836
+ daily_trend: list[dict]
837
+ top_sessions: list[dict]
838
+ """
839
+ today = datetime.now().astimezone().date()
840
+ date_from, date_to = _period_bounds(period, today)
841
+ hours_back = 0 if date_from is None else ((date_to - date_from).days + 2) * 24
842
+ if date_from is not None and _period_spec(period).has_comparison:
843
+ total_days_for_comparison = (date_to - date_from).days + 1
844
+ prev_date_from = date_from - timedelta(days=total_days_for_comparison)
845
+ hours_back = ((date_to - prev_date_from).days + 2) * 24
846
+
847
+ raw_entries: list[UsageEntry] = []
848
+ for agent in agents:
849
+ raw_entries.extend(_load_agent_entries(agent, hours_back))
850
+ entry_dates = {id(entry): _entry_date(entry) for entry in raw_entries}
851
+
852
+ if date_from is None and raw_entries:
853
+ date_from = min(entry_dates[id(entry)] for entry in raw_entries)
854
+ if date_from is None:
855
+ date_from = date_to
856
+
857
+ entries = [
858
+ entry
859
+ for entry in raw_entries
860
+ if date_from <= entry_dates[id(entry)] <= date_to
861
+ ]
862
+
863
+ total_tokens = sum(entry.total_tokens for entry in entries)
864
+ total_cost = 0.0
865
+ session_ids = {entry.session_id for entry in entries}
866
+ active_dates = {entry_dates[id(entry)] for entry in entries}
867
+ total_days = (date_to - date_from).days + 1
868
+ comparison = _build_comparison(raw_entries, entry_dates, period, date_from, date_to)
869
+
870
+ by_agent_totals: dict[str, dict[str, Any]] = defaultdict(lambda: {"tokens": 0, "cost": 0.0, "sessions": set(), "messages": 0})
871
+ by_project_totals: dict[str, dict[str, Any]] = defaultdict(lambda: {"tokens": 0, "cost": 0.0, "sessions": set()})
872
+ by_model_totals: dict[str, dict[str, Any]] = defaultdict(lambda: {"tokens": 0, "cost": 0.0})
873
+ by_model_project: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
874
+ daily_totals: dict[date, dict[str, Any]] = defaultdict(lambda: {"tokens": 0, "cost": 0.0})
875
+
876
+ for entry in entries:
877
+ cost = calculate_cost(entry)
878
+ total_cost += cost
879
+ agent_totals = by_agent_totals[entry.agent_id or "unknown"]
880
+ agent_totals["tokens"] += entry.total_tokens
881
+ agent_totals["cost"] += cost
882
+ agent_totals["sessions"].add(entry.session_id)
883
+ agent_totals["messages"] += entry.message_count
884
+
885
+ project = by_project_totals[entry.project or "unknown"]
886
+ project["tokens"] += entry.total_tokens
887
+ project["cost"] += cost
888
+ project["sessions"].add(entry.session_id)
889
+
890
+ model = by_model_totals[entry.model or "unknown"]
891
+ model["tokens"] += entry.total_tokens
892
+ model["cost"] += cost
893
+ by_model_project[entry.model or "unknown"][entry.project or "unknown"] += (
894
+ entry.total_tokens
895
+ )
896
+
897
+ day = daily_totals[entry_dates[id(entry)]]
898
+ day["tokens"] += entry.total_tokens
899
+ day["cost"] += cost
900
+
901
+ agent_names = {agent.id: agent.name for agent in agents}
902
+ by_agent: list[AgentReportRow] = [
903
+ {
904
+ "id": agent_id,
905
+ "name": agent_names.get(agent_id, AGENT_NAMES.get(agent_id, agent_id)),
906
+ "tokens": data["tokens"],
907
+ "cost": _round_cost(data["cost"]),
908
+ "sessions": len(data["sessions"]),
909
+ "messages": data["messages"],
910
+ "pct": _pct(data["tokens"], total_tokens),
911
+ }
912
+ for agent_id, data in by_agent_totals.items()
913
+ ]
914
+ by_agent.sort(key=lambda item: item["tokens"], reverse=True)
915
+
916
+ by_project: list[ProjectReportRow] = [
917
+ {
918
+ "project": project,
919
+ "tokens": data["tokens"],
920
+ "cost": _round_cost(data["cost"]),
921
+ "sessions": len(data["sessions"]),
922
+ "pct": _pct(data["tokens"], total_tokens),
923
+ }
924
+ for project, data in by_project_totals.items()
925
+ ]
926
+ by_project.sort(key=lambda item: item["tokens"], reverse=True)
927
+
928
+ by_model: list[ModelReportRow] = [
929
+ {
930
+ "model": model,
931
+ "tokens": data["tokens"],
932
+ "cost": _round_cost(data["cost"]),
933
+ "cost_known": is_model_priced(model),
934
+ "pct": _pct(data["tokens"], total_tokens),
935
+ "top_project": _top_project(by_model_project.get(model, {})),
936
+ }
937
+ for model, data in by_model_totals.items()
938
+ ]
939
+ by_model.sort(key=lambda item: item["tokens"], reverse=True)
940
+
941
+ daily_trend: list[DailyTrendPoint] = []
942
+ cursor = date_from
943
+ while cursor <= date_to:
944
+ day = daily_totals[cursor]
945
+ daily_trend.append({
946
+ "date": cursor.isoformat(),
947
+ "tokens": day["tokens"],
948
+ "cost": _round_cost(day["cost"]),
949
+ })
950
+ cursor += timedelta(days=1)
951
+
952
+ top_sessions: list[TopSessionReportRow] = []
953
+ sessions_by_cost = sorted(aggregate_sessions(entries), key=lambda session: session.cost_usd, reverse=True)
954
+ for session in sessions_by_cost[:5]:
955
+ top_sessions.append({
956
+ "start_time": session.start_time.astimezone().strftime("%Y-%m-%d %H:%M") if session.start_time.tzinfo else session.start_time.strftime("%Y-%m-%d %H:%M"),
957
+ "project": session.project or "unknown",
958
+ "model": session.model or "unknown",
959
+ "duration_min": session.duration_minutes,
960
+ "tokens": session.total_tokens,
961
+ "cost": _round_cost(session.cost_usd),
962
+ })
963
+
964
+ year_data = _load_year_data_cached(agents)
965
+
966
+ return {
967
+ "period": period,
968
+ "period_label": f"{date_from.isoformat()} -> {date_to.isoformat()}",
969
+ "date_from": date_from.isoformat(),
970
+ "date_to": date_to.isoformat(),
971
+ "summary": {
972
+ "total_tokens": total_tokens,
973
+ "cost_usd": _round_cost(total_cost),
974
+ "sessions": len(session_ids),
975
+ "messages": sum(entry.message_count for entry in entries),
976
+ "active_days": len(active_dates),
977
+ "total_days": total_days,
978
+ },
979
+ "by_agent": by_agent,
980
+ "by_project": by_project[:10],
981
+ "by_model": by_model,
982
+ "daily_trend": daily_trend,
983
+ "top_sessions": top_sessions,
984
+ "comparison": comparison,
985
+ "subscriptions": subscription.load_subscriptions(),
986
+ "persona": _load_persona_for_period(period),
987
+ "contribution": year_data["contribution"],
988
+ "wrapped": year_data["wrapped"],
989
+ }