@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,19 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Compatibility facade for the split graph modules."""
|
|
3
|
+
|
|
4
|
+
import graph_core as _graph_core
|
|
5
|
+
import graph_legacy as _graph_legacy
|
|
6
|
+
|
|
7
|
+
from graph_legacy import * # noqa: F403
|
|
8
|
+
from graph_core import * # noqa: F403
|
|
9
|
+
from graph_validation import * # noqa: F403
|
|
10
|
+
from graph_io import * # noqa: F403
|
|
11
|
+
from graph_slice import * # noqa: F403
|
|
12
|
+
from graph_queries import * # noqa: F403
|
|
13
|
+
from graph_outcomes_core import * # noqa: F403
|
|
14
|
+
|
|
15
|
+
_now_iso = _graph_core._now_iso
|
|
16
|
+
_slug = _graph_core._slug
|
|
17
|
+
_summary = _graph_legacy._summary
|
|
18
|
+
_parse_constraints_yaml = _graph_legacy._parse_constraints_yaml
|
|
19
|
+
_extract_violation_pattern = _graph_legacy._extract_violation_pattern
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Core graph registries and mutation helpers."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import datetime as _dt
|
|
7
|
+
import logging
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
log = logging.getLogger("0dai.graph")
|
|
12
|
+
|
|
13
|
+
SCHEMA_VERSION = 1
|
|
14
|
+
|
|
15
|
+
NODE_TYPES = frozenset({
|
|
16
|
+
"Component",
|
|
17
|
+
"Technology",
|
|
18
|
+
"Decision",
|
|
19
|
+
"Requirement",
|
|
20
|
+
"Risk",
|
|
21
|
+
"TestPlan",
|
|
22
|
+
"Endpoint",
|
|
23
|
+
"DesignArtifact",
|
|
24
|
+
"MarketEntity",
|
|
25
|
+
"Session",
|
|
26
|
+
"Deliberation",
|
|
27
|
+
"Outcome",
|
|
28
|
+
"Artifact",
|
|
29
|
+
"Event",
|
|
30
|
+
"Constraint",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
NODE_ID_PREFIXES: dict[str, str] = {
|
|
34
|
+
"Component": "comp",
|
|
35
|
+
"Technology": "tech",
|
|
36
|
+
"Decision": "dec",
|
|
37
|
+
"Requirement": "req",
|
|
38
|
+
"Risk": "risk",
|
|
39
|
+
"TestPlan": "test",
|
|
40
|
+
"Endpoint": "ep",
|
|
41
|
+
"DesignArtifact": "design",
|
|
42
|
+
"MarketEntity": "mkt",
|
|
43
|
+
"Session": "session",
|
|
44
|
+
"Deliberation": "delib",
|
|
45
|
+
"Outcome": "outcome",
|
|
46
|
+
"Artifact": "artifact",
|
|
47
|
+
"Event": "event",
|
|
48
|
+
"Constraint": "cstr",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
EDGE_TYPES = frozenset({
|
|
52
|
+
"uses",
|
|
53
|
+
"depends_on",
|
|
54
|
+
"exposes",
|
|
55
|
+
"part_of",
|
|
56
|
+
"affects",
|
|
57
|
+
"chose",
|
|
58
|
+
"satisfies",
|
|
59
|
+
"supersedes",
|
|
60
|
+
"introduces",
|
|
61
|
+
"mitigates",
|
|
62
|
+
"decided_in",
|
|
63
|
+
"covers",
|
|
64
|
+
"tests",
|
|
65
|
+
"blocks",
|
|
66
|
+
"violates",
|
|
67
|
+
"designs",
|
|
68
|
+
"follows",
|
|
69
|
+
"targets",
|
|
70
|
+
"competes_with",
|
|
71
|
+
"owned_by",
|
|
72
|
+
"identified_by",
|
|
73
|
+
"created_by",
|
|
74
|
+
"approved_by",
|
|
75
|
+
"produced",
|
|
76
|
+
"updated",
|
|
77
|
+
"evaluates",
|
|
78
|
+
"decision_outcome",
|
|
79
|
+
"decision_ancestry",
|
|
80
|
+
"released_as",
|
|
81
|
+
"contains",
|
|
82
|
+
"triggered_by",
|
|
83
|
+
"observed_in",
|
|
84
|
+
"declared_by",
|
|
85
|
+
"implies",
|
|
86
|
+
"constrains",
|
|
87
|
+
"forbids",
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
OUTCOME_STATUSES = frozenset({
|
|
91
|
+
"confirmed",
|
|
92
|
+
"revised",
|
|
93
|
+
"reverted",
|
|
94
|
+
"partially_applied",
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
STAGE_OUTCOME_THRESHOLDS_DAYS: dict[str, int] = {
|
|
98
|
+
"idea": 14,
|
|
99
|
+
"mvp": 14,
|
|
100
|
+
"growth": 30,
|
|
101
|
+
"scale": 60,
|
|
102
|
+
}
|
|
103
|
+
DEFAULT_STAGE_THRESHOLD_DAYS = 30
|
|
104
|
+
|
|
105
|
+
ROLE_TYPE_INTERESTS: dict[str, frozenset[str]] = {
|
|
106
|
+
"cto": frozenset({"Decision", "Technology", "Risk", "Component", "Requirement", "Constraint"}),
|
|
107
|
+
"arch": frozenset({"Component", "Technology", "Endpoint", "Requirement", "Risk", "Constraint"}),
|
|
108
|
+
"designer": frozenset({"DesignArtifact", "Component", "Requirement"}),
|
|
109
|
+
"art_director": frozenset({"DesignArtifact", "Component", "MarketEntity"}),
|
|
110
|
+
"qa": frozenset({"TestPlan", "Component", "Risk", "Requirement", "Constraint"}),
|
|
111
|
+
"security": frozenset({"Risk", "Requirement", "Component", "Technology", "Constraint"}),
|
|
112
|
+
"sre": frozenset({"Component", "Technology", "Risk", "Endpoint", "Constraint"}),
|
|
113
|
+
"cmo": frozenset({"MarketEntity", "Requirement", "Component", "Decision"}),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
DEFAULT_EDGE_WEIGHT = 1.0
|
|
117
|
+
HOP2_WEIGHT_THRESHOLD = 0.5
|
|
118
|
+
|
|
119
|
+
CHARS_PER_TOKEN = 4
|
|
120
|
+
DEFAULT_SLICE_TOKEN_BUDGET = 400
|
|
121
|
+
|
|
122
|
+
DETERMINISTIC_SOURCES = frozenset({
|
|
123
|
+
"bootstrap",
|
|
124
|
+
"file_parse",
|
|
125
|
+
"git_diff",
|
|
126
|
+
"operator",
|
|
127
|
+
"tool_output",
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
AI_DERIVED_SOURCES = frozenset({
|
|
131
|
+
"scout_ai",
|
|
132
|
+
"deliberation_ai",
|
|
133
|
+
"hard_block",
|
|
134
|
+
"red_team_ai",
|
|
135
|
+
"forecast_ai",
|
|
136
|
+
"pattern_ai",
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
DEFAULT_SOURCE = "operator"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def is_deterministic_source(source_type: str) -> bool:
|
|
143
|
+
return source_type in DETERMINISTIC_SOURCES
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def source_marker(source_type: str) -> str:
|
|
147
|
+
if is_deterministic_source(source_type):
|
|
148
|
+
return "✓"
|
|
149
|
+
if source_type in AI_DERIVED_SOURCES:
|
|
150
|
+
return "~"
|
|
151
|
+
return "?"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def empty_graph() -> dict:
|
|
155
|
+
now = _now_iso()
|
|
156
|
+
return {
|
|
157
|
+
"nodes": {},
|
|
158
|
+
"edges": [],
|
|
159
|
+
"meta": {
|
|
160
|
+
"schema_version": SCHEMA_VERSION,
|
|
161
|
+
"created_at": now,
|
|
162
|
+
"updated_at": now,
|
|
163
|
+
"node_count": 0,
|
|
164
|
+
"edge_count": 0,
|
|
165
|
+
},
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _now_iso() -> str:
|
|
170
|
+
return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _slug(text: str) -> str:
|
|
174
|
+
cleaned = re.sub(r"[^a-zA-Z0-9]+", "_", text.strip().lower())
|
|
175
|
+
return cleaned.strip("_") or "unnamed"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def make_node_id(node_type: str, name: str) -> str:
|
|
179
|
+
prefix = NODE_ID_PREFIXES.get(node_type)
|
|
180
|
+
if not prefix:
|
|
181
|
+
raise ValueError(f"unknown node type: {node_type}")
|
|
182
|
+
return f"{prefix}_{_slug(name)}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def add_node(
|
|
186
|
+
graph: dict,
|
|
187
|
+
node_id: str,
|
|
188
|
+
node_type: str,
|
|
189
|
+
name: str,
|
|
190
|
+
*,
|
|
191
|
+
status: str = "active",
|
|
192
|
+
description: str = "",
|
|
193
|
+
source_type: str = DEFAULT_SOURCE,
|
|
194
|
+
extra: Optional[dict[str, Any]] = None,
|
|
195
|
+
) -> dict:
|
|
196
|
+
if node_type not in NODE_TYPES:
|
|
197
|
+
raise ValueError(f"unknown node type: {node_type!r}")
|
|
198
|
+
if not node_id:
|
|
199
|
+
raise ValueError("node id must be non-empty")
|
|
200
|
+
|
|
201
|
+
now = _now_iso()
|
|
202
|
+
existing = graph["nodes"].get(node_id)
|
|
203
|
+
if existing:
|
|
204
|
+
existing["type"] = node_type
|
|
205
|
+
existing["name"] = name
|
|
206
|
+
existing["status"] = status
|
|
207
|
+
existing["description"] = description
|
|
208
|
+
existing["updated_at"] = now
|
|
209
|
+
existing_source = existing.get("source_type", DEFAULT_SOURCE)
|
|
210
|
+
if is_deterministic_source(source_type):
|
|
211
|
+
existing["source_type"] = source_type
|
|
212
|
+
elif not is_deterministic_source(existing_source):
|
|
213
|
+
existing["source_type"] = source_type
|
|
214
|
+
if extra:
|
|
215
|
+
existing.update(extra)
|
|
216
|
+
graph["meta"]["updated_at"] = now
|
|
217
|
+
return existing
|
|
218
|
+
|
|
219
|
+
node = {
|
|
220
|
+
"id": node_id,
|
|
221
|
+
"type": node_type,
|
|
222
|
+
"name": name,
|
|
223
|
+
"status": status,
|
|
224
|
+
"description": description,
|
|
225
|
+
"source_type": source_type,
|
|
226
|
+
"created_at": now,
|
|
227
|
+
"updated_at": now,
|
|
228
|
+
}
|
|
229
|
+
if extra:
|
|
230
|
+
node.update(extra)
|
|
231
|
+
graph["nodes"][node_id] = node
|
|
232
|
+
graph["meta"]["node_count"] = len(graph["nodes"])
|
|
233
|
+
graph["meta"]["updated_at"] = now
|
|
234
|
+
return node
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def add_edge(
|
|
238
|
+
graph: dict,
|
|
239
|
+
source: str,
|
|
240
|
+
target: str,
|
|
241
|
+
edge_type: str,
|
|
242
|
+
*,
|
|
243
|
+
weight: float = DEFAULT_EDGE_WEIGHT,
|
|
244
|
+
extra: Optional[dict[str, Any]] = None,
|
|
245
|
+
) -> dict:
|
|
246
|
+
if edge_type not in EDGE_TYPES:
|
|
247
|
+
raise ValueError(f"unknown edge type: {edge_type!r}")
|
|
248
|
+
if source not in graph["nodes"]:
|
|
249
|
+
raise ValueError(f"edge source {source!r} not in graph")
|
|
250
|
+
if target not in graph["nodes"]:
|
|
251
|
+
raise ValueError(f"edge target {target!r} not in graph")
|
|
252
|
+
|
|
253
|
+
edge = {
|
|
254
|
+
"from": source,
|
|
255
|
+
"to": target,
|
|
256
|
+
"type": edge_type,
|
|
257
|
+
"weight": float(weight),
|
|
258
|
+
"created_at": _now_iso(),
|
|
259
|
+
}
|
|
260
|
+
if extra:
|
|
261
|
+
edge.update(extra)
|
|
262
|
+
graph["edges"].append(edge)
|
|
263
|
+
graph["meta"]["edge_count"] = len(graph["edges"])
|
|
264
|
+
graph["meta"]["updated_at"] = edge["created_at"]
|
|
265
|
+
return edge
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def find_edge(
|
|
269
|
+
graph: dict,
|
|
270
|
+
source: str,
|
|
271
|
+
target: str,
|
|
272
|
+
edge_type: Optional[str] = None,
|
|
273
|
+
) -> Optional[dict]:
|
|
274
|
+
for edge in graph["edges"]:
|
|
275
|
+
if edge["from"] == source and edge["to"] == target:
|
|
276
|
+
if edge_type is None or edge["type"] == edge_type:
|
|
277
|
+
return edge
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def outgoing_edges(graph: dict, node_id: str) -> list[dict]:
|
|
282
|
+
return [e for e in graph["edges"] if e["from"] == node_id]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def incoming_edges(graph: dict, node_id: str) -> list[dict]:
|
|
286
|
+
return [e for e in graph["edges"] if e["to"] == node_id]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def nodes_by_type(graph: dict, node_type: str) -> list[dict]:
|
|
290
|
+
return sorted(
|
|
291
|
+
(n for n in graph["nodes"].values() if n.get("type") == node_type),
|
|
292
|
+
key=lambda n: n["id"],
|
|
293
|
+
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Graph JSON I/O and usage tracking."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import pathlib
|
|
8
|
+
|
|
9
|
+
from graph_core import _now_iso, empty_graph, log
|
|
10
|
+
from graph_validation import GraphValidationError, validate_graph
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _usage_path(path: pathlib.Path) -> pathlib.Path:
|
|
14
|
+
path = pathlib.Path(path)
|
|
15
|
+
return path.with_name(f"{path.stem}_usage{path.suffix}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _default_usage() -> dict:
|
|
19
|
+
return {
|
|
20
|
+
"schema_version": 1,
|
|
21
|
+
"updated_at": _now_iso(),
|
|
22
|
+
"totals": {
|
|
23
|
+
"loads": 0,
|
|
24
|
+
"queries": 0,
|
|
25
|
+
"updates": 0,
|
|
26
|
+
"saves": 0,
|
|
27
|
+
},
|
|
28
|
+
"operations": {},
|
|
29
|
+
"recent": [],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_graph_usage(path: pathlib.Path) -> dict:
|
|
34
|
+
usage_path = _usage_path(path)
|
|
35
|
+
if not usage_path.exists():
|
|
36
|
+
return _default_usage()
|
|
37
|
+
try:
|
|
38
|
+
payload = json.loads(usage_path.read_text(encoding="utf-8"))
|
|
39
|
+
except (json.JSONDecodeError, OSError):
|
|
40
|
+
return _default_usage()
|
|
41
|
+
|
|
42
|
+
default = _default_usage()
|
|
43
|
+
if not isinstance(payload, dict):
|
|
44
|
+
return default
|
|
45
|
+
|
|
46
|
+
totals = payload.get("totals")
|
|
47
|
+
if not isinstance(totals, dict):
|
|
48
|
+
payload["totals"] = default["totals"]
|
|
49
|
+
else:
|
|
50
|
+
for key, value in default["totals"].items():
|
|
51
|
+
totals[key] = int(totals.get(key, value) or 0)
|
|
52
|
+
|
|
53
|
+
operations = payload.get("operations")
|
|
54
|
+
if not isinstance(operations, dict):
|
|
55
|
+
payload["operations"] = {}
|
|
56
|
+
|
|
57
|
+
recent = payload.get("recent")
|
|
58
|
+
if not isinstance(recent, list):
|
|
59
|
+
payload["recent"] = []
|
|
60
|
+
|
|
61
|
+
payload["schema_version"] = int(payload.get("schema_version", 1) or 1)
|
|
62
|
+
payload["updated_at"] = str(payload.get("updated_at") or default["updated_at"])
|
|
63
|
+
return payload
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_graph_usage(path: pathlib.Path, usage: dict) -> None:
|
|
67
|
+
usage_path = _usage_path(path)
|
|
68
|
+
usage_path.parent.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
usage_path.write_text(json.dumps(usage, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def record_graph_usage(
|
|
73
|
+
graph: dict,
|
|
74
|
+
operation: str,
|
|
75
|
+
*,
|
|
76
|
+
kind: str = "queries",
|
|
77
|
+
count: int = 1,
|
|
78
|
+
) -> None:
|
|
79
|
+
usage_path_raw = graph.get("_usage_path")
|
|
80
|
+
if not usage_path_raw:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
graph_path = pathlib.Path(str(usage_path_raw))
|
|
85
|
+
payload = load_graph_usage(graph_path)
|
|
86
|
+
except (TypeError, ValueError):
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
totals = payload.setdefault("totals", _default_usage()["totals"])
|
|
90
|
+
if kind in totals:
|
|
91
|
+
totals[kind] = int(totals.get(kind, 0) or 0) + count
|
|
92
|
+
operations = payload.setdefault("operations", {})
|
|
93
|
+
operations[operation] = int(operations.get(operation, 0) or 0) + count
|
|
94
|
+
payload["updated_at"] = _now_iso()
|
|
95
|
+
recent = payload.setdefault("recent", [])
|
|
96
|
+
recent.insert(0, {
|
|
97
|
+
"operation": operation,
|
|
98
|
+
"kind": kind,
|
|
99
|
+
"count": count,
|
|
100
|
+
"at": payload["updated_at"],
|
|
101
|
+
})
|
|
102
|
+
del recent[20:]
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
save_graph_usage(graph_path, payload)
|
|
106
|
+
except OSError:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def summarize_graph_usage(path: pathlib.Path) -> dict:
|
|
111
|
+
usage = load_graph_usage(path)
|
|
112
|
+
operations = usage.get("operations", {})
|
|
113
|
+
if isinstance(operations, dict):
|
|
114
|
+
usage["top_operations"] = [
|
|
115
|
+
{"operation": name, "count": count}
|
|
116
|
+
for name, count in sorted(
|
|
117
|
+
((str(name), int(count or 0)) for name, count in operations.items()),
|
|
118
|
+
key=lambda item: (-item[1], item[0]),
|
|
119
|
+
)[:8]
|
|
120
|
+
]
|
|
121
|
+
else:
|
|
122
|
+
usage["top_operations"] = []
|
|
123
|
+
return usage
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def load_graph(path: pathlib.Path, *, track_usage: bool = True) -> dict:
|
|
127
|
+
path = pathlib.Path(path)
|
|
128
|
+
if not path.exists():
|
|
129
|
+
log.info("graph file %s does not exist; returning empty graph", path)
|
|
130
|
+
graph = empty_graph()
|
|
131
|
+
if track_usage:
|
|
132
|
+
graph["_usage_path"] = str(path)
|
|
133
|
+
record_graph_usage(graph, "load_graph", kind="loads")
|
|
134
|
+
return graph
|
|
135
|
+
|
|
136
|
+
with path.open("r", encoding="utf-8") as f:
|
|
137
|
+
data = json.load(f)
|
|
138
|
+
if track_usage:
|
|
139
|
+
data["_usage_path"] = str(path)
|
|
140
|
+
record_graph_usage(data, "load_graph", kind="loads")
|
|
141
|
+
else:
|
|
142
|
+
data.pop("_usage_path", None)
|
|
143
|
+
|
|
144
|
+
errors = validate_graph(data)
|
|
145
|
+
if errors:
|
|
146
|
+
raise GraphValidationError(
|
|
147
|
+
f"graph at {path} failed validation:\n " + "\n ".join(errors)
|
|
148
|
+
)
|
|
149
|
+
return data
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def save_graph(path: pathlib.Path, graph: dict, *, validate: bool = True) -> None:
|
|
153
|
+
path = pathlib.Path(path)
|
|
154
|
+
if validate:
|
|
155
|
+
errors = validate_graph(graph)
|
|
156
|
+
if errors:
|
|
157
|
+
raise GraphValidationError(
|
|
158
|
+
"refusing to save invalid graph:\n " + "\n ".join(errors)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
graph["meta"]["updated_at"] = _now_iso()
|
|
162
|
+
graph["meta"]["node_count"] = len(graph["nodes"])
|
|
163
|
+
graph["meta"]["edge_count"] = len(graph["edges"])
|
|
164
|
+
|
|
165
|
+
stable = {
|
|
166
|
+
"meta": graph["meta"],
|
|
167
|
+
"nodes": dict(sorted(graph["nodes"].items())),
|
|
168
|
+
"edges": sorted(
|
|
169
|
+
graph["edges"],
|
|
170
|
+
key=lambda e: (e.get("from", ""), e.get("to", ""), e.get("type", "")),
|
|
171
|
+
),
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
with path.open("w", encoding="utf-8") as f:
|
|
176
|
+
json.dump(stable, f, indent=2, ensure_ascii=False)
|
|
177
|
+
f.write("\n")
|
|
178
|
+
graph["_usage_path"] = str(path)
|
|
179
|
+
record_graph_usage(graph, "save_graph", kind="saves")
|