@0dai-dev/cli 4.3.6 → 4.3.8

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 (77) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +133 -33
  3. package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
  4. package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
  5. package/lib/ai/registry/mcp-catalog.json +98 -0
  6. package/lib/commands/auth.js +2 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/doctor.js +707 -12
  9. package/lib/commands/experience.js +40 -5
  10. package/lib/commands/feedback.js +157 -15
  11. package/lib/commands/gh.js +26 -0
  12. package/lib/commands/graph.js +9 -4
  13. package/lib/commands/heatmap.js +1 -1
  14. package/lib/commands/init.js +298 -27
  15. package/lib/commands/mcp.js +111 -33
  16. package/lib/commands/models.js +138 -41
  17. package/lib/commands/play.js +20 -4
  18. package/lib/commands/provider.js +30 -59
  19. package/lib/commands/quota.js +1 -1
  20. package/lib/commands/receipt.js +1 -1
  21. package/lib/commands/run.js +14 -6
  22. package/lib/commands/runner.js +31 -1
  23. package/lib/commands/status.js +176 -11
  24. package/lib/commands/swarm.js +130 -12
  25. package/lib/commands/trust.js +1 -1
  26. package/lib/commands/update.js +184 -38
  27. package/lib/commands/usage.js +1 -1
  28. package/lib/commands/validate.js +32 -3
  29. package/lib/commands/vault.js +43 -8
  30. package/lib/python/__init__.py +0 -0
  31. package/lib/python/agent_quotas.py +525 -0
  32. package/lib/python/anomaly_alert.py +397 -0
  33. package/lib/python/anti_pattern_detector.py +799 -0
  34. package/lib/python/auth.py +443 -0
  35. package/lib/python/capi_profile_guard.py +477 -0
  36. package/lib/python/compliance_report.py +581 -0
  37. package/lib/python/drift_detector.py +388 -0
  38. package/lib/python/experience_pipeline.py +1130 -0
  39. package/lib/python/graph.py +19 -0
  40. package/lib/python/graph_core.py +293 -0
  41. package/lib/python/graph_io.py +179 -0
  42. package/lib/python/graph_legacy.py +2052 -0
  43. package/lib/python/graph_legacy_helpers.py +221 -0
  44. package/lib/python/graph_outcomes_core.py +85 -0
  45. package/lib/python/graph_queries.py +171 -0
  46. package/lib/python/graph_slice.py +198 -0
  47. package/lib/python/graph_slicer.py +576 -0
  48. package/lib/python/graph_slicer_cli.py +60 -0
  49. package/lib/python/graph_validation.py +64 -0
  50. package/lib/python/heatmap.py +943 -0
  51. package/lib/python/json_utils.py +193 -0
  52. package/lib/python/mcp_exposure_check.py +247 -0
  53. package/lib/python/model_router.py +1434 -0
  54. package/lib/python/project_manager.py +621 -0
  55. package/lib/python/provider_profiles.py +1618 -0
  56. package/lib/python/provider_registry.py +1211 -0
  57. package/lib/python/provider_registry_cli.py +125 -0
  58. package/lib/python/receipt_png.py +727 -0
  59. package/lib/python/structural_memory.py +325 -0
  60. package/lib/python/swarm_cost.py +177 -0
  61. package/lib/python/usage_ledger.py +569 -0
  62. package/lib/scripts/mcp_tier_config.py +240 -0
  63. package/lib/shared.js +96 -12
  64. package/lib/tui/index.mjs +35174 -0
  65. package/lib/utils/activation_telemetry.js +1 -4
  66. package/lib/utils/constants.js +7 -1
  67. package/lib/utils/identity.js +184 -0
  68. package/lib/utils/mcp-auth.js +81 -15
  69. package/lib/utils/plan.js +1 -1
  70. package/lib/vault/index.js +19 -3
  71. package/lib/vault/storage.js +21 -2
  72. package/lib/wizard.js +5 -2
  73. package/package.json +9 -3
  74. package/scripts/build-python-bundle.js +106 -0
  75. package/scripts/build-tui.js +14 -1
  76. package/scripts/harvest_experience.py +523 -0
  77. package/scripts/postinstall.js +15 -9
@@ -0,0 +1,569 @@
1
+ #!/usr/bin/env python3
2
+ """0dai Usage Ledger — per-request cost accounting tied to billing plans.
3
+
4
+ The ledger bridges three existing systems:
5
+ 1. model_usage_log.py — raw token counts per agent invocation
6
+ 2. swarm_cost.py — USD cost estimates per model
7
+ 3. tier_gate.py — daily swarm task quotas per billing plan
8
+ 4. billing_state.py — payment records and plan entitlements
9
+
10
+ It produces:
11
+ - ai/telemetry/usage-ledger.jsonl — append-only row per billing event
12
+ - ai/telemetry/usage-ledger-daily.json — daily rollup (cost, tasks, by-model)
13
+ - ai/telemetry/usage-ledger-monthly.json — monthly rollup for billing cycles
14
+
15
+ CLI:
16
+ python3 usage_ledger.py record --task-id <id> --agent <agent> --model <model> \
17
+ --tokens-input <n> --tokens-output <n> [--plan <plan>]
18
+ python3 usage_ledger.py daily [--date YYYY-MM-DD] [--json]
19
+ python3 usage_ledger.py monthly [--month YYYY-MM] [--json]
20
+ python3 usage_ledger.py status [--plan <plan>] [--json]
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import datetime as _dt
26
+ import hashlib
27
+ import json
28
+ import pathlib
29
+ import sys
30
+ import time
31
+ from dataclasses import dataclass, asdict
32
+
33
+ SCRIPTS_DIR = pathlib.Path(__file__).resolve().parent
34
+ sys.path.insert(0, str(SCRIPTS_DIR))
35
+
36
+ import swarm_cost as sc # noqa: E402
37
+
38
+ LEDGER_DIR = pathlib.Path("ai/telemetry")
39
+ LEDGER_FILE = LEDGER_DIR / "usage-ledger.jsonl"
40
+ LEDGER_DAILY = LEDGER_DIR / "usage-ledger-daily.json"
41
+ LEDGER_MONTHLY = LEDGER_DIR / "usage-ledger-monthly.json"
42
+
43
+ # Free tier defaults from ai/work/strategy/free-tier-spec.md.
44
+ FREE_TIER_LIMITS = {
45
+ "monthly_merges": 10,
46
+ "monthly_tokens": 100_000,
47
+ "concurrent_agents": 2,
48
+ "api_requests_per_hour": 60,
49
+ "storage_kg_entries": 500,
50
+ "team_members": 1,
51
+ "reset_policy": "calendar_month_utc",
52
+ }
53
+
54
+ # Plan entitlements (mirrors tier_gate.py PLAN_LIMITS for task budgets).
55
+ PLAN_ENTITLEMENTS = {
56
+ "free": {"daily_tasks": 0, "monthly_budget_usd": 0.0, "max_model_tier": "fast", "free_tier": FREE_TIER_LIMITS},
57
+ "pro": {"daily_tasks": 50, "monthly_budget_usd": 15.0, "max_model_tier": "balanced"},
58
+ "team": {"daily_tasks": 200, "monthly_budget_usd": 49.0, "max_model_tier": "deep"},
59
+ "enterprise": {"daily_tasks": 999999, "monthly_budget_usd": 999999.0, "max_model_tier": "deep"},
60
+ }
61
+
62
+ # Model tier mapping
63
+ MODEL_TIERS = {
64
+ "fast": {"haiku", "gpt-5.4-mini", "gemini-1.5-flash"},
65
+ "balanced": {"sonnet", "gpt-5.3", "gpt-5.4", "gemini-3", "gemini-2.5-pro"},
66
+ "deep": {"opus", "gpt-5.5", "gpt-5.3-codex", "claude-opus-4.7", "gemini-3.1-pro"},
67
+ }
68
+
69
+ # Reverse lookup: model name -> tier
70
+ _MODEL_TO_TIER: dict[str, str] = {}
71
+ for tier, models in MODEL_TIERS.items():
72
+ for m in models:
73
+ _MODEL_TO_TIER[m] = tier
74
+
75
+
76
+ def _get_model_tier(model: str) -> str:
77
+ """Return the cost tier for a model name."""
78
+ # Normalize
79
+ normalized = model.lower().strip()
80
+ if normalized in _MODEL_TO_TIER:
81
+ return _MODEL_TO_TIER[normalized]
82
+ # Fallback: check rate aliases
83
+ for tier, models in MODEL_TIERS.items():
84
+ for m in models:
85
+ if m in normalized or normalized in m:
86
+ return tier
87
+ return "balanced" # default assumption
88
+
89
+
90
+ @dataclass
91
+ class LedgerEntry:
92
+ """A single billing event."""
93
+ ts: str # ISO-8601 UTC
94
+ task_id: str # Swarm task ID or loop run ID
95
+ agent: str # claude, codex, gemini, loop, etc.
96
+ model: str # Model name
97
+ model_tier: str # fast, balanced, deep
98
+ tokens_input: int
99
+ tokens_output: int
100
+ tokens_total: int
101
+ cost_usd: float # Computed from swarm_cost rates
102
+ plan: str # free, pro, team, enterprise
103
+ auth_mode: str # api-key, subscription
104
+ source: str # swarm, loop, manual, adhoc
105
+ source_hash: str # Dedup hash
106
+
107
+
108
+ def _load_ledger() -> list[dict]:
109
+ if not LEDGER_FILE.is_file():
110
+ return []
111
+ entries = []
112
+ with LEDGER_FILE.open() as f:
113
+ for line in f:
114
+ line = line.strip()
115
+ if line:
116
+ try:
117
+ entries.append(json.loads(line))
118
+ except json.JSONDecodeError:
119
+ continue
120
+ return entries
121
+
122
+
123
+ def _append_entry(entry: dict) -> None:
124
+ LEDGER_DIR.mkdir(parents=True, exist_ok=True)
125
+ with LEDGER_FILE.open("a") as f:
126
+ f.write(json.dumps(entry) + "\n")
127
+
128
+
129
+ def _source_hash(task_id: str, agent: str, ts: str) -> str:
130
+ """Short SHA-256 for dedup."""
131
+ return hashlib.sha256(f"{task_id}:{agent}:{ts}".encode()).hexdigest()[:12]
132
+
133
+
134
+ def compute_cost(model: str, tokens_input: int, tokens_output: int) -> float:
135
+ """Compute USD cost using swarm_cost rates."""
136
+ try:
137
+ rate = sc.DEFAULT_RATES.get(model)
138
+ if rate:
139
+ input_rate, output_rate = rate
140
+ cost = (tokens_input / 1_000_000) * input_rate + (tokens_output / 1_000_000) * output_rate
141
+ return round(cost, 6)
142
+ # Fallback: estimate using sonnet rates
143
+ input_rate, output_rate = sc.DEFAULT_RATES.get("sonnet", (3.0, 15.0))
144
+ cost = (tokens_input / 1_000_000) * input_rate + (tokens_output / 1_000_000) * output_rate
145
+ return round(cost, 6)
146
+ except Exception: # noqa: BLE001 — cost estimation failed
147
+ return 0.0
148
+
149
+
150
+ def record_event(
151
+ task_id: str,
152
+ agent: str,
153
+ model: str,
154
+ tokens_input: int,
155
+ tokens_output: int,
156
+ plan: str = "pro",
157
+ auth_mode: str = "subscription",
158
+ source: str = "swarm",
159
+ ) -> LedgerEntry:
160
+ """Record a usage event in the ledger."""
161
+ ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
162
+ model_tier = _get_model_tier(model)
163
+ cost = compute_cost(model, tokens_input, tokens_output)
164
+ tokens_total = tokens_input + tokens_output
165
+ shash = _source_hash(task_id, agent, ts)
166
+
167
+ entry = LedgerEntry(
168
+ ts=ts,
169
+ task_id=task_id,
170
+ agent=agent,
171
+ model=model,
172
+ model_tier=model_tier,
173
+ tokens_input=tokens_input,
174
+ tokens_output=tokens_output,
175
+ tokens_total=tokens_total,
176
+ cost_usd=cost,
177
+ plan=plan,
178
+ auth_mode=auth_mode,
179
+ source=source,
180
+ source_hash=shash,
181
+ )
182
+ entry_dict = asdict(entry)
183
+ _append_entry(entry_dict)
184
+ _update_daily_rollup(entry_dict)
185
+ _update_monthly_rollup(entry_dict)
186
+ return entry
187
+
188
+
189
+ def _update_daily_rollup(entry: dict) -> None:
190
+ """Update the daily rollup file."""
191
+ daily = _load_daily_rollup()
192
+ date = entry["ts"][:10] # YYYY-MM-DD
193
+ day = daily.setdefault(date, {
194
+ "date": date,
195
+ "total_cost_usd": 0.0,
196
+ "total_tasks": 0,
197
+ "total_tokens": 0,
198
+ "by_model": {},
199
+ "by_agent": {},
200
+ "by_plan": {},
201
+ "by_source": {},
202
+ })
203
+ day["total_cost_usd"] = round(day["total_cost_usd"] + entry["cost_usd"], 6)
204
+ day["total_tasks"] += 1
205
+ day["total_tokens"] += entry["tokens_total"]
206
+
207
+ # By model
208
+ model_stats = day["by_model"].setdefault(entry["model"], {"tasks": 0, "cost_usd": 0.0, "tokens": 0})
209
+ model_stats["tasks"] += 1
210
+ model_stats["cost_usd"] = round(model_stats["cost_usd"] + entry["cost_usd"], 6)
211
+ model_stats["tokens"] += entry["tokens_total"]
212
+
213
+ # By agent
214
+ agent_stats = day["by_agent"].setdefault(entry["agent"], {"tasks": 0, "cost_usd": 0.0})
215
+ agent_stats["tasks"] += 1
216
+ agent_stats["cost_usd"] = round(agent_stats["cost_usd"] + entry["cost_usd"], 6)
217
+
218
+ # By plan
219
+ plan_stats = day["by_plan"].setdefault(entry["plan"], {"tasks": 0, "cost_usd": 0.0})
220
+ plan_stats["tasks"] += 1
221
+ plan_stats["cost_usd"] = round(plan_stats["cost_usd"] + entry["cost_usd"], 6)
222
+
223
+ # By source
224
+ source_stats = day["by_source"].setdefault(entry["source"], {"tasks": 0, "cost_usd": 0.0, "tokens": 0})
225
+ source_stats["tasks"] += 1
226
+ source_stats["cost_usd"] = round(source_stats["cost_usd"] + entry["cost_usd"], 6)
227
+ source_stats["tokens"] += entry["tokens_total"]
228
+
229
+ _save_daily_rollup(daily)
230
+
231
+
232
+ def _update_monthly_rollup(entry: dict) -> None:
233
+ """Update the monthly rollup file."""
234
+ monthly = _load_monthly_rollup()
235
+ month = entry["ts"][:7] # YYYY-MM
236
+ mon = monthly.setdefault(month, {
237
+ "month": month,
238
+ "total_cost_usd": 0.0,
239
+ "total_tasks": 0,
240
+ "total_tokens": 0,
241
+ "daily_count": 0,
242
+ "avg_daily_cost_usd": 0.0,
243
+ "by_model": {},
244
+ "by_plan": {},
245
+ "by_source": {},
246
+ })
247
+ mon["total_cost_usd"] = round(mon["total_cost_usd"] + entry["cost_usd"], 6)
248
+ mon["total_tasks"] += 1
249
+ mon["total_tokens"] += entry["tokens_total"]
250
+ mon["daily_count"] = len(set(e[:10] for e in _get_daily_keys()))
251
+
252
+ # Recompute averages
253
+ days_in_month = max(1, mon["daily_count"])
254
+ mon["avg_daily_cost_usd"] = round(mon["total_cost_usd"] / days_in_month, 6)
255
+
256
+ # By model
257
+ model_stats = mon["by_model"].setdefault(entry["model"], {"tasks": 0, "cost_usd": 0.0, "tokens": 0})
258
+ model_stats["tasks"] += 1
259
+ model_stats["cost_usd"] = round(model_stats["cost_usd"] + entry["cost_usd"], 6)
260
+ model_stats["tokens"] += entry["tokens_total"]
261
+
262
+ # By plan
263
+ plan_stats = mon["by_plan"].setdefault(entry["plan"], {"tasks": 0, "cost_usd": 0.0})
264
+ plan_stats["tasks"] += 1
265
+ plan_stats["cost_usd"] = round(plan_stats["cost_usd"] + entry["cost_usd"], 6)
266
+
267
+ # By source
268
+ source_stats = mon["by_source"].setdefault(entry["source"], {"tasks": 0, "cost_usd": 0.0, "tokens": 0})
269
+ source_stats["tasks"] += 1
270
+ source_stats["cost_usd"] = round(source_stats["cost_usd"] + entry["cost_usd"], 6)
271
+ source_stats["tokens"] += entry["tokens_total"]
272
+
273
+ _save_monthly_rollup(monthly)
274
+
275
+
276
+ def _load_daily_rollup() -> dict:
277
+ if not LEDGER_DAILY.is_file():
278
+ return {}
279
+ try:
280
+ return json.loads(LEDGER_DAILY.read_text("utf-8"))
281
+ except (json.JSONDecodeError, OSError):
282
+ return {}
283
+
284
+
285
+ def _save_daily_rollup(data: dict) -> None:
286
+ LEDGER_DIR.mkdir(parents=True, exist_ok=True)
287
+ LEDGER_DAILY.write_text(json.dumps(data, indent=2), "utf-8")
288
+
289
+
290
+ def _load_monthly_rollup() -> dict:
291
+ if not LEDGER_MONTHLY.is_file():
292
+ return {}
293
+ try:
294
+ return json.loads(LEDGER_MONTHLY.read_text("utf-8"))
295
+ except (json.JSONDecodeError, OSError):
296
+ return {}
297
+
298
+
299
+ def _save_monthly_rollup(data: dict) -> None:
300
+ LEDGER_DIR.mkdir(parents=True, exist_ok=True)
301
+ LEDGER_MONTHLY.write_text(json.dumps(data, indent=2), "utf-8")
302
+
303
+
304
+ def _get_daily_keys() -> list[str]:
305
+ daily = _load_daily_rollup()
306
+ return list(daily.keys())
307
+
308
+
309
+ def get_daily_summary(date: str | None = None) -> dict:
310
+ """Get daily usage summary."""
311
+ if date is None:
312
+ date = _dt.date.today().isoformat()
313
+ daily = _load_daily_rollup()
314
+ return daily.get(date, {"date": date, "total_cost_usd": 0.0, "total_tasks": 0, "total_tokens": 0, "by_model": {}, "by_agent": {}, "by_plan": {}, "by_source": {}})
315
+
316
+
317
+ def get_monthly_summary(month: str | None = None) -> dict:
318
+ """Get monthly usage summary."""
319
+ if month is None:
320
+ month = _dt.date.today().strftime("%Y-%m")
321
+ monthly = _load_monthly_rollup()
322
+ return monthly.get(month, {"month": month, "total_cost_usd": 0.0, "total_tasks": 0, "total_tokens": 0, "daily_count": 0, "avg_daily_cost_usd": 0.0, "by_model": {}, "by_plan": {}, "by_source": {}})
323
+
324
+
325
+ def _next_month_reset(today: _dt.date) -> _dt.date:
326
+ """Return the next calendar-month UTC reset date."""
327
+ if today.month == 12:
328
+ return _dt.date(today.year + 1, 1, 1)
329
+ return _dt.date(today.year, today.month + 1, 1)
330
+
331
+
332
+ def _free_tier_meter(monthly: dict, today: _dt.date) -> dict:
333
+ """Build the free-tier meter from monthly rollup data."""
334
+ limits = FREE_TIER_LIMITS.copy()
335
+ by_source = monthly.get("by_source", {})
336
+ merge_stats = by_source.get("merge", {})
337
+ merges_used = int(merge_stats.get("tasks", 0))
338
+ tokens_used = int(monthly.get("total_tokens", 0))
339
+ reset_at = _next_month_reset(today)
340
+ return {
341
+ "limits": limits,
342
+ "usage": {
343
+ "merges_used": merges_used,
344
+ "tokens_used": tokens_used,
345
+ },
346
+ "remaining": {
347
+ "merges": max(0, limits["monthly_merges"] - merges_used),
348
+ "tokens": max(0, limits["monthly_tokens"] - tokens_used),
349
+ },
350
+ "reset": {
351
+ "policy": limits["reset_policy"],
352
+ "reset_at": reset_at.isoformat(),
353
+ "days_remaining": max(0, (reset_at - today).days),
354
+ },
355
+ "cap_reached": merges_used >= limits["monthly_merges"] or tokens_used >= limits["monthly_tokens"],
356
+ }
357
+
358
+
359
+ def get_plan_status(plan: str) -> dict:
360
+ """Get current plan usage status with entitlements."""
361
+ entitlements = PLAN_ENTITLEMENTS.get(plan, PLAN_ENTITLEMENTS["pro"])
362
+ today = _dt.date.today().isoformat()
363
+ month = today[:7]
364
+ daily = get_daily_summary(today)
365
+ monthly = get_monthly_summary(month)
366
+ status = {
367
+ "plan": plan,
368
+ "entitlements": entitlements,
369
+ "today": {
370
+ "date": today,
371
+ "tasks_used": daily.get("total_tasks", 0),
372
+ "tasks_remaining": max(0, entitlements["daily_tasks"] - daily.get("total_tasks", 0)),
373
+ "cost_usd": daily.get("total_cost_usd", 0.0),
374
+ },
375
+ "this_month": {
376
+ "month": month,
377
+ "tasks_used": monthly.get("total_tasks", 0),
378
+ "cost_usd": monthly.get("total_cost_usd", 0.0),
379
+ "budget_remaining": round(max(0, entitlements["monthly_budget_usd"] - monthly.get("total_cost_usd", 0.0)), 2),
380
+ },
381
+ }
382
+ if plan == "free":
383
+ status["free_tier"] = _free_tier_meter(monthly, _dt.date.fromisoformat(today))
384
+ return status
385
+
386
+
387
+ def check_budget_alerts(plan: str) -> list[dict]:
388
+ """Check if usage is approaching budget limits. Returns alerts if any."""
389
+ status = get_plan_status(plan)
390
+ alerts = []
391
+
392
+ # Daily task usage
393
+ daily_limit = status["entitlements"]["daily_tasks"]
394
+ daily_pct = status["today"]["tasks_used"] / max(1, daily_limit)
395
+ if daily_limit > 0 and daily_pct >= 0.9:
396
+ alerts.append({
397
+ "severity": "warning" if daily_pct < 1.0 else "critical",
398
+ "type": "daily_tasks_near_limit",
399
+ "message": f"Daily tasks at {daily_pct * 100:.0f}% ({status['today']['tasks_used']}/{status['entitlements']['daily_tasks']})",
400
+ })
401
+
402
+ # Monthly budget
403
+ monthly_budget = status["entitlements"]["monthly_budget_usd"]
404
+ monthly_pct = status["this_month"]["cost_usd"] / max(0.01, monthly_budget)
405
+ if monthly_budget > 0 and monthly_pct >= 0.8:
406
+ alerts.append({
407
+ "severity": "warning" if monthly_pct < 1.0 else "critical",
408
+ "type": "monthly_budget_near_limit",
409
+ "message": f"Monthly budget at {monthly_pct * 100:.0f}% (${status['this_month']['cost_usd']:.2f}/${status['entitlements']['monthly_budget_usd']:.2f})",
410
+ })
411
+
412
+ free_tier = status.get("free_tier")
413
+ if free_tier:
414
+ limits = free_tier["limits"]
415
+ usage = free_tier["usage"]
416
+ merge_pct = usage["merges_used"] / max(1, limits["monthly_merges"])
417
+ token_pct = usage["tokens_used"] / max(1, limits["monthly_tokens"])
418
+ if merge_pct >= 0.8:
419
+ alerts.append({
420
+ "severity": "warning" if merge_pct < 1.0 else "critical",
421
+ "type": "free_merges_near_limit",
422
+ "message": f"Free tier merges at {merge_pct * 100:.0f}% ({usage['merges_used']}/{limits['monthly_merges']})",
423
+ })
424
+ if token_pct >= 0.8:
425
+ alerts.append({
426
+ "severity": "warning" if token_pct < 1.0 else "critical",
427
+ "type": "free_tokens_near_limit",
428
+ "message": f"Free tier tokens at {token_pct * 100:.0f}% ({usage['tokens_used']}/{limits['monthly_tokens']})",
429
+ })
430
+
431
+ return alerts
432
+
433
+
434
+ def check_free_tier_merge_allowed(plan: str) -> tuple[bool, str]:
435
+ """Return (allowed, message). Blocks when free tier merge/token cap reached."""
436
+ if plan != "free":
437
+ return True, ""
438
+ status = get_plan_status("free")
439
+ free_tier = status.get("free_tier") or {}
440
+ if free_tier.get("cap_reached"):
441
+ usage = free_tier.get("usage", {})
442
+ limits = free_tier.get("limits", {})
443
+ reset = free_tier.get("reset", {})
444
+ merges_used = usage.get("merges_used", "?")
445
+ merge_limit = limits.get("monthly_merges", 10)
446
+ days = reset.get("days_remaining", "?")
447
+ return False, (
448
+ f"BLOCKED: free tier cap reached ({merges_used}/{merge_limit} merges). "
449
+ f"Reset in {days}d. Upgrade: 0dai upgrade pro"
450
+ )
451
+ remaining = free_tier.get("remaining", {}).get("merges", 0)
452
+ if remaining == 1:
453
+ return True, "WARNING: 1 merge left this month on free tier"
454
+ return True, ""
455
+
456
+
457
+ def main() -> int:
458
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
459
+ sub = parser.add_subparsers(dest="command")
460
+
461
+ # record
462
+ p_record = sub.add_parser("record", help="Record a usage event")
463
+ p_record.add_argument("--task-id", required=True)
464
+ p_record.add_argument("--agent", required=True)
465
+ p_record.add_argument("--model", required=True)
466
+ p_record.add_argument("--tokens-input", type=int, required=True)
467
+ p_record.add_argument("--tokens-output", type=int, required=True)
468
+ p_record.add_argument("--plan", default="pro")
469
+ p_record.add_argument("--auth-mode", default="subscription")
470
+ p_record.add_argument("--source", default="swarm")
471
+ p_record.add_argument("--json", dest="as_json", action="store_true")
472
+
473
+ # daily
474
+ p_daily = sub.add_parser("daily", help="Show daily usage summary")
475
+ p_daily.add_argument("--date", help="YYYY-MM-DD (default today)")
476
+ p_daily.add_argument("--json", dest="as_json", action="store_true")
477
+
478
+ # monthly
479
+ p_monthly = sub.add_parser("monthly", help="Show monthly usage summary")
480
+ p_monthly.add_argument("--month", help="YYYY-MM (default current month)")
481
+ p_monthly.add_argument("--json", dest="as_json", action="store_true")
482
+
483
+ # status
484
+ p_status = sub.add_parser("status", help="Show plan status and budget alerts")
485
+ p_status.add_argument("--plan", default="pro")
486
+ p_status.add_argument("--json", dest="as_json", action="store_true")
487
+
488
+ p_check = sub.add_parser("check-merge", help="Exit 0 if plan may perform a merge")
489
+ p_check.add_argument("--plan", default="pro")
490
+
491
+ args = parser.parse_args()
492
+
493
+ if args.command == "check-merge":
494
+ allowed, message = check_free_tier_merge_allowed(args.plan)
495
+ if message:
496
+ print(message, file=sys.stderr if not allowed else sys.stdout)
497
+ return 0 if allowed else 2
498
+
499
+ if args.command == "record":
500
+ entry = record_event(
501
+ task_id=args.task_id,
502
+ agent=args.agent,
503
+ model=args.model,
504
+ tokens_input=args.tokens_input,
505
+ tokens_output=args.tokens_output,
506
+ plan=args.plan,
507
+ auth_mode=args.auth_mode,
508
+ source=args.source,
509
+ )
510
+ if args.as_json:
511
+ print(json.dumps(asdict(entry), indent=2))
512
+ else:
513
+ print(f"[recorded] {entry.task_id} — {entry.model} ({entry.model_tier}) — {entry.tokens_total:,} tokens — ${entry.cost_usd:.6f}")
514
+
515
+ elif args.command == "daily":
516
+ summary = get_daily_summary(getattr(args, "date", None))
517
+ if args.as_json:
518
+ print(json.dumps(summary, indent=2))
519
+ else:
520
+ print(f"📅 {summary['date']} — {summary.get('total_tasks', 0)} tasks — ${summary.get('total_cost_usd', 0):.6f} — {summary.get('total_tokens', 0):,} tokens")
521
+ for model, stats in summary.get("by_model", {}).items():
522
+ print(f" {model}: {stats['tasks']} tasks — ${stats['cost_usd']:.6f}")
523
+
524
+ elif args.command == "monthly":
525
+ summary = get_monthly_summary(getattr(args, "month", None))
526
+ if args.as_json:
527
+ print(json.dumps(summary, indent=2))
528
+ else:
529
+ print(f"📆 {summary['month']} — {summary.get('total_tasks', 0)} tasks — ${summary.get('total_cost_usd', 0):.6f} — avg ${summary.get('avg_daily_cost_usd', 0):.6f}/day")
530
+ for model, stats in summary.get("by_model", {}).items():
531
+ print(f" {model}: {stats['tasks']} tasks — ${stats['cost_usd']:.6f}")
532
+
533
+ elif args.command == "status":
534
+ status = get_plan_status(args.plan)
535
+ alerts = check_budget_alerts(args.plan)
536
+ output = {"status": status, "alerts": alerts}
537
+ if args.as_json:
538
+ print(json.dumps(output, indent=2))
539
+ else:
540
+ print(f"Plan: {status['plan']}")
541
+ if status["entitlements"]["daily_tasks"] > 0:
542
+ print(f" Today: {status['today']['tasks_used']}/{status['entitlements']['daily_tasks']} tasks — ${status['today']['cost_usd']:.6f}")
543
+ else:
544
+ print(f" Today: {status['today']['tasks_used']} tasks — ${status['today']['cost_usd']:.6f}")
545
+ print(f" This month: {status['this_month']['tasks_used']} tasks — ${status['this_month']['cost_usd']:.2f}/${status['entitlements']['monthly_budget_usd']:.2f}")
546
+ if "free_tier" in status:
547
+ ft = status["free_tier"]
548
+ print(
549
+ " Free tier: "
550
+ f"{ft['remaining']['merges']}/{ft['limits']['monthly_merges']} merges remaining, "
551
+ f"{ft['remaining']['tokens']:,}/{ft['limits']['monthly_tokens']:,} tokens remaining"
552
+ )
553
+ print(f" Reset: {ft['reset']['reset_at']} UTC ({ft['reset']['days_remaining']} days)")
554
+ if alerts:
555
+ print(" ⚠️ Alerts:")
556
+ for a in alerts:
557
+ print(f" [{a['severity']}] {a['message']}")
558
+ else:
559
+ print(" ✅ Within budget")
560
+
561
+ else:
562
+ parser.print_help()
563
+ return 1
564
+
565
+ return 0
566
+
567
+
568
+ if __name__ == "__main__":
569
+ sys.exit(main())