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
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
|