generflow-core 0.2.0__py3-none-any.whl

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.
@@ -0,0 +1,257 @@
1
+ """A2UI interop: convert A2UI JSONL messages to GF-Lang specs.
2
+
3
+ A2UI is Google's Agent-to-UI protocol (v0.8/0.9). Messages are JSONL:
4
+
5
+ {"version":"v0.8","type":"beginRendering","surfaceId":"main","root":"root","components":[
6
+ {"id":"root","component":"Card","child":"body","title":"Dashboard"},
7
+ {"id":"body","component":"Column","children":["hdr","chart"]},
8
+ {"id":"hdr","component":"Text","text":"Hello"},
9
+ {"id":"chart","component":"Chart","type":"bar","data":[{"x":"Q1","y":1.2}]}
10
+ ]}
11
+
12
+ We translate to a single GF-Lang spec. The mapping is lossy in places
13
+ (component names, prop names) but reversible — we ship a reverse
14
+ converter too for A2UI consumers who want to embed Generflow output.
15
+
16
+ References:
17
+ https://github.com/google/a2ui
18
+ https://a2ui.org/spec/v0.8
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from dataclasses import dataclass
24
+ from typing import Any
25
+
26
+ from ..spec.ast import Assignment, Component, Literal
27
+
28
+
29
+ # A2UI → Generflow name map (extend as needed)
30
+ _A2UI_NAME_MAP = {
31
+ "Column": "Col",
32
+ "Row": "Row",
33
+ "Card": "Card",
34
+ "Text": "Text",
35
+ "Heading": "Header",
36
+ "Chart": "Chart",
37
+ "List": "List",
38
+ "Button": "Button",
39
+ "Form": "Form",
40
+ "TextField": "Field",
41
+ "Modal": "Modal",
42
+ "NavigationBar": "Navbar",
43
+ }
44
+
45
+ # A2UI → Generflow prop map (per component)
46
+ _A2UI_PROP_MAP = {
47
+ "Card": {"title": "title"},
48
+ "Header": {"text": "text"},
49
+ "Text": {"text": "text"},
50
+ "Button": {"text": "label"},
51
+ "Field": {"label": "label", "type": "type"},
52
+ }
53
+
54
+
55
+ @dataclass
56
+ class A2UIParseError(Exception):
57
+ line: int
58
+ reason: str
59
+
60
+ def __str__(self) -> str:
61
+ return f"A2UI parse error on line {self.line}: {self.reason}"
62
+
63
+
64
+ def a2ui_to_gflang(a2ui_text: str) -> str:
65
+ """Convert A2UI JSONL to a GF-Lang spec string.
66
+
67
+ Only the `beginRendering` message is required. We rebuild the tree
68
+ from the components list and emit it as a single root expression.
69
+ """
70
+ components: dict[str, dict] = {}
71
+ root_id: str | None = None
72
+ surface_id: str = "main"
73
+ version: str = "v0.8"
74
+
75
+ for i, raw in enumerate(a2ui_text.splitlines()):
76
+ raw = raw.strip()
77
+ if not raw:
78
+ continue
79
+ # Try to parse as a single message first (compact JSONL or pretty-printed
80
+ # single message). Then fall back to JSONL splitting by top-level '{' starts.
81
+ try:
82
+ msg = json.loads(raw)
83
+ except json.JSONDecodeError:
84
+ # likely a pretty-printed multi-line message; we need to find the
85
+ # top-level message boundaries. For v1, we support either:
86
+ # - one message per line (JSONL)
87
+ # - one message total (possibly multi-line)
88
+ # So we concatenate remaining lines and try again.
89
+ remaining = "\n".join(a2ui_text.splitlines()[i:]).strip()
90
+ try:
91
+ msg = json.loads(remaining)
92
+ except json.JSONDecodeError as e:
93
+ raise A2UIParseError(i + 1, f"invalid JSON: {e}")
94
+ # process the single message and stop
95
+ mtype = msg.get("type")
96
+ if mtype == "beginRendering":
97
+ version = msg.get("version", version)
98
+ surface_id = msg.get("surfaceId", surface_id)
99
+ root_id = msg.get("root", "root")
100
+ for comp in msg.get("components", []):
101
+ cid = comp.get("id")
102
+ if cid:
103
+ components[cid] = comp
104
+ break
105
+ mtype = msg.get("type")
106
+ if mtype == "beginRendering":
107
+ version = msg.get("version", version)
108
+ surface_id = msg.get("surfaceId", surface_id)
109
+ root_id = msg.get("root", "root")
110
+ for comp in msg.get("components", []):
111
+ cid = comp.get("id")
112
+ if cid:
113
+ components[cid] = comp
114
+ elif mtype == "dataModelUpdate":
115
+ # store as a side-channel for later use
116
+ pass
117
+ # other types (surfaceUpdate, etc.) ignored in v1
118
+
119
+ if root_id is None or root_id not in components:
120
+ raise A2UIParseError(0, "no beginRendering message or missing root component")
121
+
122
+ rendered = _render_node(root_id, components)
123
+ return rendered
124
+
125
+
126
+ def _render_node(node_id: str, comps: dict[str, dict]) -> str:
127
+ """Render a single node (and recursively its children) as GF-Lang."""
128
+ comp = comps.get(node_id)
129
+ if comp is None:
130
+ return f"Text([ \"[missing:{node_id}]\" ])"
131
+ raw_name = comp.get("component", "Text")
132
+ name = _A2UI_NAME_MAP.get(raw_name, raw_name)
133
+ prop_map = _A2UI_PROP_MAP.get(name, {})
134
+
135
+ # collect kwargs
136
+ kwargs: list[str] = []
137
+ for a2ui_prop, gf_prop in prop_map.items():
138
+ if a2ui_prop in comp:
139
+ kwargs.append(f"{gf_prop}={_render_value(comp[a2ui_prop])}")
140
+
141
+ # special: Chart has `type` and `data`
142
+ if name == "Chart":
143
+ if "type" in comp:
144
+ kwargs.append(f"type={_render_value(comp['type'])}")
145
+ if "data" in comp:
146
+ # A2UI inline data → wrap as a List child with Text children
147
+ data_items = comp["data"]
148
+ inner = ", ".join(_render_value(d) for d in data_items)
149
+ kwargs.append(f"src=__inline__:{len(data_items)}")
150
+
151
+ # render children if present
152
+ children_str = ""
153
+ if "children" in comp:
154
+ # A2UI: child is a string id or a list of ids
155
+ kids = comp["children"]
156
+ if isinstance(kids, str):
157
+ kids = [kids]
158
+ kids_rendered = [_render_node(k, comps) for k in kids if k in comps]
159
+ if kids_rendered:
160
+ children_str = "[\n " + ",\n ".join(kids_rendered) + ",\n]"
161
+ elif "child" in comp:
162
+ cid = comp["child"]
163
+ if cid in comps:
164
+ children_str = "[\n " + _render_node(cid, comps) + ",\n]"
165
+
166
+ if children_str:
167
+ return f"{name}({', '.join(kwargs) + ', ' if kwargs else ''}{children_str})"
168
+ if kwargs:
169
+ return f"{name}({', '.join(kwargs)})"
170
+ return f"{name}()"
171
+
172
+
173
+ def _render_value(v: Any) -> str:
174
+ """Render a Python value as a GF-Lang literal."""
175
+ if v is None:
176
+ return "null"
177
+ if isinstance(v, bool):
178
+ return "true" if v else "false"
179
+ if isinstance(v, (int, float)):
180
+ return str(v)
181
+ if isinstance(v, str):
182
+ if any(c in v for c in " \t\"\\'"):
183
+ return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
184
+ return v
185
+ if isinstance(v, list):
186
+ return "[" + ", ".join(_render_value(x) for x in v) + "]"
187
+ if isinstance(v, dict):
188
+ return json.dumps(v)
189
+ return str(v)
190
+
191
+
192
+ def gflang_to_a2ui(gf_text: str, surface_id: str = "main") -> str:
193
+ """Reverse: convert a GF-Lang spec to A2UI JSONL.
194
+
195
+ Emits a single `beginRendering` message. Children get synthetic ids
196
+ (`n0`, `n1`, ...). Useful for sharing Generflow output with
197
+ A2UI consumers (or for debugging).
198
+ """
199
+ from ..spec.parser import GFLangParser
200
+
201
+ p = GFLangParser()
202
+ nodes = p.feed_chunk(gf_text)
203
+ if not nodes:
204
+ return ""
205
+ root_node = nodes[0]
206
+ if isinstance(root_node, Assignment):
207
+ root_node = root_node.value
208
+ if not isinstance(root_node, Component):
209
+ return ""
210
+
211
+ components: list[dict] = []
212
+ node_counter = [0]
213
+
214
+ def alloc() -> str:
215
+ node_counter[0] += 1
216
+ return f"n{node_counter[0]}"
217
+
218
+ def walk(comp: Component) -> str:
219
+ cid = alloc()
220
+ a2ui_name = {v: k for k, v in _A2UI_NAME_MAP.items()}.get(comp.name, comp.name)
221
+ entry: dict[str, Any] = {"id": cid, "component": a2ui_name}
222
+ # reverse prop map
223
+ rev = {v: k for k, v in _A2UI_PROP_MAP.get(comp.name, {}).items()}
224
+ for k, v in comp.kwargs.items():
225
+ a2ui_prop = rev.get(k, k)
226
+ entry[a2ui_prop] = _value_to_json(v)
227
+ # children: prefer the new .children field; fall back to args[-1] for back-compat
228
+ kids = getattr(comp, "children", None)
229
+ if not kids and comp.args:
230
+ last = comp.args[-1]
231
+ if isinstance(last, Literal) and isinstance(last.value, list):
232
+ kids = [c for c in last.value if isinstance(c, Component)]
233
+ if kids:
234
+ child_ids = [walk(c) for c in kids]
235
+ if len(child_ids) == 1:
236
+ entry["child"] = child_ids[0]
237
+ else:
238
+ entry["children"] = child_ids
239
+ components.append(entry)
240
+ return cid
241
+
242
+ root_id = walk(root_node)
243
+
244
+ msg = {
245
+ "version": "v0.8",
246
+ "type": "beginRendering",
247
+ "surfaceId": surface_id,
248
+ "root": root_id,
249
+ "components": components,
250
+ }
251
+ return json.dumps(msg, indent=2)
252
+
253
+
254
+ def _value_to_json(v: Any) -> Any:
255
+ if isinstance(v, Literal):
256
+ return v.value
257
+ return str(v)
@@ -0,0 +1,208 @@
1
+ """Observability: lightweight metrics + tracing.
2
+
3
+ Production usage would back this with OpenTelemetry. For v1 we ship an
4
+ in-process collector that:
5
+ - Counts requests, tokens, components mounted, actions executed
6
+ - Records latency histograms (P50/P95/P99)
7
+ - Tracks errors per adapter
8
+ - Exports Prometheus text format and JSON
9
+
10
+ Exposed via /v1/metrics (Prometheus) and /v1/trace (recent spans).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import threading
16
+ import time
17
+ from collections import defaultdict
18
+ from dataclasses import asdict, dataclass, field
19
+ from typing import Any
20
+
21
+
22
+ @dataclass
23
+ class Span:
24
+ """A single timed operation."""
25
+ name: str
26
+ start: float
27
+ end: float = 0.0
28
+ attributes: dict = field(default_factory=dict)
29
+ status: str = "ok" # "ok" | "error"
30
+ error: str | None = None
31
+
32
+ @property
33
+ def duration_ms(self) -> float:
34
+ return (self.end - self.start) * 1000 if self.end > 0 else 0.0
35
+
36
+ def to_dict(self) -> dict:
37
+ return {
38
+ "name": self.name,
39
+ "start": self.start,
40
+ "end": self.end,
41
+ "duration_ms": self.duration_ms,
42
+ "attributes": self.attributes,
43
+ "status": self.status,
44
+ "error": self.error,
45
+ }
46
+
47
+
48
+ class MetricsCollector:
49
+ """Thread-safe metrics + span collector."""
50
+
51
+ def __init__(self) -> None:
52
+ self._lock = threading.Lock()
53
+ self._counters: dict[str, int] = defaultdict(int)
54
+ self._histograms: dict[str, list[float]] = defaultdict(list)
55
+ self._spans: list[Span] = []
56
+ self._max_spans = 1000
57
+ self._max_histogram = 1000
58
+
59
+ # ── Counters ──────────────────────────────────────────────────────────
60
+
61
+ def incr(self, name: str, value: int = 1, **labels: str) -> None:
62
+ key = self._key(name, labels)
63
+ with self._lock:
64
+ self._counters[key] += value
65
+
66
+ def observe(self, name: str, value: float, **labels: str) -> None:
67
+ key = self._key(name, labels)
68
+ with self._lock:
69
+ self._histograms[key].append(value)
70
+ if len(self._histograms[key]) > self._max_histogram:
71
+ self._histograms[key] = self._histograms[key][-self._max_histogram:]
72
+
73
+ def counter_value(self, name: str, **labels: str) -> int:
74
+ return self._counters.get(self._key(name, labels), 0)
75
+
76
+ # ── Spans ─────────────────────────────────────────────────────────────
77
+
78
+ def start_span(self, name: str, **attributes: Any) -> Span:
79
+ return Span(name=name, start=time.time(), attributes=dict(attributes))
80
+
81
+ def end_span(self, span: Span, status: str = "ok", error: str | None = None) -> None:
82
+ span.end = time.time()
83
+ span.status = status
84
+ span.error = error
85
+ with self._lock:
86
+ self._spans.append(span)
87
+ if len(self._spans) > self._max_spans:
88
+ self._spans = self._spans[-self._max_spans:]
89
+ # auto-record latency in a histogram
90
+ self.observe(f"{span.name}.duration_ms", span.duration_ms, **span.attributes)
91
+
92
+ def recent_spans(self, limit: int = 50) -> list[dict]:
93
+ with self._lock:
94
+ return [s.to_dict() for s in self._spans[-limit:]]
95
+
96
+ # ── Percentiles ──────────────────────────────────────────────────────
97
+
98
+ def percentiles(self, name: str, pcts: list[float] | None = None, **labels: str) -> dict[float, float]:
99
+ if pcts is None:
100
+ pcts = [50, 95, 99]
101
+ key = self._key(name, labels)
102
+ with self._lock:
103
+ data = sorted(self._histograms.get(key, []))
104
+ if not data:
105
+ return {p: 0.0 for p in pcts}
106
+ out = {}
107
+ for p in pcts:
108
+ idx = min(int(len(data) * p / 100), len(data) - 1)
109
+ out[p] = data[idx]
110
+ return out
111
+
112
+ # ── Export ───────────────────────────────────────────────────────────
113
+
114
+ def to_prometheus(self) -> str:
115
+ """Render in Prometheus text format."""
116
+ lines = []
117
+ with self._lock:
118
+ for key, value in self._counters.items():
119
+ name, labels = self._split_key(key)
120
+ label_str = self._labels_str(labels)
121
+ lines.append(f"{name}{label_str} {value}")
122
+ for key, values in self._histograms.items():
123
+ name, labels = self._split_key(key)
124
+ label_str = self._labels_str(labels)
125
+ # emit summary
126
+ if values:
127
+ sorted_v = sorted(values)
128
+ p50 = sorted_v[len(sorted_v) // 2]
129
+ p95 = sorted_v[min(int(len(sorted_v) * 0.95), len(sorted_v) - 1)]
130
+ p99 = sorted_v[min(int(len(sorted_v) * 0.99), len(sorted_v) - 1)]
131
+ lines.append(f"{name}_p50{label_str} {p50:.2f}")
132
+ lines.append(f"{name}_p95{label_str} {p95:.2f}")
133
+ lines.append(f"{name}_p99{label_str} {p99:.2f}")
134
+ lines.append(f"{name}_count{label_str} {len(values)}")
135
+ return "\n".join(lines) + "\n"
136
+
137
+ def to_json(self) -> dict:
138
+ with self._lock:
139
+ return {
140
+ "version": 1,
141
+ "counters": dict(self._counters),
142
+ "histograms": {k: {"count": len(v), "p50": self._percentile(v, 50), "p95": self._percentile(v, 95), "p99": self._percentile(v, 99)} for k, v in self._histograms.items()},
143
+ "spans_recent": [s.to_dict() for s in self._spans[-50:]],
144
+ }
145
+
146
+ # ── Internal helpers ─────────────────────────────────────────────────
147
+
148
+ def _key(self, name: str, labels: dict) -> str:
149
+ if not labels:
150
+ return name
151
+ sorted_labels = sorted(labels.items())
152
+ return f"{name}?{','.join(f'{k}={v}' for k, v in sorted_labels)}"
153
+
154
+ def _split_key(self, key: str) -> tuple[str, dict]:
155
+ if "?" not in key:
156
+ return key, {}
157
+ name, label_str = key.split("?", 1)
158
+ labels = {}
159
+ for pair in label_str.split(","):
160
+ if "=" in pair:
161
+ k, v = pair.split("=", 1)
162
+ labels[k] = v
163
+ return name, labels
164
+
165
+ def _labels_str(self, labels: dict) -> str:
166
+ if not labels:
167
+ return ""
168
+ return "{" + ",".join(f'{k}="{v}"' for k, v in sorted(labels.items())) + "}"
169
+
170
+ def _percentile(self, values: list[float], p: float) -> float:
171
+ if not values:
172
+ return 0.0
173
+ s = sorted(values)
174
+ return s[min(int(len(s) * p / 100), len(s) - 1)]
175
+
176
+
177
+ # ── Singleton + standard Generflow metrics ────────────────────────────────
178
+
179
+ _metrics: MetricsCollector | None = None
180
+
181
+
182
+ def get_metrics() -> MetricsCollector:
183
+ global _metrics
184
+ if _metrics is None:
185
+ _metrics = MetricsCollector()
186
+ return _metrics
187
+
188
+
189
+ def reset_metrics() -> None:
190
+ """Reset for tests."""
191
+ global _metrics
192
+ _metrics = MetricsCollector()
193
+
194
+
195
+ # Standard metric names (so dashboards have something stable)
196
+ M = {
197
+ "REQUESTS": "generflow_requests_total",
198
+ "TOKENS": "generflow_tokens_total",
199
+ "COMPONENTS_MOUNTED": "generflow_components_mounted_total",
200
+ "DATA_RESOLVED": "generflow_data_resolved_total",
201
+ "DATA_FAILED": "generflow_data_failed_total",
202
+ "ACTIONS_DISPATCHED": "generflow_actions_dispatched_total",
203
+ "ACTIONS_EXECUTED": "generflow_actions_executed_total",
204
+ "HITL_TRIGGERED": "generflow_hitl_triggered_total",
205
+ "LATENCY_STREAM": "generflow_stream_duration_ms",
206
+ "LATENCY_LLM": "generflow_llm_duration_ms",
207
+ "LATENCY_DATA": "generflow_data_duration_ms",
208
+ }
File without changes
@@ -0,0 +1,4 @@
1
+ """Registry module — component allow-list (security boundary)."""
2
+ from .registry import ComponentSpec, Registry, DEFAULT_CATALOG, default_registry, parse_ref
3
+
4
+ __all__ = ["ComponentSpec", "Registry", "DEFAULT_CATALOG", "default_registry", "parse_ref"]
@@ -0,0 +1,194 @@
1
+ """Component registry: the allow-list that prevents prompt injection UI
2
+ and enforces the design system.
3
+
4
+ Both backend and frontend load this same manifest. The renderer refuses
5
+ to mount any component not in the registry. The backend refuses to emit
6
+ any spec.line event for an unknown component.
7
+
8
+ Phase 3 adds versioning: components can be referenced as `Card` (latest),
9
+ `Card@v1` (legacy), or `Card@v2` (latest). The renderer enforces the
10
+ requested version or falls back gracefully.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+
20
+ _VERSION_RE = re.compile(r"^([A-Za-z_][\w]*)@v(\d+)$")
21
+
22
+
23
+ def parse_ref(ref: str) -> tuple[str, int]:
24
+ """Parse 'Card' → ('Card', 1), 'Card@v2' → ('Card', 2)."""
25
+ m = _VERSION_RE.match(ref)
26
+ if m:
27
+ return m.group(1), int(m.group(2))
28
+ return ref, 1
29
+
30
+
31
+ @dataclass
32
+ class ComponentSpec:
33
+ name: str
34
+ description: str
35
+ props: dict[str, str] = field(default_factory=dict)
36
+ has_children: bool = False
37
+ category: str = "base"
38
+ version: int = 1 # Phase 3 addition
39
+
40
+ @property
41
+ def key(self) -> str:
42
+ """'Card@v2' or 'Card' for v1."""
43
+ return f"{self.name}@v{self.version}" if self.version > 1 else self.name
44
+
45
+ def to_dict(self) -> dict:
46
+ d = {
47
+ "name": self.name,
48
+ "description": self.description,
49
+ "props": self.props,
50
+ "has_children": self.has_children,
51
+ "category": self.category,
52
+ "version": self.version,
53
+ "key": self.key,
54
+ }
55
+ return d
56
+
57
+
58
+ # Built-in default catalog — 14 base components for v1
59
+ DEFAULT_CATALOG: list[ComponentSpec] = [
60
+ ComponentSpec(name="Card", description="A container with padding and a border. Most layouts start here.", props={"p": "string", "title": "string"}, has_children=True, category="layout"),
61
+ ComponentSpec(name="Stack", description="Vertical or horizontal stack of children.", props={"dir": "string", "gap": "string", "align": "string"}, has_children=True, category="layout"),
62
+ ComponentSpec(name="Header", description="A heading. Levels 1-3 via 'level' prop.", props={"level": "string", "text": "string"}, category="text"),
63
+ ComponentSpec(name="Text", description="Inline or block text. 'size' controls emphasis.", props={"size": "string", "color": "string"}, category="text"),
64
+ ComponentSpec(name="Metric", description="Big number with label, optionally bound to a data source.", props={"label": "string", "value": "string", "src": "string", "fmt": "string", "delta": "string"}, category="data"),
65
+ ComponentSpec(name="Chart", description="Data visualization. 'type' is bar/line/pie. 'src' binds the data.", props={"type": "string", "src": "string", "title": "string", "span": "string"}, category="data"),
66
+ ComponentSpec(name="Button", description="Clickable action. 'intent' triggers an action from the app config.", props={"label": "string", "intent": "string"}, category="interactive"),
67
+ ComponentSpec(name="Form", description="Form container. Children are Field and Button.", has_children=True, props={"title": "string"}, category="interactive"),
68
+ ComponentSpec(name="Field", description="Input field. 'name' binds to form state.", props={"name": "string", "label": "string", "value": "string", "type": "string"}, category="interactive"),
69
+ ComponentSpec(name="List", description="List of items, each rendered as a Text child.", has_children=True, props={"src": "string"}, category="data"),
70
+ ComponentSpec(name="Modal", description="Overlaid dialog. Children render inside.", has_children=True, props={"title": "string", "open": "string"}, category="layout"),
71
+ ComponentSpec(name="Navbar", description="Top navigation bar.", has_children=True, props={"title": "string"}, category="layout"),
72
+ ComponentSpec(name="Row", description="Horizontal flex row.", has_children=True, props={"gap": "string", "align": "string"}, category="layout"),
73
+ ComponentSpec(name="Col", description="Vertical flex column. Default for top-level containers.", has_children=True, props={"gap": "string"}, category="layout"),
74
+ ]
75
+
76
+
77
+ class Registry:
78
+ """Component registry with version-aware lookups.
79
+
80
+ Internal storage is keyed by full key ('Card' or 'Card@v2'). Bare
81
+ name lookups return the latest version. Both shapes preserved for
82
+ backwards compatibility — has('Card') and has('Card@v2') both work.
83
+ """
84
+
85
+ def __init__(self, catalog: list[ComponentSpec] | None = None) -> None:
86
+ # keyed by full key
87
+ self._by_key: dict[str, ComponentSpec] = {}
88
+ # name → sorted list of versions
89
+ self._versions: dict[str, list[int]] = {}
90
+ for c in catalog or DEFAULT_CATALOG:
91
+ self._register(c)
92
+
93
+ def _register(self, spec: ComponentSpec) -> None:
94
+ self._by_key[spec.key] = spec
95
+ self._versions.setdefault(spec.name, []).append(spec.version)
96
+ self._versions[spec.name].sort()
97
+
98
+ def register(self, spec: ComponentSpec) -> None:
99
+ """Public registration (Phase 3 addition). Lets users add versions."""
100
+ self._register(spec)
101
+
102
+ def has(self, name: str) -> bool:
103
+ """True if the component exists (any version). Works with bare name or 'Name@vN'."""
104
+ name, _ = parse_ref(name)
105
+ return name in self._versions
106
+
107
+ def has_exact(self, key: str) -> bool:
108
+ """True if the exact key (with @vN) exists."""
109
+ return key in self._by_key
110
+
111
+ def get(self, name: str) -> ComponentSpec | None:
112
+ """Look up by name or key. Bare name → latest version."""
113
+ if "@" in name:
114
+ name_only, ver = parse_ref(name)
115
+ # v1 uses bare name as key, v2+ uses @vN suffix
116
+ key = name if ver > 1 else name_only
117
+ return self._by_key.get(key)
118
+ name_only, _ = parse_ref(name)
119
+ versions = self._versions.get(name_only, [])
120
+ if not versions:
121
+ return None
122
+ latest = versions[-1]
123
+ key = f"{name_only}@v{latest}" if latest > 1 else name_only
124
+ return self._by_key.get(key)
125
+
126
+ def versions(self, name: str) -> list[int]:
127
+ """List of registered versions for a component."""
128
+ return list(self._versions.get(name, []))
129
+
130
+ def names(self) -> list[str]:
131
+ """Bare component names (latest version per name)."""
132
+ return sorted(self._versions.keys())
133
+
134
+ def all_keys(self) -> list[str]:
135
+ """All keys, including versioned ones."""
136
+ return sorted(self._by_key.keys())
137
+
138
+ def to_prompt(self) -> str:
139
+ """Render the catalog as a section of the system prompt."""
140
+ lines = ["Available components (use EXACTLY these names):", ""]
141
+ # group by category, show latest version of each name
142
+ by_cat: dict[str, list[ComponentSpec]] = {}
143
+ for name in self.names():
144
+ spec = self.get(name)
145
+ if spec:
146
+ by_cat.setdefault(spec.category, []).append(spec)
147
+ for cat in sorted(by_cat):
148
+ lines.append(f"## {cat}")
149
+ for c in by_cat[cat]:
150
+ v = f"@v{c.version}" if c.version > 1 else ""
151
+ props_str = ", ".join(f"{k}:{v}" for k, v in c.props.items()) or "—"
152
+ child_marker = " (has children)" if c.has_children else ""
153
+ lines.append(f"- {c.name}{v}{child_marker} — {c.description}")
154
+ lines.append(f" props: {props_str}")
155
+ lines.append("")
156
+ # versioned aliases note
157
+ all_keys = self.all_keys()
158
+ versioned = [k for k in all_keys if "@v" in k and self.versions(k.split("@v")[0])[0] != int(k.split("@v")[1])]
159
+ if versioned:
160
+ lines.append("## Available versions")
161
+ for k in versioned:
162
+ lines.append(f"- {k}")
163
+ return "\n".join(lines)
164
+
165
+ def manifest(self) -> dict:
166
+ """Return the registry as a manifest dict (versioned)."""
167
+ return {
168
+ "version": 2, # Phase 3 bump — manifest now includes per-component versions
169
+ "components": [c.to_dict() for c in self._by_key.values()],
170
+ }
171
+
172
+ def manifest_json(self) -> str:
173
+ return json.dumps(self.manifest(), indent=2)
174
+
175
+ @classmethod
176
+ def from_file(cls, path: str | Path) -> "Registry":
177
+ data = json.loads(Path(path).read_text())
178
+ cat = [
179
+ ComponentSpec(
180
+ name=c["name"],
181
+ description=c.get("description", ""),
182
+ props=c.get("props", {}),
183
+ has_children=c.get("has_children", False),
184
+ category=c.get("category", "base"),
185
+ version=c.get("version", 1),
186
+ )
187
+ for c in data.get("components", [])
188
+ ]
189
+ return cls(cat)
190
+
191
+
192
+ def default_registry() -> Registry:
193
+ """Convenience: build a fresh Registry from DEFAULT_CATALOG."""
194
+ return Registry(DEFAULT_CATALOG)