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,174 @@
1
+ """In-page capture for the Vector recorder: observe API calls, pick a request and its JSON fields.
2
+
3
+ Symmetric with the DOM recorder, but for Act I. It patches ``fetch``/``XMLHttpRequest`` (and treats a
4
+ JSON *document* — navigating straight to an API URL — as a request too), lists the JSON responses in a
5
+ floating panel, and lets the user turn one into an ``http.request`` + JSONPath extraction. The panel
6
+ shows a **structured summary** of the response (a top-level array → "extract N records" with
7
+ auto-detected scalar fields; an object → a chip per key) rather than an infinite tree: simpler, and it
8
+ matches real API shapes (a list of records, or an object of fields/arrays).
9
+
10
+ Everything funnels through ``window.__aeVector.emit(request, extract)``, which the panel calls and
11
+ integration tests drive directly. The Python side (:mod:`.vector_backend`) turns it into a Blueprint.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ # Self-invoking, guarded. Emits through the shared __aetherius_capture binding with the http_request
17
+ # kind. Auth-bearing headers are handled Python-side (turned into secrets), never stored literally.
18
+ VECTOR_JS = r"""
19
+ (() => {
20
+ if (window.__aeVector) return;
21
+ const OVERLAY_ID = '__ae-recorder-overlay';
22
+ const ACCENT = '#c9a94a';
23
+ const requests = [];
24
+ const send = (p) => { try { window.__aetherius_capture(JSON.stringify(p)); } catch (e) {} };
25
+
26
+ const headersToObj = (h) => {
27
+ const o = {};
28
+ try {
29
+ if (h && typeof h.forEach === 'function') h.forEach((v, k) => { o[k] = v; });
30
+ else if (h && typeof h === 'object') Object.assign(o, h);
31
+ } catch (e) {}
32
+ return o;
33
+ };
34
+ const bodyToStr = (b) => (b == null ? null : (typeof b === 'string' ? b : null));
35
+
36
+ let refresh = () => {};
37
+ const record = (r) => {
38
+ if ((r.contentType || '').includes('json')) { requests.push(r); refresh(); }
39
+ };
40
+
41
+ const origFetch = window.fetch;
42
+ if (origFetch) {
43
+ window.fetch = function (...args) {
44
+ const arg0 = args[0], init = args[1] || {};
45
+ const url = (typeof arg0 === 'string') ? arg0 : (arg0 && arg0.url) || '';
46
+ const method = (init.method || (arg0 && arg0.method) || 'GET').toUpperCase();
47
+ const headers = headersToObj(init.headers);
48
+ const body = bodyToStr(init.body);
49
+ return origFetch.apply(this, args).then((resp) => {
50
+ const ct = resp.headers.get('content-type') || '';
51
+ resp.clone().text()
52
+ .then((text) => record({ method, url, headers, body, status: resp.status, contentType: ct, responseText: text }))
53
+ .catch(() => {});
54
+ return resp;
55
+ });
56
+ };
57
+ }
58
+
59
+ const xOpen = XMLHttpRequest.prototype.open, xSend = XMLHttpRequest.prototype.send;
60
+ XMLHttpRequest.prototype.open = function (m, u) {
61
+ this.__ae = { method: (m || 'GET').toUpperCase(), url: u };
62
+ return xOpen.apply(this, arguments);
63
+ };
64
+ XMLHttpRequest.prototype.send = function (b) {
65
+ const xhr = this, info = xhr.__ae || {};
66
+ xhr.addEventListener('load', () => {
67
+ const ct = xhr.getResponseHeader('content-type') || '';
68
+ record({ method: info.method, url: info.url, headers: {}, body: bodyToStr(b),
69
+ status: xhr.status, contentType: ct, responseText: xhr.responseText });
70
+ });
71
+ return xSend.apply(this, arguments);
72
+ };
73
+
74
+ window.__aeVector = {
75
+ requests,
76
+ emit(request, extract) { send({ kind: 'http_request', request, extract }); },
77
+ };
78
+
79
+ // ── UI ──────────────────────────────────────────────────────────────────────
80
+ const el = (tag, css, text) => {
81
+ const n = document.createElement(tag);
82
+ if (css) n.style.cssText = css;
83
+ if (text != null) n.textContent = text;
84
+ return n;
85
+ };
86
+ const chip = (label, onclick) => {
87
+ const b = el('button', 'margin:2px;padding:3px 7px;font:12px system-ui;cursor:pointer;' +
88
+ 'border-radius:5px;border:1px solid ' + ACCENT + '55;background:#1c1830;color:#e7e2f3;', label);
89
+ b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onclick(); });
90
+ return b;
91
+ };
92
+
93
+ const scalarFields = (obj) => { // { key: '$.key' } for each scalar field of a sample record
94
+ const out = {};
95
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
96
+ for (const k of Object.keys(obj)) {
97
+ const v = obj[k];
98
+ if (v == null || typeof v !== 'object') out[k] = '$.' + k;
99
+ }
100
+ }
101
+ return out;
102
+ };
103
+
104
+ let host, root, statusEl, listEl, detailEl;
105
+ const setStatus = (t) => { if (statusEl) statusEl.textContent = t; };
106
+
107
+ const pickRecords = (request, path, sample, name) => {
108
+ window.__aeVector.emit(request, { name, path, fields: scalarFields(sample) });
109
+ setStatus('Added records "' + name + '". Pick more, or Finish.');
110
+ };
111
+ const pickValue = (request, path, name) => {
112
+ window.__aeVector.emit(request, { name, path });
113
+ setStatus('Added "' + name + '". Pick more, or Finish.');
114
+ };
115
+
116
+ const showDetail = (request) => {
117
+ detailEl.innerHTML = '';
118
+ let data;
119
+ try { data = JSON.parse(request.responseText); } catch (e) { detailEl.textContent = 'Not JSON.'; return; }
120
+ detailEl.appendChild(el('div', 'color:' + ACCENT + ';margin:4px 0;', request.method + ' ' + request.url));
121
+ if (Array.isArray(data)) {
122
+ detailEl.appendChild(chip('Extract ' + data.length + ' records', () =>
123
+ pickRecords(request, '$[*]', data[0], 'records')));
124
+ } else if (data && typeof data === 'object') {
125
+ for (const k of Object.keys(data)) {
126
+ const v = data[k];
127
+ if (Array.isArray(v)) {
128
+ detailEl.appendChild(chip(k + ' (' + v.length + ' records)', () =>
129
+ pickRecords(request, '$.' + k + '[*]', v[0], k)));
130
+ } else if (v == null || typeof v !== 'object') {
131
+ detailEl.appendChild(chip(k, () => pickValue(request, '$.' + k, k)));
132
+ }
133
+ }
134
+ } else {
135
+ detailEl.appendChild(chip('Extract value', () => pickValue(request, '$', 'value')));
136
+ }
137
+ };
138
+
139
+ const install = () => {
140
+ if (!document.documentElement || document.getElementById(OVERLAY_ID)) return;
141
+ host = el('div'); host.id = OVERLAY_ID; host.style.cssText = 'all:initial;position:fixed;z-index:2147483647;';
142
+ document.documentElement.appendChild(host);
143
+ root = host.attachShadow({ mode: 'open' });
144
+ const bar = el('div', 'position:fixed;top:10px;right:10px;width:320px;max-height:80vh;overflow:auto;' +
145
+ 'padding:8px 10px;font:12px system-ui;background:#171327ee;color:#e7e2f3;' +
146
+ 'border:1px solid ' + ACCENT + '66;border-radius:8px;box-shadow:0 6px 24px #000a;');
147
+ bar.appendChild(el('div', 'font-weight:600;color:' + ACCENT + ';', 'Aetherius recorder — API'));
148
+ statusEl = el('div', 'color:#b9b2d0;margin:4px 0;', 'Browse to trigger API calls, then pick one.');
149
+ bar.appendChild(statusEl);
150
+ bar.appendChild(el('div', 'color:#8f88a8;margin-top:4px;', 'Requests'));
151
+ listEl = el('div'); bar.appendChild(listEl);
152
+ detailEl = el('div', 'margin-top:6px;border-top:1px solid #ffffff22;padding-top:4px;'); bar.appendChild(detailEl);
153
+ bar.appendChild(chip('Finish', () => send({ kind: 'finish' })));
154
+ root.appendChild(bar);
155
+
156
+ refresh = () => {
157
+ listEl.innerHTML = '';
158
+ requests.slice(-12).forEach((r) => {
159
+ const label = r.method + ' ' + (r.url.length > 34 ? '…' + r.url.slice(-33) : r.url);
160
+ listEl.appendChild(chip(label, () => showDetail(r)));
161
+ });
162
+ };
163
+ refresh();
164
+ if ((document.contentType || '').includes('json')) {
165
+ record({ method: 'GET', url: location.href, headers: {}, body: null,
166
+ status: 200, contentType: 'application/json',
167
+ responseText: document.body ? document.body.textContent : '' });
168
+ }
169
+ };
170
+
171
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', install);
172
+ else install();
173
+ })();
174
+ """
@@ -0,0 +1,92 @@
1
+ """Recorder backends behind a common interface, selected by Act — a mirror of the driver pattern.
2
+
3
+ A recorder captures a demonstration in a live browser and turns it into a Blueprint. *How* it captures
4
+ and *what* it emits depends on the Act: Continuum records DOM interactions, Vector records network
5
+ calls. Both plug into the same generic browser shell (:mod:`.session`) through this interface, and the
6
+ registry maps ``act -> backend`` exactly as ``ACT_CAPABILITIES`` maps ``act -> capabilities``.
7
+
8
+ Acts without a recorder yet (Oracle, Phantom) are declared here as pending holes: asking for one
9
+ raises a clear :class:`RecorderError` instead of failing obscurely, so the CLI/Console can say so
10
+ honestly — the same "honest milestone" pattern used elsewhere (PendingScreen, DependencyError).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import TYPE_CHECKING, Any, Callable, Protocol
17
+
18
+ from ..core.errors import RecorderError
19
+
20
+ if TYPE_CHECKING:
21
+ from .session import RecordingSession
22
+
23
+ # Acts that are real Aetherius Acts but whose recorder is a later milestone (documented in
24
+ # docs/recorder.md). Kept distinct from a plainly unknown act so the message can be specific.
25
+ _PENDING_RECORDERS: dict[str, str] = {
26
+ "oracle": "visual annotation (bounding boxes on screenshots) feeding training/",
27
+ "phantom": "goal-based demonstration",
28
+ }
29
+
30
+ _BACKENDS: dict[str, Callable[..., "RecorderBackend"]] = {}
31
+
32
+
33
+ @dataclass
34
+ class RecordingResult:
35
+ """What a backend produces from a session: the pieces of the Blueprint (minus the envelope)."""
36
+
37
+ steps: list[dict[str, Any]] = field(default_factory=list)
38
+ secrets: list[str] = field(default_factory=list)
39
+ inputs: dict[str, Any] = field(default_factory=dict)
40
+ outputs: dict[str, Any] = field(default_factory=dict)
41
+
42
+
43
+ class RecorderBackend(Protocol):
44
+ """One Act's way of recording. The generic session drives it through these three hooks."""
45
+
46
+ act: str
47
+
48
+ def init_scripts(self) -> list[str]:
49
+ """Scripts to inject (``add_init_script``) into every document before the page runs."""
50
+ ...
51
+
52
+ def attach(self, session: "RecordingSession") -> None:
53
+ """Register bindings and page hooks on *session* (called once the context exists)."""
54
+ ...
55
+
56
+ def result(self) -> RecordingResult:
57
+ """Transform everything captured during the session into a RecordingResult."""
58
+ ...
59
+
60
+
61
+ def register_backend(act: str, factory: Callable[..., "RecorderBackend"]) -> None:
62
+ """Register a backend factory for *act* (called at backend-module import).
63
+
64
+ The factory accepts arbitrary keyword options and picks the ones its Act understands, so
65
+ ``get_recorder`` can forward Act-specific knobs (e.g. ``credentials_as_secrets``) uniformly.
66
+ """
67
+ _BACKENDS[act] = factory
68
+
69
+
70
+ def _ensure_loaded() -> None:
71
+ """Import the backend modules so their registrations run (idempotent, dependency-light)."""
72
+ from . import continuum_backend, vector_backend # noqa: F401 (import for side effects)
73
+
74
+
75
+ def get_recorder(act: str, **options: Any) -> "RecorderBackend":
76
+ """Return a fresh backend for *act* (forwarding *options*), or raise a clear RecorderError."""
77
+ _ensure_loaded()
78
+ factory = _BACKENDS.get(act)
79
+ if factory is not None:
80
+ return factory(**options)
81
+ if act in _PENDING_RECORDERS:
82
+ raise RecorderError(
83
+ f"The recorder for act {act!r} is a pending milestone "
84
+ f"({_PENDING_RECORDERS[act]}); see docs/recorder.md."
85
+ )
86
+ raise RecorderError(f"Unknown act {act!r} (no recorder backend).")
87
+
88
+
89
+ def recorder_acts() -> set[str]:
90
+ """Acts that have a working recorder today (single source of truth, like IMPLEMENTED_ACTS)."""
91
+ _ensure_loaded()
92
+ return set(_BACKENDS)
@@ -0,0 +1,72 @@
1
+ """Blueprint recorder: launch a visible browser, capture a demonstration, emit a clean Blueprint.
2
+
3
+ Act-agnostic orchestrator: it picks the recorder backend for the requested Act (:mod:`.base`), drives
4
+ a generic :class:`~.session.RecordingSession`, assembles the backend's result into a Blueprint, and
5
+ re-validates the produced file through the canonical loader/validator — so the recorder can only ever
6
+ hand back a schema-valid, runnable Blueprint (or fail loudly). What is captured and how it maps to
7
+ steps is the backend's job (Continuum: DOM; Vector: network).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import threading
14
+ from pathlib import Path
15
+
16
+ from ..builder.factory import assemble_blueprint, slugify_name
17
+ from ..core.blueprint.loader import load_blueprint
18
+ from ..core.blueprint.validator import validate_for_act
19
+ from ..core.errors import BlueprintValidationError
20
+ from .base import get_recorder
21
+ from .session import NotifyCallback, RecordingSession
22
+
23
+ # assemble_blueprint is re-exported from builder.factory: Blueprint construction is the builder's
24
+ # job, and the recorder is one of its callers. Kept importable here for existing callers and tests.
25
+ __all__ = ["assemble_blueprint", "record_blueprint"]
26
+
27
+
28
+ def record_blueprint(
29
+ name: str,
30
+ start_url: str,
31
+ *,
32
+ act: str = "continuum",
33
+ out_dir: Path | str | None = None,
34
+ on_event: NotifyCallback | None = None,
35
+ stop_event: threading.Event | None = None,
36
+ credentials_as_secrets: bool = True,
37
+ ) -> Path:
38
+ """Record a demonstration for *act* in a live browser and write a validated Blueprint file.
39
+
40
+ Returns the path of the written ``.blueprint.json`` (under *out_dir* or ``./blueprints/``).
41
+ Raises :class:`~aetherius.core.errors.RecorderError` if *act* has no recorder backend, or
42
+ :class:`BlueprintValidationError` if the assembled Blueprint fails canonical validation.
43
+ """
44
+ backend = get_recorder(act, credentials_as_secrets=credentials_as_secrets)
45
+ session = RecordingSession(start_url, backend, on_event=on_event, stop_event=stop_event)
46
+ result = session.record()
47
+
48
+ description = (
49
+ f"Genere par le recorder Aetherius (act {act}) a partir d'une demo sur {start_url}."
50
+ )
51
+ blueprint = assemble_blueprint(
52
+ name,
53
+ result.steps,
54
+ result.secrets,
55
+ act=act,
56
+ inputs=result.inputs,
57
+ outputs=result.outputs,
58
+ description=description,
59
+ )
60
+
61
+ target_dir = Path(out_dir) if out_dir is not None else Path.cwd() / "blueprints"
62
+ target_dir.mkdir(parents=True, exist_ok=True)
63
+ path = target_dir / f"{slugify_name(name)}.blueprint.json"
64
+ path.write_text(json.dumps(blueprint, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
65
+
66
+ try:
67
+ validate_for_act(load_blueprint(path))
68
+ except Exception as exc: # a produced-invalid Blueprint is our bug: surface it, do not hide it
69
+ raise BlueprintValidationError(
70
+ f"The recorder produced an invalid Blueprint: {exc}"
71
+ ) from exc
72
+ return path
@@ -0,0 +1,59 @@
1
+ """DOM capture data types shared by the Continuum backend and its tests.
2
+
3
+ The live-browser lifecycle now lives in the generic :mod:`.session`; this module holds the typed
4
+ representation of a captured DOM event and the parser that builds an element descriptor from the JSON
5
+ the in-page scripts report (:mod:`._capture_js`, :mod:`._overlay_js`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+
14
+ from .selector_synth import Candidate, ElementDescriptor
15
+
16
+ # Overlay pick kinds, carrying an extraction/meta ``config`` rather than a plain action. ``finish`` is
17
+ # a control message handled by the backend (end the session), never turned into a recorded event.
18
+ _OVERLAY_KINDS = frozenset({"extract", "extract_records", "wait_for", "parameterize"})
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class RecordedEvent:
23
+ """One captured user action, before it is transformed into a Blueprint step.
24
+
25
+ ``descriptor`` is the target element (the repeating container for records) or ``None`` for a
26
+ navigation. A password field never carries its value: ``redacted`` is set instead. ``config``
27
+ carries an overlay pick's details (name / as / attr / list / fields / input name).
28
+ """
29
+
30
+ kind: str # navigate | click | fill | select | press | extract | extract_records | wait_for | parameterize
31
+ descriptor: ElementDescriptor | None = None
32
+ value: str | None = None
33
+ key: str | None = None
34
+ url: str | None = None
35
+ redacted: bool = False
36
+ config: dict[str, Any] | None = None
37
+ ts: float = field(default_factory=time.monotonic)
38
+
39
+
40
+ def _descriptor_from_raw(raw: dict[str, Any]) -> ElementDescriptor:
41
+ """Build an :class:`ElementDescriptor` from the JSON an in-page script sent."""
42
+ candidates = tuple(
43
+ Candidate(
44
+ strategy=str(c["strategy"]),
45
+ selector=str(c["selector"]),
46
+ selector_type=str(c.get("selector_type", "css")),
47
+ unique=bool(c["unique"]),
48
+ )
49
+ for c in raw.get("candidates", [])
50
+ )
51
+ return ElementDescriptor(
52
+ tag=str(raw.get("tag", "")),
53
+ css_path=str(raw.get("css_path", "")),
54
+ candidates=candidates,
55
+ text=raw.get("text"),
56
+ name=raw.get("name"),
57
+ field_type=raw.get("field_type"),
58
+ autocomplete=raw.get("autocomplete"),
59
+ )
@@ -0,0 +1,119 @@
1
+ """Continuum recorder backend: capture DOM interactions and picks into a browser Blueprint.
2
+
3
+ Injects the shared selector primitives, the action-capture script and the pick overlay; collects the
4
+ DOM events they report (plus top-level navigations); and transforms them into ``continuum`` steps via
5
+ :mod:`._transform`. This is the Act II half of the Act-agnostic recorder (:mod:`.base`).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from typing import Any
12
+
13
+ from ._capture_js import RECORDER_JS
14
+ from ._overlay_js import OVERLAY_JS
15
+ from ._selector_js import SELECTOR_JS
16
+ from ._transform import events_to_steps
17
+ from .base import RecordingResult, register_backend
18
+ from .capture import _OVERLAY_KINDS, RecordedEvent, _descriptor_from_raw
19
+ from .selector_synth import synthesize
20
+ from .session import RecordingSession
21
+
22
+
23
+ def describe_event(event: RecordedEvent) -> str:
24
+ """Sober one-line description of a captured DOM action, for the Console event log / CLI."""
25
+ if event.kind == "navigate" or (event.kind == "click" and event.url):
26
+ return f"navigate {event.url or ''}"
27
+ selector = synthesize(event.descriptor).selector if event.descriptor else "?"
28
+ config = event.config or {}
29
+ if event.kind == "extract":
30
+ return f"extract {config.get('name', '?')} {selector}"
31
+ if event.kind == "extract_records":
32
+ fields = ", ".join(f.get("name", "?") for f in config.get("fields", []))
33
+ return f"extract table {config.get('name', '?')} ({fields}) {selector}"
34
+ if event.kind == "wait_for":
35
+ return f"wait_for {selector}"
36
+ if event.kind == "parameterize":
37
+ return f"input {config.get('name', '?')} {selector}"
38
+ if event.kind == "press":
39
+ return f"press {event.key or 'Enter'} {selector}"
40
+ return f"{event.kind} {selector}"
41
+
42
+
43
+ class ContinuumRecorder:
44
+ """Records DOM interactions and overlay picks into a ``continuum`` Blueprint."""
45
+
46
+ act = "continuum"
47
+
48
+ def __init__(self, *, credentials_as_secrets: bool = True) -> None:
49
+ self._credentials_as_secrets = credentials_as_secrets
50
+ self._events: list[RecordedEvent] = []
51
+ self._last_nav_url: str | None = None
52
+ self._session: RecordingSession | None = None
53
+
54
+ def init_scripts(self) -> list[str]:
55
+ return [SELECTOR_JS, RECORDER_JS, OVERLAY_JS]
56
+
57
+ def attach(self, session: RecordingSession) -> None:
58
+ self._session = session
59
+ session.expose("__aetherius_capture", self._on_binding)
60
+ session.on_new_page(self._wire_page)
61
+
62
+ def result(self) -> RecordingResult:
63
+ steps, secrets, inputs, outputs = events_to_steps(
64
+ self._events, credentials_as_secrets=self._credentials_as_secrets
65
+ )
66
+ return RecordingResult(steps=steps, secrets=secrets, inputs=inputs, outputs=outputs)
67
+
68
+ # ── capture ────────────────────────────────────────────────────────────────
69
+ def _wire_page(self, page: Any) -> None:
70
+ page.on("framenavigated", self._on_framenavigated)
71
+
72
+ def _on_framenavigated(self, frame: Any) -> None:
73
+ if frame.parent_frame is not None:
74
+ return # only the main frame is a user-visible navigation
75
+ url = frame.url
76
+ if not url or url == "about:blank" or url == self._last_nav_url:
77
+ return
78
+ self._last_nav_url = url
79
+ self._emit(RecordedEvent(kind="navigate", url=url))
80
+
81
+ def _on_binding(self, _source: dict[str, Any], payload: str) -> None:
82
+ try:
83
+ data = json.loads(payload)
84
+ except (TypeError, ValueError):
85
+ return
86
+ kind = str(data.get("kind", ""))
87
+ if kind == "finish":
88
+ if self._session is not None:
89
+ self._session.finish() # overlay Finish: end without closing the window
90
+ return
91
+ raw = data.get("descriptor")
92
+ descriptor = _descriptor_from_raw(raw) if raw else None
93
+ if kind in _OVERLAY_KINDS:
94
+ config = {k: v for k, v in data.items() if k not in ("kind", "descriptor")}
95
+ self._emit(RecordedEvent(kind=kind, descriptor=descriptor, config=config))
96
+ return
97
+ self._emit(
98
+ RecordedEvent(
99
+ kind=kind,
100
+ descriptor=descriptor,
101
+ value=data.get("value"),
102
+ key=data.get("key"),
103
+ url=data.get("href"), # set for a link click: replayed as a navigate, not a click
104
+ redacted=bool(data.get("redacted", False)),
105
+ )
106
+ )
107
+
108
+ def _emit(self, event: RecordedEvent) -> None:
109
+ self._events.append(event)
110
+ if self._session is not None:
111
+ self._session.notify(describe_event(event))
112
+
113
+
114
+ register_backend(
115
+ "continuum",
116
+ lambda **o: ContinuumRecorder(
117
+ credentials_as_secrets=bool(o.get("credentials_as_secrets", True))
118
+ ),
119
+ )