@0dai-dev/cli 4.3.5 → 4.3.7
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 +214 -40
- 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 +55 -1
- package/lib/commands/compliance.js +1 -1
- package/lib/commands/detect.js +10 -4
- package/lib/commands/doctor.js +545 -26
- package/lib/commands/experience.js +40 -5
- package/lib/commands/export.js +73 -0
- 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 +222 -30
- package/lib/commands/mcp.js +129 -21
- package/lib/commands/models.js +138 -41
- 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 +18 -7
- package/lib/commands/runner.js +31 -1
- package/lib/commands/status.js +44 -11
- package/lib/commands/swarm.js +130 -12
- package/lib/commands/trust.js +286 -0
- 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 +46 -9
- 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 +934 -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 +97 -14
- package/lib/tui/index.mjs +35174 -0
- package/lib/utils/activation_telemetry.js +230 -11
- package/lib/utils/constants.js +7 -1
- package/lib/utils/export-bundler.js +285 -0
- package/lib/utils/identity.js +198 -1
- 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,727 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The Bill — shareable receipt PNG for a 0dai session.
|
|
3
|
+
|
|
4
|
+
Industrializes the "AI-dev receipt screenshot" format (vinext $1,100,
|
|
5
|
+
Huntley $297 → $50k, Fynn API-leak) by fusing cost + LOC + agent identity
|
|
6
|
+
+ elapsed time + CI status from existing 0dai telemetry into a 1200×630
|
|
7
|
+
OG-sized PNG.
|
|
8
|
+
|
|
9
|
+
Pipeline
|
|
10
|
+
--------
|
|
11
|
+
1. `collect_session_stats(target, session)` — pure data: reads the session
|
|
12
|
+
payload (active.json or saved/<id>.json) + experience events in window.
|
|
13
|
+
2. `render_receipt(stats, out_path)` — Pillow 1200×630, monospace,
|
|
14
|
+
neural-substrate palette, big number + 4 chips.
|
|
15
|
+
3. `copy_to_clipboard(path)` — pbcopy (mac) / xclip / wl-copy (linux),
|
|
16
|
+
image/png content type where the tool supports it.
|
|
17
|
+
|
|
18
|
+
Issue: #529
|
|
19
|
+
Design: ai/ideas/20260418-062835-0dai_feature_ideation_33_bold_plays_for_April_2026.md #1
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import pathlib
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Palette — neural-substrate (matches CLI T = \x1b[38;2;45;212;168m)
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
PALETTE = {
|
|
37
|
+
"bg": (11, 18, 32), # #0B1220
|
|
38
|
+
"panel": (17, 25, 43), # #11192B
|
|
39
|
+
"accent": (45, 212, 168), # #2DD4A8 (0dai teal)
|
|
40
|
+
"text": (230, 237, 243), # #E6EDF3
|
|
41
|
+
"dim": (107, 114, 128), # #6B7280
|
|
42
|
+
"success": (134, 239, 172), # #86EFAC
|
|
43
|
+
"warn": (252, 211, 77), # #FCD34D
|
|
44
|
+
"error": (248, 113, 113), # #F87171
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
CANVAS = (1200, 630)
|
|
48
|
+
RECEIPTS_DIR_ENV = "ODAI_RECEIPTS_DIR"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# File helpers
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _receipts_dir() -> pathlib.Path:
|
|
57
|
+
override = os.environ.get(RECEIPTS_DIR_ENV)
|
|
58
|
+
if override:
|
|
59
|
+
return pathlib.Path(override).expanduser()
|
|
60
|
+
return pathlib.Path.home() / ".0dai" / "receipts"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_json(path: pathlib.Path) -> dict | None:
|
|
64
|
+
try:
|
|
65
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
66
|
+
except (OSError, json.JSONDecodeError):
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_iso(value: str | None) -> float:
|
|
71
|
+
if not value:
|
|
72
|
+
return 0.0
|
|
73
|
+
try:
|
|
74
|
+
from datetime import datetime, timezone
|
|
75
|
+
text = str(value).strip().replace("Z", "+00:00")
|
|
76
|
+
return datetime.fromisoformat(text).astimezone(timezone.utc).timestamp()
|
|
77
|
+
except (ValueError, TypeError):
|
|
78
|
+
return 0.0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Session lookup
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _active_session(target: pathlib.Path) -> dict | None:
|
|
87
|
+
return _load_json(target / "ai" / "sessions" / "active.json")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _saved_session(target: pathlib.Path, session_id: str) -> dict | None:
|
|
91
|
+
saved_dir = target / "ai" / "sessions" / "saved"
|
|
92
|
+
if not saved_dir.is_dir():
|
|
93
|
+
return None
|
|
94
|
+
# Exact match first
|
|
95
|
+
direct = saved_dir / f"{session_id}.json"
|
|
96
|
+
if direct.is_file():
|
|
97
|
+
return _load_json(direct)
|
|
98
|
+
# Then search payloads by id field
|
|
99
|
+
for path in saved_dir.glob("*.json"):
|
|
100
|
+
payload = _load_json(path)
|
|
101
|
+
if payload and str(payload.get("session_id") or payload.get("id") or "") == session_id:
|
|
102
|
+
return payload
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _last_saved_session(target: pathlib.Path) -> dict | None:
|
|
107
|
+
saved_dir = target / "ai" / "sessions" / "saved"
|
|
108
|
+
if not saved_dir.is_dir():
|
|
109
|
+
return None
|
|
110
|
+
candidates: list[tuple[float, dict]] = []
|
|
111
|
+
for path in saved_dir.glob("*.json"):
|
|
112
|
+
payload = _load_json(path)
|
|
113
|
+
if not payload:
|
|
114
|
+
continue
|
|
115
|
+
ts = _parse_iso(
|
|
116
|
+
payload.get("saved_at") or payload.get("updated") or payload.get("started")
|
|
117
|
+
)
|
|
118
|
+
candidates.append((ts, payload))
|
|
119
|
+
if not candidates:
|
|
120
|
+
return None
|
|
121
|
+
candidates.sort(key=lambda x: x[0], reverse=True)
|
|
122
|
+
return candidates[0][1]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _resolve_session(target: pathlib.Path, selector: str) -> dict | None:
|
|
126
|
+
"""selector: 'active' | 'last' | <session_id>."""
|
|
127
|
+
if selector == "active":
|
|
128
|
+
return _active_session(target)
|
|
129
|
+
if selector == "last":
|
|
130
|
+
last = _last_saved_session(target)
|
|
131
|
+
if last:
|
|
132
|
+
return last
|
|
133
|
+
return _active_session(target)
|
|
134
|
+
explicit = _saved_session(target, selector)
|
|
135
|
+
if explicit:
|
|
136
|
+
return explicit
|
|
137
|
+
active = _active_session(target)
|
|
138
|
+
if active and str(active.get("id") or active.get("session_id") or "") == selector:
|
|
139
|
+
return active
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Experience events → cost / agents / tasks
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _iter_events(target: pathlib.Path) -> list[dict]:
|
|
149
|
+
events_dir = target / "ai" / "experience" / "events"
|
|
150
|
+
if not events_dir.is_dir():
|
|
151
|
+
return []
|
|
152
|
+
events: list[dict] = []
|
|
153
|
+
for path in sorted(events_dir.glob("*.jsonl")):
|
|
154
|
+
try:
|
|
155
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
156
|
+
line = line.strip()
|
|
157
|
+
if not line:
|
|
158
|
+
continue
|
|
159
|
+
try:
|
|
160
|
+
events.append(json.loads(line))
|
|
161
|
+
except json.JSONDecodeError:
|
|
162
|
+
continue
|
|
163
|
+
except OSError:
|
|
164
|
+
continue
|
|
165
|
+
# Also allow single-event .json files (saved event payloads)
|
|
166
|
+
for path in sorted(events_dir.glob("*.json")):
|
|
167
|
+
payload = _load_json(path)
|
|
168
|
+
if payload:
|
|
169
|
+
events.append(payload)
|
|
170
|
+
return events
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _event_cost(event: dict) -> float:
|
|
174
|
+
task = event.get("task") or {}
|
|
175
|
+
value = task.get("cost_usd") or event.get("cost_usd") or event.get("cost")
|
|
176
|
+
try:
|
|
177
|
+
return float(value or 0.0)
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
return 0.0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _event_agent(event: dict) -> str:
|
|
183
|
+
return str(
|
|
184
|
+
event.get("tool") or event.get("agent") or (event.get("task") or {}).get("agent") or ""
|
|
185
|
+
).strip()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _event_elapsed(event: dict) -> float:
|
|
189
|
+
task = event.get("task") or {}
|
|
190
|
+
value = (
|
|
191
|
+
task.get("elapsed_seconds")
|
|
192
|
+
or event.get("elapsed_seconds")
|
|
193
|
+
or event.get("elapsed")
|
|
194
|
+
or 0.0
|
|
195
|
+
)
|
|
196
|
+
try:
|
|
197
|
+
return float(value or 0.0)
|
|
198
|
+
except (TypeError, ValueError):
|
|
199
|
+
return 0.0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _event_result(event: dict) -> str:
|
|
203
|
+
task = event.get("task") or {}
|
|
204
|
+
result = task.get("result") or event.get("result") or event.get("event_type") or ""
|
|
205
|
+
if event.get("success") is True:
|
|
206
|
+
return "success"
|
|
207
|
+
if event.get("success") is False:
|
|
208
|
+
return "failure"
|
|
209
|
+
if event.get("ci_passed") is True:
|
|
210
|
+
return "success"
|
|
211
|
+
if event.get("ci_passed") is False:
|
|
212
|
+
return "failure"
|
|
213
|
+
return str(result or "").lower()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Git diff → lines_added / lines_removed
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _git_shortstat(target: pathlib.Path, base_ref: str | None) -> tuple[int, int]:
|
|
222
|
+
"""Return (added, removed) via `git diff --shortstat`.
|
|
223
|
+
|
|
224
|
+
base_ref can be a commit SHA, branch, or None (working-tree diff).
|
|
225
|
+
Silent on any git error — returns (0, 0).
|
|
226
|
+
"""
|
|
227
|
+
cmd = ["git", "-C", str(target), "diff", "--shortstat"]
|
|
228
|
+
if base_ref:
|
|
229
|
+
cmd.append(base_ref)
|
|
230
|
+
try:
|
|
231
|
+
out = subprocess.run(
|
|
232
|
+
cmd, check=False, capture_output=True, text=True, timeout=10,
|
|
233
|
+
).stdout.strip()
|
|
234
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
235
|
+
return (0, 0)
|
|
236
|
+
added = removed = 0
|
|
237
|
+
for chunk in out.split(","):
|
|
238
|
+
chunk = chunk.strip()
|
|
239
|
+
if "insertion" in chunk:
|
|
240
|
+
added = int(chunk.split(" ", 1)[0])
|
|
241
|
+
elif "deletion" in chunk:
|
|
242
|
+
removed = int(chunk.split(" ", 1)[0])
|
|
243
|
+
return (added, removed)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# Stats collection
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def collect_session_stats(
|
|
252
|
+
target: pathlib.Path | str,
|
|
253
|
+
session: str = "active",
|
|
254
|
+
*,
|
|
255
|
+
now_epoch: float | None = None,
|
|
256
|
+
diff_reader=_git_shortstat,
|
|
257
|
+
) -> dict:
|
|
258
|
+
"""Build a receipt-ready stats dict for a session.
|
|
259
|
+
|
|
260
|
+
Values default to neutral (0 / empty / "unknown") when telemetry is
|
|
261
|
+
partial — the receipt still renders so operators can share *something*
|
|
262
|
+
even on low-data sessions.
|
|
263
|
+
"""
|
|
264
|
+
target = pathlib.Path(target).resolve()
|
|
265
|
+
payload = _resolve_session(target, session) or {}
|
|
266
|
+
|
|
267
|
+
session_id = str(payload.get("session_id") or payload.get("id") or "session")
|
|
268
|
+
started = _parse_iso(payload.get("started") or payload.get("started_at"))
|
|
269
|
+
ended = _parse_iso(
|
|
270
|
+
payload.get("saved_at") or payload.get("updated") or payload.get("completed_at")
|
|
271
|
+
)
|
|
272
|
+
if ended <= 0:
|
|
273
|
+
ended = now_epoch if now_epoch is not None else time.time()
|
|
274
|
+
elapsed_s = max(0.0, ended - started) if started else 0.0
|
|
275
|
+
|
|
276
|
+
# Agents: started_by + current_agent + history + event-tool set
|
|
277
|
+
agents: list[str] = []
|
|
278
|
+
def _add(agent: str) -> None:
|
|
279
|
+
a = str(agent or "").strip()
|
|
280
|
+
if a and a not in agents:
|
|
281
|
+
agents.append(a)
|
|
282
|
+
|
|
283
|
+
_add(payload.get("started_by") or "")
|
|
284
|
+
_add(payload.get("current_agent") or "")
|
|
285
|
+
for entry in (payload.get("history") or []):
|
|
286
|
+
if isinstance(entry, dict):
|
|
287
|
+
_add(entry.get("agent") or "")
|
|
288
|
+
|
|
289
|
+
events = _iter_events(target)
|
|
290
|
+
in_window = []
|
|
291
|
+
for event in events:
|
|
292
|
+
ts = _parse_iso(event.get("timestamp") or event.get("ts") or "")
|
|
293
|
+
if started and ended and (ts == 0.0 or not (started <= ts <= ended)):
|
|
294
|
+
continue
|
|
295
|
+
in_window.append(event)
|
|
296
|
+
|
|
297
|
+
for event in in_window:
|
|
298
|
+
_add(_event_agent(event))
|
|
299
|
+
|
|
300
|
+
cost_usd = round(sum(_event_cost(e) for e in in_window), 4)
|
|
301
|
+
tasks_count = sum(
|
|
302
|
+
1 for e in in_window
|
|
303
|
+
if str(e.get("event_type") or e.get("type") or "").startswith("task_")
|
|
304
|
+
or e.get("task_id")
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
results = [_event_result(e) for e in in_window if _event_result(e)]
|
|
308
|
+
failures = sum(1 for r in results if r in ("failure", "stuck", "timeout", "error"))
|
|
309
|
+
regressions = failures
|
|
310
|
+
if results and failures == 0:
|
|
311
|
+
status = "green"
|
|
312
|
+
elif results and failures > 0:
|
|
313
|
+
status = "red"
|
|
314
|
+
else:
|
|
315
|
+
status = "unknown"
|
|
316
|
+
|
|
317
|
+
base_ref = (payload.get("context") or {}).get("last_commit") or payload.get("base_commit")
|
|
318
|
+
lines_added, lines_removed = diff_reader(target, base_ref)
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
"session_id": session_id,
|
|
322
|
+
"started": payload.get("started") or payload.get("started_at") or "",
|
|
323
|
+
"ended": payload.get("updated") or payload.get("saved_at") or "",
|
|
324
|
+
"elapsed_seconds": round(elapsed_s, 1),
|
|
325
|
+
"agents": agents or ["unknown"],
|
|
326
|
+
"cost_usd": cost_usd,
|
|
327
|
+
"lines_added": int(lines_added),
|
|
328
|
+
"lines_removed": int(lines_removed),
|
|
329
|
+
"lines_changed": int(lines_added + lines_removed),
|
|
330
|
+
"tasks_count": tasks_count,
|
|
331
|
+
"regressions": regressions,
|
|
332
|
+
"status": status,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
# Formatting — chips
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _format_elapsed(seconds: float) -> str:
|
|
342
|
+
seconds = max(0, int(seconds))
|
|
343
|
+
if seconds < 60:
|
|
344
|
+
return f"{seconds}s"
|
|
345
|
+
if seconds < 3600:
|
|
346
|
+
return f"{seconds // 60}m"
|
|
347
|
+
hours, rem = divmod(seconds, 3600)
|
|
348
|
+
minutes = rem // 60
|
|
349
|
+
return f"{hours}h{minutes:02d}m" if minutes else f"{hours}h"
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _format_agents(agents: list[str]) -> str:
|
|
353
|
+
if not agents:
|
|
354
|
+
return "0"
|
|
355
|
+
if len(agents) == 1:
|
|
356
|
+
return agents[0]
|
|
357
|
+
return f"{len(agents)} agents"
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def format_chips(stats: dict) -> list[tuple[str, str]]:
|
|
361
|
+
"""Return the 4 display chips rendered under the headline cost.
|
|
362
|
+
|
|
363
|
+
Order matches the pitch: lines · agents · time · regressions.
|
|
364
|
+
"""
|
|
365
|
+
lines = stats.get("lines_changed") or 0
|
|
366
|
+
agents = stats.get("agents") or []
|
|
367
|
+
elapsed = stats.get("elapsed_seconds") or 0
|
|
368
|
+
regressions = stats.get("regressions") or 0
|
|
369
|
+
return [
|
|
370
|
+
("LINES", f"{int(lines):,}"),
|
|
371
|
+
("AGENTS", _format_agents(agents)),
|
|
372
|
+
("TIME", _format_elapsed(elapsed)),
|
|
373
|
+
("REGRESSIONS", "zero" if regressions == 0 else str(int(regressions))),
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def format_cost(cost_usd: float) -> str:
|
|
378
|
+
try:
|
|
379
|
+
cost = float(cost_usd)
|
|
380
|
+
except (TypeError, ValueError):
|
|
381
|
+
cost = 0.0
|
|
382
|
+
if cost >= 100:
|
|
383
|
+
return f"${cost:,.0f}"
|
|
384
|
+
if cost >= 10:
|
|
385
|
+
return f"${cost:,.2f}"
|
|
386
|
+
return f"${cost:.2f}"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ---------------------------------------------------------------------------
|
|
390
|
+
# Rendering
|
|
391
|
+
# ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _load_font(size: int, *, bold: bool = False):
|
|
395
|
+
from PIL import ImageFont # type: ignore
|
|
396
|
+
|
|
397
|
+
candidates = []
|
|
398
|
+
if bold:
|
|
399
|
+
candidates += [
|
|
400
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",
|
|
401
|
+
"/System/Library/Fonts/Menlo.ttc",
|
|
402
|
+
"/usr/share/fonts/truetype/liberation/LiberationMono-Bold.ttf",
|
|
403
|
+
]
|
|
404
|
+
candidates += [
|
|
405
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
|
406
|
+
"/System/Library/Fonts/Menlo.ttc",
|
|
407
|
+
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
|
|
408
|
+
"/Library/Fonts/Andale Mono.ttf",
|
|
409
|
+
]
|
|
410
|
+
for path in candidates:
|
|
411
|
+
if pathlib.Path(path).is_file():
|
|
412
|
+
try:
|
|
413
|
+
return ImageFont.truetype(path, size)
|
|
414
|
+
except OSError:
|
|
415
|
+
continue
|
|
416
|
+
return ImageFont.load_default()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def render_receipt(
|
|
420
|
+
stats: dict,
|
|
421
|
+
out_path: pathlib.Path | str,
|
|
422
|
+
) -> pathlib.Path:
|
|
423
|
+
"""Render a 1200×630 PNG receipt for `stats` to `out_path`. Returns path."""
|
|
424
|
+
from PIL import Image, ImageDraw # type: ignore
|
|
425
|
+
|
|
426
|
+
out = pathlib.Path(out_path)
|
|
427
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
428
|
+
|
|
429
|
+
img = Image.new("RGB", CANVAS, PALETTE["bg"])
|
|
430
|
+
draw = ImageDraw.Draw(img)
|
|
431
|
+
|
|
432
|
+
# Thin accent bar on the left edge — signature 0dai teal
|
|
433
|
+
draw.rectangle([(0, 0), (8, CANVAS[1])], fill=PALETTE["accent"])
|
|
434
|
+
|
|
435
|
+
# Header
|
|
436
|
+
header_font = _load_font(28, bold=True)
|
|
437
|
+
draw.text(
|
|
438
|
+
(56, 48),
|
|
439
|
+
"0dai · session receipt",
|
|
440
|
+
font=header_font,
|
|
441
|
+
fill=PALETTE["accent"],
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
session_id = str(stats.get("session_id") or "session")[:28]
|
|
445
|
+
sid_font = _load_font(20)
|
|
446
|
+
draw.text((56, 90), session_id, font=sid_font, fill=PALETTE["dim"])
|
|
447
|
+
|
|
448
|
+
# Big headline: cost
|
|
449
|
+
cost_font = _load_font(168, bold=True)
|
|
450
|
+
cost_text = format_cost(stats.get("cost_usd") or 0)
|
|
451
|
+
bbox = draw.textbbox((0, 0), cost_text, font=cost_font)
|
|
452
|
+
cost_w = bbox[2] - bbox[0]
|
|
453
|
+
cost_x = (CANVAS[0] - cost_w) // 2
|
|
454
|
+
draw.text((cost_x, 160), cost_text, font=cost_font, fill=PALETTE["text"])
|
|
455
|
+
|
|
456
|
+
# Sub-headline
|
|
457
|
+
sub_font = _load_font(22)
|
|
458
|
+
sub_text = "session total · receipts that ship themselves"
|
|
459
|
+
sbbox = draw.textbbox((0, 0), sub_text, font=sub_font)
|
|
460
|
+
sub_w = sbbox[2] - sbbox[0]
|
|
461
|
+
draw.text(
|
|
462
|
+
((CANVAS[0] - sub_w) // 2, 350),
|
|
463
|
+
sub_text,
|
|
464
|
+
font=sub_font,
|
|
465
|
+
fill=PALETTE["dim"],
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
# 4 chips along the bottom — equal widths
|
|
469
|
+
chips = format_chips(stats)
|
|
470
|
+
chip_label_font = _load_font(18)
|
|
471
|
+
chip_value_font = _load_font(30, bold=True)
|
|
472
|
+
|
|
473
|
+
chip_area_top = 420
|
|
474
|
+
chip_area_bottom = 560
|
|
475
|
+
chip_gap = 24
|
|
476
|
+
margin = 56
|
|
477
|
+
chip_w = (CANVAS[0] - margin * 2 - chip_gap * (len(chips) - 1)) // len(chips)
|
|
478
|
+
|
|
479
|
+
for idx, (label, value) in enumerate(chips):
|
|
480
|
+
x0 = margin + idx * (chip_w + chip_gap)
|
|
481
|
+
x1 = x0 + chip_w
|
|
482
|
+
draw.rectangle([(x0, chip_area_top), (x1, chip_area_bottom)], fill=PALETTE["panel"])
|
|
483
|
+
# Teal accent rail on top of each chip
|
|
484
|
+
draw.rectangle([(x0, chip_area_top), (x1, chip_area_top + 4)], fill=PALETTE["accent"])
|
|
485
|
+
|
|
486
|
+
draw.text(
|
|
487
|
+
(x0 + 18, chip_area_top + 22),
|
|
488
|
+
label,
|
|
489
|
+
font=chip_label_font,
|
|
490
|
+
fill=PALETTE["dim"],
|
|
491
|
+
)
|
|
492
|
+
value_color = PALETTE["text"]
|
|
493
|
+
if label == "REGRESSIONS":
|
|
494
|
+
value_color = PALETTE["success"] if value == "zero" else PALETTE["error"]
|
|
495
|
+
draw.text(
|
|
496
|
+
(x0 + 18, chip_area_top + 58),
|
|
497
|
+
value,
|
|
498
|
+
font=chip_value_font,
|
|
499
|
+
fill=value_color,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# Footer — 0dai.dev · status dot
|
|
503
|
+
footer_font = _load_font(18)
|
|
504
|
+
status = str(stats.get("status") or "unknown").lower()
|
|
505
|
+
status_color = {
|
|
506
|
+
"green": PALETTE["success"],
|
|
507
|
+
"red": PALETTE["error"],
|
|
508
|
+
"unknown": PALETTE["warn"],
|
|
509
|
+
}.get(status, PALETTE["warn"])
|
|
510
|
+
draw.ellipse([(56, 588), (72, 604)], fill=status_color)
|
|
511
|
+
draw.text((84, 586), f"ci: {status}", font=footer_font, fill=PALETTE["dim"])
|
|
512
|
+
draw.text(
|
|
513
|
+
(CANVAS[0] - 140, 586),
|
|
514
|
+
"0dai.dev",
|
|
515
|
+
font=footer_font,
|
|
516
|
+
fill=PALETTE["dim"],
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
img.save(out, format="PNG")
|
|
520
|
+
return out
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# ---------------------------------------------------------------------------
|
|
524
|
+
# Clipboard
|
|
525
|
+
# ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _which(name: str) -> str | None:
|
|
529
|
+
return shutil.which(name)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def copy_to_clipboard(
|
|
533
|
+
image_path: pathlib.Path | str,
|
|
534
|
+
*,
|
|
535
|
+
platform: str | None = None,
|
|
536
|
+
) -> dict:
|
|
537
|
+
"""Copy the PNG at `image_path` to the system clipboard.
|
|
538
|
+
|
|
539
|
+
Returns {copied: bool, tool: str|None, error: str|None}. Silent on
|
|
540
|
+
missing clipboard tools — receipts are saved to disk regardless.
|
|
541
|
+
"""
|
|
542
|
+
path = pathlib.Path(image_path)
|
|
543
|
+
if not path.is_file():
|
|
544
|
+
return {"copied": False, "tool": None, "error": f"not a file: {path}"}
|
|
545
|
+
|
|
546
|
+
plat = (platform or sys.platform or "").lower()
|
|
547
|
+
|
|
548
|
+
# macOS: pbcopy does not accept image/png — use osascript to set the
|
|
549
|
+
# clipboard to a PNG file URL.
|
|
550
|
+
if plat.startswith("darwin"):
|
|
551
|
+
osascript = _which("osascript")
|
|
552
|
+
if not osascript:
|
|
553
|
+
return {"copied": False, "tool": None, "error": "osascript not found"}
|
|
554
|
+
script = (
|
|
555
|
+
'set the clipboard to (read (POSIX file "'
|
|
556
|
+
+ str(path.resolve())
|
|
557
|
+
+ '") as «class PNGf»)'
|
|
558
|
+
)
|
|
559
|
+
try:
|
|
560
|
+
subprocess.run(
|
|
561
|
+
[osascript, "-e", script],
|
|
562
|
+
check=True, capture_output=True, timeout=10,
|
|
563
|
+
)
|
|
564
|
+
return {"copied": True, "tool": "osascript", "error": None}
|
|
565
|
+
except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired) as exc:
|
|
566
|
+
return {"copied": False, "tool": "osascript", "error": str(exc)}
|
|
567
|
+
|
|
568
|
+
# Wayland first, fall back to X11
|
|
569
|
+
for tool, args in (
|
|
570
|
+
("wl-copy", ["--type", "image/png"]),
|
|
571
|
+
("xclip", ["-selection", "clipboard", "-t", "image/png", "-i", str(path)]),
|
|
572
|
+
):
|
|
573
|
+
binary = _which(tool)
|
|
574
|
+
if not binary:
|
|
575
|
+
continue
|
|
576
|
+
try:
|
|
577
|
+
if tool == "wl-copy":
|
|
578
|
+
with path.open("rb") as fh:
|
|
579
|
+
subprocess.run(
|
|
580
|
+
[binary, *args],
|
|
581
|
+
check=True, stdin=fh, capture_output=True, timeout=10,
|
|
582
|
+
)
|
|
583
|
+
else:
|
|
584
|
+
subprocess.run(
|
|
585
|
+
[binary, *args],
|
|
586
|
+
check=True, capture_output=True, timeout=10,
|
|
587
|
+
)
|
|
588
|
+
return {"copied": True, "tool": tool, "error": None}
|
|
589
|
+
except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired) as exc:
|
|
590
|
+
return {"copied": False, "tool": tool, "error": str(exc)}
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
"copied": False,
|
|
594
|
+
"tool": None,
|
|
595
|
+
"error": "no clipboard tool found (install xclip, wl-copy, or use macOS)",
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
# ---------------------------------------------------------------------------
|
|
600
|
+
# End-to-end
|
|
601
|
+
# ---------------------------------------------------------------------------
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def generate_receipt(
|
|
605
|
+
target: pathlib.Path | str,
|
|
606
|
+
session: str = "last",
|
|
607
|
+
*,
|
|
608
|
+
output: pathlib.Path | str | None = None,
|
|
609
|
+
copy: bool = True,
|
|
610
|
+
now_epoch: float | None = None,
|
|
611
|
+
) -> dict:
|
|
612
|
+
"""Collect stats, render PNG, optionally copy to clipboard.
|
|
613
|
+
|
|
614
|
+
Returns a result dict with stats, path, and clipboard status.
|
|
615
|
+
"""
|
|
616
|
+
target_p = pathlib.Path(target).resolve()
|
|
617
|
+
stats = collect_session_stats(target_p, session, now_epoch=now_epoch)
|
|
618
|
+
|
|
619
|
+
if output is None:
|
|
620
|
+
base = _receipts_dir()
|
|
621
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
622
|
+
slug = "".join(c if c.isalnum() or c in "-_" else "-" for c in stats["session_id"])[:40]
|
|
623
|
+
stamp = time.strftime("%Y%m%d-%H%M%S", time.gmtime())
|
|
624
|
+
out_path = base / f"{stamp}-{slug or 'session'}.png"
|
|
625
|
+
else:
|
|
626
|
+
out_path = pathlib.Path(output)
|
|
627
|
+
|
|
628
|
+
rendered = render_receipt(stats, out_path)
|
|
629
|
+
clipboard = copy_to_clipboard(rendered) if copy else {
|
|
630
|
+
"copied": False, "tool": None, "error": "clipboard disabled"
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
"stats": stats,
|
|
635
|
+
"path": str(rendered),
|
|
636
|
+
"clipboard": clipboard,
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# ---------------------------------------------------------------------------
|
|
641
|
+
# CLI
|
|
642
|
+
# ---------------------------------------------------------------------------
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
646
|
+
parser = argparse.ArgumentParser(
|
|
647
|
+
prog="receipt_png",
|
|
648
|
+
description="The Bill — shareable receipt PNG for a 0dai session.",
|
|
649
|
+
)
|
|
650
|
+
parser.add_argument("--target", default=".", help="Repo root (default: cwd)")
|
|
651
|
+
group = parser.add_mutually_exclusive_group()
|
|
652
|
+
group.add_argument(
|
|
653
|
+
"--last", action="store_const", dest="session", const="last",
|
|
654
|
+
help="Use the most recent saved session (default fallback to active).",
|
|
655
|
+
)
|
|
656
|
+
group.add_argument(
|
|
657
|
+
"--active", action="store_const", dest="session", const="active",
|
|
658
|
+
help="Use the current active session.",
|
|
659
|
+
)
|
|
660
|
+
group.add_argument(
|
|
661
|
+
"--session", dest="session",
|
|
662
|
+
help="Use a specific session id (matches saved/<id>.json or active.json id).",
|
|
663
|
+
)
|
|
664
|
+
parser.set_defaults(session="last")
|
|
665
|
+
parser.add_argument("--output", help="Output PNG path (default: ~/.0dai/receipts/)")
|
|
666
|
+
parser.add_argument(
|
|
667
|
+
"--no-clipboard", action="store_true",
|
|
668
|
+
help="Skip the clipboard copy step.",
|
|
669
|
+
)
|
|
670
|
+
parser.add_argument("--json", action="store_true", help="Emit JSON result.")
|
|
671
|
+
parser.add_argument(
|
|
672
|
+
"--stats-only", action="store_true",
|
|
673
|
+
help="Print the collected stats and exit (no PNG render).",
|
|
674
|
+
)
|
|
675
|
+
return parser
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def main(argv: list[str] | None = None) -> int:
|
|
679
|
+
parser = _build_parser()
|
|
680
|
+
args = parser.parse_args(argv)
|
|
681
|
+
|
|
682
|
+
target = pathlib.Path(args.target).resolve()
|
|
683
|
+
session = args.session or "last"
|
|
684
|
+
|
|
685
|
+
if args.stats_only:
|
|
686
|
+
stats = collect_session_stats(target, session)
|
|
687
|
+
if args.json:
|
|
688
|
+
print(json.dumps(stats, indent=2, ensure_ascii=False))
|
|
689
|
+
else:
|
|
690
|
+
print(_render_stats_text(stats))
|
|
691
|
+
return 0
|
|
692
|
+
|
|
693
|
+
result = generate_receipt(
|
|
694
|
+
target, session, output=args.output, copy=not args.no_clipboard,
|
|
695
|
+
)
|
|
696
|
+
if args.json:
|
|
697
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
698
|
+
else:
|
|
699
|
+
stats = result["stats"]
|
|
700
|
+
print(f"\n \U0001f9fe Receipt saved: {result['path']}")
|
|
701
|
+
print(f" {format_cost(stats['cost_usd'])} · "
|
|
702
|
+
f"{stats['lines_changed']:,} lines · "
|
|
703
|
+
f"{_format_agents(stats['agents'])} · "
|
|
704
|
+
f"{_format_elapsed(stats['elapsed_seconds'])} · "
|
|
705
|
+
f"{'zero regressions' if stats['regressions'] == 0 else str(stats['regressions']) + ' regressions'}")
|
|
706
|
+
clip = result["clipboard"]
|
|
707
|
+
if clip.get("copied"):
|
|
708
|
+
print(f" copied to clipboard via {clip['tool']}")
|
|
709
|
+
elif clip.get("error"):
|
|
710
|
+
print(f" clipboard: {clip['error']}")
|
|
711
|
+
print()
|
|
712
|
+
return 0
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _render_stats_text(stats: dict) -> str:
|
|
716
|
+
chips = format_chips(stats)
|
|
717
|
+
chip_line = " · ".join(f"{label.lower()}: {value}" for label, value in chips)
|
|
718
|
+
return (
|
|
719
|
+
f"\n session: {stats['session_id']}\n"
|
|
720
|
+
f" cost: {format_cost(stats['cost_usd'])}\n"
|
|
721
|
+
f" status: {stats['status']}\n"
|
|
722
|
+
f" {chip_line}\n"
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
if __name__ == "__main__":
|
|
727
|
+
sys.exit(main())
|