@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.
Files changed (79) hide show
  1. package/README.md +12 -11
  2. package/bin/0dai.js +214 -40
  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 +55 -1
  7. package/lib/commands/compliance.js +1 -1
  8. package/lib/commands/detect.js +10 -4
  9. package/lib/commands/doctor.js +545 -26
  10. package/lib/commands/experience.js +40 -5
  11. package/lib/commands/export.js +73 -0
  12. package/lib/commands/feedback.js +157 -15
  13. package/lib/commands/gh.js +26 -0
  14. package/lib/commands/graph.js +9 -4
  15. package/lib/commands/heatmap.js +1 -1
  16. package/lib/commands/init.js +222 -30
  17. package/lib/commands/mcp.js +129 -21
  18. package/lib/commands/models.js +138 -41
  19. package/lib/commands/provider.js +30 -59
  20. package/lib/commands/quota.js +1 -1
  21. package/lib/commands/receipt.js +1 -1
  22. package/lib/commands/run.js +18 -7
  23. package/lib/commands/runner.js +31 -1
  24. package/lib/commands/status.js +44 -11
  25. package/lib/commands/swarm.js +130 -12
  26. package/lib/commands/trust.js +286 -0
  27. package/lib/commands/update.js +184 -38
  28. package/lib/commands/usage.js +1 -1
  29. package/lib/commands/validate.js +32 -3
  30. package/lib/commands/vault.js +46 -9
  31. package/lib/python/__init__.py +0 -0
  32. package/lib/python/agent_quotas.py +525 -0
  33. package/lib/python/anomaly_alert.py +397 -0
  34. package/lib/python/anti_pattern_detector.py +799 -0
  35. package/lib/python/auth.py +443 -0
  36. package/lib/python/capi_profile_guard.py +477 -0
  37. package/lib/python/compliance_report.py +581 -0
  38. package/lib/python/drift_detector.py +388 -0
  39. package/lib/python/experience_pipeline.py +1130 -0
  40. package/lib/python/graph.py +19 -0
  41. package/lib/python/graph_core.py +293 -0
  42. package/lib/python/graph_io.py +179 -0
  43. package/lib/python/graph_legacy.py +2052 -0
  44. package/lib/python/graph_legacy_helpers.py +221 -0
  45. package/lib/python/graph_outcomes_core.py +85 -0
  46. package/lib/python/graph_queries.py +171 -0
  47. package/lib/python/graph_slice.py +198 -0
  48. package/lib/python/graph_slicer.py +576 -0
  49. package/lib/python/graph_slicer_cli.py +60 -0
  50. package/lib/python/graph_validation.py +64 -0
  51. package/lib/python/heatmap.py +934 -0
  52. package/lib/python/json_utils.py +193 -0
  53. package/lib/python/mcp_exposure_check.py +247 -0
  54. package/lib/python/model_router.py +1434 -0
  55. package/lib/python/project_manager.py +621 -0
  56. package/lib/python/provider_profiles.py +1618 -0
  57. package/lib/python/provider_registry.py +1211 -0
  58. package/lib/python/provider_registry_cli.py +125 -0
  59. package/lib/python/receipt_png.py +727 -0
  60. package/lib/python/structural_memory.py +325 -0
  61. package/lib/python/swarm_cost.py +177 -0
  62. package/lib/python/usage_ledger.py +569 -0
  63. package/lib/scripts/mcp_tier_config.py +240 -0
  64. package/lib/shared.js +97 -14
  65. package/lib/tui/index.mjs +35174 -0
  66. package/lib/utils/activation_telemetry.js +230 -11
  67. package/lib/utils/constants.js +7 -1
  68. package/lib/utils/export-bundler.js +285 -0
  69. package/lib/utils/identity.js +198 -1
  70. package/lib/utils/mcp-auth.js +81 -15
  71. package/lib/utils/plan.js +1 -1
  72. package/lib/vault/index.js +19 -3
  73. package/lib/vault/storage.js +21 -2
  74. package/lib/wizard.js +5 -2
  75. package/package.json +9 -3
  76. package/scripts/build-python-bundle.js +106 -0
  77. package/scripts/build-tui.js +14 -1
  78. package/scripts/harvest_experience.py +523 -0
  79. package/scripts/postinstall.js +15 -9
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only structural-memory hints for 0dai context surfaces.
3
+
4
+ This module does not rewrite memory, experience, or graph records. It derives a
5
+ small role/status/source envelope from existing metadata so agents can tell raw
6
+ artifacts, hypotheses, accepted knowledge, specs, and decisions apart.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import pathlib
13
+ from collections import Counter
14
+ from typing import Any
15
+
16
+
17
+ ROLE_RAW_ARTIFACT = "raw_artifact"
18
+ ROLE_HYPOTHESIS = "hypothesis"
19
+ ROLE_ACCEPTED_KNOWLEDGE = "accepted_knowledge"
20
+ ROLE_SPEC = "spec"
21
+ ROLE_DECISION = "decision"
22
+ ROLE_RISK = "risk"
23
+ ROLE_EVENT = "event"
24
+ ROLE_PROCESS = "process"
25
+ ROLE_REFERENCE = "reference"
26
+ ROLE_SYSTEM = "system"
27
+ ROLE_UNKNOWN = "unknown"
28
+
29
+ STATUS_ACCEPTED = "accepted"
30
+ STATUS_CANDIDATE = "candidate"
31
+ STATUS_UNVERIFIED = "unverified"
32
+ STATUS_LOW_CONFIDENCE = "low_confidence"
33
+ STATUS_ACTIVE = "active"
34
+ STATUS_ARCHIVED = "archived"
35
+
36
+ RAW_TAGS = {"raw", "log", "logs", "runtime", "artifact", "audit", "telemetry", "transcript"}
37
+ HYPOTHESIS_TAGS = {"hypothesis", "idea", "draft", "candidate", "proposal", "experiment"}
38
+ SPEC_TAGS = {"spec", "specification", "requirement", "contract", "acceptance"}
39
+ DECISION_TAGS = {"decision", "adr", "rule", "directive", "policy"}
40
+ REFERENCE_TAGS = {"source", "reference", "external", "article", "link"}
41
+
42
+ MEMORY_CATEGORY_ROLES = {
43
+ "constraint": ROLE_SPEC,
44
+ "preference": ROLE_ACCEPTED_KNOWLEDGE,
45
+ "pattern": ROLE_ACCEPTED_KNOWLEDGE,
46
+ "lesson": ROLE_ACCEPTED_KNOWLEDGE,
47
+ "gotcha": ROLE_RISK,
48
+ "fact": ROLE_ACCEPTED_KNOWLEDGE,
49
+ }
50
+
51
+ GRAPH_TYPE_ROLES = {
52
+ "Component": ROLE_SYSTEM,
53
+ "Technology": ROLE_SYSTEM,
54
+ "Endpoint": ROLE_SYSTEM,
55
+ "Decision": ROLE_DECISION,
56
+ "Constraint": ROLE_SPEC,
57
+ "Requirement": ROLE_SPEC,
58
+ "TestPlan": ROLE_SPEC,
59
+ "Risk": ROLE_RISK,
60
+ "Artifact": ROLE_RAW_ARTIFACT,
61
+ "Event": ROLE_EVENT,
62
+ "Session": ROLE_EVENT,
63
+ "Deliberation": ROLE_PROCESS,
64
+ "Outcome": ROLE_ACCEPTED_KNOWLEDGE,
65
+ "DesignArtifact": ROLE_REFERENCE,
66
+ "MarketEntity": ROLE_REFERENCE,
67
+ }
68
+
69
+
70
+ def _tags(record: dict[str, Any]) -> set[str]:
71
+ raw = record.get("tags") or []
72
+ if isinstance(raw, str):
73
+ raw = raw.split(",")
74
+ return {str(tag).strip().lower() for tag in raw if str(tag).strip()}
75
+
76
+
77
+ def _source(record: dict[str, Any], key: str = "source") -> str:
78
+ value = record.get(key) or ""
79
+ return str(value).strip() or "unknown"
80
+
81
+
82
+ def _confidence(record: dict[str, Any]) -> float:
83
+ try:
84
+ return float(record.get("confidence", 0.0))
85
+ except (TypeError, ValueError):
86
+ return 0.0
87
+
88
+
89
+ def classify_memory_entry(entry: dict[str, Any]) -> dict[str, Any]:
90
+ """Return structural hints for one ai/memory entry."""
91
+ tags = _tags(entry)
92
+ category = str(entry.get("category") or "fact").lower()
93
+ confidence = _confidence(entry)
94
+ verified = bool(entry.get("verified"))
95
+ source = _source(entry)
96
+
97
+ role = MEMORY_CATEGORY_ROLES.get(category, ROLE_UNKNOWN)
98
+ signals: list[str] = []
99
+
100
+ if tags & RAW_TAGS:
101
+ role = ROLE_RAW_ARTIFACT
102
+ signals.append("raw_tag")
103
+ elif tags & HYPOTHESIS_TAGS:
104
+ role = ROLE_HYPOTHESIS
105
+ signals.append("hypothesis_tag")
106
+ elif tags & SPEC_TAGS:
107
+ role = ROLE_SPEC
108
+ signals.append("spec_tag")
109
+ elif tags & DECISION_TAGS:
110
+ role = ROLE_DECISION
111
+ signals.append("decision_tag")
112
+ elif tags & REFERENCE_TAGS:
113
+ role = ROLE_REFERENCE
114
+ signals.append("reference_tag")
115
+
116
+ if verified:
117
+ status = STATUS_ACCEPTED
118
+ elif confidence < 0.5:
119
+ status = STATUS_LOW_CONFIDENCE
120
+ elif role in {ROLE_HYPOTHESIS, ROLE_RAW_ARTIFACT, ROLE_REFERENCE}:
121
+ status = STATUS_CANDIDATE
122
+ else:
123
+ status = STATUS_UNVERIFIED
124
+
125
+ drift: list[str] = []
126
+ if verified and confidence < 0.8:
127
+ drift.append("verified_low_confidence")
128
+ if category in {"constraint", "preference", "lesson", "pattern"} and role == ROLE_RAW_ARTIFACT:
129
+ drift.append("accepted_category_marked_raw")
130
+ if category == "fact" and role == ROLE_HYPOTHESIS:
131
+ drift.append("fact_marked_hypothesis")
132
+
133
+ return {
134
+ "role": role,
135
+ "status": status,
136
+ "source": source,
137
+ "confidence": confidence,
138
+ "signals": signals,
139
+ "drift": drift,
140
+ }
141
+
142
+
143
+ def enrich_memory_entry(entry: dict[str, Any]) -> dict[str, Any]:
144
+ enriched = dict(entry)
145
+ enriched["structural"] = classify_memory_entry(entry)
146
+ return enriched
147
+
148
+
149
+ def classify_graph_node(node: dict[str, Any]) -> dict[str, Any]:
150
+ """Return structural hints for one project graph node."""
151
+ node_type = str(node.get("type") or "")
152
+ role = GRAPH_TYPE_ROLES.get(node_type, ROLE_UNKNOWN)
153
+ raw_status = str(node.get("status") or STATUS_ACTIVE).lower()
154
+ source = _source(node, key="source_type")
155
+
156
+ if raw_status in {"deprecated", "archived", "superseded", "deleted"}:
157
+ status = STATUS_ARCHIVED
158
+ elif raw_status in {"confirmed", "accepted", "active"}:
159
+ status = STATUS_ACCEPTED if role in {ROLE_DECISION, ROLE_SPEC, ROLE_ACCEPTED_KNOWLEDGE} else STATUS_ACTIVE
160
+ elif raw_status in {"draft", "candidate", "proposed"}:
161
+ status = STATUS_CANDIDATE
162
+ else:
163
+ status = raw_status or STATUS_ACTIVE
164
+
165
+ drift: list[str] = []
166
+ if role == ROLE_UNKNOWN:
167
+ drift.append("unknown_graph_node_role")
168
+ if node_type == "Decision" and raw_status not in {"active", "confirmed", "accepted", "superseded", "deprecated", "archived"}:
169
+ drift.append("decision_status_not_normalized")
170
+
171
+ return {
172
+ "role": role,
173
+ "status": status,
174
+ "source": source,
175
+ "signals": [f"graph_type:{node_type}"] if node_type else [],
176
+ "drift": drift,
177
+ }
178
+
179
+
180
+ def _load_memory_entries(target: pathlib.Path) -> list[dict[str, Any]]:
181
+ path = target / "ai" / "memory" / "memory.jsonl"
182
+ if not path.is_file():
183
+ return []
184
+ entries: list[dict[str, Any]] = []
185
+ for line in path.read_text(encoding="utf-8").splitlines():
186
+ line = line.strip()
187
+ if not line:
188
+ continue
189
+ try:
190
+ payload = json.loads(line)
191
+ except json.JSONDecodeError:
192
+ continue
193
+ if isinstance(payload, dict) and not payload.get("deleted"):
194
+ entries.append(payload)
195
+ return entries
196
+
197
+
198
+ def _load_graph_nodes(target: pathlib.Path) -> list[dict[str, Any]]:
199
+ path = target / "ai" / "manifest" / "project_graph.json"
200
+ if not path.is_file():
201
+ return []
202
+ try:
203
+ graph = json.loads(path.read_text(encoding="utf-8"))
204
+ except (json.JSONDecodeError, OSError):
205
+ return []
206
+ nodes = graph.get("nodes") if isinstance(graph, dict) else {}
207
+ if isinstance(nodes, dict):
208
+ return [n for n in nodes.values() if isinstance(n, dict)]
209
+ if isinstance(nodes, list):
210
+ return [n for n in nodes if isinstance(n, dict)]
211
+ return []
212
+
213
+
214
+ def _experience_role(subdir: str) -> str:
215
+ return {
216
+ "events": ROLE_RAW_ARTIFACT,
217
+ "outbox": ROLE_RAW_ARTIFACT,
218
+ "candidates": ROLE_HYPOTHESIS,
219
+ "accepted": ROLE_ACCEPTED_KNOWLEDGE,
220
+ }.get(subdir, ROLE_UNKNOWN)
221
+
222
+
223
+ def _experience_status(subdir: str) -> str:
224
+ return {
225
+ "events": STATUS_UNVERIFIED,
226
+ "outbox": STATUS_CANDIDATE,
227
+ "candidates": STATUS_CANDIDATE,
228
+ "accepted": STATUS_ACCEPTED,
229
+ }.get(subdir, STATUS_UNVERIFIED)
230
+
231
+
232
+ def _experience_counts(target: pathlib.Path) -> tuple[Counter, Counter, int]:
233
+ roles: Counter = Counter()
234
+ statuses: Counter = Counter()
235
+ total = 0
236
+ root = target / "ai" / "experience"
237
+ for subdir in ("events", "outbox", "candidates", "accepted"):
238
+ base = root / subdir
239
+ if not base.is_dir():
240
+ continue
241
+ for path in base.rglob("*"):
242
+ if path.is_file() and path.suffix in {".json", ".jsonl", ".md"}:
243
+ total += 1
244
+ roles[_experience_role(subdir)] += 1
245
+ statuses[_experience_status(subdir)] += 1
246
+ return roles, statuses, total
247
+
248
+
249
+ def summarize_project(target: pathlib.Path) -> dict[str, Any]:
250
+ """Build a read-only structural-memory summary for a project."""
251
+ target = target.resolve()
252
+ memory_entries = _load_memory_entries(target)
253
+ graph_nodes = _load_graph_nodes(target)
254
+
255
+ role_counts: Counter = Counter()
256
+ status_counts: Counter = Counter()
257
+ source_counts: Counter = Counter()
258
+ warnings: list[dict[str, str]] = []
259
+
260
+ for entry in memory_entries:
261
+ structural = classify_memory_entry(entry)
262
+ role_counts[structural["role"]] += 1
263
+ status_counts[structural["status"]] += 1
264
+ source_counts[structural["source"]] += 1
265
+ for drift in structural["drift"]:
266
+ warnings.append({"surface": "memory", "id": str(entry.get("id") or ""), "drift": drift})
267
+
268
+ for node in graph_nodes:
269
+ structural = classify_graph_node(node)
270
+ role_counts[structural["role"]] += 1
271
+ status_counts[structural["status"]] += 1
272
+ source_counts[structural["source"]] += 1
273
+ for drift in structural["drift"]:
274
+ warnings.append({"surface": "graph", "id": str(node.get("id") or ""), "drift": drift})
275
+
276
+ exp_roles, exp_statuses, exp_total = _experience_counts(target)
277
+ role_counts.update(exp_roles)
278
+ status_counts.update(exp_statuses)
279
+
280
+ return {
281
+ "schema": 1,
282
+ "target": str(target),
283
+ "total_records": len(memory_entries) + len(graph_nodes) + exp_total,
284
+ "surfaces": {
285
+ "memory": len(memory_entries),
286
+ "graph": len(graph_nodes),
287
+ "experience": exp_total,
288
+ },
289
+ "roles": dict(sorted(role_counts.items())),
290
+ "statuses": dict(sorted(status_counts.items())),
291
+ "sources": dict(sorted(source_counts.items())),
292
+ "drift_warnings": warnings[:20],
293
+ "drift_warning_count": len(warnings),
294
+ }
295
+
296
+
297
+ def main() -> int:
298
+ parser = argparse.ArgumentParser(description="Show read-only structural-memory hints.")
299
+ parser.add_argument("--target", default=".", help="Project root")
300
+ parser.add_argument("--json", action="store_true", help="Print JSON")
301
+ args = parser.parse_args()
302
+
303
+ summary = summarize_project(pathlib.Path(args.target))
304
+ if args.json:
305
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
306
+ return 0
307
+
308
+ print("Structural memory:")
309
+ print(f" records: {summary['total_records']}")
310
+ print(
311
+ " surfaces: "
312
+ + ", ".join(f"{key}={value}" for key, value in summary["surfaces"].items())
313
+ )
314
+ if summary["roles"]:
315
+ print(
316
+ " roles: "
317
+ + ", ".join(f"{key}={value}" for key, value in summary["roles"].items())
318
+ )
319
+ if summary["drift_warning_count"]:
320
+ print(f" drift warnings: {summary['drift_warning_count']}")
321
+ return 0
322
+
323
+
324
+ if __name__ == "__main__":
325
+ raise SystemExit(main())
@@ -0,0 +1,177 @@
1
+ """Swarm Cost — normalize telemetry data from different agents.
2
+
3
+ Implements Layer 3.5 cost monitoring.
4
+
5
+ Note on accuracy (2026-04-26 update per operator directive):
6
+ - Rates table updated for current pool models (gpt-5.5, gpt-5.3-codex,
7
+ claude-opus-4.7, claude-sonnet-4.6, gemini-3, qoder-native).
8
+ - For subscription-mode sessions (auth_mode=subscription per
9
+ ai/manifest/agent-pool.yaml), the USD estimate is "subscription-covered"
10
+ (SC) — operator's flat monthly fee covers it within quota. The estimate
11
+ is still computed (for cross-mode comparison + future API-mode parity)
12
+ but rendered with `(SC)` suffix when displayed.
13
+ - For api-key sessions (RuAPI proxy / opencode anthropic / ghost ensemble),
14
+ the USD estimate maps to actual incremental spend.
15
+ - CLI footers expose real subscription quota usage (5h block %, weekly %)
16
+ — see hop session pane, e.g. "5h 87% · weekly 86%". That's the
17
+ authoritative subscription cost signal; estimate_cost() is for paper
18
+ comparison only.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import pathlib
24
+
25
+ # Per-agent rates (USD per 1M tokens, public list-price reference).
26
+ # Updated 2026-04-26: added current pool models.
27
+ DEFAULT_RATES = {
28
+ # Anthropic (current — Claude 4 family)
29
+ "claude-opus-4.7": (15.0, 75.0), # (input, output)
30
+ "claude-opus-4-7": (15.0, 75.0), # alias
31
+ "claude-opus-4.6": (15.0, 75.0),
32
+ "claude-opus-4-6": (15.0, 75.0),
33
+ "claude-sonnet-4.6": (3.0, 15.0),
34
+ "claude-sonnet-4-6": (3.0, 15.0),
35
+ "claude-haiku-4.5": (0.80, 4.0),
36
+ "claude-haiku-4-5": (0.80, 4.0),
37
+ "opus": (15.0, 75.0), # CLI alias
38
+ "sonnet": (3.0, 15.0), # CLI alias
39
+ "haiku": (0.80, 4.0), # CLI alias
40
+ # Anthropic (legacy aliases)
41
+ "claude-opus": (15.0, 75.0),
42
+ "claude-sonnet": (3.0, 15.0),
43
+ "claude-3-5-sonnet": (3.0, 15.0),
44
+ # OpenAI / codex (current pool)
45
+ "gpt-5.5": (10.0, 40.0), # 2026 pricing per OpenAI public list
46
+ "gpt-5.4": (10.0, 30.0),
47
+ "gpt-5.4-mini": (0.15, 0.60),
48
+ "gpt-5.3-codex": (12.0, 36.0), # codex-tuned, deep tier
49
+ "gpt-5.3": (8.0, 24.0),
50
+ "gpt-5.2-codex": (10.0, 30.0),
51
+ # Google / gemini (current)
52
+ "gemini-3": (3.50, 10.50),
53
+ "gemini-3.1-pro": (3.50, 10.50),
54
+ "gemini-2.5-pro": (3.50, 10.50),
55
+ "gemini-1.5-pro": (3.50, 10.50),
56
+ "gemini-1.5-flash": (0.35, 1.05),
57
+ # Qoder native (subscription-only, public list approximate)
58
+ "qoder-default": (5.0, 20.0),
59
+ "qoder-performance": (5.0, 20.0),
60
+ "qoder-ultimate": (10.0, 40.0),
61
+ }
62
+
63
+ # Sessions with subscription-flat-cost (per ai/manifest/agent-pool.yaml
64
+ # auth_mode: subscription). USD estimate for these is paper-only — the
65
+ # operator's monthly subscription fee covers it within quota. Add (SC)
66
+ # suffix when rendering.
67
+ SUBSCRIPTION_AGENTS = {
68
+ "claude", # Claude Max via /root/.claude/ (claude-2/3 pool)
69
+ "codex", # Codex via OpenAI subscription (codex-1/2/3 pool)
70
+ "gemini", # Gemini via Google Code Assist Pro (gemini-1/2/3 pool)
71
+ "qoder", # Qoder native subscription
72
+ }
73
+
74
+ # Sessions with api-key (real incremental spend via RuAPI / direct API):
75
+ API_KEY_AGENTS = {
76
+ "ruapi-opus47", "ruapi-sonnet46", "ruapi-gemini31",
77
+ "opencode", # opencode via api-key per manifest
78
+ "ghost", "ghost-opus47", "ghost-gemini31",
79
+ "zeus", "athena", "hephaestus", "hermes", "ares", "apollo", # Greek pantheon
80
+ }
81
+
82
+
83
+ def is_subscription_covered(agent: str) -> bool:
84
+ """True if this agent's USD estimate is subscription-covered (paper only)."""
85
+ base = (agent or "").lower().split("-")[0] # claude-2 → claude
86
+ return base in SUBSCRIPTION_AGENTS or agent in SUBSCRIPTION_AGENTS
87
+
88
+
89
+ def format_cost(usd: float, agent: str = "") -> str:
90
+ """Render USD with (SC) suffix when subscription-covered.
91
+
92
+ Examples:
93
+ format_cost(3.16, "codex") → "$3.1600 (SC)"
94
+ format_cost(3.16, "ruapi-opus47") → "$3.1600"
95
+ format_cost(0.0, "claude") → "$0.0000 (SC)"
96
+ """
97
+ base = f"${usd:.4f}"
98
+ if is_subscription_covered(agent):
99
+ return f"{base} (SC)"
100
+ return base
101
+
102
+
103
+ class LaneCostReport:
104
+ def __init__(self, lane_id: str, agent: str, model: str):
105
+ self.lane_id = lane_id
106
+ self.agent = agent
107
+ self.model = model
108
+ self.input_tokens = 0
109
+ self.output_tokens = 0
110
+ self.usd_estimate = 0.0
111
+ self.wall_clock_seconds = 0
112
+
113
+ @property
114
+ def subscription_covered(self) -> bool:
115
+ return is_subscription_covered(self.agent)
116
+
117
+ def to_dict(self) -> dict:
118
+ return {
119
+ "lane_id": self.lane_id,
120
+ "agent": self.agent,
121
+ "model": self.model,
122
+ "input_tokens": self.input_tokens,
123
+ "output_tokens": self.output_tokens,
124
+ "usd_estimate": round(self.usd_estimate, 6),
125
+ "subscription_covered": self.subscription_covered,
126
+ "wall_clock_seconds": self.wall_clock_seconds,
127
+ }
128
+
129
+
130
+ def estimate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
131
+ """Calculate USD cost based on token counts (paper estimate, list-price).
132
+
133
+ For subscription-covered sessions this is the equivalent api-mode cost,
134
+ NOT actual incremental spend. Use is_subscription_covered() / format_cost()
135
+ when displaying.
136
+ """
137
+ rates = DEFAULT_RATES.get(model, (10.0, 30.0)) # fallback mid-range
138
+ in_cost = (input_tokens / 1_000_000) * rates[0]
139
+ out_cost = (output_tokens / 1_000_000) * rates[1]
140
+ return in_cost + out_cost
141
+
142
+
143
+ def get_lane_telemetry(lane_id: str, target: pathlib.Path) -> LaneCostReport | None:
144
+ """Sample lane telemetry from ai/work/swarm/active/<lane_id>.json."""
145
+ active_path = target / "ai" / "work" / "swarm" / "active" / f"{lane_id}.json"
146
+ if not active_path.is_file():
147
+ # Check done dir as fallback
148
+ active_path = target / "ai" / "work" / "swarm" / "done" / f"{lane_id}.json"
149
+ if not active_path.is_file():
150
+ return None
151
+
152
+ try:
153
+ data = json.loads(active_path.read_text(encoding="utf-8"))
154
+ report = LaneCostReport(
155
+ lane_id=lane_id,
156
+ agent=data.get("agent", "unknown"),
157
+ model=data.get("model", "unknown"),
158
+ )
159
+ report.input_tokens = int(data.get("tokens_input", 0))
160
+ report.output_tokens = int(data.get("tokens_output", 0))
161
+ report.usd_estimate = estimate_cost(report.model, report.input_tokens, report.output_tokens)
162
+
163
+ # Calculate wall clock if timestamps are present
164
+ start = data.get("started_at")
165
+ if start:
166
+ # Simple epoch diff for now
167
+ pass
168
+
169
+ return report
170
+ except (json.JSONDecodeError, OSError, ValueError):
171
+ return None
172
+
173
+
174
+ if __name__ == "__main__":
175
+ print(f"Cost of 1M Opus 4.7 tokens (500K in / 500K out): {format_cost(estimate_cost('claude-opus-4.7', 500000, 500000), 'claude')}")
176
+ print(f"Cost of 1M gpt-5.5 tokens (500K in / 500K out): {format_cost(estimate_cost('gpt-5.5', 500000, 500000), 'codex')}")
177
+ print(f"Cost of 1M ruapi-opus47 tokens: {format_cost(estimate_cost('claude-opus-4.7', 500000, 500000), 'ruapi-opus47')}")