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/insights.py ADDED
@@ -0,0 +1,277 @@
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 math import sqrt
10
+ from typing import Any, cast
11
+
12
+ INSIGHT_CHANGE_HEADLINE = "change_headline"
13
+ INSIGHT_SPIKE = "spike"
14
+ INSIGHT_SHIFT = "shift"
15
+ INSIGHT_ACTION = "action"
16
+
17
+ # Insights are anomaly-only: every component below stays silent unless its
18
+ # threshold trips, and an empty list is a valid (common) result — the report
19
+ # renders a quiet "nothing unusual" line instead of padding with filler.
20
+ _SPIKE_MULTIPLIER_THRESHOLD = 3.0
21
+ _CHANGE_SURGE_PCT = 50
22
+
23
+
24
+ def build_insights(data: dict[str, Any]) -> list[dict[str, Any]]:
25
+ components: list[dict[str, Any]] = []
26
+
27
+ change = _build_change_headline(data)
28
+ if change is not None:
29
+ components.append(change)
30
+
31
+ spike = _find_spike(data.get("daily_trend"))
32
+ if spike is not None:
33
+ components.append(
34
+ {
35
+ "type": INSIGHT_SPIKE,
36
+ "key": "insights_spike_v2",
37
+ "date": spike["date"],
38
+ "tokens": spike["tokens"],
39
+ "mean_multiplier": spike["mean_multiplier"],
40
+ }
41
+ )
42
+
43
+ shift = _build_shift(data)
44
+ if shift is not None:
45
+ components.append(shift)
46
+
47
+ action = _build_action(change, spike, data)
48
+ if action is not None:
49
+ components.append(action)
50
+
51
+ return components[:5]
52
+
53
+
54
+ def _build_change_headline(data: dict[str, Any]) -> dict[str, Any] | None:
55
+ comparison = _mapping_value(data.get("comparison"))
56
+ if comparison is None or not bool(comparison.get("has_prev")):
57
+ return None
58
+
59
+ prev_tokens = _int_value(comparison.get("prev_tokens"))
60
+ if prev_tokens <= 0:
61
+ return None
62
+
63
+ summary = _mapping_value(data.get("summary"))
64
+ if summary is None:
65
+ return None
66
+
67
+ cur_tokens = _int_value(summary.get("total_tokens"))
68
+ delta_pct = round((cur_tokens - prev_tokens) / prev_tokens * 100)
69
+ if delta_pct < _CHANGE_SURGE_PCT:
70
+ return None
71
+
72
+ return {
73
+ "type": INSIGHT_CHANGE_HEADLINE,
74
+ "key": "insights_change_up",
75
+ "tokens": cur_tokens,
76
+ "cost_usd": _round_cost(_float_value(summary.get("cost_usd"))),
77
+ "pct": abs(delta_pct),
78
+ "direction": "up",
79
+ "delta_pct": delta_pct,
80
+ }
81
+
82
+
83
+ def _build_shift(data: dict[str, Any]) -> dict[str, Any] | None:
84
+ return _build_new_project_shift(data) or _build_model_shift(data)
85
+
86
+
87
+ def _build_new_project_shift(data: dict[str, Any]) -> dict[str, Any] | None:
88
+ comparison = _mapping_value(data.get("comparison"))
89
+ if comparison is None or not bool(comparison.get("has_prev")):
90
+ return None
91
+
92
+ prev_projects = {
93
+ _str_value(project, "")
94
+ for project in _list_value(comparison.get("prev_projects"))
95
+ }
96
+ for project in _list_value(data.get("by_project")):
97
+ item = _mapping_value(project)
98
+ if item is None:
99
+ continue
100
+ name = _str_value(item.get("project"), "unknown")
101
+ pct = _float_value(item.get("pct"))
102
+ if pct >= 15.0 and name not in prev_projects:
103
+ return {
104
+ "type": INSIGHT_SHIFT,
105
+ "key": "insights_shift_new_project",
106
+ "project": name,
107
+ "pct": _round_pct(pct),
108
+ }
109
+ return None
110
+
111
+
112
+ def _build_model_shift(data: dict[str, Any]) -> dict[str, Any] | None:
113
+ comparison = _mapping_value(data.get("comparison"))
114
+ if comparison is None or not bool(comparison.get("has_prev")):
115
+ return None
116
+
117
+ top_model = _first_mapping(data.get("by_model"))
118
+ prev_model_share = _mapping_value(comparison.get("prev_model_share"))
119
+ if top_model is None or prev_model_share is None:
120
+ return None
121
+
122
+ model = _str_value(top_model.get("model"), "unknown")
123
+ pct = _float_value(top_model.get("pct"))
124
+ prev_pct = _float_value(prev_model_share.get(model))
125
+ if pct - prev_pct < 10.0:
126
+ return None
127
+
128
+ top_project = _str_value(top_model.get("top_project"), "")
129
+ if top_project and top_project != "unknown":
130
+ return {
131
+ "type": INSIGHT_SHIFT,
132
+ "key": "insights_shift_model_up",
133
+ "model": model,
134
+ "prev_pct": _round_pct(prev_pct),
135
+ "pct": _round_pct(pct),
136
+ "project": top_project,
137
+ }
138
+ return {
139
+ "type": INSIGHT_SHIFT,
140
+ "key": "insights_shift_model_up_plain",
141
+ "model": model,
142
+ "prev_pct": _round_pct(prev_pct),
143
+ "pct": _round_pct(pct),
144
+ }
145
+
146
+
147
+ def _build_action(
148
+ change: dict[str, Any] | None,
149
+ spike: dict[str, Any] | None,
150
+ data: dict[str, Any],
151
+ ) -> dict[str, Any] | None:
152
+ if (
153
+ change is not None
154
+ and change.get("direction") == "up"
155
+ and _int_value(change.get("delta_pct")) >= 50
156
+ ):
157
+ return {"type": INSIGHT_ACTION, "key": "insights_action_watch_quota"}
158
+
159
+ if spike is not None:
160
+ summary = _mapping_value(data.get("summary"))
161
+ total_tokens = _int_value(summary.get("total_tokens")) if summary else 0
162
+ spike_tokens = _int_value(spike.get("tokens"))
163
+ if total_tokens <= 0:
164
+ return None
165
+ return {
166
+ "type": INSIGHT_ACTION,
167
+ "key": "insights_action_spike_share",
168
+ "date": spike["date"],
169
+ "share": round(spike_tokens / total_tokens * 100),
170
+ }
171
+ return None
172
+
173
+
174
+ def _find_spike(raw_daily: object) -> dict[str, Any] | None:
175
+ daily = _daily_points(raw_daily)
176
+ if len(daily) < 2:
177
+ return None
178
+
179
+ token_values = [point["tokens"] for point in daily]
180
+ mean = sum(token_values) / len(token_values)
181
+ if mean <= 0.0:
182
+ return None
183
+
184
+ variance = sum((tokens - mean) ** 2 for tokens in token_values) / len(token_values)
185
+ stdev = sqrt(variance)
186
+ threshold = mean + stdev
187
+
188
+ candidates = [
189
+ point
190
+ for point in daily
191
+ if point["tokens"] > threshold
192
+ and point["tokens"] >= mean * _SPIKE_MULTIPLIER_THRESHOLD
193
+ ]
194
+ if not candidates:
195
+ return None
196
+
197
+ spike = sorted(candidates, key=lambda point: (-point["tokens"], point["date"]))[0]
198
+ return {
199
+ "date": spike["date"],
200
+ "tokens": spike["tokens"],
201
+ "cost_usd": _round_cost(spike["cost"]),
202
+ "mean_tokens": round(mean, 1),
203
+ "stdev_tokens": round(stdev, 1),
204
+ "mean_multiplier": round(spike["tokens"] / mean, 2),
205
+ }
206
+
207
+
208
+ def _daily_points(raw_daily: object) -> list[dict[str, Any]]:
209
+ daily = _list_value(raw_daily)
210
+ points: list[dict[str, Any]] = []
211
+ for raw_point in daily:
212
+ point = _mapping_value(raw_point)
213
+ if point is None:
214
+ continue
215
+ date = _str_value(point.get("date"), "")
216
+ tokens = _int_value(point.get("tokens"))
217
+ if not date or tokens < 0:
218
+ continue
219
+ points.append(
220
+ {
221
+ "date": date,
222
+ "tokens": tokens,
223
+ "cost": _float_value(point.get("cost")),
224
+ }
225
+ )
226
+ return points
227
+
228
+
229
+ def _first_mapping(value: object) -> dict[str, Any] | None:
230
+ items = _list_value(value)
231
+ if not items:
232
+ return None
233
+ return _mapping_value(items[0])
234
+
235
+
236
+ def _mapping_value(value: object) -> dict[str, Any] | None:
237
+ if isinstance(value, dict):
238
+ return cast(dict[str, Any], value)
239
+ return None
240
+
241
+
242
+ def _list_value(value: object) -> list[object]:
243
+ if isinstance(value, list):
244
+ return value
245
+ return []
246
+
247
+
248
+ def _str_value(value: object, default: str) -> str:
249
+ if isinstance(value, str):
250
+ return value
251
+ return default
252
+
253
+
254
+ def _int_value(value: object) -> int:
255
+ if isinstance(value, bool):
256
+ return 0
257
+ if isinstance(value, int):
258
+ return value
259
+ if isinstance(value, float):
260
+ return int(value)
261
+ return 0
262
+
263
+
264
+ def _float_value(value: object) -> float:
265
+ if isinstance(value, bool):
266
+ return 0.0
267
+ if isinstance(value, int | float):
268
+ return float(value)
269
+ return 0.0
270
+
271
+
272
+ def _round_cost(value: float) -> float:
273
+ return round(value, 4)
274
+
275
+
276
+ def _round_pct(value: float) -> float:
277
+ return round(value, 1)
@@ -0,0 +1,199 @@
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 logging
10
+ import os
11
+ import time
12
+ from collections import Counter
13
+ from dataclasses import dataclass, field
14
+ from datetime import UTC, datetime, timedelta
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from jsonl_utils import iter_jsonl_dicts
19
+ from project_resolver import project_from_encoded_path, resolve_project_name
20
+ from time_utils import parse_optional_iso8601_utc
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ CLAUDE_PROJECTS_DIR = Path(os.path.expanduser("~/.claude/projects"))
25
+ _CACHE_TTL_SECONDS = 300.0
26
+ _cache: dict[int, tuple[float, PersonaProfile]] = {}
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class PersonaProfile:
31
+ hour_histogram: list[int]
32
+ top_projects: list[tuple[str, int]]
33
+ recent_titles: list[str]
34
+ total_sessions: int
35
+ total_messages: int
36
+ titles_by_session: dict[str, str] = field(default_factory=dict)
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class _MetadataLine:
41
+ type: str
42
+ timestamp: datetime | None
43
+ session_id: str
44
+ cwd: str
45
+ title: str
46
+
47
+
48
+ def load_profile(days_back: int = 30) -> PersonaProfile:
49
+ now = time.time()
50
+ cached = _cache.get(days_back)
51
+ if cached is not None:
52
+ cached_at, cached_profile = cached
53
+ if now - cached_at < _CACHE_TTL_SECONDS:
54
+ return cached_profile
55
+
56
+ profile = _load_profile_uncached(days_back)
57
+ _cache[days_back] = (now, profile)
58
+ return profile
59
+
60
+
61
+ def _reset_cache() -> None:
62
+ _cache.clear()
63
+
64
+
65
+ def _load_profile_uncached(days_back: int) -> PersonaProfile:
66
+ histogram = [0] * 24
67
+ sessions_by_project: dict[str, set[str]] = {}
68
+ message_sessions: set[str] = set()
69
+ session_last_message_at: dict[str, datetime] = {}
70
+ titles_by_session: dict[str, str] = {}
71
+ total_messages = 0
72
+
73
+ cutoff = datetime.now(UTC) - timedelta(days=max(0, days_back))
74
+ cutoff_ts = cutoff.timestamp()
75
+
76
+ if not CLAUDE_PROJECTS_DIR.is_dir():
77
+ return _empty_profile()
78
+
79
+ for jsonl_path in CLAUDE_PROJECTS_DIR.rglob("*.jsonl"):
80
+ try:
81
+ if jsonl_path.stat().st_mtime < cutoff_ts:
82
+ continue
83
+ except OSError as exc:
84
+ logger.warning("failed to stat Claude project log %s: %s", jsonl_path, exc)
85
+ continue
86
+
87
+ fallback_project = _project_from_path(jsonl_path)
88
+ try:
89
+ for data in iter_jsonl_dicts(jsonl_path, errors="replace"):
90
+ parsed = _parse_metadata_line(data)
91
+ if parsed is None:
92
+ continue
93
+
94
+ session_id = parsed.session_id
95
+ if session_id:
96
+ title = parsed.title.strip()
97
+ if parsed.type == "ai-title" and title:
98
+ titles_by_session[session_id] = title
99
+
100
+ timestamp = parsed.timestamp
101
+ if timestamp is None or timestamp < cutoff:
102
+ continue
103
+
104
+ is_message = parsed.type in {"user", "assistant"}
105
+ if is_message:
106
+ histogram[timestamp.astimezone().hour] += 1
107
+ total_messages += 1
108
+
109
+ if session_id and is_message:
110
+ project = _project_from_cwd(parsed.cwd) or fallback_project
111
+ sessions_by_project.setdefault(project, set()).add(session_id)
112
+ message_sessions.add(session_id)
113
+ current_last = session_last_message_at.get(session_id)
114
+ if current_last is None or timestamp > current_last:
115
+ session_last_message_at[session_id] = timestamp
116
+ except OSError as exc:
117
+ logger.warning("failed to read Claude project log %s: %s", jsonl_path, exc)
118
+
119
+ project_counts = Counter(
120
+ {project: len(session_ids) for project, session_ids in sessions_by_project.items()}
121
+ )
122
+ top_projects = sorted(project_counts.items(), key=lambda item: (-item[1], item[0]))[:5]
123
+ recent_titles = _recent_unique_titles(titles_by_session, session_last_message_at)
124
+
125
+ return PersonaProfile(
126
+ hour_histogram=histogram,
127
+ top_projects=top_projects,
128
+ recent_titles=recent_titles,
129
+ total_sessions=len(message_sessions),
130
+ total_messages=total_messages,
131
+ titles_by_session=titles_by_session,
132
+ )
133
+
134
+
135
+ def _empty_profile() -> PersonaProfile:
136
+ return PersonaProfile(
137
+ hour_histogram=[0] * 24,
138
+ top_projects=[],
139
+ recent_titles=[],
140
+ total_sessions=0,
141
+ total_messages=0,
142
+ titles_by_session={},
143
+ )
144
+
145
+
146
+ def _parse_metadata_line(data: dict[str, Any]) -> _MetadataLine | None:
147
+ return _MetadataLine(
148
+ type=_as_str(data.get("type")),
149
+ timestamp=_parse_timestamp(data.get("timestamp")),
150
+ session_id=_as_str(data.get("sessionId") or data.get("session_id")),
151
+ cwd=_as_str(data.get("cwd")),
152
+ title=_as_str(data.get("aiTitle")),
153
+ )
154
+
155
+
156
+ def _parse_timestamp(value: Any) -> datetime | None:
157
+ return parse_optional_iso8601_utc(value)
158
+
159
+
160
+ def _project_from_cwd(cwd: str) -> str:
161
+ if not cwd:
162
+ return ""
163
+ return resolve_project_name(cwd)
164
+
165
+
166
+ def _project_from_path(jsonl_path: Path) -> str:
167
+ return project_from_encoded_path(jsonl_path, CLAUDE_PROJECTS_DIR)
168
+
169
+
170
+ def _recent_unique_titles(
171
+ titles_by_session: dict[str, str],
172
+ session_last_message_at: dict[str, datetime],
173
+ ) -> list[str]:
174
+ titles: list[str] = []
175
+ seen: set[str] = set()
176
+ ordered_sessions = sorted(
177
+ titles_by_session,
178
+ key=lambda session_id: session_last_message_at.get(
179
+ session_id,
180
+ datetime.min.replace(tzinfo=UTC),
181
+ ),
182
+ reverse=True,
183
+ )
184
+ for session_id in ordered_sessions:
185
+ if session_id not in session_last_message_at:
186
+ continue
187
+ title = titles_by_session[session_id]
188
+ normalized = title.strip()
189
+ if not normalized or normalized in seen:
190
+ continue
191
+ seen.add(normalized)
192
+ titles.append(normalized)
193
+ if len(titles) >= 8:
194
+ break
195
+ return titles
196
+
197
+
198
+ def _as_str(value: Any) -> str:
199
+ return value if isinstance(value, str) else ""