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,133 @@
1
+ """StealthPolicy: the decoded, typed intent behind ``options.stealth``.
2
+
3
+ The Blueprint carries ``options.stealth`` as loosely-typed data (off, a preset name, or an inline
4
+ object) so the JSON Schema stays the single source of shape. This module turns that data into one
5
+ frozen policy object the browser Acts can query without re-parsing: which humanizer features are on
6
+ and which fingerprint profile to wear. ``build_policy`` is the only entry point; a no-op policy
7
+ (everything off) preserves the historical default behaviour exactly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Any, Literal, get_args
14
+
15
+ from ..core.errors import BlueprintValidationError
16
+
17
+ MouseMode = Literal["off", "gestures"]
18
+ KeyboardMode = Literal["off", "human"]
19
+ ScrollMode = Literal["off", "eased"]
20
+
21
+ _MOUSE_MODES = frozenset(get_args(MouseMode))
22
+ _KEYBOARD_MODES = frozenset(get_args(KeyboardMode))
23
+ _SCROLL_MODES = frozenset(get_args(ScrollMode))
24
+ _KNOWN_KEYS = frozenset({"mouse", "keyboard", "scroll", "timing", "fingerprint"})
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class StealthPolicy:
29
+ """Which discretion features are active for a run. Immutable once assembled.
30
+
31
+ ``mouse``/``keyboard``/``scroll`` gate the humanizers; ``distraction`` is the probability of an
32
+ occasional idle pause; ``fingerprint`` names a profile (or ``None`` to keep Playwright's stock
33
+ fingerprint). ``OFF`` is the identity policy: no feature touches the page.
34
+ """
35
+
36
+ mouse: MouseMode = "off"
37
+ keyboard: KeyboardMode = "off"
38
+ scroll: ScrollMode = "off"
39
+ distraction: float = 0.0
40
+ fingerprint: str | None = None
41
+
42
+ @property
43
+ def is_active(self) -> bool:
44
+ """True when at least one feature would alter the browser or its inputs."""
45
+ return (
46
+ self.mouse != "off"
47
+ or self.keyboard != "off"
48
+ or self.scroll != "off"
49
+ or self.fingerprint is not None
50
+ )
51
+
52
+
53
+ # Identity policy shared by every "off" path, so callers can compare against a single instance.
54
+ OFF = StealthPolicy()
55
+
56
+ # Named presets usable as ``"stealth": "<name>"``. Kept deliberately small; a preset is just a
57
+ # curated StealthPolicy so power users can still spell every field out inline.
58
+ _PRESETS: dict[str, StealthPolicy] = {
59
+ "off": OFF,
60
+ "human": StealthPolicy(
61
+ mouse="gestures",
62
+ keyboard="human",
63
+ scroll="eased",
64
+ distraction=0.1,
65
+ fingerprint="chrome-desktop",
66
+ ),
67
+ }
68
+
69
+
70
+ def preset_names() -> list[str]:
71
+ """The stealth preset names usable as ``"stealth": "<name>"``, sorted. A public view of the
72
+ preset table so the builder offers exactly what ``build_policy`` accepts, without duplicating it.
73
+ """
74
+ return sorted(_PRESETS)
75
+
76
+
77
+ def _fail(detail: str) -> BlueprintValidationError:
78
+ return BlueprintValidationError(f"Invalid options.stealth: {detail}")
79
+
80
+
81
+ def _distraction(timing: Any) -> float:
82
+ if timing is None:
83
+ return 0.0
84
+ if not isinstance(timing, dict):
85
+ raise _fail("'timing' must be an object.")
86
+ value = timing.get("distraction", 0.0)
87
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
88
+ raise _fail("'timing.distraction' must be a number between 0 and 1.")
89
+ if not 0.0 <= value <= 1.0:
90
+ raise _fail("'timing.distraction' must be between 0 and 1.")
91
+ return float(value)
92
+
93
+
94
+ def _choice(value: Any, allowed: frozenset[str], field: str) -> str:
95
+ if value is None:
96
+ return "off"
97
+ if value not in allowed:
98
+ raise _fail(f"'{field}' must be one of {sorted(allowed)}, got {value!r}.")
99
+ return str(value)
100
+
101
+
102
+ def build_policy(raw: Any) -> StealthPolicy:
103
+ """Decode ``options.stealth`` into a :class:`StealthPolicy`.
104
+
105
+ Accepts the three shapes the schema allows: ``None``/``"off"`` (no-op), a preset name, or an
106
+ inline object. Anything else raises a typed :class:`BlueprintValidationError` rather than
107
+ silently degrading, so a malformed Blueprint fails loud and early.
108
+ """
109
+ if raw is None:
110
+ return OFF
111
+ if isinstance(raw, str):
112
+ preset = _PRESETS.get(raw)
113
+ if preset is None:
114
+ raise _fail(f"unknown preset {raw!r} (known: {sorted(_PRESETS)}).")
115
+ return preset
116
+ if not isinstance(raw, dict):
117
+ raise _fail("expected 'off', a preset name, or an object.")
118
+
119
+ unknown = set(raw) - _KNOWN_KEYS
120
+ if unknown:
121
+ raise _fail(f"unknown keys {sorted(unknown)} (allowed: {sorted(_KNOWN_KEYS)}).")
122
+
123
+ fingerprint = raw.get("fingerprint")
124
+ if fingerprint is not None and not isinstance(fingerprint, str):
125
+ raise _fail("'fingerprint' must be a profile name (string).")
126
+
127
+ return StealthPolicy(
128
+ mouse=_choice(raw.get("mouse"), _MOUSE_MODES, "mouse"), # type: ignore[arg-type]
129
+ keyboard=_choice(raw.get("keyboard"), _KEYBOARD_MODES, "keyboard"), # type: ignore[arg-type]
130
+ scroll=_choice(raw.get("scroll"), _SCROLL_MODES, "scroll"), # type: ignore[arg-type]
131
+ distraction=_distraction(raw.get("timing")),
132
+ fingerprint=fingerprint,
133
+ )
@@ -0,0 +1 @@
1
+ """Persistent browser sessions and profile warmup."""
@@ -0,0 +1,35 @@
1
+ """Persistent browser profiles and per-run artifact directories.
2
+
3
+ A *profile* is a directory Playwright reuses across runs to carry authentic cookies, cache and
4
+ history (``launch_persistent_context``). A *run directory* collects the artifacts a single run
5
+ produces (screenshots, HAR, DOM snapshots). Both live under the configured ``data_dir``.
6
+
7
+ Only directory resolution lives here for now. Warmup (building an authentic history before an
8
+ automation) and storage-state export are later milestones and are intentionally left out.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from ...config.settings import get_settings
16
+
17
+
18
+ def resolve_profile_dir(profile: str | None) -> Path | None:
19
+ """Return the directory for a persistent browser *profile*, creating it on demand.
20
+
21
+ Returns ``None`` when *profile* is falsy, signalling the caller to use an ephemeral
22
+ (non-persistent) browser context instead.
23
+ """
24
+ if not profile:
25
+ return None
26
+ path = get_settings().profiles_dir / profile
27
+ path.mkdir(parents=True, exist_ok=True)
28
+ return path
29
+
30
+
31
+ def run_dir(run_id: str) -> Path:
32
+ """Return the artifact directory for *run_id*, creating it on demand."""
33
+ path = get_settings().runs_dir / run_id
34
+ path.mkdir(parents=True, exist_ok=True)
35
+ return path
@@ -0,0 +1,52 @@
1
+ """Profile warmup: build an authentic browsing history before automation.
2
+
3
+ A persistent profile that jumps straight to a sensitive target on its very first request looks
4
+ synthetic. Warmup visits a few innocuous pages first, dwelling on each, so the profile accrues real
5
+ cookies, cache and history — the human touch a cold profile lacks. Generalizes the manual
6
+ login-setup flow of the legacy projects.
7
+
8
+ :func:`plan_warmup` is pure (the itinerary and dwell times, deterministic given an ``rng``);
9
+ :func:`warmup_profile` plays it against a page. Kept intentionally small.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from collections.abc import Iterable
15
+ from dataclasses import dataclass
16
+ from random import Random
17
+ from typing import Any
18
+
19
+ from ..humanizer.timing import Sleeper, precise_sleep
20
+
21
+ _DEFAULT_DWELL_MS = 2000.0
22
+ _DEFAULT_RNG = Random()
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class WarmupStep:
27
+ """One warmup visit: a URL and how long (seconds) to dwell before moving on."""
28
+
29
+ url: str
30
+ dwell_s: float
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class WarmupPlan:
35
+ """An ordered warmup itinerary."""
36
+
37
+ steps: list[WarmupStep]
38
+
39
+
40
+ def plan_warmup(
41
+ urls: Iterable[str], *, dwell_ms: float = _DEFAULT_DWELL_MS, rng: Random = _DEFAULT_RNG
42
+ ) -> WarmupPlan:
43
+ """Build a warmup itinerary from *urls*, each with a randomized dwell around *dwell_ms*."""
44
+ steps = [WarmupStep(url, rng.uniform(dwell_ms * 0.6, dwell_ms * 1.4) / 1000.0) for url in urls]
45
+ return WarmupPlan(steps)
46
+
47
+
48
+ def warmup_profile(page: Any, plan: WarmupPlan, *, sleep: Sleeper = precise_sleep) -> None:
49
+ """Play *plan* against a page: navigate to each URL and dwell, accreting authentic history."""
50
+ for step in plan.steps:
51
+ page.goto(step.url)
52
+ sleep(step.dwell_s)
aetherius/version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source of the package version."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,144 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://aetherius.dev/contracts/blueprint.schema.json",
4
+ "title": "Aetherius Blueprint",
5
+ "description": "The declarative instruction file executed by Aetherius. Language-agnostic source of truth shared by the Python core and every SDK.",
6
+ "type": "object",
7
+ "required": ["aetherius", "name", "act"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "aetherius": {
11
+ "type": "string",
12
+ "description": "Blueprint format version.",
13
+ "pattern": "^[0-9]+\\.[0-9]+$"
14
+ },
15
+ "name": {
16
+ "type": "string",
17
+ "description": "Dotted identifier, e.g. domain.task.",
18
+ "minLength": 1
19
+ },
20
+ "description": { "type": "string" },
21
+ "act": {
22
+ "description": "Execution engine.",
23
+ "enum": ["vector", "continuum", "oracle", "phantom"]
24
+ },
25
+ "inputs": {
26
+ "type": "object",
27
+ "description": "Declared, typed parameters that make the Blueprint reusable.",
28
+ "additionalProperties": { "$ref": "#/$defs/inputSpec" }
29
+ },
30
+ "secrets": {
31
+ "type": "array",
32
+ "description": "Names of runtime-only secrets. Values are injected at execution and never stored in the file.",
33
+ "items": { "type": "string" },
34
+ "uniqueItems": true
35
+ },
36
+ "vars": {
37
+ "type": "object",
38
+ "description": "Local constants (domains, paths) available for interpolation."
39
+ },
40
+ "options": { "$ref": "#/$defs/options" },
41
+ "vision": {
42
+ "type": "object",
43
+ "description": "Vision configuration for Oracle/Phantom.",
44
+ "properties": { "model": { "type": "string" } },
45
+ "additionalProperties": true
46
+ },
47
+ "goal": {
48
+ "type": "string",
49
+ "description": "High-level objective for Phantom (Act IV) when the run is not fully scripted."
50
+ },
51
+ "constraints": {
52
+ "type": "array",
53
+ "description": "Guardrails for Phantom.",
54
+ "items": { "type": "string" }
55
+ },
56
+ "steps": {
57
+ "type": "array",
58
+ "description": "The ordered action sequence (the action dictionary).",
59
+ "items": { "$ref": "#/$defs/step" }
60
+ },
61
+ "outputs": {
62
+ "type": "object",
63
+ "description": "Shape of the returned data, via interpolation of step results."
64
+ }
65
+ },
66
+ "anyOf": [
67
+ { "required": ["steps"] },
68
+ { "required": ["goal"] }
69
+ ],
70
+ "$defs": {
71
+ "inputSpec": {
72
+ "type": "object",
73
+ "additionalProperties": false,
74
+ "required": ["type"],
75
+ "properties": {
76
+ "type": { "enum": ["string", "number", "integer", "boolean", "date", "path", "object", "array"] },
77
+ "required": { "type": "boolean", "default": false },
78
+ "format": { "type": "string" },
79
+ "default": {},
80
+ "description": { "type": "string" }
81
+ }
82
+ },
83
+ "options": {
84
+ "type": "object",
85
+ "additionalProperties": false,
86
+ "properties": {
87
+ "debug": {
88
+ "type": "boolean",
89
+ "default": false,
90
+ "description": "Visible browser + cursor overlay + slow-mo for browser Acts; structured terminal logs otherwise."
91
+ },
92
+ "stealth": { "$ref": "#/$defs/stealth" },
93
+ "session": {
94
+ "type": "object",
95
+ "additionalProperties": false,
96
+ "properties": {
97
+ "profile": { "type": "string" },
98
+ "persist": { "type": "boolean", "default": false }
99
+ }
100
+ },
101
+ "timeout_ms": { "type": "integer", "minimum": 0 },
102
+ "retries": {
103
+ "type": "object",
104
+ "additionalProperties": false,
105
+ "properties": {
106
+ "max": { "type": "integer", "minimum": 0 },
107
+ "backoff": { "enum": ["none", "linear", "exponential"] }
108
+ }
109
+ }
110
+ }
111
+ },
112
+ "stealth": {
113
+ "description": "Modular discretion. Off by default; a preset name; or an inline configuration.",
114
+ "oneOf": [
115
+ { "type": "string", "description": "\"off\" or a named preset." },
116
+ {
117
+ "type": "object",
118
+ "additionalProperties": false,
119
+ "properties": {
120
+ "mouse": { "enum": ["off", "gestures"] },
121
+ "keyboard": { "enum": ["off", "human"] },
122
+ "scroll": { "enum": ["off", "eased"] },
123
+ "timing": {
124
+ "type": "object",
125
+ "additionalProperties": false,
126
+ "properties": { "distraction": { "type": "number", "minimum": 0, "maximum": 1 } }
127
+ },
128
+ "fingerprint": { "type": "string" }
129
+ }
130
+ }
131
+ ]
132
+ },
133
+ "step": {
134
+ "type": "object",
135
+ "description": "A single action. Parameters depend on the action; extra properties are allowed and validated per-action by the engine.",
136
+ "properties": {
137
+ "id": { "type": "string" },
138
+ "action": { "type": "string" }
139
+ },
140
+ "required": ["action"],
141
+ "additionalProperties": true
142
+ }
143
+ }
144
+ }