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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- 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
|
+
)
|