@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,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')}")
|