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
@@ -0,0 +1,108 @@
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 the locally-stored subscription plan + start date for each agent.
8
+
9
+ Everything here is read-only and stays on disk — we only pull the plan name and
10
+ the subscription start date out of the OAuth account files that Claude Code and
11
+ Codex already keep. Tokens, emails and account IDs are never read or returned.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import binascii
17
+ import json
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from codex_paths import codex_home
22
+
23
+ CLAUDE_CONFIG = Path.home() / ".claude.json"
24
+ CODEX_AUTH = codex_home() / "auth.json"
25
+
26
+ _CLAUDE_PLAN_NAMES = {
27
+ "claude_pro": "Claude Pro",
28
+ "claude_max": "Claude Max",
29
+ "claude_team": "Claude Team",
30
+ "claude_enterprise": "Claude Enterprise",
31
+ }
32
+
33
+
34
+ def _decode_jwt_payload(token: str) -> dict[str, Any]:
35
+ if not isinstance(token, str):
36
+ return {}
37
+ parts = token.split(".")
38
+ if len(parts) < 2:
39
+ return {}
40
+ payload = parts[1]
41
+ payload += "=" * (-len(payload) % 4)
42
+ try:
43
+ claims = json.loads(base64.urlsafe_b64decode(payload))
44
+ if not isinstance(claims, dict):
45
+ return {}
46
+ return claims
47
+ except (binascii.Error, ValueError):
48
+ return {}
49
+
50
+
51
+ def _load_claude_subscription() -> dict[str, str | None] | None:
52
+ try:
53
+ data = json.loads(CLAUDE_CONFIG.read_text(encoding="utf-8"))
54
+ except (OSError, ValueError):
55
+ return None
56
+ if not isinstance(data, dict):
57
+ return None
58
+ account = data.get("oauthAccount")
59
+ if not isinstance(account, dict):
60
+ account = {}
61
+ org_type = account.get("organizationType")
62
+ since = account.get("subscriptionCreatedAt")
63
+ if not isinstance(org_type, str):
64
+ org_type = None
65
+ if not isinstance(since, str):
66
+ since = None
67
+ if not org_type and not since:
68
+ return None
69
+ plan = _CLAUDE_PLAN_NAMES.get(org_type or "")
70
+ if not plan:
71
+ plan = (org_type or "Claude").replace("claude_", "Claude ").replace("_", " ").title()
72
+ return {"agent": "Claude Code", "plan": plan, "since": since[:10] if since else None}
73
+
74
+
75
+ def _load_codex_subscription() -> dict[str, str | None] | None:
76
+ try:
77
+ data = json.loads(CODEX_AUTH.read_text(encoding="utf-8"))
78
+ except (OSError, ValueError):
79
+ return None
80
+ if not isinstance(data, dict):
81
+ return None
82
+ tokens = data.get("tokens")
83
+ if not isinstance(tokens, dict):
84
+ tokens = {}
85
+ claims = _decode_jwt_payload(tokens.get("id_token", ""))
86
+ auth = claims.get("https://api.openai.com/auth")
87
+ if not isinstance(auth, dict):
88
+ auth = {}
89
+ plan_type = auth.get("chatgpt_plan_type")
90
+ since = auth.get("chatgpt_subscription_active_start")
91
+ if not isinstance(plan_type, str):
92
+ plan_type = None
93
+ if not isinstance(since, str):
94
+ since = None
95
+ if not plan_type and not since:
96
+ return None
97
+ plan = f"ChatGPT {plan_type.title()}" if plan_type else "ChatGPT"
98
+ return {"agent": "Codex", "plan": plan, "since": since[:10] if since else None}
99
+
100
+
101
+ def load_subscriptions() -> list[dict[str, str | None]]:
102
+ """Return ``[{agent, plan, since}]`` for whichever agents we can detect."""
103
+ subs: list[dict[str, str | None]] = []
104
+ for loader in (_load_claude_subscription, _load_codex_subscription):
105
+ sub = loader()
106
+ if sub:
107
+ subs.append(sub)
108
+ return subs
burn_rate.py ADDED
@@ -0,0 +1,75 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import deque
10
+ from dataclasses import dataclass
11
+
12
+ ROLLING_WINDOW_SECONDS = 60 * 60
13
+ FORECAST_WINDOW_SECONDS = 10 * 60
14
+ RESET_DROP_PERCENT = 5.0
15
+ MIN_FORECAST_SAMPLES = 5
16
+ MIN_FORECAST_SPAN_SECONDS = 5 * 60
17
+ WARNING_PERCENT_FLOOR = 50.0
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class BurnSample:
22
+ timestamp: float
23
+ percent: float
24
+
25
+
26
+ class BurnRateTracker:
27
+ def __init__(self) -> None:
28
+ self._samples: deque[BurnSample] = deque()
29
+
30
+ def record(self, now: float, percent: float) -> None:
31
+ sample = BurnSample(timestamp=float(now), percent=float(percent))
32
+ previous = self._samples[-1] if self._samples else None
33
+ if previous is not None and (previous.percent - sample.percent) > RESET_DROP_PERCENT:
34
+ self._samples.clear()
35
+ self._samples.append(sample)
36
+ self._prune(now=sample.timestamp)
37
+
38
+ def forecast_seconds(
39
+ self,
40
+ window_seconds: float | None = None,
41
+ min_span_seconds: float | None = None,
42
+ ) -> float | None:
43
+ if len(self._samples) < 2:
44
+ return None
45
+
46
+ latest = self._samples[-1]
47
+ window = window_seconds if window_seconds is not None else FORECAST_WINDOW_SECONDS
48
+ cutoff = latest.timestamp - window
49
+ selected = [sample for sample in self._samples if sample.timestamp >= cutoff]
50
+ if len(selected) < MIN_FORECAST_SAMPLES:
51
+ return None
52
+
53
+ first = selected[0]
54
+ elapsed = latest.timestamp - first.timestamp
55
+ span_threshold = (
56
+ min_span_seconds if min_span_seconds is not None else MIN_FORECAST_SPAN_SECONDS
57
+ )
58
+ if elapsed < span_threshold:
59
+ return None
60
+ if elapsed <= 0:
61
+ return None
62
+
63
+ slope_per_second = (latest.percent - first.percent) / elapsed
64
+ if slope_per_second <= 0:
65
+ return None
66
+
67
+ remaining_percent = 100.0 - latest.percent
68
+ if remaining_percent <= 0:
69
+ return 0.0
70
+ return remaining_percent / slope_per_second
71
+
72
+ def _prune(self, now: float) -> None:
73
+ cutoff = now - ROLLING_WINDOW_SECONDS
74
+ while self._samples and self._samples[0].timestamp < cutoff:
75
+ self._samples.popleft()
cache_quarantine.py ADDED
@@ -0,0 +1,50 @@
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
+ """Best-effort quarantine for corrupt disk cache files."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import errno
12
+ import logging
13
+ import os
14
+ import shutil
15
+ import time
16
+ from pathlib import Path
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ QUARANTINE_DIR = Path.home() / ".usage" / "quarantine"
21
+ _MAX_FILE_SIZE = 5 * 1024 * 1024
22
+ _MAX_BACKUPS = 10
23
+
24
+
25
+ def quarantine(path: Path, reason: str) -> None:
26
+ """Best-effort move of a corrupt cache file into the quarantine directory."""
27
+ try:
28
+ if path.stat().st_size > _MAX_FILE_SIZE:
29
+ return
30
+
31
+ quarantine_dir = QUARANTINE_DIR
32
+ quarantine_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
33
+ timestamp_ms = time.time_ns() // 1_000_000
34
+ backup_path = quarantine_dir / f"{path.name}.{timestamp_ms}.bak"
35
+ try:
36
+ os.replace(path, backup_path)
37
+ except OSError as exc:
38
+ if exc.errno != errno.EXDEV:
39
+ raise
40
+ shutil.copy2(path, backup_path)
41
+ path.unlink()
42
+
43
+ backups = sorted(
44
+ quarantine_dir.glob("*.bak"), key=lambda candidate: candidate.stat().st_mtime
45
+ )
46
+ for backup in backups[:-_MAX_BACKUPS]:
47
+ backup.unlink()
48
+ except Exception as exc:
49
+ if os.environ.get("USAGE_DEBUG") == "1":
50
+ logger.warning("failed to quarantine cache file %s (%s): %s", path, reason, exc)
codex_disk_cache.py ADDED
@@ -0,0 +1,227 @@
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
+ """Sharded disk persistence for codex_loader's parse caches."""
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import os
13
+ from collections import OrderedDict
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from codex_events import _SessionFileInfo, _TokenUsage
18
+ from disk_cache_common import (
19
+ _SHARD_COUNT,
20
+ _cache_dir,
21
+ _deserialize_usage_entry,
22
+ _encoded_payload,
23
+ _load_payload,
24
+ _remove_legacy_cache,
25
+ _serialize_usage_entry,
26
+ _shard_index,
27
+ _shard_path,
28
+ _write_if_changed,
29
+ )
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ __all__ = ["_shard_index", "_shard_path", "flush_caches", "seed_caches"]
34
+
35
+ _JsonlCache = OrderedDict[Path, Any]
36
+ _FileInfoCache = OrderedDict[Path, tuple[float, int, _SessionFileInfo]]
37
+
38
+
39
+ def _serialize_token_usage(value: _TokenUsage | None) -> dict[str, int] | None:
40
+ if value is None:
41
+ return None
42
+ return {
43
+ "input_tokens": value.input_tokens,
44
+ "output_tokens": value.output_tokens,
45
+ "cache_read_tokens": value.cache_read_tokens,
46
+ }
47
+
48
+
49
+ def _deserialize_token_usage(value: Any) -> _TokenUsage | None:
50
+ if not isinstance(value, dict):
51
+ return None
52
+ try:
53
+ return _TokenUsage(
54
+ input_tokens=int(value["input_tokens"]),
55
+ output_tokens=int(value["output_tokens"]),
56
+ cache_read_tokens=int(value["cache_read_tokens"]),
57
+ )
58
+ except (KeyError, TypeError, ValueError):
59
+ return None
60
+
61
+
62
+ def _sqlite_log_path(cache_path: Path) -> Path:
63
+ return _cache_dir(cache_path) / "sqlite-log.json"
64
+
65
+
66
+ def seed_caches(
67
+ cache_path: Path,
68
+ schema_version: int,
69
+ maxsize: int,
70
+ jsonl_cache: _JsonlCache,
71
+ file_info_cache: _FileInfoCache,
72
+ sqlite_log_cache: Any,
73
+ ) -> None:
74
+ """Seed valid shards, skipping only corrupt or stale shards."""
75
+ from codex_loader import _JsonlCacheEntry, _JsonlParseState
76
+
77
+ _remove_legacy_cache(cache_path)
78
+ sqlite_payload = _load_payload(_sqlite_log_path(cache_path), schema_version)
79
+ if sqlite_payload is not None:
80
+ try:
81
+ watermark = sqlite_payload["watermark"]
82
+ entries_data = sqlite_payload["entries"]
83
+ if (
84
+ isinstance(watermark, list)
85
+ and len(watermark) == 3
86
+ and isinstance(entries_data, list)
87
+ ):
88
+ sqlite_log_cache.watermark = tuple(int(item) for item in watermark)
89
+ sqlite_log_cache.entries = [
90
+ _deserialize_usage_entry(entry) for entry in entries_data
91
+ ]
92
+ except (KeyError, TypeError, ValueError):
93
+ pass
94
+
95
+ for index in range(_SHARD_COUNT):
96
+ payload = _load_payload(_shard_path(cache_path, index), schema_version)
97
+ if payload is None:
98
+ continue
99
+ files = payload.get("files")
100
+ if not isinstance(files, dict):
101
+ continue
102
+ for path_str, file_data in files.items():
103
+ if not isinstance(file_data, dict):
104
+ continue
105
+ try:
106
+ path = Path(path_str)
107
+ mtime = file_data["mtime"]
108
+ size = file_data["size"]
109
+ session_id = file_data["session_id"]
110
+ forked_from_id = file_data["forked_from_id"]
111
+ entries_data = file_data["entries"]
112
+ confirmed_offset = int(file_data.get("confirmed_offset", 0))
113
+ digest_hex = file_data.get("confirmed_prefix_digest", "")
114
+ parse_state_data = file_data.get("parse_state", {})
115
+ if not isinstance(parse_state_data, dict):
116
+ parse_state_data = {}
117
+
118
+ if len(file_info_cache) >= maxsize:
119
+ file_info_cache.popitem(last=False)
120
+ file_info_cache[path] = (
121
+ mtime,
122
+ size,
123
+ _SessionFileInfo(session_id=session_id, forked_from_id=forked_from_id),
124
+ )
125
+ if entries_data is None:
126
+ continue
127
+ if not isinstance(entries_data, list):
128
+ continue
129
+ if (
130
+ "confirmed_offset" not in file_data
131
+ or "confirmed_prefix_digest" not in file_data
132
+ or "parse_state" not in file_data
133
+ ):
134
+ continue
135
+ state = _JsonlParseState(
136
+ session_timestamp=str(parse_state_data.get("session_timestamp", "")),
137
+ project=str(parse_state_data.get("project", "unknown")),
138
+ session_model=str(parse_state_data.get("session_model", "unknown")),
139
+ previous_usage=_deserialize_token_usage(parse_state_data.get("previous_usage")),
140
+ token_count_index=int(parse_state_data.get("token_count_index", 0)),
141
+ )
142
+ if len(jsonl_cache) >= maxsize:
143
+ jsonl_cache.popitem(last=False)
144
+ jsonl_cache[path] = _JsonlCacheEntry(
145
+ mtime=mtime,
146
+ size=size,
147
+ replay_cache_key=None,
148
+ entries=[_deserialize_usage_entry(entry) for entry in entries_data],
149
+ confirmed_offset=confirmed_offset,
150
+ confirmed_prefix_digest=bytes.fromhex(digest_hex)
151
+ if isinstance(digest_hex, str)
152
+ else b"",
153
+ state=state,
154
+ )
155
+ except (KeyError, TypeError, ValueError):
156
+ continue
157
+
158
+
159
+ def flush_caches(
160
+ cache_path: Path,
161
+ schema_version: int,
162
+ jsonl_cache: _JsonlCache,
163
+ file_info_cache: _FileInfoCache,
164
+ sqlite_log_cache: Any,
165
+ ) -> None:
166
+ """Atomically replace only shards whose serialized contents changed."""
167
+ try:
168
+ _remove_legacy_cache(cache_path)
169
+ shards: list[dict[str, Any]] = [{} for _ in range(_SHARD_COUNT)]
170
+ for path, entry in jsonl_cache.items():
171
+ info = file_info_cache.get(path)
172
+ if entry.replay_cache_key is not None:
173
+ if info is None:
174
+ continue
175
+ entries_data = None
176
+ session_id = info[2].session_id
177
+ forked_from_id = info[2].forked_from_id
178
+ else:
179
+ entries_data = [_serialize_usage_entry(item) for item in entry.entries]
180
+ if entry.entries:
181
+ session_id = entry.entries[0].session_id
182
+ forked_from_id = ""
183
+ elif info is not None:
184
+ session_id = info[2].session_id
185
+ forked_from_id = info[2].forked_from_id
186
+ else:
187
+ continue
188
+ shards[_shard_index(path)][str(path)] = {
189
+ "mtime": entry.mtime,
190
+ "size": entry.size,
191
+ "session_id": session_id,
192
+ "forked_from_id": forked_from_id,
193
+ "entries": entries_data,
194
+ "confirmed_offset": entry.confirmed_offset,
195
+ "confirmed_prefix_digest": entry.confirmed_prefix_digest.hex(),
196
+ "parse_state": {
197
+ "session_timestamp": entry.state.session_timestamp,
198
+ "project": entry.state.project,
199
+ "session_model": entry.state.session_model,
200
+ "previous_usage": _serialize_token_usage(entry.state.previous_usage),
201
+ "token_count_index": entry.state.token_count_index,
202
+ },
203
+ }
204
+
205
+ for index, files in enumerate(shards):
206
+ path = _shard_path(cache_path, index)
207
+ if files:
208
+ _write_if_changed(
209
+ path,
210
+ _encoded_payload({"schema_version": schema_version, "files": files}),
211
+ )
212
+ else:
213
+ path.unlink(missing_ok=True)
214
+
215
+ sqlite_payload = {
216
+ "schema_version": schema_version,
217
+ "watermark": list(sqlite_log_cache.watermark)
218
+ if sqlite_log_cache.watermark is not None
219
+ else None,
220
+ "entries": [
221
+ _serialize_usage_entry(entry) for entry in sqlite_log_cache.entries
222
+ ],
223
+ }
224
+ _write_if_changed(_sqlite_log_path(cache_path), _encoded_payload(sqlite_payload))
225
+ except Exception as exc:
226
+ if os.environ.get("USAGE_DEBUG") == "1":
227
+ logger.warning("failed to write codex jsonl cache %s: %s", cache_path, exc)
codex_events.py ADDED
@@ -0,0 +1,136 @@
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
+ """Stateless value parsing for Codex session logs.
8
+
9
+ Leaf helpers shared by codex_loader's JSONL and sqlite readers: coercing raw
10
+ JSON/log values into scalars, token-usage payloads and per-file metadata.
11
+ Nothing here touches module state, the filesystem layout constants, or caches —
12
+ those all stay in codex_loader so test monkeypatching keeps working.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import math
19
+ import re
20
+ from dataclasses import dataclass
21
+ from datetime import UTC, datetime
22
+ from typing import Any
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class _ThreadMetadata:
27
+ model: str = "unknown"
28
+ cwd: str = ""
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class _SessionFileInfo:
33
+ session_id: str = ""
34
+ forked_from_id: str = ""
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class _TokenUsage:
39
+ input_tokens: int
40
+ output_tokens: int
41
+ cache_read_tokens: int
42
+
43
+ @property
44
+ def total_tokens(self) -> int:
45
+ return self.input_tokens + self.output_tokens + self.cache_read_tokens
46
+
47
+ def delta(self, previous: _TokenUsage | None) -> _TokenUsage:
48
+ if previous is None:
49
+ return self
50
+ return _TokenUsage(
51
+ input_tokens=max(0, self.input_tokens - previous.input_tokens),
52
+ output_tokens=max(0, self.output_tokens - previous.output_tokens),
53
+ cache_read_tokens=max(0, self.cache_read_tokens - previous.cache_read_tokens),
54
+ )
55
+
56
+
57
+ def _token_usage_from_payload(usage: dict[str, Any]) -> _TokenUsage:
58
+ cached = _as_int(usage.get("cached_input_tokens"))
59
+ input_tokens = max(0, _as_int(usage.get("input_tokens")) - cached)
60
+ output_tokens = _as_int(usage.get("output_tokens"))
61
+ return _TokenUsage(
62
+ input_tokens=input_tokens,
63
+ output_tokens=output_tokens,
64
+ cache_read_tokens=cached,
65
+ )
66
+
67
+
68
+ def _load_json_line(line: str) -> dict[str, Any] | None:
69
+ try:
70
+ data = json.loads(line)
71
+ except (json.JSONDecodeError, RecursionError):
72
+ return None
73
+ return data if isinstance(data, dict) else None
74
+
75
+
76
+ _EVENT_VALUE_RE_CACHE: dict[str, re.Pattern[str]] = {}
77
+
78
+
79
+ def _event_value(body: str, key: str) -> str:
80
+ pattern = _EVENT_VALUE_RE_CACHE.get(key)
81
+ if pattern is None:
82
+ pattern = re.compile(rf'(?:^|[\s{{]){re.escape(key)}=(?:"([^"]*)"|([^\s}}]+))')
83
+ _EVENT_VALUE_RE_CACHE[key] = pattern
84
+ match = pattern.search(body)
85
+ if match is None:
86
+ return ""
87
+ return match.group(1) if match.group(1) is not None else match.group(2)
88
+
89
+
90
+ def _timestamp_from_log_ts(value: Any) -> datetime | None:
91
+ if isinstance(value, bool):
92
+ return None
93
+ try:
94
+ timestamp = float(value)
95
+ except (TypeError, ValueError):
96
+ return None
97
+ if not math.isfinite(timestamp):
98
+ return None
99
+ return datetime.fromtimestamp(timestamp, UTC)
100
+
101
+
102
+ def _as_dict(value: Any) -> dict[str, Any]:
103
+ return value if isinstance(value, dict) else {}
104
+
105
+
106
+ def _session_model(payload: Any, fallback: str) -> str:
107
+ model = _as_str(_as_dict(payload).get("model"))
108
+ return model or fallback
109
+
110
+
111
+ def _as_int(value: Any) -> int:
112
+ if isinstance(value, bool):
113
+ return 0
114
+ try:
115
+ number = float(value)
116
+ except (TypeError, ValueError):
117
+ return 0
118
+ if not math.isfinite(number):
119
+ return 0
120
+ return max(0, int(number))
121
+
122
+
123
+ def _as_str(value: Any) -> str:
124
+ return value if isinstance(value, str) else ""
125
+
126
+
127
+ def _as_optional_float(value: Any) -> float | None:
128
+ if isinstance(value, bool):
129
+ return None
130
+ try:
131
+ number = float(value)
132
+ except (TypeError, ValueError):
133
+ return None
134
+ if not math.isfinite(number):
135
+ return None
136
+ return number