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/diagnoser.py ADDED
@@ -0,0 +1,638 @@
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
+ from collections import defaultdict
10
+ from dataclasses import dataclass
11
+ from datetime import date, datetime
12
+ from pathlib import Path
13
+ from statistics import median
14
+ from typing import Any
15
+
16
+ from adapters import claude
17
+ from adapters.types import UsageEntry
18
+ from jsonl_utils import iter_jsonl_dicts
19
+
20
+ TOOLS = {"Read", "Edit", "Bash", "Grep", "Glob", "LS"}
21
+ # Below this estimated waste a finding stays "info": cents must not outrank
22
+ # dollar-sized findings when the session-start reminder picks what to mention.
23
+ CRITICAL_WASTE_USD = 1.0
24
+ # Long sessions are dominated by cache reads, which the API bills at a tenth
25
+ # of the input rate — pricing them at $3/MTok would inflate anomaly findings
26
+ # several-fold and cost the diagnosis its credibility.
27
+ CACHE_READ_USD_PER_MTOK = 0.3
28
+ POLLUTER_DIRS = (
29
+ "node_modules",
30
+ "dist",
31
+ "build",
32
+ ".next",
33
+ ".nuxt",
34
+ ".turbo",
35
+ "__pycache__",
36
+ ".cache",
37
+ ".venv",
38
+ "venv",
39
+ "target",
40
+ ".pytest_cache",
41
+ ".mypy_cache",
42
+ ".ruff_cache",
43
+ "coverage",
44
+ ".gradle",
45
+ ".idea",
46
+ ".vscode",
47
+ ".git",
48
+ ".hg",
49
+ ".svn",
50
+ "bower_components",
51
+ "vendor",
52
+ )
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class ToolCall:
57
+ timestamp: datetime
58
+ session_id: str
59
+ project: str
60
+ tool_name: str
61
+ target_path: str
62
+ result_size_chars: int
63
+ result_tokens: int = 0
64
+
65
+
66
+ @dataclass(slots=True)
67
+ class DiagnosisFinding:
68
+ severity: str
69
+ kind: str
70
+ headline_plain: str
71
+ headline_detail: str
72
+ estimated_waste_usd: float
73
+ items: list[dict[str, object]]
74
+ estimated_waste_tokens: int = 0
75
+
76
+
77
+ @dataclass(slots=True)
78
+ class DiagnosisResult:
79
+ total_waste_usd: float
80
+ monthly_savings_estimate_usd: float
81
+ total_waste_tokens: int
82
+ fixable_waste_tokens: int
83
+ findings: list[DiagnosisFinding]
84
+ suggested_claudeignore: str
85
+ has_data: bool
86
+
87
+
88
+ @dataclass(slots=True)
89
+ class _SessionUsage:
90
+ session_id: str
91
+ project: str
92
+ total_tokens: int
93
+ start_time: datetime
94
+ cache_read_tokens: int = 0
95
+
96
+
97
+ def analyze(
98
+ date_from: date,
99
+ date_to: date,
100
+ total_cost_usd: float,
101
+ ) -> DiagnosisResult:
102
+ tool_calls, sessions = _load_records(date_from, date_to)
103
+ return analyze_loaded_records(
104
+ date_from=date_from,
105
+ date_to=date_to,
106
+ total_cost_usd=total_cost_usd,
107
+ tool_calls=tool_calls,
108
+ entries=None,
109
+ sessions=sessions,
110
+ )
111
+
112
+
113
+ def analyze_loaded_records(
114
+ *,
115
+ date_from: date,
116
+ date_to: date,
117
+ total_cost_usd: float,
118
+ tool_calls: list[ToolCall],
119
+ entries: list[UsageEntry] | None = None,
120
+ sessions: list[_SessionUsage] | None = None,
121
+ ) -> DiagnosisResult:
122
+ if sessions is None:
123
+ sessions = _aggregate_sessions(entries or [], date_from, date_to)
124
+ if not tool_calls and not sessions:
125
+ return DiagnosisResult(0.0, 0.0, 0, 0, [], "", False)
126
+
127
+ repeated = _find_repeated_reads(tool_calls)
128
+ polluters, ignored_dirs = _find_polluter_dirs(tool_calls)
129
+ anomalies = _find_anomaly_sessions(sessions)
130
+ noisy = _find_noisy_bash(tool_calls)
131
+ repeated_bash = _find_repeated_bash(tool_calls)
132
+ findings = [
133
+ finding
134
+ for finding in (repeated, polluters, anomalies, noisy, repeated_bash)
135
+ if finding is not None
136
+ ]
137
+
138
+ total_waste = sum(finding.estimated_waste_usd for finding in findings)
139
+ total_waste_tokens = sum(finding.estimated_waste_tokens for finding in findings)
140
+ fixable_waste_tokens = sum(
141
+ finding.estimated_waste_tokens
142
+ for finding in findings
143
+ if finding.kind == "polluter_dirs"
144
+ )
145
+ if total_cost_usd > 0:
146
+ total_waste = min(total_waste, total_cost_usd)
147
+
148
+ days = max(1, (date_to - date_from).days + 1)
149
+ monthly_savings = min(total_waste / days * 30, total_waste)
150
+ return DiagnosisResult(
151
+ total_waste_usd=total_waste,
152
+ monthly_savings_estimate_usd=monthly_savings,
153
+ total_waste_tokens=total_waste_tokens,
154
+ fixable_waste_tokens=fixable_waste_tokens,
155
+ findings=findings,
156
+ suggested_claudeignore="\n".join(f"{name}/" for name in sorted(ignored_dirs)),
157
+ has_data=True,
158
+ )
159
+
160
+
161
+ def _load_records(
162
+ date_from: date,
163
+ date_to: date,
164
+ ) -> tuple[list[ToolCall], list[_SessionUsage]]:
165
+ tool_calls: list[ToolCall] = []
166
+ entries: list[UsageEntry] = []
167
+ seen_entries: set[str] = set()
168
+
169
+ for base_dir in claude.get_claude_dirs():
170
+ base = Path(base_dir)
171
+ if not base.is_dir():
172
+ continue
173
+ for jsonl_path in base.rglob("*.jsonl"):
174
+ fallback_project = claude.extract_project_from_dir(jsonl_path, base)
175
+ tool_calls.extend(
176
+ parse_tool_calls(jsonl_path, fallback_project, date_from, date_to)
177
+ )
178
+ claude.parse_jsonl(
179
+ jsonl_path,
180
+ fallback_project,
181
+ entries,
182
+ seen_entries,
183
+ cutoff=None,
184
+ )
185
+
186
+ sessions = _aggregate_sessions(entries, date_from, date_to)
187
+ return tool_calls, sessions
188
+
189
+
190
+ def parse_tool_calls(
191
+ path: Path,
192
+ fallback_project: str,
193
+ date_from: date,
194
+ date_to: date,
195
+ ) -> list[ToolCall]:
196
+ tool_calls: list[ToolCall] = []
197
+ pending: dict[str, ToolCall] = {}
198
+
199
+ try:
200
+ for data in iter_jsonl_dicts(path):
201
+ record_type = data.get("type")
202
+ if record_type == "assistant":
203
+ _parse_assistant_tool_uses(
204
+ data,
205
+ fallback_project,
206
+ date_from,
207
+ date_to,
208
+ pending,
209
+ )
210
+ elif record_type == "user":
211
+ _parse_user_results(data, pending, tool_calls)
212
+ except (OSError, UnicodeDecodeError):
213
+ return tool_calls
214
+
215
+ tool_calls.extend(pending.values())
216
+ return tool_calls
217
+
218
+
219
+ def _parse_assistant_tool_uses(
220
+ data: dict[str, Any],
221
+ fallback_project: str,
222
+ date_from: date,
223
+ date_to: date,
224
+ pending: dict[str, ToolCall],
225
+ ) -> None:
226
+ timestamp = _parse_timestamp(data.get("timestamp"))
227
+ if timestamp is None or not _in_range(timestamp, date_from, date_to):
228
+ return
229
+
230
+ message = data.get("message")
231
+ if not isinstance(message, dict):
232
+ return
233
+ content = message.get("content")
234
+ if not isinstance(content, list):
235
+ return
236
+
237
+ session_id = str(data.get("sessionId") or "")
238
+ for part in content:
239
+ if not isinstance(part, dict) or part.get("type") != "tool_use":
240
+ continue
241
+ tool_name = str(part.get("name") or "")
242
+ if tool_name not in TOOLS:
243
+ continue
244
+ tool_id = str(part.get("id") or "")
245
+ target = _tool_target(tool_name, part.get("input"))
246
+ if not tool_id or not target:
247
+ continue
248
+ pending[tool_id] = ToolCall(
249
+ timestamp=timestamp,
250
+ session_id=session_id,
251
+ project=fallback_project,
252
+ tool_name=tool_name,
253
+ target_path=target,
254
+ result_size_chars=0,
255
+ result_tokens=0,
256
+ )
257
+
258
+
259
+ def _parse_user_results(
260
+ data: dict[str, Any],
261
+ pending: dict[str, ToolCall],
262
+ tool_calls: list[ToolCall],
263
+ ) -> None:
264
+ message = data.get("message")
265
+ if not isinstance(message, dict):
266
+ return
267
+ content = message.get("content")
268
+ if not isinstance(content, list):
269
+ return
270
+
271
+ for part in content:
272
+ if not isinstance(part, dict) or part.get("type") != "tool_result":
273
+ continue
274
+ tool_id = str(part.get("tool_use_id") or "")
275
+ call = pending.pop(tool_id, None)
276
+ if call is None:
277
+ continue
278
+ content = part.get("content")
279
+ call.result_size_chars = _content_size(content)
280
+ call.result_tokens = _content_tokens(content)
281
+ tool_calls.append(call)
282
+
283
+
284
+ def _aggregate_sessions(
285
+ entries: list[UsageEntry],
286
+ date_from: date,
287
+ date_to: date,
288
+ ) -> list[_SessionUsage]:
289
+ sessions: dict[tuple[str, str], _SessionUsage] = {}
290
+
291
+ for entry in entries:
292
+ if not _in_range(entry.timestamp, date_from, date_to):
293
+ continue
294
+ key = (entry.project, entry.session_id)
295
+ current = sessions.get(key)
296
+ if current is None:
297
+ sessions[key] = _SessionUsage(
298
+ session_id=entry.session_id,
299
+ project=entry.project,
300
+ total_tokens=entry.total_tokens,
301
+ start_time=entry.timestamp,
302
+ cache_read_tokens=entry.cache_read_tokens,
303
+ )
304
+ continue
305
+ current.total_tokens += entry.total_tokens
306
+ current.cache_read_tokens += entry.cache_read_tokens
307
+ if entry.timestamp < current.start_time:
308
+ current.start_time = entry.timestamp
309
+
310
+ return list(sessions.values())
311
+
312
+
313
+ def _parse_timestamp(value: object) -> datetime | None:
314
+ if not isinstance(value, str):
315
+ return None
316
+ try:
317
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
318
+ except ValueError:
319
+ return None
320
+
321
+
322
+ def _in_range(timestamp: datetime, date_from: date, date_to: date) -> bool:
323
+ if timestamp.tzinfo is not None:
324
+ timestamp = timestamp.astimezone()
325
+ return date_from <= timestamp.date() <= date_to
326
+
327
+
328
+ def _tool_target(tool_name: str, raw_input: object) -> str:
329
+ if not isinstance(raw_input, dict):
330
+ return ""
331
+ if tool_name in {"Read", "Edit"}:
332
+ return str(raw_input.get("file_path") or "")
333
+ if tool_name in {"Grep", "Glob", "LS"}:
334
+ path = str(raw_input.get("path") or "")
335
+ pattern = str(raw_input.get("pattern") or raw_input.get("query") or "")
336
+ return f"{path} [{pattern}]" if pattern else path
337
+ return str(raw_input.get("command") or "")[:200]
338
+
339
+
340
+ def _content_size(content: object) -> int:
341
+ if isinstance(content, str):
342
+ return len(content)
343
+ if isinstance(content, list):
344
+ total = 0
345
+ for item in content:
346
+ if isinstance(item, str):
347
+ total += len(item)
348
+ continue
349
+ if not isinstance(item, dict):
350
+ continue
351
+ text = item.get("text") or item.get("content")
352
+ if isinstance(text, str):
353
+ total += len(text)
354
+ return total
355
+ return 0
356
+
357
+
358
+ def _content_tokens(content: object) -> int:
359
+ if isinstance(content, str):
360
+ return _estimate_tokens(content)
361
+ if isinstance(content, list):
362
+ total = 0
363
+ for item in content:
364
+ if isinstance(item, str):
365
+ total += _estimate_tokens(item)
366
+ continue
367
+ if not isinstance(item, dict):
368
+ continue
369
+ text = item.get("text") or item.get("content")
370
+ if isinstance(text, str):
371
+ total += _estimate_tokens(text)
372
+ return total
373
+ return 0
374
+
375
+
376
+ def _estimate_tokens(text: str) -> int:
377
+ cjk_chars = sum(_is_cjk(char) for char in text)
378
+ return cjk_chars + (len(text) - cjk_chars) // 4
379
+
380
+
381
+ def _is_cjk(char: str) -> bool:
382
+ codepoint = ord(char)
383
+ return (
384
+ 0x1100 <= codepoint <= 0x11FF # Hangul Jamo
385
+ or 0x3040 <= codepoint <= 0x309F # Hiragana
386
+ or 0x30A0 <= codepoint <= 0x30FF # Katakana
387
+ or 0x3130 <= codepoint <= 0x318F # Hangul Compatibility Jamo
388
+ or 0x31F0 <= codepoint <= 0x31FF # Katakana Phonetic Extensions
389
+ or 0x3400 <= codepoint <= 0x4DBF # CJK Unified Ideographs Extension A
390
+ or 0x4E00 <= codepoint <= 0x9FFF # CJK Unified Ideographs
391
+ or 0xA960 <= codepoint <= 0xA97F # Hangul Jamo Extended-A
392
+ or 0xAC00 <= codepoint <= 0xD7AF # Hangul Syllables and Extended-B
393
+ or 0xF900 <= codepoint <= 0xFAFF # CJK Compatibility Ideographs
394
+ or 0x1AFF0 <= codepoint <= 0x1AFFF # Kana Extended-B
395
+ or 0x1B000 <= codepoint <= 0x1B16F # Kana Supplement and Extensions
396
+ or 0x20000 <= codepoint <= 0x2EE5D # CJK Unified Ideographs Extensions B-I
397
+ or 0x30000 <= codepoint <= 0x323AF # CJK Unified Ideographs Extensions G-H
398
+ )
399
+
400
+
401
+ def _find_repeated_reads(tool_calls: list[ToolCall]) -> DiagnosisFinding | None:
402
+ grouped: dict[str, list[ToolCall]] = defaultdict(list)
403
+ for call in tool_calls:
404
+ if call.tool_name == "Read":
405
+ grouped[call.target_path].append(call)
406
+
407
+ candidates: list[dict[str, object]] = []
408
+ total_cost = 0.0
409
+ total_tokens = 0
410
+ for path, calls in sorted(grouped.items(), key=lambda item: len(item[1]), reverse=True):
411
+ if len(calls) < 10:
412
+ continue
413
+ estimated_tokens = sum(call.result_tokens for call in calls)
414
+ cost = _tokens_to_usd(estimated_tokens)
415
+ total_cost += cost
416
+ total_tokens += estimated_tokens
417
+ candidates.append(
418
+ {
419
+ "label": path,
420
+ "stat": "diag_item_read_times",
421
+ "n": len(calls),
422
+ "size_bytes": int(sum(call.result_size_chars for call in calls)),
423
+ "cost": round(cost, 4),
424
+ "estimated_waste_tokens": estimated_tokens,
425
+ }
426
+ )
427
+
428
+ if not candidates:
429
+ return None
430
+ return DiagnosisFinding(
431
+ severity="critical" if total_cost >= CRITICAL_WASTE_USD else "info",
432
+ kind="repeated_reads",
433
+ headline_plain="diag_kind_repeated_reads",
434
+ headline_detail="diag_kind_repeated_reads_d",
435
+ estimated_waste_usd=total_cost,
436
+ estimated_waste_tokens=total_tokens,
437
+ items=candidates[:5],
438
+ )
439
+
440
+
441
+ def _find_polluter_dirs(tool_calls: list[ToolCall]) -> tuple[DiagnosisFinding | None, set[str]]:
442
+ stats: dict[str, dict[str, int]] = defaultdict(
443
+ lambda: {"count": 0, "chars": 0, "tokens": 0}
444
+ )
445
+ for call in tool_calls:
446
+ if call.tool_name not in {"Read", "Edit"}:
447
+ continue
448
+ polluter = _polluter_dir(call.target_path)
449
+ if polluter is None:
450
+ continue
451
+ stats[polluter]["count"] += 1
452
+ stats[polluter]["chars"] += call.result_size_chars
453
+ stats[polluter]["tokens"] += call.result_tokens
454
+
455
+ items = [
456
+ {
457
+ "label": name,
458
+ "stat": "diag_item_read_times",
459
+ "n": values["count"],
460
+ "size_bytes": values["chars"],
461
+ "cost": round(_tokens_to_usd(values["tokens"]), 4),
462
+ "estimated_waste_tokens": values["tokens"],
463
+ }
464
+ for name, values in sorted(
465
+ stats.items(),
466
+ key=lambda item: item[1]["count"],
467
+ reverse=True,
468
+ )[:8]
469
+ ]
470
+ if not items:
471
+ return None, set()
472
+
473
+ total_cost = sum(_tokens_to_usd(values["tokens"]) for values in stats.values())
474
+ total_tokens = sum(values["tokens"] for values in stats.values())
475
+ return (
476
+ DiagnosisFinding(
477
+ severity="critical" if total_cost >= CRITICAL_WASTE_USD else "info",
478
+ kind="polluter_dirs",
479
+ headline_plain="diag_kind_polluter_dirs",
480
+ headline_detail="diag_kind_polluter_dirs_d",
481
+ estimated_waste_usd=total_cost,
482
+ estimated_waste_tokens=total_tokens,
483
+ items=items,
484
+ ),
485
+ set(stats),
486
+ )
487
+
488
+
489
+ def _polluter_dir(path: str) -> str | None:
490
+ normalized = path.replace("\\", "/")
491
+ for part in normalized.split("/"):
492
+ if part in POLLUTER_DIRS:
493
+ return part
494
+ return None
495
+
496
+
497
+ def _find_anomaly_sessions(sessions: list[_SessionUsage]) -> DiagnosisFinding | None:
498
+ by_project: dict[str, list[_SessionUsage]] = defaultdict(list)
499
+ for session in sessions:
500
+ by_project[session.project].append(session)
501
+
502
+ candidates: list[tuple[float, _SessionUsage, float]] = []
503
+ for project_sessions in by_project.values():
504
+ baseline = median(session.total_tokens for session in project_sessions)
505
+ if baseline <= 0:
506
+ continue
507
+ for session in project_sessions:
508
+ ratio = session.total_tokens / baseline
509
+ if session.total_tokens > 30_000 and ratio > 5:
510
+ candidates.append((ratio, session, baseline))
511
+
512
+ candidates.sort(key=lambda item: item[0], reverse=True)
513
+ items: list[dict[str, object]] = []
514
+ estimated_waste_usd = 0.0
515
+ estimated_waste_tokens = 0
516
+ for ratio, session, baseline in candidates[:3]:
517
+ # Only the excess over the project baseline is waste: the baseline-sized
518
+ # part of an anomalous session is the work the user came to do.
519
+ excess_tokens = int(session.total_tokens - baseline)
520
+ excess_share = excess_tokens / session.total_tokens
521
+ cost = round(_session_cost_usd(session) * excess_share, 4)
522
+ items.append(
523
+ {
524
+ "label": session.session_id[:8] or "unknown",
525
+ "cost": cost,
526
+ "tokens": session.total_tokens,
527
+ "estimated_waste_tokens": excess_tokens,
528
+ "baseline_tokens": int(baseline),
529
+ "ratio": round(ratio, 1),
530
+ "session_start_iso": (
531
+ session.start_time.astimezone().isoformat()
532
+ if session.start_time.tzinfo
533
+ else session.start_time.isoformat()
534
+ ),
535
+ "project": session.project or "unknown",
536
+ }
537
+ )
538
+ estimated_waste_usd += cost
539
+ estimated_waste_tokens += excess_tokens
540
+ if not items:
541
+ return None
542
+
543
+ return DiagnosisFinding(
544
+ severity="warning",
545
+ kind="anomaly_session",
546
+ headline_plain="diag_kind_anomaly_session",
547
+ headline_detail="diag_kind_anomaly_session_d",
548
+ estimated_waste_usd=estimated_waste_usd,
549
+ estimated_waste_tokens=estimated_waste_tokens,
550
+ items=items,
551
+ )
552
+
553
+
554
+ def _find_noisy_bash(tool_calls: list[ToolCall]) -> DiagnosisFinding | None:
555
+ calls = [
556
+ call
557
+ for call in tool_calls
558
+ if call.tool_name == "Bash" and call.result_size_chars > 20_000
559
+ ]
560
+ calls.sort(key=lambda call: call.result_size_chars, reverse=True)
561
+ items: list[dict[str, object]] = []
562
+ estimated_waste_usd = 0.0
563
+ estimated_waste_tokens = 0
564
+ for call in calls[:5]:
565
+ cost = round(_tokens_to_usd(call.result_tokens), 4)
566
+ tokens = call.result_tokens
567
+ items.append(
568
+ {
569
+ "label": call.target_path[:80],
570
+ "n": call.result_size_chars,
571
+ "size_bytes": call.result_size_chars,
572
+ "cost": cost,
573
+ "estimated_waste_tokens": tokens,
574
+ }
575
+ )
576
+ estimated_waste_usd += cost
577
+ estimated_waste_tokens += tokens
578
+ if not items:
579
+ return None
580
+ return DiagnosisFinding(
581
+ severity="info",
582
+ kind="noisy_bash",
583
+ headline_plain="diag_kind_noisy_bash",
584
+ headline_detail="diag_kind_noisy_bash_d",
585
+ estimated_waste_usd=estimated_waste_usd,
586
+ estimated_waste_tokens=estimated_waste_tokens,
587
+ items=items,
588
+ )
589
+
590
+
591
+ def _find_repeated_bash(tool_calls: list[ToolCall]) -> DiagnosisFinding | None:
592
+ grouped: dict[str, list[ToolCall]] = defaultdict(list)
593
+ for call in tool_calls:
594
+ if call.tool_name == "Bash":
595
+ grouped[call.target_path].append(call)
596
+
597
+ candidates: list[dict[str, object]] = []
598
+ total_cost = 0.0
599
+ total_tokens = 0
600
+ for command, calls in sorted(grouped.items(), key=lambda item: len(item[1]), reverse=True):
601
+ if len(calls) < 15:
602
+ continue
603
+ estimated_waste_tokens = len(calls) * 500
604
+ cost = _tokens_to_usd(estimated_waste_tokens)
605
+ total_cost += cost
606
+ total_tokens += estimated_waste_tokens
607
+ candidates.append(
608
+ {
609
+ "label": command[:100],
610
+ "stat": "diag_item_read_times",
611
+ "n": len(calls),
612
+ "cost": round(cost, 4),
613
+ "estimated_waste_tokens": estimated_waste_tokens,
614
+ }
615
+ )
616
+
617
+ if not candidates:
618
+ return None
619
+ return DiagnosisFinding(
620
+ severity="info",
621
+ kind="repeated_bash",
622
+ headline_plain="diag_kind_repeated_bash",
623
+ headline_detail="diag_kind_repeated_bash_d",
624
+ estimated_waste_usd=total_cost,
625
+ estimated_waste_tokens=total_tokens,
626
+ items=candidates[:5],
627
+ )
628
+
629
+
630
+ def _tokens_to_usd(tokens: int) -> float:
631
+ return tokens / 1_000_000 * 3
632
+
633
+
634
+ def _session_cost_usd(session: _SessionUsage) -> float:
635
+ other_tokens = session.total_tokens - session.cache_read_tokens
636
+ return _tokens_to_usd(other_tokens) + (
637
+ session.cache_read_tokens / 1_000_000 * CACHE_READ_USD_PER_MTOK
638
+ )