thwip-cli 1.0.0__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.
thwip/config.py ADDED
@@ -0,0 +1,363 @@
1
+ """
2
+ Configuration management for thwip.
3
+
4
+ Handles:
5
+ - Loading/saving ~/.thwip/config.toml
6
+ - Auto-discovering API keys from installed agent configs and env vars
7
+ - First-run setup wizard
8
+ - Merging keys from all sources
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import sys
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ try:
21
+ import tomllib
22
+ except ImportError:
23
+ import tomli as tomllib # type: ignore[no-redef]
24
+
25
+ import tomli_w
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Paths
30
+ # ---------------------------------------------------------------------------
31
+
32
+ def get_config_dir() -> Path:
33
+ """Return ~/.thwip/, creating it if needed."""
34
+ config_dir = Path.home() / ".thwip"
35
+ config_dir.mkdir(parents=True, exist_ok=True)
36
+ return config_dir
37
+
38
+
39
+ def get_sessions_dir() -> Path:
40
+ """Return ~/.thwip/sessions/, creating it if needed."""
41
+ d = get_config_dir() / "sessions"
42
+ d.mkdir(parents=True, exist_ok=True)
43
+ return d
44
+
45
+
46
+ def get_config_path() -> Path:
47
+ return get_config_dir() / "config.toml"
48
+
49
+
50
+ def get_usage_path() -> Path:
51
+ return get_config_dir() / "usage.json"
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # API Key Discovery
56
+ # ---------------------------------------------------------------------------
57
+
58
+ # Map of env var names to provider keys
59
+ ENV_KEY_MAP: dict[str, str] = {
60
+ "ANTHROPIC_API_KEY": "anthropic",
61
+ "OPENAI_API_KEY": "openai",
62
+ "GEMINI_API_KEY": "google",
63
+ "GOOGLE_API_KEY": "google",
64
+ "DEEPSEEK_API_KEY": "deepseek",
65
+ "MISTRAL_API_KEY": "mistral",
66
+ "GROQ_API_KEY": "groq",
67
+ "OPENROUTER_API_KEY": "openrouter",
68
+ }
69
+
70
+ # Known agent config file locations where we can find API keys
71
+ AGENT_CONFIG_LOCATIONS: list[dict[str, Any]] = [
72
+ {
73
+ "provider": "anthropic",
74
+ "paths": ["~/.claude.json", "~/.claude/config.json", "~/.config/claude/config.json"],
75
+ "key_field": "apiKey",
76
+ "format": "json",
77
+ },
78
+ {
79
+ "provider": "openai",
80
+ "paths": ["~/.config/openai/config.json"],
81
+ "key_field": "api_key",
82
+ "format": "json",
83
+ },
84
+ {
85
+ "provider": "google",
86
+ "paths": [
87
+ "~/.config/gemini/config.json",
88
+ "~/.gemini/config.json",
89
+ ],
90
+ "key_field": "api_key",
91
+ "format": "json",
92
+ },
93
+ ]
94
+
95
+
96
+ def discover_api_keys() -> dict[str, str]:
97
+ """
98
+ Auto-discover API keys from:
99
+ 1. Environment variables
100
+ 2. Installed agent config files
101
+ 3. thwip's own config
102
+
103
+ Returns a dict of {provider: api_key}.
104
+ """
105
+ keys: dict[str, str] = {}
106
+
107
+ # --- 1. Environment variables ---
108
+ for env_var, provider in ENV_KEY_MAP.items():
109
+ val = os.environ.get(env_var, "").strip()
110
+ if val:
111
+ keys[provider] = val
112
+
113
+ # --- 2. Installed agent config files ---
114
+ for agent_conf in AGENT_CONFIG_LOCATIONS:
115
+ provider = agent_conf["provider"]
116
+ if provider in keys:
117
+ continue # Already found from env
118
+
119
+ for path_str in agent_conf["paths"]:
120
+ path = Path(path_str).expanduser()
121
+ if not path.is_file():
122
+ continue
123
+ try:
124
+ if agent_conf["format"] == "json":
125
+ data = json.loads(path.read_text())
126
+ key_field = agent_conf["key_field"]
127
+ # Support nested keys like {"credentials": {"api_key": "..."}}
128
+ if isinstance(data, dict):
129
+ val = data.get(key_field, "")
130
+ if not val and "credentials" in data:
131
+ val = data["credentials"].get(key_field, "")
132
+ if val:
133
+ keys[provider] = val
134
+ break
135
+ except (json.JSONDecodeError, KeyError, TypeError, OSError):
136
+ continue
137
+
138
+ return keys
139
+
140
+
141
+ def get_key_sources() -> dict[str, str]:
142
+ """Return where each discovered key came from (for display)."""
143
+ sources: dict[str, str] = {}
144
+
145
+ for env_var, provider in ENV_KEY_MAP.items():
146
+ if os.environ.get(env_var, "").strip():
147
+ sources[provider] = f"env:{env_var}"
148
+
149
+ for agent_conf in AGENT_CONFIG_LOCATIONS:
150
+ provider = agent_conf["provider"]
151
+ if provider in sources:
152
+ continue
153
+ for path_str in agent_conf["paths"]:
154
+ path = Path(path_str).expanduser()
155
+ if path.is_file():
156
+ try:
157
+ data = json.loads(path.read_text())
158
+ if data.get(agent_conf["key_field"]):
159
+ sources[provider] = str(path)
160
+ break
161
+ except (json.JSONDecodeError, OSError):
162
+ continue
163
+
164
+ return sources
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Config Dataclass
169
+ # ---------------------------------------------------------------------------
170
+
171
+ @dataclass
172
+ class FallbackConfig:
173
+ enabled: bool = True
174
+ chain: list[str] = field(default_factory=lambda: [
175
+ "claude/claude-sonnet-4",
176
+ "google/gemini-2.5-pro",
177
+ "openai/gpt-4.1",
178
+ "deepseek/deepseek-chat",
179
+ "ollama/llama3.3",
180
+ ])
181
+
182
+
183
+ @dataclass
184
+ class DisplayConfig:
185
+ show_agent_badge: bool = True
186
+ show_token_count: bool = True
187
+ show_cost: bool = True
188
+ show_capabilities: bool = True
189
+ markdown: bool = True
190
+ syntax_highlight: bool = True
191
+ max_width: int = 120
192
+ dynamic_ui: bool = True # Change UI based on agent capabilities
193
+
194
+
195
+ @dataclass
196
+ class LimitsConfig:
197
+ warn_at_percent: int = 80
198
+ auto_switch: bool = False # Prompt user vs auto-switch
199
+
200
+
201
+ @dataclass
202
+ class ThwipConfig:
203
+ """Main configuration object."""
204
+
205
+ # Defaults
206
+ default_agent: str = "claude"
207
+ default_model: str = "claude-sonnet-4"
208
+ project: str = "."
209
+ theme: str = "dark"
210
+ stream: bool = True
211
+ auto_save: bool = True
212
+ confirm_tools: bool = True
213
+
214
+ # API keys (merged from all sources)
215
+ keys: dict[str, str] = field(default_factory=dict)
216
+ key_sources: dict[str, str] = field(default_factory=dict)
217
+
218
+ # Ollama
219
+ ollama_host: str = "http://localhost:11434"
220
+
221
+ # Sub-configs
222
+ fallback: FallbackConfig = field(default_factory=FallbackConfig)
223
+ display: DisplayConfig = field(default_factory=DisplayConfig)
224
+ limits: LimitsConfig = field(default_factory=LimitsConfig)
225
+
226
+ @classmethod
227
+ def load(cls) -> "ThwipConfig":
228
+ """Load config from file, env vars, and agent configs."""
229
+ config = cls()
230
+ config_path = get_config_path()
231
+
232
+ # Load from file if exists
233
+ if config_path.is_file():
234
+ try:
235
+ with open(config_path, "rb") as f:
236
+ data = tomllib.load(f)
237
+ config._apply_toml(data)
238
+ except Exception:
239
+ pass # Use defaults on parse error
240
+
241
+ # Merge API keys from all sources
242
+ discovered = discover_api_keys()
243
+ config.key_sources = get_key_sources()
244
+
245
+ # Config file keys take priority, then discovered keys fill gaps
246
+ for provider, key in discovered.items():
247
+ if provider not in config.keys or not config.keys[provider]:
248
+ config.keys[provider] = key
249
+
250
+ return config
251
+
252
+ def _apply_toml(self, data: dict[str, Any]) -> None:
253
+ """Apply parsed TOML data to config fields."""
254
+ defaults = data.get("defaults", {})
255
+ if "agent" in defaults:
256
+ self.default_agent = defaults["agent"]
257
+ if "model" in defaults:
258
+ self.default_model = defaults["model"]
259
+ if "project" in defaults:
260
+ self.project = defaults["project"]
261
+ if "theme" in defaults:
262
+ self.theme = defaults["theme"]
263
+ if "stream" in defaults:
264
+ self.stream = defaults["stream"]
265
+ if "auto_save" in defaults:
266
+ self.auto_save = defaults["auto_save"]
267
+ if "confirm_tools" in defaults:
268
+ self.confirm_tools = defaults["confirm_tools"]
269
+
270
+ # Keys from config file
271
+ keys_data = data.get("keys", {})
272
+ for provider, key in keys_data.items():
273
+ if key: # Only if non-empty
274
+ self.keys[provider] = key
275
+ self.key_sources[provider] = "config.toml"
276
+
277
+ # Ollama
278
+ ollama = data.get("ollama", {})
279
+ if "host" in ollama:
280
+ self.ollama_host = ollama["host"]
281
+
282
+ # Fallback
283
+ fallback = data.get("fallback", {})
284
+ if "enabled" in fallback:
285
+ self.fallback.enabled = fallback["enabled"]
286
+ if "chain" in fallback:
287
+ self.fallback.chain = fallback["chain"]
288
+
289
+ # Display
290
+ display = data.get("display", {})
291
+ for key in ("show_agent_badge", "show_token_count", "show_cost",
292
+ "show_capabilities", "markdown", "syntax_highlight",
293
+ "max_width", "dynamic_ui"):
294
+ if key in display:
295
+ setattr(self.display, key, display[key])
296
+
297
+ # Limits
298
+ limits = data.get("limits", {})
299
+ if "warn_at_percent" in limits:
300
+ self.limits.warn_at_percent = limits["warn_at_percent"]
301
+ if "auto_switch" in limits:
302
+ self.limits.auto_switch = limits["auto_switch"]
303
+
304
+ def save(self) -> None:
305
+ """Save current config to TOML file."""
306
+ data: dict[str, Any] = {
307
+ "defaults": {
308
+ "agent": self.default_agent,
309
+ "model": self.default_model,
310
+ "project": self.project,
311
+ "theme": self.theme,
312
+ "stream": self.stream,
313
+ "auto_save": self.auto_save,
314
+ "confirm_tools": self.confirm_tools,
315
+ },
316
+ "keys": {k: v for k, v in self.keys.items()},
317
+ "ollama": {
318
+ "host": self.ollama_host,
319
+ },
320
+ "fallback": {
321
+ "enabled": self.fallback.enabled,
322
+ "chain": self.fallback.chain,
323
+ },
324
+ "display": {
325
+ "show_agent_badge": self.display.show_agent_badge,
326
+ "show_token_count": self.display.show_token_count,
327
+ "show_cost": self.display.show_cost,
328
+ "show_capabilities": self.display.show_capabilities,
329
+ "markdown": self.display.markdown,
330
+ "syntax_highlight": self.display.syntax_highlight,
331
+ "max_width": self.display.max_width,
332
+ "dynamic_ui": self.display.dynamic_ui,
333
+ },
334
+ "limits": {
335
+ "warn_at_percent": self.limits.warn_at_percent,
336
+ "auto_switch": self.limits.auto_switch,
337
+ },
338
+ }
339
+
340
+ config_path = get_config_path()
341
+ with open(config_path, "wb") as f:
342
+ tomli_w.dump(data, f)
343
+
344
+ def get_key(self, provider: str) -> str | None:
345
+ """Get API key for a provider, returning None if not found."""
346
+ return self.keys.get(provider)
347
+
348
+ def has_key(self, provider: str) -> bool:
349
+ """Check if we have a valid API key for a provider."""
350
+ key = self.keys.get(provider, "")
351
+ return bool(key and key.strip())
352
+
353
+
354
+ def create_default_config() -> None:
355
+ """Create default config file if it doesn't exist."""
356
+ config_path = get_config_path()
357
+ if config_path.is_file():
358
+ return
359
+
360
+ config = ThwipConfig()
361
+ # Discover any existing keys
362
+ config.keys = discover_api_keys()
363
+ config.save()
thwip/detector.py ADDED
@@ -0,0 +1,259 @@
1
+ """
2
+ System-wide coding agent & LLM detector.
3
+
4
+ Scans the local machine for:
5
+ 1. CLI binaries (claude, agy, gemini, codex, aider, copilot, ollama)
6
+ 2. npm global packages
7
+ 3. Python / pip packages
8
+ 4. VS Code & Cursor extensions (Cline, Continue, Copilot, etc.)
9
+ 5. macOS Applications (/Applications/Cursor.app, /Applications/Windsurf.app)
10
+ 6. Environment variables & agent credential files
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from thwip.agents.base import Capability
24
+
25
+
26
+ @dataclass
27
+ class DetectedTool:
28
+ """An AI coding tool or agent discovered on the system."""
29
+ name: str
30
+ company: str
31
+ category: str # "CLI Agent", "IDE App", "Editor Extension", "Local Engine"
32
+ install_path: str
33
+ version: str | None = None
34
+ is_configured: bool = False
35
+ config_source: str = ""
36
+ capabilities: list[str] = field(default_factory=list)
37
+ subscription_status: str = "Unknown"
38
+
39
+
40
+ # Comprehensive signature database of AI coding tools
41
+ KNOWN_CLI_TOOLS: list[dict[str, Any]] = [
42
+ {
43
+ "name": "Claude Code",
44
+ "company": "Anthropic",
45
+ "binaries": ["claude"],
46
+ "category": "CLI Agent",
47
+ "capabilities": ["Chat", "File Edit", "Code Run", "Terminal", "Git", "Search"],
48
+ "key_env": "ANTHROPIC_API_KEY",
49
+ "config_files": ["~/.claude.json", "~/.claude/config.json"],
50
+ },
51
+ {
52
+ "name": "Antigravity / Gemini CLI",
53
+ "company": "Google",
54
+ "binaries": ["agy", "gemini", "antigravity"],
55
+ "category": "CLI Agent",
56
+ "capabilities": ["Chat", "File Edit", "Code Run", "Terminal", "Browser", "Search"],
57
+ "key_env": "GEMINI_API_KEY",
58
+ "config_files": ["~/.config/gemini/config.json", "~/.gemini/config.json"],
59
+ },
60
+ {
61
+ "name": "Codex / OpenAI CLI",
62
+ "company": "OpenAI",
63
+ "binaries": ["codex", "openai"],
64
+ "category": "CLI Agent",
65
+ "capabilities": ["Chat", "File Edit", "Code Run", "Terminal"],
66
+ "key_env": "OPENAI_API_KEY",
67
+ "config_files": ["~/.config/openai/config.json"],
68
+ },
69
+ {
70
+ "name": "Aider",
71
+ "company": "Independent",
72
+ "binaries": ["aider"],
73
+ "category": "CLI Agent",
74
+ "capabilities": ["Chat", "File Edit", "Git"],
75
+ "key_env": "OPENAI_API_KEY",
76
+ "config_files": ["~/.aider.conf.yml"],
77
+ },
78
+ {
79
+ "name": "GitHub Copilot CLI",
80
+ "company": "GitHub / Microsoft",
81
+ "binaries": ["github-copilot-cli", "copilot"],
82
+ "category": "CLI Agent",
83
+ "capabilities": ["Chat", "Terminal Suggestions"],
84
+ "key_env": "GITHUB_TOKEN",
85
+ "config_files": ["~/.config/github-copilot/hosts.json"],
86
+ },
87
+ {
88
+ "name": "Ollama",
89
+ "company": "Ollama",
90
+ "binaries": ["ollama"],
91
+ "category": "Local Engine",
92
+ "capabilities": ["Chat", "File Edit", "Code Run"],
93
+ "key_env": "",
94
+ "config_files": [],
95
+ },
96
+ ]
97
+
98
+ KNOWN_APPS: list[dict[str, Any]] = [
99
+ {
100
+ "name": "Cursor",
101
+ "company": "Cursor Inc",
102
+ "mac_path": "/Applications/Cursor.app",
103
+ "category": "IDE App",
104
+ "capabilities": ["Chat", "File Edit", "Terminal", "Codebase Indexing"],
105
+ },
106
+ {
107
+ "name": "Windsurf",
108
+ "company": "Codeium",
109
+ "mac_path": "/Applications/Windsurf.app",
110
+ "category": "IDE App",
111
+ "capabilities": ["Chat", "File Edit", "Terminal", "Cascade Flows"],
112
+ },
113
+ ]
114
+
115
+ KNOWN_EXTENSIONS: list[dict[str, Any]] = [
116
+ {
117
+ "id": "saoudrizwan.claude-dev",
118
+ "name": "Cline (Claude Dev)",
119
+ "company": "Independent",
120
+ "capabilities": ["Chat", "File Edit", "Terminal", "Browser"],
121
+ },
122
+ {
123
+ "id": "continue.continue",
124
+ "name": "Continue.dev",
125
+ "company": "Continue",
126
+ "capabilities": ["Chat", "File Edit", "Autocomplete"],
127
+ },
128
+ {
129
+ "id": "github.copilot",
130
+ "name": "GitHub Copilot",
131
+ "company": "Microsoft",
132
+ "capabilities": ["Chat", "Autocomplete"],
133
+ },
134
+ ]
135
+
136
+
137
+ class SystemDetector:
138
+ """Scans and discovers all AI coding agents and installed environments."""
139
+
140
+ def __init__(self) -> None:
141
+ self.detected_tools: list[DetectedTool] = []
142
+
143
+ def scan_all(self) -> list[DetectedTool]:
144
+ """Perform a full system discovery scan."""
145
+ tools: list[DetectedTool] = []
146
+
147
+ # 1. Scan CLI Tools
148
+ for spec in KNOWN_CLI_TOOLS:
149
+ binary_found = None
150
+ for b in spec["binaries"]:
151
+ p = shutil.which(b)
152
+ if p:
153
+ binary_found = p
154
+ break
155
+
156
+ is_configured = False
157
+ config_source = ""
158
+
159
+ # Check environment variables
160
+ if spec.get("key_env") and os.environ.get(spec["key_env"]):
161
+ is_configured = True
162
+ config_source = f"env:{spec['key_env']}"
163
+
164
+ # Check config files
165
+ if not is_configured:
166
+ for c_file in spec.get("config_files", []):
167
+ cp = Path(c_file).expanduser()
168
+ if cp.is_file():
169
+ is_configured = True
170
+ config_source = str(cp)
171
+ break
172
+
173
+ # Ollama is configured if binary is present
174
+ if spec["name"] == "Ollama" and binary_found:
175
+ is_configured = True
176
+ config_source = "localhost:11434"
177
+
178
+ if binary_found or is_configured:
179
+ # Try getting version
180
+ ver = None
181
+ if binary_found:
182
+ try:
183
+ res = subprocess.run(
184
+ [binary_found, "--version"],
185
+ capture_output=True,
186
+ text=True,
187
+ timeout=3,
188
+ )
189
+ if res.returncode == 0:
190
+ ver = res.stdout.strip().split("\n")[0]
191
+ except Exception:
192
+ pass
193
+
194
+ sub_status = "Active" if is_configured else "Needs Key"
195
+ if spec["name"] == "Ollama":
196
+ sub_status = "Unlimited"
197
+
198
+ tools.append(
199
+ DetectedTool(
200
+ name=spec["name"],
201
+ company=spec["company"],
202
+ category=spec["category"],
203
+ install_path=binary_found or "(API only)",
204
+ version=ver,
205
+ is_configured=is_configured,
206
+ config_source=config_source,
207
+ capabilities=spec["capabilities"],
208
+ subscription_status=sub_status,
209
+ )
210
+ )
211
+
212
+ # 2. Scan macOS /Applications
213
+ for app in KNOWN_APPS:
214
+ p = Path(app["mac_path"])
215
+ if p.exists():
216
+ tools.append(
217
+ DetectedTool(
218
+ name=app["name"],
219
+ company=app["company"],
220
+ category=app["category"],
221
+ install_path=str(p),
222
+ version="Installed",
223
+ is_configured=True,
224
+ config_source="macOS App",
225
+ capabilities=app["capabilities"],
226
+ subscription_status="Active",
227
+ )
228
+ )
229
+
230
+ # 3. Scan VS Code extensions
231
+ try:
232
+ res = subprocess.run(
233
+ ["code", "--list-extensions"],
234
+ capture_output=True,
235
+ text=True,
236
+ timeout=4,
237
+ )
238
+ if res.returncode == 0:
239
+ installed_exts = res.stdout.lower().splitlines()
240
+ for ext in KNOWN_EXTENSIONS:
241
+ if ext["id"].lower() in installed_exts:
242
+ tools.append(
243
+ DetectedTool(
244
+ name=f"{ext['name']} (VS Code)",
245
+ company=ext["company"],
246
+ category="Editor Extension",
247
+ install_path="VS Code Extension",
248
+ version="Installed",
249
+ is_configured=True,
250
+ config_source="VS Code Config",
251
+ capabilities=ext["capabilities"],
252
+ subscription_status="Active",
253
+ )
254
+ )
255
+ except Exception:
256
+ pass
257
+
258
+ self.detected_tools = tools
259
+ return tools
thwip/limits.py ADDED
@@ -0,0 +1,84 @@
1
+ """
2
+ Usage tracking & limit management for thwip.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import time
9
+ from dataclasses import asdict, dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from thwip.config import get_usage_path
14
+ from thwip.utils import estimate_cost
15
+
16
+
17
+ @dataclass
18
+ class AgentUsageStats:
19
+ """Cumulative usage statistics for a specific agent."""
20
+ input_tokens: int = 0
21
+ output_tokens: int = 0
22
+ request_count: int = 0
23
+ estimated_cost: float = 0.0
24
+ last_limit_hit_timestamp: float = 0.0
25
+ last_error: str = ""
26
+
27
+
28
+ class UsageTracker:
29
+ """Tracks token usage and costs across sessions and agents."""
30
+
31
+ def __init__(self) -> None:
32
+ self.stats: dict[str, AgentUsageStats] = {}
33
+ self.load()
34
+
35
+ def record_usage(self, agent_name: str, model: str, input_tokens: int, output_tokens: int) -> None:
36
+ """Record tokens used for an agent call."""
37
+ if agent_name not in self.stats:
38
+ self.stats[agent_name] = AgentUsageStats()
39
+
40
+ st = self.stats[agent_name]
41
+ st.input_tokens += input_tokens
42
+ st.output_tokens += output_tokens
43
+ st.request_count += 1
44
+ st.estimated_cost += estimate_cost(model, input_tokens, output_tokens)
45
+ self.save()
46
+
47
+ def record_limit_hit(self, agent_name: str, error_msg: str) -> None:
48
+ """Record when an agent hits a rate limit or quota."""
49
+ if agent_name not in self.stats:
50
+ self.stats[agent_name] = AgentUsageStats()
51
+ self.stats[agent_name].last_limit_hit_timestamp = time.time()
52
+ self.stats[agent_name].last_error = error_msg
53
+ self.save()
54
+
55
+ def get_summary(self) -> dict[str, Any]:
56
+ """Return summary of total spend and tokens."""
57
+ total_tokens = sum(s.input_tokens + s.output_tokens for s in self.stats.values())
58
+ total_cost = sum(s.estimated_cost for s in self.stats.values())
59
+ total_reqs = sum(s.request_count for s in self.stats.values())
60
+ return {
61
+ "total_tokens": total_tokens,
62
+ "total_cost": total_cost,
63
+ "total_requests": total_reqs,
64
+ "by_agent": {k: asdict(v) for k, v in self.stats.items()},
65
+ }
66
+
67
+ def save(self) -> None:
68
+ path = get_usage_path()
69
+ data = {k: asdict(v) for k, v in self.stats.items()}
70
+ try:
71
+ path.write_text(json.dumps(data, indent=2))
72
+ except Exception:
73
+ pass
74
+
75
+ def load(self) -> None:
76
+ path = get_usage_path()
77
+ if not path.is_file():
78
+ return
79
+ try:
80
+ data = json.loads(path.read_text())
81
+ for k, v in data.items():
82
+ self.stats[k] = AgentUsageStats(**v)
83
+ except Exception:
84
+ pass