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
adapters/types.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 dataclasses import dataclass, field
8
+ from datetime import datetime
9
+
10
+
11
+ @dataclass
12
+ class UsageEntry:
13
+ timestamp: datetime
14
+ session_id: str
15
+ message_id: str
16
+ request_id: str
17
+ model: str
18
+ input_tokens: int
19
+ output_tokens: int
20
+ cache_creation_tokens: int
21
+ cache_read_tokens: int
22
+ cost_usd: float | None
23
+ project: str
24
+ agent_id: str
25
+ message_count: int = 1
26
+
27
+ @property
28
+ def total_tokens(self) -> int:
29
+ return self.input_tokens + self.output_tokens + self.cache_creation_tokens + self.cache_read_tokens
30
+
31
+ @property
32
+ def dedup_key(self) -> str:
33
+ return f"{self.message_id}:{self.request_id}"
34
+
35
+
36
+ @dataclass
37
+ class AgentInfo:
38
+ id: str
39
+ name: str
40
+ data_dir: str
41
+ installed: bool
42
+
43
+
44
+ @dataclass
45
+ class DailyStats:
46
+ date: str
47
+ input_tokens: int = 0
48
+ output_tokens: int = 0
49
+ cache_creation_tokens: int = 0
50
+ cache_read_tokens: int = 0
51
+ total_tokens: int = 0
52
+ cost_usd: float = 0.0
53
+ session_count: int = 0
54
+ message_count: int = 0
55
+ models: dict[str, int] = field(default_factory=dict)
56
+ agent_id: str = ""
57
+
58
+
59
+ @dataclass
60
+ class WeeklyStats:
61
+ week: str
62
+ week_start: str = ""
63
+ week_end: str = ""
64
+ input_tokens: int = 0
65
+ output_tokens: int = 0
66
+ cache_creation_tokens: int = 0
67
+ cache_read_tokens: int = 0
68
+ total_tokens: int = 0
69
+ cost_usd: float = 0.0
70
+ session_count: int = 0
71
+ message_count: int = 0
72
+ models: dict[str, int] = field(default_factory=dict)
73
+ agent_id: str = ""
74
+
75
+
76
+ @dataclass
77
+ class SessionStats:
78
+ session_id: str
79
+ project: str
80
+ model: str
81
+ start_time: datetime
82
+ end_time: datetime
83
+ duration_minutes: float
84
+ input_tokens: int = 0
85
+ output_tokens: int = 0
86
+ cache_creation_tokens: int = 0
87
+ cache_read_tokens: int = 0
88
+ total_tokens: int = 0
89
+ cost_usd: float = 0.0
90
+ message_count: int = 0
91
+ agent_id: str = ""
92
+
93
+
94
+ @dataclass
95
+ class MonthlyStats:
96
+ month: str
97
+ input_tokens: int = 0
98
+ output_tokens: int = 0
99
+ cache_creation_tokens: int = 0
100
+ cache_read_tokens: int = 0
101
+ total_tokens: int = 0
102
+ cost_usd: float = 0.0
103
+ session_count: int = 0
104
+ message_count: int = 0
105
+ models: dict[str, int] = field(default_factory=dict)
106
+ agent_id: str = ""
107
+
108
+
109
+ @dataclass
110
+ class RateLimits:
111
+ five_hour_pct: float | None = None
112
+ five_hour_resets_at: int | None = None
113
+ seven_day_pct: float | None = None
114
+ seven_day_resets_at: int | None = None
115
+ model: str = ""
116
+ updated_at: str = ""
117
+
118
+
119
+ @dataclass
120
+ class P90Limits:
121
+ token_limit: int = 0
122
+ cost_limit: float = 0.0
123
+ message_limit: int = 0
124
+
125
+
126
+ @dataclass
127
+ class SessionBlock:
128
+ start_time: datetime
129
+ end_time: datetime
130
+ entries: list[UsageEntry] = field(default_factory=list)
131
+ input_tokens: int = 0
132
+ output_tokens: int = 0
133
+ cache_creation_tokens: int = 0
134
+ cache_read_tokens: int = 0
135
+ total_tokens: int = 0
136
+ cost_usd: float = 0.0
137
+ is_active: bool = False
138
+ burn_rate: float = 0.0
139
+ is_gap: bool = False
agy_disk_cache.py ADDED
@@ -0,0 +1,135 @@
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
+ """Disk persistence for agy_loader's in-memory SQLite parse cache."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import json
13
+ import logging
14
+ import os
15
+ import tempfile
16
+ from collections import OrderedDict
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from cache_quarantine import quarantine
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _FileCache = OrderedDict[Path, Any]
26
+
27
+
28
+ def _serialize_entry(entry: Any) -> dict[str, Any]:
29
+ return {
30
+ "timestamp": entry.timestamp.isoformat(),
31
+ "model": entry.model,
32
+ "input_tokens": entry.input_tokens,
33
+ "output_tokens": entry.output_tokens,
34
+ "cache_read_tokens": entry.cache_read_tokens,
35
+ "thinking_tokens": entry.thinking_tokens,
36
+ "dedup_key": entry.dedup_key,
37
+ "session_id": entry.session_id,
38
+ }
39
+
40
+
41
+ def _deserialize_entry(data: dict[str, Any]) -> Any:
42
+ from agy_loader import AgyUsageEntry
43
+
44
+ return AgyUsageEntry(
45
+ timestamp=datetime.fromisoformat(data["timestamp"]),
46
+ model=str(data["model"]),
47
+ input_tokens=int(data["input_tokens"]),
48
+ output_tokens=int(data["output_tokens"]),
49
+ cache_read_tokens=int(data["cache_read_tokens"]),
50
+ thinking_tokens=int(data["thinking_tokens"]),
51
+ dedup_key=str(data["dedup_key"]),
52
+ session_id=str(data["session_id"]),
53
+ )
54
+
55
+
56
+ def seed_caches(
57
+ cache_path: Path,
58
+ schema_version: int,
59
+ maxsize: int,
60
+ file_cache: _FileCache,
61
+ ) -> None:
62
+ """Seed the given in-memory cache from disk. Silently fails on any error."""
63
+ from agy_loader import _FileCacheEntry
64
+
65
+ try:
66
+ with cache_path.open(encoding="utf-8") as f:
67
+ cache = json.load(f)
68
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
69
+ if isinstance(exc, UnicodeDecodeError):
70
+ quarantine(cache_path, "decode-error")
71
+ elif isinstance(exc, json.JSONDecodeError):
72
+ quarantine(cache_path, "json-error")
73
+ return
74
+ if not isinstance(cache, dict):
75
+ quarantine(cache_path, "not-a-mapping")
76
+ return
77
+ if cache.get("schema_version") != schema_version:
78
+ return
79
+ files = cache.get("files")
80
+ if not isinstance(files, dict):
81
+ return
82
+
83
+ for path_str, file_data in files.items():
84
+ if not isinstance(file_data, dict):
85
+ continue
86
+ try:
87
+ entries_data = file_data["entries"]
88
+ if not isinstance(entries_data, list):
89
+ continue
90
+ if len(file_cache) >= maxsize:
91
+ file_cache.popitem(last=False)
92
+ file_cache[Path(path_str)] = _FileCacheEntry(
93
+ mtime=float(file_data["mtime"]),
94
+ size=int(file_data["size"]),
95
+ entries=[_deserialize_entry(entry) for entry in entries_data],
96
+ skipped_missing_dedup_key=int(file_data["skipped_missing_dedup_key"]),
97
+ )
98
+ except (KeyError, TypeError, ValueError):
99
+ continue
100
+
101
+
102
+ def flush_caches(
103
+ cache_path: Path,
104
+ schema_version: int,
105
+ file_cache: _FileCache,
106
+ ) -> None:
107
+ """Atomically write the given in-memory cache to disk."""
108
+ tmp_path: str | None = None
109
+ try:
110
+ cache_path.parent.mkdir(parents=True, exist_ok=True)
111
+ fd, tmp_path = tempfile.mkstemp(dir=cache_path.parent, suffix=".tmp")
112
+ payload = {
113
+ "schema_version": schema_version,
114
+ "cached_at": datetime.now(UTC).timestamp(),
115
+ "files": {
116
+ str(path): {
117
+ "mtime": entry.mtime,
118
+ "size": entry.size,
119
+ "entries": [_serialize_entry(item) for item in entry.entries],
120
+ "skipped_missing_dedup_key": entry.skipped_missing_dedup_key,
121
+ }
122
+ for path, entry in file_cache.items()
123
+ },
124
+ }
125
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
126
+ json.dump(payload, f, ensure_ascii=False, indent=2, sort_keys=True)
127
+ os.replace(tmp_path, cache_path)
128
+ tmp_path = None
129
+ except Exception as exc:
130
+ if os.environ.get("USAGE_DEBUG") == "1":
131
+ logger.warning("failed to write Antigravity database cache %s: %s", cache_path, exc)
132
+ finally:
133
+ if tmp_path and os.path.exists(tmp_path):
134
+ with contextlib.suppress(OSError):
135
+ os.unlink(tmp_path)
agy_loader.py ADDED
@@ -0,0 +1,416 @@
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
+ """Read Antigravity CLI generation usage from its local SQLite conversations."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ import sqlite3
14
+ import time
15
+ from collections import OrderedDict
16
+ from collections.abc import Iterator
17
+ from dataclasses import dataclass
18
+ from datetime import UTC, datetime, timedelta
19
+ from pathlib import Path
20
+
21
+ from agy_disk_cache import flush_caches, seed_caches
22
+ from disk_cache_lifecycle import (
23
+ flush_caches_if_due,
24
+ needs_cache_seed,
25
+ )
26
+ from disk_cache_lifecycle import (
27
+ flush_caches_on_terminate as _flush_caches_on_terminate,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ AGY_SESSIONS_DIR = Path(os.path.expanduser("~/.gemini/antigravity-cli/conversations"))
33
+
34
+ # Must comfortably exceed a real user's total *.db conversation count. A cap
35
+ # below that count evicts databases parsed during the prior refresh, causing a
36
+ # permanent full SQLite/protobuf reparse on every load (LRU thrashing).
37
+ _FILE_CACHE_MAXSIZE = 4096
38
+ _AGY_DB_CACHE_SCHEMA = 1
39
+ AGY_CACHE_PATH = Path(os.path.expanduser("~/.usage/agy_db_cache.json"))
40
+ _disk_cache_seeded = False
41
+ _DISK_CACHE_FLUSH_INTERVAL_S = 300.0
42
+
43
+
44
+ def _readonly_sqlite_uri(path: Path) -> str:
45
+ return f"{path.resolve().as_uri()}?mode=ro"
46
+ _disk_cache_dirty = False
47
+ _last_disk_cache_flush_at: float | None = None
48
+ _monotonic = time.monotonic
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class AgyUsageEntry:
53
+ """One deduplicated Antigravity CLI generation."""
54
+
55
+ timestamp: datetime
56
+ model: str
57
+ input_tokens: int
58
+ output_tokens: int
59
+ cache_read_tokens: int
60
+ thinking_tokens: int
61
+ dedup_key: str
62
+ session_id: str
63
+
64
+ @property
65
+ def total_tokens(self) -> int:
66
+ return (
67
+ self.input_tokens
68
+ + self.output_tokens
69
+ + self.cache_read_tokens
70
+ + self.thinking_tokens
71
+ )
72
+
73
+ @property
74
+ def active_tokens(self) -> int:
75
+ return self.input_tokens + self.output_tokens + self.thinking_tokens
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class AgyLoadResult:
80
+ """Loaded entries plus rows omitted because they lacked a deduplication key."""
81
+
82
+ entries: list[AgyUsageEntry]
83
+ skipped_missing_dedup_key: int
84
+
85
+
86
+ @dataclass(slots=True)
87
+ class _FileCacheEntry:
88
+ mtime: float
89
+ size: int
90
+ entries: list[AgyUsageEntry]
91
+ skipped_missing_dedup_key: int
92
+
93
+
94
+ _file_cache: OrderedDict[Path, _FileCacheEntry] = OrderedDict()
95
+
96
+
97
+ def load_entries(hours_back: int = 0) -> list[AgyUsageEntry]:
98
+ """Load deduplicated entries from the last ``hours_back`` hours.
99
+
100
+ A zero value means all available history. Files being written by Antigravity
101
+ are skipped rather than interrupting the caller's refresh cycle.
102
+ """
103
+ return load_entries_with_stats(hours_back).entries
104
+
105
+
106
+ def load_entries_with_stats(hours_back: int = 0) -> AgyLoadResult:
107
+ """Load entries and report how many usage rows lacked a deduplication key."""
108
+ global _disk_cache_dirty
109
+
110
+ _seed_caches_from_disk()
111
+ cutoff = datetime.now(UTC) - timedelta(hours=hours_back) if hours_back > 0 else None
112
+ if not AGY_SESSIONS_DIR.is_dir():
113
+ return AgyLoadResult(entries=[], skipped_missing_dedup_key=0)
114
+
115
+ entries: list[AgyUsageEntry] = []
116
+ seen_dedup_keys: set[str] = set()
117
+ skipped_missing_dedup_key = 0
118
+ cutoff_ts = cutoff.timestamp() if cutoff else None
119
+ file_cache_snapshot = {
120
+ str(path): (entry.mtime, entry.size) for path, entry in _file_cache.items()
121
+ }
122
+ for path in AGY_SESSIONS_DIR.glob("*.db"):
123
+ if cutoff_ts is not None:
124
+ try:
125
+ if path.stat().st_mtime < cutoff_ts:
126
+ continue
127
+ except OSError as exc:
128
+ logger.warning("failed to stat Antigravity database %s: %s", path, exc)
129
+ continue
130
+ file_entries, skipped = _load_database(path, cutoff, seen_dedup_keys)
131
+ entries.extend(file_entries)
132
+ skipped_missing_dedup_key += skipped
133
+
134
+ if {
135
+ str(path): (entry.mtime, entry.size) for path, entry in _file_cache.items()
136
+ } != file_cache_snapshot:
137
+ _disk_cache_dirty = True
138
+ _flush_caches_to_disk()
139
+ elif _disk_cache_dirty:
140
+ _flush_caches_to_disk()
141
+
142
+ entries.sort(key=lambda entry: entry.timestamp)
143
+ return AgyLoadResult(
144
+ entries=entries,
145
+ skipped_missing_dedup_key=skipped_missing_dedup_key,
146
+ )
147
+
148
+
149
+ def _seed_caches_from_disk() -> None:
150
+ global _disk_cache_seeded
151
+
152
+ if not needs_cache_seed(_disk_cache_seeded):
153
+ return
154
+ _disk_cache_seeded = True
155
+ seed_caches(AGY_CACHE_PATH, _AGY_DB_CACHE_SCHEMA, _FILE_CACHE_MAXSIZE, _file_cache)
156
+
157
+
158
+ def _flush_caches_to_disk(*, force: bool = False) -> None:
159
+ global _disk_cache_dirty, _last_disk_cache_flush_at
160
+
161
+ _disk_cache_dirty, _last_disk_cache_flush_at = flush_caches_if_due(
162
+ _disk_cache_dirty,
163
+ _last_disk_cache_flush_at,
164
+ _monotonic,
165
+ _DISK_CACHE_FLUSH_INTERVAL_S,
166
+ lambda: flush_caches(AGY_CACHE_PATH, _AGY_DB_CACHE_SCHEMA, _file_cache),
167
+ force=force,
168
+ )
169
+
170
+
171
+ def flush_caches_on_terminate() -> None:
172
+ """Best-effort persistence of cache changes still waiting for the throttle."""
173
+ _flush_caches_on_terminate(lambda: _flush_caches_to_disk(force=True))
174
+
175
+
176
+ def recent_input_output_tokens(hours_back: int) -> int:
177
+ """Return recent input plus output tokens, excluding cache-read and thinking."""
178
+ return sum(
179
+ entry.input_tokens + entry.output_tokens for entry in load_entries(hours_back)
180
+ )
181
+
182
+
183
+ def _load_database(
184
+ path: Path,
185
+ cutoff: datetime | None,
186
+ seen_dedup_keys: set[str],
187
+ ) -> tuple[list[AgyUsageEntry], int]:
188
+ try:
189
+ st = path.stat()
190
+ except OSError as exc:
191
+ logger.warning("failed to stat Antigravity database %s: %s", path, exc)
192
+ return [], 0
193
+
194
+ cached = _file_cache.get(path)
195
+ if cached is not None and cached.mtime == st.st_mtime and cached.size == st.st_size:
196
+ _file_cache.move_to_end(path)
197
+ return _filter_entries(
198
+ cached.entries,
199
+ cached.skipped_missing_dedup_key,
200
+ cutoff,
201
+ seen_dedup_keys,
202
+ )
203
+
204
+ parsed = _parse_database(path)
205
+ if parsed is None:
206
+ return [], 0
207
+ parsed_entries, skipped_missing_dedup_key = parsed
208
+ if path not in _file_cache and len(_file_cache) >= _FILE_CACHE_MAXSIZE:
209
+ _file_cache.popitem(last=False)
210
+ _file_cache[path] = _FileCacheEntry(
211
+ mtime=st.st_mtime,
212
+ size=st.st_size,
213
+ entries=parsed_entries,
214
+ skipped_missing_dedup_key=skipped_missing_dedup_key,
215
+ )
216
+ return _filter_entries(parsed_entries, skipped_missing_dedup_key, cutoff, seen_dedup_keys)
217
+
218
+
219
+ def _filter_entries(
220
+ candidates: list[AgyUsageEntry],
221
+ skipped_missing_dedup_key: int,
222
+ cutoff: datetime | None,
223
+ seen_dedup_keys: set[str],
224
+ ) -> tuple[list[AgyUsageEntry], int]:
225
+ entries: list[AgyUsageEntry] = []
226
+ for entry in candidates:
227
+ if cutoff is not None and entry.timestamp < cutoff:
228
+ continue
229
+ if entry.dedup_key in seen_dedup_keys:
230
+ continue
231
+ seen_dedup_keys.add(entry.dedup_key)
232
+ entries.append(entry)
233
+ return entries, skipped_missing_dedup_key
234
+
235
+
236
+ def _parse_database(path: Path) -> tuple[list[AgyUsageEntry], int] | None:
237
+ try:
238
+ with sqlite3.connect(_readonly_sqlite_uri(path), uri=True) as connection:
239
+ session_timestamp = _session_timestamp(connection, path)
240
+ rows = connection.execute("SELECT data FROM gen_metadata ORDER BY idx")
241
+ entries: list[AgyUsageEntry] = []
242
+ skipped_missing_dedup_key = 0
243
+ for (blob,) in rows:
244
+ if not isinstance(blob, bytes):
245
+ continue
246
+ entry, missing_dedup_key = _parse_generation(
247
+ blob,
248
+ path.stem,
249
+ session_timestamp,
250
+ set(),
251
+ )
252
+ skipped_missing_dedup_key += missing_dedup_key
253
+ if entry is not None:
254
+ entries.append(entry)
255
+ return entries, skipped_missing_dedup_key
256
+ except sqlite3.Error as exc:
257
+ logger.debug("skipping Antigravity database %s: %s", path, exc)
258
+ return None
259
+
260
+
261
+ def _session_timestamp(connection: sqlite3.Connection, path: Path) -> datetime:
262
+ try:
263
+ row = connection.execute(
264
+ "SELECT data FROM trajectory_metadata_blob LIMIT 1"
265
+ ).fetchone()
266
+ except sqlite3.Error:
267
+ row = None
268
+ if row is not None and isinstance(row[0], bytes):
269
+ timestamp = _timestamp(_message_field(row[0], 2))
270
+ if timestamp is not None:
271
+ return timestamp
272
+ try:
273
+ return datetime.fromtimestamp(path.stat().st_mtime, UTC)
274
+ except OSError:
275
+ return datetime.fromtimestamp(0, UTC)
276
+
277
+
278
+ def _parse_generation(
279
+ blob: bytes,
280
+ session_id: str,
281
+ session_timestamp: datetime,
282
+ seen_dedup_keys: set[str],
283
+ ) -> tuple[AgyUsageEntry | None, int]:
284
+ chat_model = _message_field(blob, 1)
285
+ if chat_model is None:
286
+ return None, 0
287
+ usage = _message_field(chat_model, 4)
288
+ if usage is None:
289
+ return None, 0
290
+
291
+ system_tokens = _varint_field(usage, 1) or 0
292
+ new_input_tokens = _varint_field(usage, 2) or 0
293
+ cache_read_tokens = _varint_field(usage, 5) or 0
294
+ output_tokens = _varint_field(usage, 9) or 0
295
+ thinking_tokens = _varint_field(usage, 10) or 0
296
+ token_counts = (
297
+ system_tokens,
298
+ new_input_tokens,
299
+ cache_read_tokens,
300
+ output_tokens,
301
+ thinking_tokens,
302
+ )
303
+ if not any(token_counts):
304
+ return None, 0
305
+
306
+ dedup_key = _string_field(usage, 11)
307
+ if dedup_key is None or not dedup_key.strip():
308
+ return None, 1
309
+ if dedup_key in seen_dedup_keys:
310
+ return None, 0
311
+ seen_dedup_keys.add(dedup_key)
312
+
313
+ timestamp = _timestamp(_message_field(_message_field(chat_model, 9) or b"", 4))
314
+ model = _string_field(chat_model, 19) or "unknown"
315
+ return (
316
+ AgyUsageEntry(
317
+ timestamp=timestamp or session_timestamp,
318
+ model=model,
319
+ input_tokens=system_tokens + new_input_tokens,
320
+ output_tokens=output_tokens,
321
+ cache_read_tokens=cache_read_tokens,
322
+ thinking_tokens=thinking_tokens,
323
+ dedup_key=dedup_key,
324
+ session_id=session_id,
325
+ ),
326
+ 0,
327
+ )
328
+
329
+
330
+ def _timestamp(blob: bytes | None) -> datetime | None:
331
+ if blob is None:
332
+ return None
333
+ seconds = _varint_field(blob, 1)
334
+ nanos = _varint_field(blob, 2) or 0
335
+ if seconds is None or not 0 <= nanos <= 999_999_999:
336
+ return None
337
+ try:
338
+ return datetime.fromtimestamp(seconds + nanos / 1_000_000_000, UTC)
339
+ except (OSError, OverflowError, ValueError):
340
+ return None
341
+
342
+
343
+ def _message_field(blob: bytes, target_field: int) -> bytes | None:
344
+ for field, wire_type, value in _fields(blob):
345
+ if field == target_field and wire_type == 2 and isinstance(value, bytes):
346
+ return value
347
+ return None
348
+
349
+
350
+ def _varint_field(blob: bytes, target_field: int) -> int | None:
351
+ for field, wire_type, value in _fields(blob):
352
+ if field == target_field and wire_type == 0 and isinstance(value, int):
353
+ return value
354
+ return None
355
+
356
+
357
+ def _string_field(blob: bytes, target_field: int) -> str | None:
358
+ value = _message_field(blob, target_field)
359
+ if value is None:
360
+ return None
361
+ try:
362
+ return value.decode("utf-8")
363
+ except UnicodeDecodeError:
364
+ return None
365
+
366
+
367
+ def _fields(blob: bytes) -> Iterator[tuple[int, int, int | bytes]]:
368
+ position = 0
369
+ while position < len(blob):
370
+ tag_and_position = _read_varint(blob, position)
371
+ if tag_and_position is None:
372
+ return
373
+ tag, position = tag_and_position
374
+ field = tag >> 3
375
+ wire_type = tag & 7
376
+ if field == 0:
377
+ return
378
+ if wire_type == 0:
379
+ value_and_position = _read_varint(blob, position)
380
+ if value_and_position is None:
381
+ return
382
+ value, position = value_and_position
383
+ yield field, wire_type, value
384
+ elif wire_type == 1:
385
+ position += 8
386
+ if position > len(blob):
387
+ return
388
+ elif wire_type == 2:
389
+ length_and_position = _read_varint(blob, position)
390
+ if length_and_position is None:
391
+ return
392
+ length, position = length_and_position
393
+ end = position + length
394
+ if end > len(blob):
395
+ return
396
+ yield field, wire_type, blob[position:end]
397
+ position = end
398
+ elif wire_type == 5:
399
+ position += 4
400
+ if position > len(blob):
401
+ return
402
+ else:
403
+ return
404
+
405
+
406
+ def _read_varint(blob: bytes, position: int) -> tuple[int, int] | None:
407
+ value = 0
408
+ for shift in range(0, 70, 7):
409
+ if position >= len(blob):
410
+ return None
411
+ byte = blob[position]
412
+ position += 1
413
+ value |= (byte & 0x7F) << shift
414
+ if byte & 0x80 == 0:
415
+ return value, position
416
+ return None