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
agy_window_keeper.py ADDED
@@ -0,0 +1,185 @@
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
+ """Auto-open the next Antigravity 5-hour window."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import tempfile
17
+ import threading
18
+ import time
19
+ from contextlib import suppress
20
+ from pathlib import Path
21
+
22
+ from menubar_agy import AgyRefreshResult
23
+ from menubar_prefs import _agy_window_keeper_enabled
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ AGY_WINDOW_KEEPER_STATE_PATH = Path(
28
+ os.path.expanduser("~/.usage/agy_window_keeper.json")
29
+ )
30
+ PING_COOLDOWN_SECONDS = 5 * 3600
31
+ PING_TIMEOUT_SECONDS = 180
32
+ AGY_MODEL = "Gemini 3.5 Flash (Low)"
33
+ _AGY_BIN_FALLBACKS = (
34
+ "~/.local/bin/agy",
35
+ "~/AppData/Local/agy/bin/agy.exe",
36
+ )
37
+
38
+ _lock = threading.Lock()
39
+ _ping_in_flight = False
40
+
41
+
42
+ def should_ping(
43
+ now: float,
44
+ enabled: bool,
45
+ last_ping_at: float | None,
46
+ remaining_percent: float | None,
47
+ stale: object | None,
48
+ fallback_projection: bool,
49
+ mock: bool,
50
+ ) -> bool:
51
+ """Return whether fresh quota data shows no active five-hour window.
52
+
53
+ At 100% remaining, the API reset time is a sliding placeholder, so it
54
+ cannot indicate an active window.
55
+ """
56
+ if not enabled or mock or fallback_projection or stale is not None:
57
+ return False
58
+ if remaining_percent is None or remaining_percent < 100.0:
59
+ return False
60
+ return last_ping_at is None or now - last_ping_at >= PING_COOLDOWN_SECONDS
61
+
62
+
63
+ def _load_last_ping(path: Path | None = None) -> float | None:
64
+ state_path = AGY_WINDOW_KEEPER_STATE_PATH if path is None else path
65
+ if not state_path.exists():
66
+ return None
67
+ try:
68
+ data = json.loads(state_path.read_text(encoding="utf-8"))
69
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
70
+ return None
71
+ if not isinstance(data, dict):
72
+ return None
73
+ value = data.get("last_ping_at")
74
+ if isinstance(value, bool) or not isinstance(value, int | float):
75
+ return None
76
+ return float(value)
77
+
78
+
79
+ def _save_last_ping(ts: float, path: Path | None = None) -> None:
80
+ state_path = AGY_WINDOW_KEEPER_STATE_PATH if path is None else path
81
+ state_path.parent.mkdir(parents=True, exist_ok=True)
82
+ payload = json.dumps({"last_ping_at": ts}) + "\n"
83
+ tmp_path: str | None = None
84
+ try:
85
+ fd, tmp_path = tempfile.mkstemp(dir=state_path.parent, suffix=".tmp")
86
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
87
+ handle.write(payload)
88
+ os.replace(tmp_path, state_path)
89
+ tmp_path = None
90
+ except OSError:
91
+ _debug_log("agy-window-keeper state write failed", exc_info=True)
92
+ finally:
93
+ if tmp_path and os.path.exists(tmp_path):
94
+ with suppress(OSError):
95
+ os.unlink(tmp_path)
96
+
97
+
98
+ def _resolve_agy_bin() -> str | None:
99
+ found = shutil.which("agy")
100
+ if found:
101
+ return found
102
+ for candidate in _AGY_BIN_FALLBACKS:
103
+ resolved = os.path.expanduser(candidate)
104
+ if os.path.isfile(resolved) and os.access(resolved, os.X_OK):
105
+ return resolved
106
+ return None
107
+
108
+
109
+ def _try_acquire() -> bool:
110
+ global _ping_in_flight
111
+ with _lock:
112
+ if _ping_in_flight:
113
+ return False
114
+ _ping_in_flight = True
115
+ return True
116
+
117
+
118
+ def _release() -> None:
119
+ global _ping_in_flight
120
+ with _lock:
121
+ _ping_in_flight = False
122
+
123
+
124
+ def _run_agy_ping(agy_bin: str) -> None:
125
+ subprocess.run( # noqa: S603 - resolved local Antigravity CLI
126
+ [agy_bin, "-p", "ok", "--model", AGY_MODEL],
127
+ stdin=subprocess.DEVNULL,
128
+ capture_output=True,
129
+ encoding="utf-8",
130
+ errors="replace",
131
+ timeout=PING_TIMEOUT_SECONDS,
132
+ cwd=os.path.expanduser("~"),
133
+ check=False,
134
+ )
135
+
136
+
137
+ def _ping_worker(started_at: float) -> None:
138
+ try:
139
+ agy_bin = _resolve_agy_bin()
140
+ if agy_bin is None:
141
+ _debug_log("agy-window-keeper: agy binary not found, skipping ping")
142
+ return
143
+ _run_agy_ping(agy_bin)
144
+ _debug_log(f"agy-window-keeper: ping completed (started_at={started_at})")
145
+ except subprocess.TimeoutExpired:
146
+ _debug_log(f"agy-window-keeper: ping timed out after {PING_TIMEOUT_SECONDS}s")
147
+ except Exception:
148
+ _debug_log("agy-window-keeper: ping failed", exc_info=True)
149
+ finally:
150
+ _release()
151
+
152
+
153
+ def maybe_ping(result: AgyRefreshResult, mock: bool) -> None:
154
+ """Read preferences and state, then dispatch a background ping if eligible."""
155
+ if mock:
156
+ return
157
+ enabled = _agy_window_keeper_enabled()
158
+ if not enabled:
159
+ return
160
+ projection = result.projection
161
+ five_hour = projection.five_hour if projection is not None else None
162
+ now = time.time()
163
+ last_ping_at = _load_last_ping()
164
+ if not should_ping(
165
+ now=now,
166
+ enabled=enabled,
167
+ last_ping_at=last_ping_at,
168
+ remaining_percent=(
169
+ five_hour.remaining_percent if five_hour is not None else None
170
+ ),
171
+ stale=projection.stale if projection is not None else None,
172
+ fallback_projection=projection is None,
173
+ mock=mock,
174
+ ):
175
+ return
176
+ if not _try_acquire():
177
+ return
178
+ # Stamp dispatch even on failure to avoid retrying every refresh.
179
+ _save_last_ping(now)
180
+ threading.Thread(target=_ping_worker, args=(now,), daemon=True).start()
181
+
182
+
183
+ def _debug_log(message: str, *, exc_info: bool = False) -> None:
184
+ if os.environ.get("USAGE_DEBUG") == "1":
185
+ logger.warning(message, exc_info=exc_info)
analyzer/__init__.py ADDED
@@ -0,0 +1,5 @@
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.
analyzer/aggregator.py ADDED
@@ -0,0 +1,139 @@
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 collections import defaultdict
8
+
9
+ from adapters.types import DailyStats, MonthlyStats, SessionStats, UsageEntry, WeeklyStats
10
+ from pricing import calculate_cost
11
+
12
+
13
+ def aggregate_daily(entries: list[UsageEntry]) -> list[DailyStats]:
14
+ by_date: dict[str, DailyStats] = {}
15
+ sessions_by_date: dict[str, set[str]] = defaultdict(set)
16
+
17
+ for e in entries:
18
+ date_str = e.timestamp.astimezone().strftime("%Y-%m-%d")
19
+ if date_str not in by_date:
20
+ by_date[date_str] = DailyStats(date=date_str)
21
+ s = by_date[date_str]
22
+ cost = calculate_cost(e)
23
+ s.input_tokens += e.input_tokens
24
+ s.output_tokens += e.output_tokens
25
+ s.cache_creation_tokens += e.cache_creation_tokens
26
+ s.cache_read_tokens += e.cache_read_tokens
27
+ s.total_tokens += e.total_tokens
28
+ s.cost_usd += cost
29
+ s.message_count += e.message_count
30
+ s.models[e.model] = s.models.get(e.model, 0) + e.total_tokens
31
+ sessions_by_date[date_str].add(e.session_id)
32
+
33
+ for date_str, sessions in sessions_by_date.items():
34
+ by_date[date_str].session_count = len(sessions)
35
+
36
+ return sorted(by_date.values(), key=lambda s: s.date)
37
+
38
+
39
+ def aggregate_monthly(entries: list[UsageEntry]) -> list[MonthlyStats]:
40
+ by_month: dict[str, MonthlyStats] = {}
41
+ sessions_by_month: dict[str, set[str]] = defaultdict(set)
42
+
43
+ for e in entries:
44
+ month_str = e.timestamp.astimezone().strftime("%Y-%m")
45
+ if month_str not in by_month:
46
+ by_month[month_str] = MonthlyStats(month=month_str)
47
+ s = by_month[month_str]
48
+ cost = calculate_cost(e)
49
+ s.input_tokens += e.input_tokens
50
+ s.output_tokens += e.output_tokens
51
+ s.cache_creation_tokens += e.cache_creation_tokens
52
+ s.cache_read_tokens += e.cache_read_tokens
53
+ s.total_tokens += e.total_tokens
54
+ s.cost_usd += cost
55
+ s.message_count += e.message_count
56
+ s.models[e.model] = s.models.get(e.model, 0) + e.total_tokens
57
+ sessions_by_month[month_str].add(e.session_id)
58
+
59
+ for month_str, sessions in sessions_by_month.items():
60
+ by_month[month_str].session_count = len(sessions)
61
+
62
+ return sorted(by_month.values(), key=lambda s: s.month)
63
+
64
+
65
+ def aggregate_weekly(entries: list[UsageEntry]) -> list[WeeklyStats]:
66
+ from datetime import timedelta
67
+
68
+ by_week: dict[str, WeeklyStats] = {}
69
+ sessions_by_week: dict[str, set[str]] = defaultdict(set)
70
+
71
+ for e in entries:
72
+ local_ts = e.timestamp.astimezone()
73
+ monday = local_ts.date() - timedelta(days=local_ts.weekday())
74
+ sunday = monday + timedelta(days=6)
75
+ week_key = monday.isoformat()
76
+ if week_key not in by_week:
77
+ by_week[week_key] = WeeklyStats(
78
+ week=week_key,
79
+ week_start=monday.strftime("%m-%d"),
80
+ week_end=sunday.strftime("%m-%d"),
81
+ )
82
+ s = by_week[week_key]
83
+ cost = calculate_cost(e)
84
+ s.input_tokens += e.input_tokens
85
+ s.output_tokens += e.output_tokens
86
+ s.cache_creation_tokens += e.cache_creation_tokens
87
+ s.cache_read_tokens += e.cache_read_tokens
88
+ s.total_tokens += e.total_tokens
89
+ s.cost_usd += cost
90
+ s.message_count += e.message_count
91
+ s.models[e.model] = s.models.get(e.model, 0) + e.total_tokens
92
+ sessions_by_week[week_key].add(e.session_id)
93
+
94
+ for week_key, sessions in sessions_by_week.items():
95
+ by_week[week_key].session_count = len(sessions)
96
+
97
+ return sorted(by_week.values(), key=lambda s: s.week)
98
+
99
+
100
+ def aggregate_sessions(entries: list[UsageEntry]) -> list[SessionStats]:
101
+ by_session: dict[str, list[UsageEntry]] = defaultdict(list)
102
+
103
+ for e in entries:
104
+ by_session[e.session_id].append(e)
105
+
106
+ sessions: list[SessionStats] = []
107
+ for session_id, session_entries in by_session.items():
108
+ session_entries.sort(key=lambda e: e.timestamp)
109
+ first = session_entries[0]
110
+ last = session_entries[-1]
111
+ duration = (last.timestamp - first.timestamp).total_seconds() / 60
112
+
113
+ models: dict[str, int] = defaultdict(int)
114
+ for e in session_entries:
115
+ models[e.model] += e.total_tokens
116
+ primary_model = max(models, key=lambda model: models[model]) if models else "unknown"
117
+
118
+ s = SessionStats(
119
+ session_id=session_id,
120
+ project=first.project,
121
+ model=primary_model,
122
+ start_time=first.timestamp,
123
+ end_time=last.timestamp,
124
+ duration_minutes=round(duration, 1),
125
+ )
126
+ for e in session_entries:
127
+ cost = calculate_cost(e)
128
+ s.input_tokens += e.input_tokens
129
+ s.output_tokens += e.output_tokens
130
+ s.cache_creation_tokens += e.cache_creation_tokens
131
+ s.cache_read_tokens += e.cache_read_tokens
132
+ s.total_tokens += e.total_tokens
133
+ s.cost_usd += cost
134
+ s.message_count += e.message_count
135
+
136
+ sessions.append(s)
137
+
138
+ sessions.sort(key=lambda s: s.start_time, reverse=True)
139
+ return sessions
analyzer/blocks.py ADDED
@@ -0,0 +1,80 @@
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 collections.abc import Sequence
8
+ from datetime import datetime, timedelta, timezone
9
+
10
+ from adapters.types import DailyStats, P90Limits, SessionBlock, UsageEntry
11
+ from pricing import calculate_cost
12
+
13
+ BLOCK_DURATION = timedelta(hours=5)
14
+
15
+
16
+ def analyze_blocks(entries: list[UsageEntry]) -> list[SessionBlock]:
17
+ if not entries:
18
+ return []
19
+
20
+ sorted_entries = sorted(entries, key=lambda e: e.timestamp)
21
+ blocks: list[SessionBlock] = []
22
+ current_block: SessionBlock | None = None
23
+
24
+ for entry in sorted_entries:
25
+ if current_block is None or entry.timestamp >= current_block.end_time:
26
+ if current_block and entry.timestamp >= current_block.end_time:
27
+ gap_duration = entry.timestamp - current_block.end_time
28
+ if gap_duration > timedelta(minutes=5):
29
+ gap = SessionBlock(
30
+ start_time=current_block.end_time,
31
+ end_time=entry.timestamp,
32
+ is_gap=True,
33
+ )
34
+ blocks.append(gap)
35
+
36
+ current_block = SessionBlock(
37
+ start_time=entry.timestamp,
38
+ end_time=entry.timestamp + BLOCK_DURATION,
39
+ )
40
+ blocks.append(current_block)
41
+
42
+ cost = calculate_cost(entry)
43
+ current_block.entries.append(entry)
44
+ current_block.input_tokens += entry.input_tokens
45
+ current_block.output_tokens += entry.output_tokens
46
+ current_block.cache_creation_tokens += entry.cache_creation_tokens
47
+ current_block.cache_read_tokens += entry.cache_read_tokens
48
+ current_block.total_tokens += entry.total_tokens
49
+ current_block.cost_usd += cost
50
+
51
+ now = datetime.now(timezone.utc)
52
+ for block in blocks:
53
+ if block.is_gap:
54
+ continue
55
+ if block.end_time > now and block.entries:
56
+ block.is_active = True
57
+ elapsed = (now - block.start_time).total_seconds() / 60
58
+ if elapsed > 0:
59
+ block.burn_rate = block.total_tokens / elapsed
60
+
61
+ return blocks
62
+
63
+
64
+ def calculate_p90(daily_stats: list[DailyStats]) -> P90Limits:
65
+ if len(daily_stats) < 3:
66
+ return P90Limits()
67
+
68
+ token_values = sorted(d.total_tokens for d in daily_stats)
69
+ cost_values = sorted(d.cost_usd for d in daily_stats)
70
+ msg_values = sorted(d.message_count for d in daily_stats)
71
+
72
+ def p90(values: Sequence[float]) -> float:
73
+ idx = int(len(values) * 0.9)
74
+ return values[min(idx, len(values) - 1)]
75
+
76
+ return P90Limits(
77
+ token_limit=int(p90(token_values)),
78
+ cost_limit=round(p90(cost_values), 2),
79
+ message_limit=int(p90(msg_values)),
80
+ )