aetherius 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.
Files changed (154) hide show
  1. aetherius/__init__.py +42 -0
  2. aetherius/__main__.py +12 -0
  3. aetherius/_contracts/__init__.py +0 -0
  4. aetherius/_contracts/blueprint.schema.json +144 -0
  5. aetherius/acts/__init__.py +1 -0
  6. aetherius/acts/_shared.py +61 -0
  7. aetherius/acts/continuum/__init__.py +7 -0
  8. aetherius/acts/continuum/actions.py +149 -0
  9. aetherius/acts/continuum/bridge.py +159 -0
  10. aetherius/acts/continuum/browser.py +164 -0
  11. aetherius/acts/continuum/debug_overlay.py +65 -0
  12. aetherius/acts/continuum/driver.py +127 -0
  13. aetherius/acts/continuum/human_actions.py +74 -0
  14. aetherius/acts/oracle/__init__.py +1 -0
  15. aetherius/acts/oracle/driver.py +1 -0
  16. aetherius/acts/oracle/locator.py +1 -0
  17. aetherius/acts/oracle/model.py +1 -0
  18. aetherius/acts/oracle/perception.py +1 -0
  19. aetherius/acts/phantom/__init__.py +1 -0
  20. aetherius/acts/phantom/driver.py +1 -0
  21. aetherius/acts/phantom/loop.py +1 -0
  22. aetherius/acts/phantom/memory.py +1 -0
  23. aetherius/acts/phantom/perception.py +1 -0
  24. aetherius/acts/phantom/planner.py +1 -0
  25. aetherius/acts/vector/__init__.py +1 -0
  26. aetherius/acts/vector/auth.py +108 -0
  27. aetherius/acts/vector/client.py +129 -0
  28. aetherius/acts/vector/driver.py +128 -0
  29. aetherius/builder/__init__.py +40 -0
  30. aetherius/builder/catalog.py +93 -0
  31. aetherius/builder/factory.py +243 -0
  32. aetherius/builder/templates.py +150 -0
  33. aetherius/builder/validation.py +120 -0
  34. aetherius/cli.py +236 -0
  35. aetherius/config/__init__.py +1 -0
  36. aetherius/config/secrets.py +71 -0
  37. aetherius/config/settings.py +44 -0
  38. aetherius/console/__init__.py +1 -0
  39. aetherius/console/app.py +66 -0
  40. aetherius/console/console.tcss +97 -0
  41. aetherius/console/daemon_control.py +83 -0
  42. aetherius/console/run_bridge.py +43 -0
  43. aetherius/console/screens/__init__.py +1 -0
  44. aetherius/console/screens/_pending.py +38 -0
  45. aetherius/console/screens/builder/__init__.py +1 -0
  46. aetherius/console/screens/builder/act_picker.py +75 -0
  47. aetherius/console/screens/builder/io_editor.py +167 -0
  48. aetherius/console/screens/builder/options_editor.py +132 -0
  49. aetherius/console/screens/builder/preview.py +57 -0
  50. aetherius/console/screens/builder/screen.py +235 -0
  51. aetherius/console/screens/builder/step_editor.py +249 -0
  52. aetherius/console/screens/catalog.py +52 -0
  53. aetherius/console/screens/home.py +90 -0
  54. aetherius/console/screens/library.py +124 -0
  55. aetherius/console/screens/library_scan.py +99 -0
  56. aetherius/console/screens/recorder.py +193 -0
  57. aetherius/console/screens/runs.py +138 -0
  58. aetherius/console/screens/sessions.py +14 -0
  59. aetherius/console/screens/settings.py +132 -0
  60. aetherius/console/screenshots.py +173 -0
  61. aetherius/console/theme.py +114 -0
  62. aetherius/console/widgets/__init__.py +1 -0
  63. aetherius/console/widgets/event_log.py +44 -0
  64. aetherius/console/widgets/form.py +101 -0
  65. aetherius/console/widgets/json_preview.py +18 -0
  66. aetherius/console/widgets/run_summary.py +82 -0
  67. aetherius/core/__init__.py +1 -0
  68. aetherius/core/actions/__init__.py +1 -0
  69. aetherius/core/actions/base.py +99 -0
  70. aetherius/core/actions/data.py +126 -0
  71. aetherius/core/actions/flow.py +76 -0
  72. aetherius/core/actions/interaction.py +109 -0
  73. aetherius/core/actions/navigation.py +32 -0
  74. aetherius/core/actions/registry.py +70 -0
  75. aetherius/core/actions/spec.py +42 -0
  76. aetherius/core/blueprint/__init__.py +1 -0
  77. aetherius/core/blueprint/loader.py +82 -0
  78. aetherius/core/blueprint/models.py +81 -0
  79. aetherius/core/blueprint/template.py +101 -0
  80. aetherius/core/blueprint/validator.py +45 -0
  81. aetherius/core/driver.py +36 -0
  82. aetherius/core/errors.py +134 -0
  83. aetherius/core/events/__init__.py +1 -0
  84. aetherius/core/events/bus.py +28 -0
  85. aetherius/core/events/models.py +29 -0
  86. aetherius/core/events/sinks.py +55 -0
  87. aetherius/core/extraction/__init__.py +1 -0
  88. aetherius/core/extraction/html_extractor.py +52 -0
  89. aetherius/core/extraction/json_extractor.py +125 -0
  90. aetherius/core/runtime/__init__.py +1 -0
  91. aetherius/core/runtime/context.py +61 -0
  92. aetherius/core/runtime/engine.py +188 -0
  93. aetherius/core/runtime/result.py +39 -0
  94. aetherius/core/runtime/selector.py +1 -0
  95. aetherius/models/__init__.py +1 -0
  96. aetherius/models/registry.py +1 -0
  97. aetherius/models/store/.gitkeep +0 -0
  98. aetherius/recorder/__init__.py +12 -0
  99. aetherius/recorder/_capture_js.py +80 -0
  100. aetherius/recorder/_gesture_js.py +40 -0
  101. aetherius/recorder/_names.py +20 -0
  102. aetherius/recorder/_overlay_js.py +262 -0
  103. aetherius/recorder/_playwright.py +49 -0
  104. aetherius/recorder/_selector_js.py +148 -0
  105. aetherius/recorder/_transform.py +214 -0
  106. aetherius/recorder/_vector_js.py +174 -0
  107. aetherius/recorder/base.py +92 -0
  108. aetherius/recorder/blueprint_recorder.py +72 -0
  109. aetherius/recorder/capture.py +59 -0
  110. aetherius/recorder/continuum_backend.py +119 -0
  111. aetherius/recorder/gesture_recorder.py +193 -0
  112. aetherius/recorder/selector_synth.py +83 -0
  113. aetherius/recorder/session.py +101 -0
  114. aetherius/recorder/vector_backend.py +158 -0
  115. aetherius/server/__init__.py +12 -0
  116. aetherius/server/app.py +37 -0
  117. aetherius/server/config.py +25 -0
  118. aetherius/server/deps.py +50 -0
  119. aetherius/server/jobs.py +155 -0
  120. aetherius/server/routes/__init__.py +1 -0
  121. aetherius/server/routes/blueprints.py +45 -0
  122. aetherius/server/routes/recorder.py +25 -0
  123. aetherius/server/routes/runs.py +49 -0
  124. aetherius/server/routes/stream.py +53 -0
  125. aetherius/server/schemas.py +78 -0
  126. aetherius/stealth/__init__.py +1 -0
  127. aetherius/stealth/fingerprint/__init__.py +1 -0
  128. aetherius/stealth/fingerprint/patch.py +38 -0
  129. aetherius/stealth/fingerprint/profile.py +95 -0
  130. aetherius/stealth/gestures/__init__.py +1 -0
  131. aetherius/stealth/gestures/data/human_library.json +7107 -0
  132. aetherius/stealth/gestures/library.py +126 -0
  133. aetherius/stealth/gestures/seed.py +97 -0
  134. aetherius/stealth/humanizer/__init__.py +1 -0
  135. aetherius/stealth/humanizer/input.py +89 -0
  136. aetherius/stealth/humanizer/keyboard.py +75 -0
  137. aetherius/stealth/humanizer/mouse.py +153 -0
  138. aetherius/stealth/humanizer/scroll.py +55 -0
  139. aetherius/stealth/humanizer/timing.py +54 -0
  140. aetherius/stealth/ml/__init__.py +1 -0
  141. aetherius/stealth/ml/fingerprint_model.py +1 -0
  142. aetherius/stealth/ml/motion_model.py +1 -0
  143. aetherius/stealth/policy.py +133 -0
  144. aetherius/stealth/session/__init__.py +1 -0
  145. aetherius/stealth/session/store.py +35 -0
  146. aetherius/stealth/session/warmup.py +52 -0
  147. aetherius/version.py +3 -0
  148. aetherius-0.2.0.data/data/aetherius/_contracts/__init__.py +0 -0
  149. aetherius-0.2.0.data/data/aetherius/_contracts/blueprint.schema.json +144 -0
  150. aetherius-0.2.0.dist-info/METADATA +606 -0
  151. aetherius-0.2.0.dist-info/RECORD +154 -0
  152. aetherius-0.2.0.dist-info/WHEEL +4 -0
  153. aetherius-0.2.0.dist-info/entry_points.txt +2 -0
  154. aetherius-0.2.0.dist-info/licenses/LICENSE +11 -0
@@ -0,0 +1,129 @@
1
+ """HTTP client wrapper: httpx.Client with tenacity-based retries and pluggable auth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from tenacity import (
9
+ RetryError,
10
+ retry,
11
+ retry_if_exception_type,
12
+ stop_after_attempt,
13
+ wait_exponential,
14
+ wait_fixed,
15
+ )
16
+
17
+ from ...core.blueprint.models import RetriesOptions
18
+ from ...core.errors import NetworkError, RetryExhaustedError, StatusAssertionError, TimeoutError
19
+ from .auth import AuthStrategy, NoAuth
20
+
21
+ _RETRYABLE = (httpx.TransportError, httpx.TimeoutException)
22
+
23
+
24
+ def _build_retry(retries: RetriesOptions) -> Any:
25
+ """Return a tenacity retry decorator configured from Blueprint options."""
26
+ if retries.max == 0:
27
+ return None
28
+ stop = stop_after_attempt(retries.max + 1)
29
+ wait: Any
30
+ if retries.backoff == "exponential":
31
+ wait = wait_exponential(multiplier=1, min=1, max=30)
32
+ elif retries.backoff == "linear":
33
+ wait = wait_fixed(1)
34
+ else:
35
+ wait = wait_fixed(0)
36
+ return retry(
37
+ retry=retry_if_exception_type(_RETRYABLE),
38
+ stop=stop,
39
+ wait=wait,
40
+ reraise=True,
41
+ )
42
+
43
+
44
+ class VectorClient:
45
+ def __init__(
46
+ self,
47
+ *,
48
+ timeout_ms: int = 30_000,
49
+ retries: RetriesOptions | None = None,
50
+ auth: AuthStrategy | None = None,
51
+ ) -> None:
52
+ self._timeout = timeout_ms / 1000
53
+ self._retries = retries or RetriesOptions()
54
+ self._auth: AuthStrategy = auth or NoAuth()
55
+ self._client = httpx.Client(timeout=self._timeout, follow_redirects=True)
56
+ self._auth.prepare(self._client)
57
+ self._retry_decorator = _build_retry(self._retries)
58
+
59
+ def request(
60
+ self,
61
+ method: str,
62
+ url: str,
63
+ *,
64
+ headers: dict[str, str] | None = None,
65
+ json: Any | None = None,
66
+ form: dict[str, str] | None = None,
67
+ params: dict[str, str] | None = None,
68
+ expected_status: int | None = None,
69
+ ) -> httpx.Response:
70
+ """Send an HTTP request with optional auth, retries, and status assertion.
71
+
72
+ Raises:
73
+ ActionError: if both json and form are provided.
74
+ TimeoutError: on httpx timeout.
75
+ RetryExhaustedError: after all retry attempts fail.
76
+ NetworkError: on other transport failures.
77
+ StatusAssertionError: if expected_status is set and response status doesn't match.
78
+ """
79
+ from ...core.errors import ActionError
80
+
81
+ if json is not None and form is not None:
82
+ raise ActionError("Cannot set both 'json' and 'form' on the same http.request step.")
83
+
84
+ req = httpx.Request(
85
+ method=method.upper(),
86
+ url=url,
87
+ headers=headers or {},
88
+ params=params,
89
+ json=json,
90
+ data=form,
91
+ )
92
+ req = self._auth.apply(req)
93
+
94
+ def _send() -> httpx.Response:
95
+ return self._client.send(req)
96
+
97
+ try:
98
+ if self._retry_decorator is not None:
99
+ response: httpx.Response = self._retry_decorator(_send)()
100
+ else:
101
+ response = _send()
102
+ except httpx.TimeoutException as exc:
103
+ raise TimeoutError(f"Request timed out: {method} {url}") from exc
104
+ except RetryError as exc:
105
+ raise RetryExhaustedError(
106
+ f"All {self._retries.max} retry attempts failed: {method} {url}",
107
+ last_error=exc,
108
+ ) from exc
109
+ except httpx.TransportError as exc:
110
+ raise NetworkError(f"Transport error: {exc}") from exc
111
+
112
+ if expected_status is not None and response.status_code != expected_status:
113
+ raise StatusAssertionError(
114
+ expected=expected_status,
115
+ actual=response.status_code,
116
+ url=url,
117
+ body_preview=response.text,
118
+ )
119
+
120
+ return response
121
+
122
+ def close(self) -> None:
123
+ self._client.close()
124
+
125
+ def __enter__(self) -> "VectorClient":
126
+ return self
127
+
128
+ def __exit__(self, *_: object) -> None:
129
+ self.close()
@@ -0,0 +1,128 @@
1
+ """Vector driver: executes http.request and utility actions for Act I (no browser)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable
6
+
7
+ from .._shared import SharedActionsMixin
8
+ from ...core.blueprint.models import StepModel
9
+ from ...core.errors import ActionError
10
+ from ...core.events.bus import EventBus
11
+ from ...core.extraction.html_extractor import HtmlExtractSpec, extract_html
12
+ from ...core.extraction.json_extractor import ExtractSpec, extract_json
13
+ from ...core.runtime.context import RunContext
14
+ from .client import VectorClient
15
+
16
+
17
+ class VectorDriver(SharedActionsMixin):
18
+ act = "vector"
19
+
20
+ def setup(self, ctx: RunContext) -> None:
21
+ timeout_ms = ctx.blueprint.options.timeout_ms or 30_000
22
+ self._client = VectorClient(
23
+ timeout_ms=timeout_ms,
24
+ retries=ctx.blueprint.options.retries,
25
+ )
26
+
27
+ def teardown(self, ctx: RunContext) -> None:
28
+ self._client.close()
29
+
30
+ def run_step(
31
+ self,
32
+ step: StepModel,
33
+ ctx: RunContext,
34
+ bus: EventBus,
35
+ renderer: Callable[[Any], Any],
36
+ ) -> dict[str, Any]:
37
+ match step.action:
38
+ case "http.request":
39
+ return self._http_request(step, ctx, bus, renderer)
40
+ case "set":
41
+ return self._set(step, renderer)
42
+ case "assert":
43
+ return self._assert(step, renderer)
44
+ case "emit":
45
+ return self._emit(step, ctx, bus, renderer)
46
+ case "wait":
47
+ return self._wait(step, renderer)
48
+ case _:
49
+ raise ActionError(f"VectorDriver: unsupported action {step.action!r}")
50
+
51
+ # ── Action handlers ───────────────────────────────────────────────────────
52
+
53
+ def _http_request(
54
+ self,
55
+ step: StepModel,
56
+ ctx: RunContext,
57
+ bus: EventBus,
58
+ renderer: Callable[[Any], Any],
59
+ ) -> dict[str, Any]:
60
+ p = step.extra_fields
61
+ method: str = renderer(p.get("method", "GET"))
62
+ url: str = renderer(p.get("url", ""))
63
+ headers: dict[str, str] = renderer(p.get("headers") or {})
64
+ form: dict[str, str] | None = renderer(p["form"]) if "form" in p else None
65
+ json_body: Any = renderer(p["json"]) if "json" in p else None
66
+ params: dict[str, str] | None = renderer(p.get("params"))
67
+ expect: dict[str, Any] = renderer(p.get("expect") or {})
68
+ expected_status: int | None = expect.get("status")
69
+
70
+ response = self._client.request(
71
+ method,
72
+ url,
73
+ headers=headers,
74
+ json=json_body,
75
+ form=form,
76
+ params=params,
77
+ expected_status=expected_status,
78
+ )
79
+
80
+ outputs: dict[str, Any] = {
81
+ "status_code": response.status_code,
82
+ "headers": dict(response.headers),
83
+ }
84
+
85
+ extract_specs: dict[str, Any] = p.get("extract") or {}
86
+ if extract_specs:
87
+ content_type = response.headers.get("content-type", "")
88
+ extracted = self._dispatch_extract(
89
+ response.content, extract_specs, content_type, renderer
90
+ )
91
+ outputs.update(extracted)
92
+
93
+ return outputs
94
+
95
+ def _dispatch_extract(
96
+ self,
97
+ body: bytes,
98
+ raw_specs: dict[str, Any],
99
+ content_type: str,
100
+ renderer: Callable[[Any], Any],
101
+ ) -> dict[str, Any]:
102
+ json_specs: dict[str, ExtractSpec] = {}
103
+ html_specs: dict[str, HtmlExtractSpec] = {}
104
+
105
+ for name, raw in raw_specs.items():
106
+ from_val: str = raw.get("from", "json")
107
+ if from_val == "json":
108
+ json_specs[name] = ExtractSpec(
109
+ from_=from_val,
110
+ path=raw.get("path", "$"),
111
+ where=raw.get("where"),
112
+ fields={k: v for k, v in (raw.get("fields") or {}).items()},
113
+ )
114
+ else:
115
+ html_specs[name] = HtmlExtractSpec(
116
+ from_=from_val,
117
+ selector=raw.get("selector", ""),
118
+ selector_type=raw.get("selector_type", "css"),
119
+ attr=raw.get("attr"),
120
+ multiple=raw.get("multiple", True),
121
+ )
122
+
123
+ result: dict[str, Any] = {}
124
+ if json_specs:
125
+ result.update(extract_json(body, json_specs))
126
+ if html_specs:
127
+ result.update(extract_html(body, html_specs))
128
+ return result
@@ -0,0 +1,40 @@
1
+ """Headless Blueprint construction, reusable by the Console, the daemon and the SDKs.
2
+
3
+ The Console is only its presentation layer: everything here is Textual-free. A ``BlueprintDraft`` is
4
+ the editing state; ``validate_draft`` reports issues live; ``build_blueprint``/``save_blueprint``
5
+ finalise it; ``act_infos``/``actions_for_act`` project the action registry into UI-ready records;
6
+ ``list_templates``/``template_draft`` seed a draft from a starter.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .catalog import ActInfo, ActionInfo, act_infos, actions_for_act, get_act_info
12
+ from .factory import (
13
+ BlueprintDraft,
14
+ StepDraft,
15
+ assemble_blueprint,
16
+ build_blueprint,
17
+ save_blueprint,
18
+ slugify_name,
19
+ )
20
+ from .templates import TemplateInfo, list_templates, template_draft
21
+ from .validation import ValidationIssue, validate_draft
22
+
23
+ __all__ = [
24
+ "ActInfo",
25
+ "ActionInfo",
26
+ "act_infos",
27
+ "actions_for_act",
28
+ "get_act_info",
29
+ "BlueprintDraft",
30
+ "StepDraft",
31
+ "assemble_blueprint",
32
+ "build_blueprint",
33
+ "save_blueprint",
34
+ "slugify_name",
35
+ "TemplateInfo",
36
+ "list_templates",
37
+ "template_draft",
38
+ "ValidationIssue",
39
+ "validate_draft",
40
+ ]
@@ -0,0 +1,93 @@
1
+ """UI-friendly metadata for Acts and actions, projected from the core action registry.
2
+
3
+ This is the projection the README calls the builder catalogue: it joins the capability table
4
+ (``core/actions/base.py``), the field specs (``core/actions/registry.action_specs``) and the
5
+ runnable-Act set (``IMPLEMENTED_ACTS``) into flat, Textual-free records the Console and the daemon
6
+ can render. No action metadata is defined here — only combined and ordered — so the registry stays
7
+ the single source of truth.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ from ..core.actions.base import ACT_CAPABILITIES, PENDING_ACTIONS, Capability
15
+ from ..core.actions.registry import action_specs
16
+ from ..core.actions.spec import ActionSpec
17
+ from ..core.errors import BuilderError
18
+ from ..core.runtime.engine import IMPLEMENTED_ACTS
19
+
20
+ # Canonical display order, escalating Act by Act.
21
+ _ACT_ORDER: tuple[str, ...] = ("vector", "continuum", "oracle", "phantom")
22
+
23
+ _ACT_TITLES: dict[str, str] = {
24
+ "vector": "Vector — HTTP / API",
25
+ "continuum": "Continuum — scripted browser",
26
+ "oracle": "Oracle — vision-guided browser",
27
+ "phantom": "Phantom — autonomous agent",
28
+ }
29
+
30
+ # One-line explanation shown next to each Act in the picker and the catalogue.
31
+ _ACT_SUMMARIES: dict[str, str] = {
32
+ "vector": "HTTP/API requests. Fastest, no browser. The 'axios' case.",
33
+ "continuum": "Scripted browser (Playwright): login, JS, session, DOM. Stable selectors.",
34
+ "oracle": "Vision-guided browser with discretion. Locates fragile/obfuscated UI on screen.",
35
+ "phantom": "Autonomous agent: perceive, reason, act in a loop. Unscripted goals.",
36
+ }
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ActInfo:
41
+ """An Act as the builder presents it: title, explanation, and whether it runs today."""
42
+
43
+ act: str
44
+ title: str
45
+ summary: str
46
+ implemented: bool
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ActionInfo:
51
+ """An action available under an Act, with its spec and whether that Act runs it yet."""
52
+
53
+ spec: ActionSpec
54
+ runnable: bool
55
+
56
+
57
+ def act_infos() -> list[ActInfo]:
58
+ """Every Act in canonical order, with its runnable status from IMPLEMENTED_ACTS."""
59
+ return [
60
+ ActInfo(
61
+ act=act,
62
+ title=_ACT_TITLES[act],
63
+ summary=_ACT_SUMMARIES[act],
64
+ implemented=act in IMPLEMENTED_ACTS,
65
+ )
66
+ for act in _ACT_ORDER
67
+ ]
68
+
69
+
70
+ def get_act_info(act: str) -> ActInfo:
71
+ """Return the :class:`ActInfo` for *act* or raise :class:`BuilderError`."""
72
+ if act not in _ACT_TITLES:
73
+ raise BuilderError(f"Unknown act {act!r} (known: {list(_ACT_ORDER)}).")
74
+ return next(info for info in act_infos() if info.act == act)
75
+
76
+
77
+ def actions_for_act(act: str) -> list[ActionInfo]:
78
+ """The actions an *act* supports, sorted by name, each flagged runnable or pending.
79
+
80
+ An action is runnable when its Act is implemented and the action is not in that Act's
81
+ PENDING_ACTIONS set (declared in the capability table but not dispatched by a driver yet).
82
+ """
83
+ caps = ACT_CAPABILITIES.get(act)
84
+ if caps is None:
85
+ raise BuilderError(f"Unknown act {act!r} (known: {list(_ACT_ORDER)}).")
86
+ specs = action_specs()
87
+ pending: frozenset[Capability] = PENDING_ACTIONS.get(act, frozenset())
88
+ implemented = act in IMPLEMENTED_ACTS
89
+ infos = [
90
+ ActionInfo(spec=specs[cap.value], runnable=implemented and cap not in pending)
91
+ for cap in caps
92
+ ]
93
+ return sorted(infos, key=lambda info: info.spec.name)
@@ -0,0 +1,243 @@
1
+ """Assemble a valid Blueprint from structured choices, validating against the schema as it is built.
2
+
3
+ The headless heart of the Blueprint Studio, reusable by the Console, the daemon and the SDKs. A
4
+ :class:`BlueprintDraft` is the mutable editing state: raw data (dicts/lists), legitimately partial
5
+ while a user is still filling it in. :func:`validate_draft` reports issues without raising (the live
6
+ preview calls it on every change); :func:`build_blueprint` / :func:`save_blueprint` finalise it and
7
+ raise typed errors. ``assemble_blueprint`` and ``slugify_name`` live here — the recorder, a producer
8
+ of steps, re-exports them.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import re
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any, Mapping
18
+
19
+ from ..core.actions.registry import get_spec
20
+ from ..core.actions.spec import ParamKind
21
+ from ..core.blueprint.loader import load_blueprint, validate_blueprint_data
22
+ from ..core.blueprint.models import Blueprint
23
+ from ..core.blueprint.validator import validate_for_act
24
+ from ..core.errors import BuilderError
25
+ from .validation import ValidationIssue, validate_draft
26
+
27
+ __all__ = [
28
+ "slugify_name",
29
+ "assemble_blueprint",
30
+ "StepDraft",
31
+ "BlueprintDraft",
32
+ "ValidationIssue",
33
+ "validate_draft",
34
+ "build_blueprint",
35
+ "save_blueprint",
36
+ ]
37
+
38
+
39
+ def slugify_name(name: str) -> str:
40
+ """Filesystem-safe stem derived from a Blueprint name (keeps dots, e.g. quotes.login)."""
41
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip("-.")
42
+ return slug or "blueprint"
43
+
44
+
45
+ def assemble_blueprint(
46
+ name: str,
47
+ steps: list[dict[str, Any]],
48
+ secrets: list[str],
49
+ *,
50
+ act: str = "continuum",
51
+ inputs: dict[str, Any] | None = None,
52
+ outputs: dict[str, Any] | None = None,
53
+ description: str | None = None,
54
+ vars: dict[str, Any] | None = None,
55
+ options: dict[str, Any] | None = None,
56
+ vision: dict[str, Any] | None = None,
57
+ goal: str | None = None,
58
+ constraints: list[str] | None = None,
59
+ ) -> dict[str, Any]:
60
+ """Assemble a minimal, ordered Blueprint dict for *act* (schema key order, empties omitted)."""
61
+ blueprint: dict[str, Any] = {"aetherius": "1.0", "name": name}
62
+ if description:
63
+ blueprint["description"] = description
64
+ blueprint["act"] = act
65
+ if inputs:
66
+ blueprint["inputs"] = inputs
67
+ if secrets:
68
+ blueprint["secrets"] = secrets
69
+ if vars:
70
+ blueprint["vars"] = vars
71
+ if options:
72
+ blueprint["options"] = options
73
+ if vision:
74
+ blueprint["vision"] = vision
75
+ if goal:
76
+ blueprint["goal"] = goal
77
+ if constraints:
78
+ blueprint["constraints"] = constraints
79
+ # A goal-only Blueprint (Phantom) legitimately has no steps; otherwise steps are always present.
80
+ if steps or not goal:
81
+ blueprint["steps"] = steps
82
+ if outputs:
83
+ blueprint["outputs"] = outputs
84
+ return blueprint
85
+
86
+
87
+ # Empty value used to pre-seed a required parameter, per kind, so a freshly added step shows what it
88
+ # still needs (and validate_draft flags it until filled). Empty object/array stay valid JSON.
89
+ def _empty_value(kind: ParamKind) -> Any:
90
+ if kind == "boolean":
91
+ return False
92
+ if kind == "object":
93
+ return {}
94
+ if kind == "array":
95
+ return []
96
+ return ""
97
+
98
+
99
+ @dataclass
100
+ class StepDraft:
101
+ """One editable step: an action, an optional id, and its raw parameters."""
102
+
103
+ action: str
104
+ id: str | None = None
105
+ params: dict[str, Any] = field(default_factory=dict)
106
+
107
+ def to_data(self) -> dict[str, Any]:
108
+ """Serialise to the step dict shape (id first when set, then action, then params)."""
109
+ data: dict[str, Any] = {}
110
+ if self.id:
111
+ data["id"] = self.id
112
+ data["action"] = self.action
113
+ data.update(self.params)
114
+ return data
115
+
116
+ @classmethod
117
+ def from_data(cls, data: Mapping[str, Any]) -> "StepDraft":
118
+ params = {k: v for k, v in data.items() if k not in ("id", "action")}
119
+ return cls(action=str(data.get("action", "")), id=data.get("id"), params=params)
120
+
121
+
122
+ @dataclass
123
+ class BlueprintDraft:
124
+ """Mutable editing state of a Blueprint. Lossless: carries every envelope field so an existing
125
+ file round-trips unchanged through ``from_data``/``to_data`` (e.g. refining recorder output)."""
126
+
127
+ name: str = ""
128
+ act: str = "vector"
129
+ description: str | None = None
130
+ inputs: dict[str, dict[str, Any]] = field(default_factory=dict)
131
+ secrets: list[str] = field(default_factory=list)
132
+ vars: dict[str, Any] = field(default_factory=dict)
133
+ options: dict[str, Any] = field(default_factory=dict)
134
+ steps: list[StepDraft] = field(default_factory=list)
135
+ outputs: dict[str, Any] = field(default_factory=dict)
136
+ vision: dict[str, Any] | None = None
137
+ goal: str | None = None
138
+ constraints: list[str] = field(default_factory=list)
139
+
140
+ def add_step(self, action: str, *, index: int | None = None) -> StepDraft:
141
+ """Append (or insert at *index*) a step, pre-seeding its required parameters."""
142
+ params: dict[str, Any] = {}
143
+ try:
144
+ spec = get_spec(action)
145
+ except Exception:
146
+ spec = None
147
+ if spec is not None:
148
+ for param in spec.required_params():
149
+ params[param.name] = _empty_value(param.kind)
150
+ step = StepDraft(action=action, params=params)
151
+ if index is None:
152
+ self.steps.append(step)
153
+ else:
154
+ self.steps.insert(max(0, min(index, len(self.steps))), step)
155
+ return step
156
+
157
+ def remove_step(self, index: int) -> None:
158
+ if 0 <= index < len(self.steps):
159
+ del self.steps[index]
160
+
161
+ def move_step(self, index: int, delta: int) -> None:
162
+ """Move the step at *index* by *delta* positions, clamped to the list bounds."""
163
+ if not (0 <= index < len(self.steps)):
164
+ return
165
+ target = max(0, min(index + delta, len(self.steps) - 1))
166
+ if target != index:
167
+ self.steps.insert(target, self.steps.pop(index))
168
+
169
+ def to_data(self) -> dict[str, Any]:
170
+ """Assemble the ordered Blueprint dict this draft represents (empties omitted)."""
171
+ return assemble_blueprint(
172
+ self.name,
173
+ [step.to_data() for step in self.steps],
174
+ list(self.secrets),
175
+ act=self.act,
176
+ inputs=dict(self.inputs) or None,
177
+ outputs=dict(self.outputs) or None,
178
+ description=self.description,
179
+ vars=dict(self.vars) or None,
180
+ options=dict(self.options) or None,
181
+ vision=self.vision,
182
+ goal=self.goal,
183
+ constraints=list(self.constraints) or None,
184
+ )
185
+
186
+ @classmethod
187
+ def from_data(cls, data: Mapping[str, Any]) -> "BlueprintDraft":
188
+ """Build a draft from a Blueprint dict (as loaded from a file), preserving every field."""
189
+ return cls(
190
+ name=str(data.get("name", "")),
191
+ act=str(data.get("act", "vector")),
192
+ description=data.get("description"),
193
+ inputs={k: dict(v) for k, v in (data.get("inputs") or {}).items()},
194
+ secrets=list(data.get("secrets") or []),
195
+ vars=dict(data.get("vars") or {}),
196
+ options=dict(data.get("options") or {}),
197
+ steps=[StepDraft.from_data(s) for s in (data.get("steps") or [])],
198
+ outputs=dict(data.get("outputs") or {}),
199
+ vision=data.get("vision"),
200
+ goal=data.get("goal"),
201
+ constraints=list(data.get("constraints") or []),
202
+ )
203
+
204
+
205
+ def build_blueprint(draft: BlueprintDraft) -> Blueprint:
206
+ """Finalise *draft* into a validated Blueprint model, raising typed errors on any problem."""
207
+ blueprint = validate_blueprint_data(draft.to_data(), source="<studio>")
208
+ validate_for_act(blueprint)
209
+ return blueprint
210
+
211
+
212
+ def save_blueprint(
213
+ draft: BlueprintDraft,
214
+ *,
215
+ path: Path | str | None = None,
216
+ out_dir: Path | str | None = None,
217
+ ) -> Path:
218
+ """Validate and write *draft* to disk, returning the file path.
219
+
220
+ With *path* (edit mode) the file is overwritten in place. Otherwise the file is created under
221
+ *out_dir* (or ``./blueprints``) as ``{slug}.blueprint.json``; a name collision with a different
222
+ existing file raises :class:`BuilderError` rather than clobbering it. Validation runs before any
223
+ write, and the written file is re-read through the canonical loader as a final guarantee.
224
+ """
225
+ build_blueprint(draft) # raise before touching disk
226
+
227
+ if path is not None:
228
+ target = Path(path)
229
+ target.parent.mkdir(parents=True, exist_ok=True)
230
+ else:
231
+ target_dir = Path(out_dir) if out_dir is not None else Path.cwd() / "blueprints"
232
+ target_dir.mkdir(parents=True, exist_ok=True)
233
+ target = target_dir / f"{slugify_name(draft.name)}.blueprint.json"
234
+ if target.exists():
235
+ raise BuilderError(
236
+ f"A Blueprint already exists at {target}. Rename this one, or open that file to edit."
237
+ )
238
+
239
+ target.write_text(
240
+ json.dumps(draft.to_data(), indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
241
+ )
242
+ validate_for_act(load_blueprint(target))
243
+ return target