@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.
- package/README.md +12 -11
- package/bin/0dai.js +133 -33
- package/lib/ai/manifest/mcp-exposure-contract.json +121 -0
- package/lib/ai/meta/manifest/mcp-tool-tiers.json +435 -0
- package/lib/ai/registry/mcp-catalog.json +98 -0
- package/lib/commands/auth.js +2 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/doctor.js +707 -12
- package/lib/commands/experience.js +40 -5
- package/lib/commands/feedback.js +157 -15
- package/lib/commands/gh.js +26 -0
- package/lib/commands/graph.js +9 -4
- package/lib/commands/heatmap.js +1 -1
- package/lib/commands/init.js +298 -27
- package/lib/commands/mcp.js +111 -33
- package/lib/commands/models.js +138 -41
- package/lib/commands/play.js +20 -4
- package/lib/commands/provider.js +30 -59
- package/lib/commands/quota.js +1 -1
- package/lib/commands/receipt.js +1 -1
- package/lib/commands/run.js +14 -6
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +176 -11
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/trust.js +1 -1
- package/lib/commands/update.js +184 -38
- package/lib/commands/usage.js +1 -1
- package/lib/commands/validate.js +32 -3
- package/lib/commands/vault.js +43 -8
- package/lib/python/__init__.py +0 -0
- package/lib/python/agent_quotas.py +525 -0
- package/lib/python/anomaly_alert.py +397 -0
- package/lib/python/anti_pattern_detector.py +799 -0
- package/lib/python/auth.py +443 -0
- package/lib/python/capi_profile_guard.py +477 -0
- package/lib/python/compliance_report.py +581 -0
- package/lib/python/drift_detector.py +388 -0
- package/lib/python/experience_pipeline.py +1130 -0
- package/lib/python/graph.py +19 -0
- package/lib/python/graph_core.py +293 -0
- package/lib/python/graph_io.py +179 -0
- package/lib/python/graph_legacy.py +2052 -0
- package/lib/python/graph_legacy_helpers.py +221 -0
- package/lib/python/graph_outcomes_core.py +85 -0
- package/lib/python/graph_queries.py +171 -0
- package/lib/python/graph_slice.py +198 -0
- package/lib/python/graph_slicer.py +576 -0
- package/lib/python/graph_slicer_cli.py +60 -0
- package/lib/python/graph_validation.py +64 -0
- package/lib/python/heatmap.py +943 -0
- package/lib/python/json_utils.py +193 -0
- package/lib/python/mcp_exposure_check.py +247 -0
- package/lib/python/model_router.py +1434 -0
- package/lib/python/project_manager.py +621 -0
- package/lib/python/provider_profiles.py +1618 -0
- package/lib/python/provider_registry.py +1211 -0
- package/lib/python/provider_registry_cli.py +125 -0
- package/lib/python/receipt_png.py +727 -0
- package/lib/python/structural_memory.py +325 -0
- package/lib/python/swarm_cost.py +177 -0
- package/lib/python/usage_ledger.py +569 -0
- package/lib/scripts/mcp_tier_config.py +240 -0
- package/lib/shared.js +96 -12
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +1 -4
- package/lib/utils/constants.js +7 -1
- package/lib/utils/identity.js +184 -0
- package/lib/utils/mcp-auth.js +81 -15
- package/lib/utils/plan.js +1 -1
- package/lib/vault/index.js +19 -3
- package/lib/vault/storage.js +21 -2
- package/lib/wizard.js +5 -2
- package/package.json +9 -3
- package/scripts/build-python-bundle.js +106 -0
- package/scripts/build-tui.js +14 -1
- package/scripts/harvest_experience.py +523 -0
- package/scripts/postinstall.js +15 -9
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Agent subscription quota poller.
|
|
3
|
+
|
|
4
|
+
Polls each known coding-agent CLI (codex, qoder, claude, opencode, gemini, capi)
|
|
5
|
+
for its current subscription usage and caches the result in
|
|
6
|
+
``ai/manifest/agent-quotas.json``. The cache TTL is 10 minutes; callers should
|
|
7
|
+
treat the file as read-mostly runtime state (it is gitignored).
|
|
8
|
+
|
|
9
|
+
Public surface
|
|
10
|
+
--------------
|
|
11
|
+
- ``get_quotas(target=None, force_refresh=False) -> dict``
|
|
12
|
+
Returns ``{agent: {plan, weekly_pct_remaining, hourly_pct_remaining,
|
|
13
|
+
resets_at, source, error}}`` for every known agent. Deprecated
|
|
14
|
+
``*_pct_used`` aliases are still emitted for old dashboards/cache readers,
|
|
15
|
+
but subscription banners expose remaining quota.
|
|
16
|
+
- ``poll_agent(agent) -> dict``
|
|
17
|
+
Polls a single agent and returns its quota record (no cache I/O).
|
|
18
|
+
- ``CACHE_TTL_SECONDS`` - default 600s.
|
|
19
|
+
|
|
20
|
+
The poller is defensive: a missing CLI, a non-zero exit, an unparseable
|
|
21
|
+
banner, or a tmux failure all yield a record with ``error`` set, never
|
|
22
|
+
an exception. The dispatcher (``swarm_lib.dispatch_candidates_for_task``)
|
|
23
|
+
treats any agent without a clean quota record as "unknown" and keeps it
|
|
24
|
+
in the candidate list.
|
|
25
|
+
|
|
26
|
+
Codex usage banner is captured by attaching tmux to ``codex --status``
|
|
27
|
+
output; the regex looks for the canonical
|
|
28
|
+
``Context X% left * Context Y% used * 5h Z% * w W%`` shape.
|
|
29
|
+
Qoder is queried via ``qodercli quota --json`` if the CLI is on PATH.
|
|
30
|
+
Claude / OpenCode / Gemini / CAPI fall back to "unknown" gracefully when
|
|
31
|
+
their respective CLIs do not expose a quota subcommand on this host.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import logging
|
|
38
|
+
import os
|
|
39
|
+
import pathlib
|
|
40
|
+
import re
|
|
41
|
+
import shutil
|
|
42
|
+
import subprocess
|
|
43
|
+
import time
|
|
44
|
+
from typing import Any, Callable
|
|
45
|
+
|
|
46
|
+
log = logging.getLogger("0dai.agent_quotas")
|
|
47
|
+
|
|
48
|
+
KNOWN_AGENTS: tuple[str, ...] = (
|
|
49
|
+
"codex",
|
|
50
|
+
"qoder",
|
|
51
|
+
"claude",
|
|
52
|
+
"opencode",
|
|
53
|
+
"gemini",
|
|
54
|
+
"capi",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
CACHE_TTL_SECONDS = 600 # 10 minutes per the spec
|
|
58
|
+
DEFAULT_TARGET = pathlib.Path.cwd()
|
|
59
|
+
CACHE_RELATIVE = pathlib.Path("ai") / "manifest" / "agent-quotas.json"
|
|
60
|
+
|
|
61
|
+
# Codex usage banner pattern, e.g.
|
|
62
|
+
# Context 41% left · Context 59% used · 5h 12% · w 38%
|
|
63
|
+
# The trailing weekly group is optional because narrow tmux panes truncate
|
|
64
|
+
# the banner to e.g. "5h 96% ·…" — we still want hourly + context%
|
|
65
|
+
# even when weekly is cut off.
|
|
66
|
+
CODEX_USAGE_RE = re.compile(
|
|
67
|
+
r"Context\s+(?P<ctx_left>\d+)%\s+left\s*[·•|-]\s*"
|
|
68
|
+
r"Context\s+(?P<ctx_used>\d+)%\s+used\s*[·•|-]\s*"
|
|
69
|
+
r"5h\s+(?P<hourly>\d+)%"
|
|
70
|
+
r"(?:\s*[·•|-]\s*w\s+(?P<weekly>\d+)%)?",
|
|
71
|
+
re.IGNORECASE,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Generic "X% used / Y% remaining" fallback
|
|
75
|
+
GENERIC_PCT_RE = re.compile(r"(?P<n>\d{1,3})\s*%")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# Cache I/O
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _resolve_target(target: str | os.PathLike[str] | None) -> pathlib.Path:
|
|
84
|
+
if target is None:
|
|
85
|
+
return DEFAULT_TARGET
|
|
86
|
+
return pathlib.Path(target).resolve()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cache_path(target: pathlib.Path) -> pathlib.Path:
|
|
90
|
+
return target / CACHE_RELATIVE
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_cache(path: pathlib.Path) -> dict[str, Any] | None:
|
|
94
|
+
try:
|
|
95
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
96
|
+
except (OSError, ValueError):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _save_cache(path: pathlib.Path, payload: dict[str, Any]) -> None:
|
|
101
|
+
try:
|
|
102
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
log.warning("agent_quotas cache write failed at %s: %s", path, exc)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_fresh(cache: dict[str, Any] | None, now: float, ttl: int) -> bool:
|
|
109
|
+
if not cache:
|
|
110
|
+
return False
|
|
111
|
+
fetched = cache.get("fetched_at")
|
|
112
|
+
if not isinstance(fetched, (int, float)):
|
|
113
|
+
return False
|
|
114
|
+
return (now - float(fetched)) < ttl
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Per-agent pollers
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _empty_record(
|
|
123
|
+
agent: str, *, error: str | None = None, source: str = "unknown"
|
|
124
|
+
) -> dict[str, Any]:
|
|
125
|
+
return {
|
|
126
|
+
"agent": agent,
|
|
127
|
+
"plan": "unknown",
|
|
128
|
+
"weekly_pct_remaining": None,
|
|
129
|
+
"hourly_pct_remaining": None,
|
|
130
|
+
# Deprecated compatibility aliases. The stored values are remaining,
|
|
131
|
+
# matching historical cache semantics despite the old field names.
|
|
132
|
+
"weekly_pct_used": None,
|
|
133
|
+
"hourly_pct_used": None,
|
|
134
|
+
"resets_at": None,
|
|
135
|
+
"source": source,
|
|
136
|
+
"error": error,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _coerce_pct(value: Any) -> int | None:
|
|
141
|
+
if value is None or isinstance(value, bool):
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
pct = int(value)
|
|
145
|
+
except (TypeError, ValueError):
|
|
146
|
+
return None
|
|
147
|
+
return max(0, min(100, pct))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _first_present(data: dict[str, Any], keys: tuple[str, ...]) -> Any:
|
|
151
|
+
for key in keys:
|
|
152
|
+
if key in data and data[key] is not None:
|
|
153
|
+
return data[key]
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _with_remaining_fields(
|
|
158
|
+
*,
|
|
159
|
+
weekly: Any = None,
|
|
160
|
+
hourly: Any = None,
|
|
161
|
+
) -> dict[str, int | None]:
|
|
162
|
+
weekly_pct = _coerce_pct(weekly)
|
|
163
|
+
hourly_pct = _coerce_pct(hourly)
|
|
164
|
+
return {
|
|
165
|
+
"weekly_pct_remaining": weekly_pct,
|
|
166
|
+
"hourly_pct_remaining": hourly_pct,
|
|
167
|
+
"weekly_pct_used": weekly_pct,
|
|
168
|
+
"hourly_pct_used": hourly_pct,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def quota_pct_remaining(record: dict[str, Any] | None, window: str) -> float | None:
|
|
173
|
+
"""Return quota remaining for ``weekly``/``hourly`` records.
|
|
174
|
+
|
|
175
|
+
New records use ``*_pct_remaining``. Old cache records only have
|
|
176
|
+
``*_pct_used``; those values were already remaining but mislabeled, so they
|
|
177
|
+
remain a fail-open compatibility fallback.
|
|
178
|
+
"""
|
|
179
|
+
if not isinstance(record, dict):
|
|
180
|
+
return None
|
|
181
|
+
canonical = record.get(f"{window}_pct_remaining")
|
|
182
|
+
if isinstance(canonical, (int, float)) and not isinstance(canonical, bool):
|
|
183
|
+
return float(canonical)
|
|
184
|
+
legacy = record.get(f"{window}_pct_used")
|
|
185
|
+
if isinstance(legacy, (int, float)) and not isinstance(legacy, bool):
|
|
186
|
+
return float(legacy)
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _which(binary: str) -> str | None:
|
|
191
|
+
return shutil.which(binary)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _run(cmd: list[str], *, timeout: int = 8) -> tuple[int, str, str]:
|
|
195
|
+
"""Run a subprocess and capture stdout/stderr without raising."""
|
|
196
|
+
try:
|
|
197
|
+
proc = subprocess.run(
|
|
198
|
+
cmd,
|
|
199
|
+
stdout=subprocess.PIPE,
|
|
200
|
+
stderr=subprocess.PIPE,
|
|
201
|
+
timeout=timeout,
|
|
202
|
+
check=False,
|
|
203
|
+
)
|
|
204
|
+
out = proc.stdout.decode("utf-8", errors="replace") if proc.stdout else ""
|
|
205
|
+
err = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""
|
|
206
|
+
return proc.returncode, out, err
|
|
207
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc:
|
|
208
|
+
return 1, "", f"{type(exc).__name__}: {exc}"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _parse_codex_banner(text: str) -> dict[str, Any] | None:
|
|
212
|
+
m = CODEX_USAGE_RE.search(text)
|
|
213
|
+
if not m:
|
|
214
|
+
return None
|
|
215
|
+
weekly_raw = m.group("weekly")
|
|
216
|
+
return _with_remaining_fields(
|
|
217
|
+
weekly=weekly_raw if weekly_raw is not None else None,
|
|
218
|
+
hourly=m.group("hourly"),
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def poll_codex(
|
|
223
|
+
*, runner: Callable[[list[str]], tuple[int, str, str]] = _run
|
|
224
|
+
) -> dict[str, Any]:
|
|
225
|
+
"""Poll codex via tmux capture if available, otherwise via ``codex --status``."""
|
|
226
|
+
rec = _empty_record("codex")
|
|
227
|
+
bin_path = _which("codex")
|
|
228
|
+
if not bin_path:
|
|
229
|
+
rec["error"] = "codex CLI not found on PATH"
|
|
230
|
+
return rec
|
|
231
|
+
rec["plan"] = "subscription"
|
|
232
|
+
|
|
233
|
+
# Try direct status first - cheap and avoids tmux churn
|
|
234
|
+
code, out, err = runner([bin_path, "--status"])
|
|
235
|
+
text = (out or "") + "\n" + (err or "")
|
|
236
|
+
parsed = _parse_codex_banner(text)
|
|
237
|
+
if parsed:
|
|
238
|
+
rec.update(parsed)
|
|
239
|
+
rec["source"] = "codex --status"
|
|
240
|
+
return rec
|
|
241
|
+
|
|
242
|
+
# Fall back to tmux session capture. Spec: "tmux capture + regex".
|
|
243
|
+
# Use -x 220 so the codex TUI status bar fits on one line — narrower
|
|
244
|
+
# panes truncate "5h 96% · w 38%" to "5h 96% ·…" via Unicode ellipsis,
|
|
245
|
+
# which breaks the banner regex even with the weekly group made optional.
|
|
246
|
+
if _which("tmux"):
|
|
247
|
+
session = f"0dai-quota-codex-{int(time.time())}"
|
|
248
|
+
try:
|
|
249
|
+
runner(
|
|
250
|
+
[
|
|
251
|
+
"tmux", "new-session", "-d", "-s", session,
|
|
252
|
+
"-x", "220", "-y", "50",
|
|
253
|
+
bin_path,
|
|
254
|
+
]
|
|
255
|
+
)
|
|
256
|
+
time.sleep(5.0) # codex TUI banner takes ~3-5s to render after spawn
|
|
257
|
+
cap_code, cap_out, _cap_err = runner(
|
|
258
|
+
["tmux", "capture-pane", "-pt", session]
|
|
259
|
+
)
|
|
260
|
+
parsed = _parse_codex_banner(cap_out or "")
|
|
261
|
+
if parsed:
|
|
262
|
+
rec.update(parsed)
|
|
263
|
+
rec["source"] = "tmux capture"
|
|
264
|
+
return rec
|
|
265
|
+
finally:
|
|
266
|
+
runner(["tmux", "kill-session", "-t", session])
|
|
267
|
+
|
|
268
|
+
rec["error"] = "codex usage banner not found"
|
|
269
|
+
return rec
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def poll_qoder(
|
|
273
|
+
*, runner: Callable[[list[str]], tuple[int, str, str]] = _run
|
|
274
|
+
) -> dict[str, Any]:
|
|
275
|
+
rec = _empty_record("qoder")
|
|
276
|
+
bin_path = _which("qodercli") or _which("qoder")
|
|
277
|
+
if not bin_path:
|
|
278
|
+
rec["error"] = "qoder CLI not found on PATH"
|
|
279
|
+
return rec
|
|
280
|
+
rec["plan"] = "subscription"
|
|
281
|
+
|
|
282
|
+
# Prefer JSON if the CLI supports it; fall back to plain text.
|
|
283
|
+
for argset in (["quota", "--json"], ["quota"], ["--quota"], ["status", "--json"]):
|
|
284
|
+
code, out, err = runner([bin_path, *argset])
|
|
285
|
+
if code != 0 and not out:
|
|
286
|
+
continue
|
|
287
|
+
try:
|
|
288
|
+
data = json.loads(out)
|
|
289
|
+
except (ValueError, TypeError):
|
|
290
|
+
data = None
|
|
291
|
+
if isinstance(data, dict):
|
|
292
|
+
weekly = _first_present(
|
|
293
|
+
data,
|
|
294
|
+
(
|
|
295
|
+
"weekly_pct_remaining",
|
|
296
|
+
"weekly_remaining_pct",
|
|
297
|
+
"weekly_remaining",
|
|
298
|
+
"weekly",
|
|
299
|
+
),
|
|
300
|
+
)
|
|
301
|
+
hourly = _first_present(
|
|
302
|
+
data,
|
|
303
|
+
(
|
|
304
|
+
"hourly_pct_remaining",
|
|
305
|
+
"hourly_remaining_pct",
|
|
306
|
+
"hourly_remaining",
|
|
307
|
+
"hourly",
|
|
308
|
+
),
|
|
309
|
+
)
|
|
310
|
+
weekly_used = _first_present(data, ("weekly_pct_used", "weekly_used_pct"))
|
|
311
|
+
hourly_used = _first_present(data, ("hourly_pct_used", "hourly_used_pct"))
|
|
312
|
+
if weekly is None and weekly_used is not None:
|
|
313
|
+
used = _coerce_pct(weekly_used)
|
|
314
|
+
weekly = None if used is None else 100 - used
|
|
315
|
+
if hourly is None and hourly_used is not None:
|
|
316
|
+
used = _coerce_pct(hourly_used)
|
|
317
|
+
hourly = None if used is None else 100 - used
|
|
318
|
+
plan = data.get("plan") or data.get("tier")
|
|
319
|
+
resets = data.get("resets_at") or data.get("reset_at")
|
|
320
|
+
if weekly is not None or hourly is not None or plan:
|
|
321
|
+
rec.update(_with_remaining_fields(weekly=weekly, hourly=hourly))
|
|
322
|
+
if plan:
|
|
323
|
+
rec["plan"] = str(plan)
|
|
324
|
+
if resets:
|
|
325
|
+
rec["resets_at"] = str(resets)
|
|
326
|
+
rec["source"] = f"{bin_path} {' '.join(argset)}"
|
|
327
|
+
return rec
|
|
328
|
+
# Plain-text fallback: scrape any "weekly: X%" / "hourly: Y%" lines
|
|
329
|
+
weekly_m = re.search(r"weekly[^0-9%]*(\d{1,3})\s*%", out, re.IGNORECASE)
|
|
330
|
+
hourly_m = re.search(r"(?:hourly|5h)[^0-9%]*(\d{1,3})\s*%", out, re.IGNORECASE)
|
|
331
|
+
if weekly_m or hourly_m:
|
|
332
|
+
rec.update(
|
|
333
|
+
_with_remaining_fields(
|
|
334
|
+
weekly=weekly_m.group(1) if weekly_m else None,
|
|
335
|
+
hourly=hourly_m.group(1) if hourly_m else None,
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
rec["source"] = f"{bin_path} {' '.join(argset)}"
|
|
339
|
+
return rec
|
|
340
|
+
|
|
341
|
+
rec["error"] = "qoder quota output not parseable"
|
|
342
|
+
return rec
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _poll_unknown(agent: str) -> dict[str, Any]:
|
|
346
|
+
"""Return a record indicating the agent has no programmatic quota source."""
|
|
347
|
+
bin_path = _which(agent)
|
|
348
|
+
rec = _empty_record(agent)
|
|
349
|
+
if not bin_path:
|
|
350
|
+
rec["error"] = f"{agent} CLI not found on PATH"
|
|
351
|
+
return rec
|
|
352
|
+
rec["plan"] = "subscription"
|
|
353
|
+
rec["source"] = "no-quota-api"
|
|
354
|
+
rec["error"] = f"{agent} CLI does not expose a quota endpoint"
|
|
355
|
+
return rec
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def poll_claude() -> dict[str, Any]:
|
|
359
|
+
return _poll_unknown("claude")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def poll_opencode() -> dict[str, Any]:
|
|
363
|
+
return _poll_unknown("opencode")
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def poll_gemini() -> dict[str, Any]:
|
|
367
|
+
return _poll_unknown("gemini")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def poll_capi() -> dict[str, Any]:
|
|
371
|
+
rec = _poll_unknown("capi")
|
|
372
|
+
# capi rides on the user's account-local token; treat as "unknown"
|
|
373
|
+
# but mark plan as 'managed' so dispatcher knows it's available.
|
|
374
|
+
if rec.get("error", "").endswith("not found on PATH"):
|
|
375
|
+
return rec
|
|
376
|
+
rec["plan"] = "managed"
|
|
377
|
+
return rec
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
_POLLERS: dict[str, Callable[[], dict[str, Any]]] = {
|
|
381
|
+
"codex": poll_codex,
|
|
382
|
+
"qoder": poll_qoder,
|
|
383
|
+
"claude": poll_claude,
|
|
384
|
+
"opencode": poll_opencode,
|
|
385
|
+
"gemini": poll_gemini,
|
|
386
|
+
"capi": poll_capi,
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def poll_agent(agent: str) -> dict[str, Any]:
|
|
391
|
+
poller = _POLLERS.get(agent)
|
|
392
|
+
if not poller:
|
|
393
|
+
return _empty_record(agent, error=f"unknown agent: {agent}")
|
|
394
|
+
try:
|
|
395
|
+
return poller()
|
|
396
|
+
except Exception as exc: # noqa: BLE001 — intentional: defensive, never let a third-party poller raise out
|
|
397
|
+
log.warning("poll_agent(%s) failed: %s", agent, exc)
|
|
398
|
+
return _empty_record(agent, error=f"poller exception: {exc}")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# Public API
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def refresh_quotas(target: str | os.PathLike[str] | None = None) -> dict[str, Any]:
|
|
407
|
+
"""Force a poll of every known agent and persist to cache."""
|
|
408
|
+
target_path = _resolve_target(target)
|
|
409
|
+
now = time.time()
|
|
410
|
+
agents: dict[str, Any] = {}
|
|
411
|
+
for agent in KNOWN_AGENTS:
|
|
412
|
+
agents[agent] = poll_agent(agent)
|
|
413
|
+
payload = {
|
|
414
|
+
"fetched_at": now,
|
|
415
|
+
"fetched_at_iso": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now)),
|
|
416
|
+
"ttl_seconds": CACHE_TTL_SECONDS,
|
|
417
|
+
"agents": agents,
|
|
418
|
+
}
|
|
419
|
+
_save_cache(_cache_path(target_path), payload)
|
|
420
|
+
return payload
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def get_quotas(
|
|
424
|
+
target: str | os.PathLike[str] | None = None,
|
|
425
|
+
*,
|
|
426
|
+
force_refresh: bool = False,
|
|
427
|
+
ttl_seconds: int = CACHE_TTL_SECONDS,
|
|
428
|
+
) -> dict[str, Any]:
|
|
429
|
+
"""Return cached quota data, refreshing if stale or forced.
|
|
430
|
+
|
|
431
|
+
Returns the ``agents`` mapping directly: ``{agent: record}``.
|
|
432
|
+
The full envelope (with ``fetched_at`` etc.) is available via
|
|
433
|
+
:func:`get_quotas_envelope`.
|
|
434
|
+
"""
|
|
435
|
+
return get_quotas_envelope(
|
|
436
|
+
target, force_refresh=force_refresh, ttl_seconds=ttl_seconds
|
|
437
|
+
)["agents"]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def get_quotas_envelope(
|
|
441
|
+
target: str | os.PathLike[str] | None = None,
|
|
442
|
+
*,
|
|
443
|
+
force_refresh: bool = False,
|
|
444
|
+
ttl_seconds: int = CACHE_TTL_SECONDS,
|
|
445
|
+
) -> dict[str, Any]:
|
|
446
|
+
target_path = _resolve_target(target)
|
|
447
|
+
cache_file = _cache_path(target_path)
|
|
448
|
+
now = time.time()
|
|
449
|
+
cache = _load_cache(cache_file)
|
|
450
|
+
if not force_refresh and _is_fresh(cache, now, ttl_seconds):
|
|
451
|
+
return cache # type: ignore[return-value]
|
|
452
|
+
return refresh_quotas(target_path)
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ---------------------------------------------------------------------------
|
|
456
|
+
# Helpers used by dispatcher
|
|
457
|
+
# ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
WEEKLY_EXHAUSTION_THRESHOLD = 5 # subscription banners expose remaining quota
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def is_agent_exhausted(
|
|
463
|
+
record: dict[str, Any] | None, threshold: int = WEEKLY_EXHAUSTION_THRESHOLD
|
|
464
|
+
) -> bool:
|
|
465
|
+
"""True iff the record reports weekly remaining quota at or below ``threshold``.
|
|
466
|
+
|
|
467
|
+
Records with no weekly data (None / unknown / error) are NOT considered
|
|
468
|
+
exhausted - the dispatcher should not penalize agents whose CLI lacks a
|
|
469
|
+
quota endpoint.
|
|
470
|
+
"""
|
|
471
|
+
weekly = quota_pct_remaining(record, "weekly")
|
|
472
|
+
if weekly is None:
|
|
473
|
+
return False
|
|
474
|
+
return float(weekly) <= float(threshold)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ---------------------------------------------------------------------------
|
|
478
|
+
# CLI entry point - useful for `python3 -m scripts.agent_quotas`
|
|
479
|
+
# ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _format_table(envelope: dict[str, Any]) -> str:
|
|
483
|
+
rows = [("AGENT", "PLAN", "WEEK%", "HOUR%", "RESETS", "SOURCE")]
|
|
484
|
+
for agent in KNOWN_AGENTS:
|
|
485
|
+
rec = envelope.get("agents", {}).get(agent) or _empty_record(agent)
|
|
486
|
+
weekly = quota_pct_remaining(rec, "weekly")
|
|
487
|
+
hourly = quota_pct_remaining(rec, "hourly")
|
|
488
|
+
rows.append(
|
|
489
|
+
(
|
|
490
|
+
agent,
|
|
491
|
+
str(rec.get("plan") or "unknown"),
|
|
492
|
+
"—" if weekly is None else f"{weekly}%",
|
|
493
|
+
"—" if hourly is None else f"{hourly}%",
|
|
494
|
+
str(rec.get("resets_at") or "—"),
|
|
495
|
+
str(rec.get("source") or rec.get("error") or "—"),
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
|
|
499
|
+
lines = []
|
|
500
|
+
for row in rows:
|
|
501
|
+
lines.append(" ".join(row[i].ljust(widths[i]) for i in range(len(row))))
|
|
502
|
+
return "\n".join(lines)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def main(argv: list[str] | None = None) -> int:
|
|
506
|
+
import argparse
|
|
507
|
+
|
|
508
|
+
parser = argparse.ArgumentParser(description="0dai agent quota poller")
|
|
509
|
+
parser.add_argument("--target", default=None, help="Project root (default: cwd)")
|
|
510
|
+
parser.add_argument(
|
|
511
|
+
"--refresh", action="store_true", help="Force refresh, ignore cache TTL"
|
|
512
|
+
)
|
|
513
|
+
parser.add_argument("--json", action="store_true", help="Emit JSON envelope")
|
|
514
|
+
args = parser.parse_args(argv)
|
|
515
|
+
|
|
516
|
+
envelope = get_quotas_envelope(args.target, force_refresh=args.refresh)
|
|
517
|
+
if args.json:
|
|
518
|
+
print(json.dumps(envelope, indent=2, sort_keys=True))
|
|
519
|
+
else:
|
|
520
|
+
print(_format_table(envelope))
|
|
521
|
+
return 0
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
if __name__ == "__main__":
|
|
525
|
+
raise SystemExit(main())
|