halyn 2.1.3__tar.gz → 2.2.0__tar.gz

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 (62) hide show
  1. {halyn-2.1.3/src/halyn.egg-info → halyn-2.2.0}/PKG-INFO +8 -8
  2. {halyn-2.1.3 → halyn-2.2.0}/README.md +7 -5
  3. {halyn-2.1.3 → halyn-2.2.0}/pyproject.toml +4 -1
  4. halyn-2.2.0/src/halyn/_nrp/__init__.py +17 -0
  5. halyn-2.2.0/src/halyn/_nrp/driver.py +112 -0
  6. halyn-2.2.0/src/halyn/_nrp/events.py +234 -0
  7. halyn-2.2.0/src/halyn/_nrp/identity.py +88 -0
  8. halyn-2.2.0/src/halyn/_nrp/manifest.py +166 -0
  9. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/control_plane.py +1 -1
  10. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/drivers/browser.py +1 -1
  11. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/drivers/dds.py +1 -1
  12. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/drivers/docker.py +1 -1
  13. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/drivers/http_auto.py +2 -2
  14. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/drivers/mqtt.py +1 -1
  15. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/drivers/opcua.py +1 -1
  16. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/drivers/ros2.py +1 -1
  17. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/drivers/serial.py +1 -1
  18. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/drivers/socket_raw.py +1 -1
  19. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/drivers/ssh.py +3 -3
  20. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/drivers/unitree.py +1 -1
  21. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/drivers/websocket.py +1 -1
  22. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/nrp_bridge.py +4 -4
  23. {halyn-2.1.3 → halyn-2.2.0/src/halyn.egg-info}/PKG-INFO +8 -8
  24. {halyn-2.1.3 → halyn-2.2.0}/src/halyn.egg-info/SOURCES.txt +5 -1
  25. halyn-2.1.3/LICENSE +0 -58
  26. {halyn-2.1.3 → halyn-2.2.0}/setup.cfg +0 -0
  27. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/__init__.py +0 -0
  28. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/__main__.py +0 -0
  29. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/audit.py +0 -0
  30. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/auth.py +0 -0
  31. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/autonomy.py +0 -0
  32. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/cli.py +0 -0
  33. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/config.py +0 -0
  34. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/consent.py +0 -0
  35. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/dashboard.py +0 -0
  36. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/discovery.py +0 -0
  37. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/drivers/__init__.py +0 -0
  38. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/engine.py +0 -0
  39. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/integrations/__init__.py +0 -0
  40. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/intent.py +0 -0
  41. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/llm.py +0 -0
  42. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/mcp.py +0 -0
  43. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/mcp_serve.py +0 -0
  44. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/memory/__init__.py +0 -0
  45. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/memory/store.py +0 -0
  46. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/py.typed +0 -0
  47. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/sanitizer.py +0 -0
  48. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/security/__init__.py +0 -0
  49. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/security/audit_guard.py +0 -0
  50. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/security/ebpf_monitor.py +0 -0
  51. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/security/fs_watch.py +0 -0
  52. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/security/process_guard.py +0 -0
  53. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/security/proxy.py +0 -0
  54. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/server.py +0 -0
  55. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/shield.py +0 -0
  56. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/types.py +0 -0
  57. {halyn-2.1.3 → halyn-2.2.0}/src/halyn/watchdog.py +0 -0
  58. {halyn-2.1.3 → halyn-2.2.0}/src/halyn.egg-info/dependency_links.txt +0 -0
  59. {halyn-2.1.3 → halyn-2.2.0}/src/halyn.egg-info/entry_points.txt +0 -0
  60. {halyn-2.1.3 → halyn-2.2.0}/src/halyn.egg-info/requires.txt +0 -0
  61. {halyn-2.1.3 → halyn-2.2.0}/src/halyn.egg-info/top_level.txt +0 -0
  62. {halyn-2.1.3 → halyn-2.2.0}/tests/test_halyn.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: halyn
3
- Version: 2.1.3
3
+ Version: 2.2.0
4
4
  Summary: Halyn — The governance layer for AI agents. Every action intercepted. Every decision auditable.
5
5
  Author-email: Elmadani SALKA <contact@halyn.dev>
6
6
  License: BSL-1.1
@@ -20,7 +20,6 @@ Classifier: Topic :: Security
20
20
  Classifier: Topic :: System :: Systems Administration
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
- License-File: LICENSE
24
23
  Requires-Dist: aiohttp>=3.9
25
24
  Provides-Extra: iot
26
25
  Requires-Dist: paho-mqtt; extra == "iot"
@@ -29,7 +28,6 @@ Requires-Dist: pytest; extra == "dev"
29
28
  Requires-Dist: pytest-asyncio; extra == "dev"
30
29
  Requires-Dist: mypy; extra == "dev"
31
30
  Requires-Dist: ruff; extra == "dev"
32
- Dynamic: license-file
33
31
 
34
32
  <div align="center">
35
33
 
@@ -80,19 +78,21 @@ Every action produces a cryptographic proof stored locally. Not in the cloud. No
80
78
 
81
79
  ## Install
82
80
 
81
+ **Option 1 — pip** (Python 3.10+):
82
+
83
83
  ```bash
84
- pip install halyn
84
+ pip install halyn==2.1.3
85
85
  halyn serve
86
86
  ```
87
87
 
88
- Opens the dashboard at `http://localhost:7420`. Nothing leaves your machine.
88
+ **Option 2 curl** (Linux / macOS):
89
89
 
90
90
  ```bash
91
- # Or with curl
92
91
  curl -fsSL https://halyn.dev/install | bash
93
92
  ```
94
93
 
95
- The install script tells you exactly what it will do before doing anything.
94
+ Both options open the dashboard at `http://localhost:7420`. Nothing leaves your machine.
95
+ The curl script verifies your Python version and asks permission before doing anything.
96
96
 
97
97
  ---
98
98
 
@@ -175,7 +175,7 @@ This means: if an AI agent makes an API call or accesses your system, Halyn sees
175
175
  | Provider | Models (March 2026) | API |
176
176
  |----------|---------------------|-----|
177
177
  | **Anthropic** | Claude Sonnet 4.6, Claude Opus 4.6, Claude Haiku 4.5 | api.anthropic.com |
178
- | **OpenAI** | GPT-4.1, GPT-4.1 mini, GPT-4.1 nano, o3, o4-mini | api.openai.com |
178
+ | **OpenAI** | GPT| api.openai.com |
179
179
  | **Google** | Gemini 3.1 Pro, Gemini 3.1 Flash, Gemini 3.1 Flash-Lite | generativelanguage.googleapis.com |
180
180
  | **Mistral AI** | Mistral Large 2, Mistral Small 3, Codestral | api.mistral.ai |
181
181
  | **xAI** | Grok-3, Grok-3 mini | api.x.ai |
@@ -47,19 +47,21 @@ Every action produces a cryptographic proof stored locally. Not in the cloud. No
47
47
 
48
48
  ## Install
49
49
 
50
+ **Option 1 — pip** (Python 3.10+):
51
+
50
52
  ```bash
51
- pip install halyn
53
+ pip install halyn==2.1.3
52
54
  halyn serve
53
55
  ```
54
56
 
55
- Opens the dashboard at `http://localhost:7420`. Nothing leaves your machine.
57
+ **Option 2 curl** (Linux / macOS):
56
58
 
57
59
  ```bash
58
- # Or with curl
59
60
  curl -fsSL https://halyn.dev/install | bash
60
61
  ```
61
62
 
62
- The install script tells you exactly what it will do before doing anything.
63
+ Both options open the dashboard at `http://localhost:7420`. Nothing leaves your machine.
64
+ The curl script verifies your Python version and asks permission before doing anything.
63
65
 
64
66
  ---
65
67
 
@@ -142,7 +144,7 @@ This means: if an AI agent makes an API call or accesses your system, Halyn sees
142
144
  | Provider | Models (March 2026) | API |
143
145
  |----------|---------------------|-----|
144
146
  | **Anthropic** | Claude Sonnet 4.6, Claude Opus 4.6, Claude Haiku 4.5 | api.anthropic.com |
145
- | **OpenAI** | GPT-4.1, GPT-4.1 mini, GPT-4.1 nano, o3, o4-mini | api.openai.com |
147
+ | **OpenAI** | GPT| api.openai.com |
146
148
  | **Google** | Gemini 3.1 Pro, Gemini 3.1 Flash, Gemini 3.1 Flash-Lite | generativelanguage.googleapis.com |
147
149
  | **Mistral AI** | Mistral Large 2, Mistral Small 3, Codestral | api.mistral.ai |
148
150
  | **xAI** | Grok-3, Grok-3 mini | api.x.ai |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "halyn"
3
- version = "2.1.3"
3
+ version = "2.2.0"
4
4
  description = "Halyn — The governance layer for AI agents. Every action intercepted. Every decision auditable."
5
5
  requires-python = ">=3.10"
6
6
  license = {text = "BSL-1.1"}
@@ -38,6 +38,9 @@ Changelog = "https://github.com/halyndev/halyn/blob/main/CHANGELOG.md"
38
38
  [project.scripts]
39
39
  halyn = "halyn.cli:main"
40
40
 
41
+ [tool.setuptools]
42
+ license-files = []
43
+
41
44
  [tool.setuptools.packages.find]
42
45
  where = ["src"]
43
46
 
@@ -0,0 +1,17 @@
1
+ # Copyright (c) 2026 Elmadani SALKA
2
+ # NRP SDK bundled inside halyn — standalone distribution.
3
+ # External imports remain compatible: `from nrp import ...`
4
+ # Internal imports use: `from halyn._nrp import ...`
5
+
6
+ from .identity import NRPId
7
+ from .manifest import NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
8
+ from .events import NRPEvent, EventBus, EventSSE, Severity
9
+ from .driver import NRPDriver, ShieldRule, ShieldType
10
+
11
+ __version__ = "0.1.0"
12
+ __all__ = [
13
+ "NRPId",
14
+ "NRPManifest", "ChannelSpec", "ActionSpec", "ShieldSpec",
15
+ "NRPEvent", "EventBus", "EventSSE", "Severity",
16
+ "NRPDriver", "ShieldRule", "ShieldType",
17
+ ]
@@ -0,0 +1,112 @@
1
+ # Copyright (c) 2026 Elmadani SALKA.
2
+ # Licensed under the See LICENSE file.
3
+ """
4
+ NRP Driver v2 — Universal interface with Manifest + Events.
5
+
6
+ Every device implements:
7
+ manifest() — declare what you are and what you can do
8
+ observe() — read state
9
+ act() — change state
10
+ on_event() — push events to the control plane
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from abc import ABC, abstractmethod
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+ from typing import Any, Callable, Awaitable
19
+
20
+ from .identity import NRPId
21
+ from .manifest import NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
22
+ from .events import NRPEvent, EventBus, Severity
23
+
24
+
25
+ class ShieldType(str, Enum):
26
+ LIMIT = "limit"
27
+ ZONE = "zone"
28
+ THRESHOLD = "threshold"
29
+ PATTERN = "pattern"
30
+ COMMAND = "command"
31
+ CONFIRM = "confirm"
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ShieldRule:
36
+ name: str
37
+ type: ShieldType
38
+ value: Any = None
39
+ unit: str = ""
40
+ description: str = ""
41
+
42
+
43
+ class NRPDriver(ABC):
44
+ """
45
+ Base class for all NRP drivers.
46
+
47
+ Implement this to connect ANY device to AI.
48
+ Subclass and implement manifest(), observe(), act(), shield_rules().
49
+ """
50
+
51
+ def __init__(self) -> None:
52
+ self._event_bus: EventBus | None = None
53
+ self._nrp_id: NRPId | None = None
54
+
55
+ def bind(self, nrp_id: NRPId, event_bus: EventBus) -> None:
56
+ """Called by NRP Bridge at registration. Gives driver access to events."""
57
+ self._nrp_id = nrp_id
58
+ self._event_bus = event_bus
59
+
60
+ # ─── The 4 core methods ─────────────────────────
61
+
62
+ @abstractmethod
63
+ def manifest(self) -> NRPManifest:
64
+ """Declare everything about this node. Called once at registration."""
65
+
66
+ @abstractmethod
67
+ async def observe(self, channels: list[str] | None = None) -> dict[str, Any]:
68
+ """Read node state. Returns {channel_name: data}."""
69
+
70
+ @abstractmethod
71
+ async def act(self, command: str, args: dict[str, Any]) -> Any:
72
+ """Execute a command. Returns result."""
73
+
74
+ @abstractmethod
75
+ def shield_rules(self) -> list[ShieldRule]:
76
+ """Safety limits. Enforced by control plane."""
77
+
78
+ # ─── Events ─────────────────────────────────────
79
+
80
+ async def emit(self, name: str, severity: str = Severity.INFO, **data: Any) -> None:
81
+ """Push an event to the control plane."""
82
+ if self._event_bus and self._nrp_id:
83
+ event = NRPEvent(
84
+ source=self._nrp_id.uri,
85
+ name=name,
86
+ severity=severity,
87
+ data=data,
88
+ )
89
+ await self._event_bus.emit(event)
90
+
91
+ async def emit_emergency(self, name: str, **data: Any) -> None:
92
+ """Push an emergency event. Bypasses all queues."""
93
+ await self.emit(name, Severity.EMERGENCY, **data)
94
+
95
+ # ─── Lifecycle ──────────────────────────────────
96
+
97
+ async def connect(self) -> bool:
98
+ """Establish connection. Override for networked devices."""
99
+ return True
100
+
101
+ async def disconnect(self) -> None:
102
+ """Clean shutdown."""
103
+ pass
104
+
105
+ async def heartbeat(self) -> dict[str, Any]:
106
+ """Health check."""
107
+ try:
108
+ state = await self.observe(["status"])
109
+ return {"alive": True, **state}
110
+ except Exception as e:
111
+ return {"alive": False, "error": str(e)[:200]}
112
+
@@ -0,0 +1,234 @@
1
+ # Copyright (c) 2026 Elmadani SALKA.
2
+ # Licensed under the See LICENSE file.
3
+ """
4
+ NRP Events — Asynchronous event bus with severity-based routing.
5
+
6
+ Nodes emit events. The control plane dispatches to subscribers
7
+ based on pattern matching. Emergency events bypass the queue.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ import time
16
+ from collections import defaultdict
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Callable, Awaitable
19
+
20
+ log = logging.getLogger("jarvis.events")
21
+
22
+ EventHandler = Callable[["NRPEvent"], Awaitable[None] | None]
23
+
24
+
25
+ class Severity:
26
+ """Event severity levels."""
27
+ DEBUG = "debug"
28
+ INFO = "info"
29
+ WARNING = "warning"
30
+ CRITICAL = "critical"
31
+ EMERGENCY = "emergency" # Bypasses all queues
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class NRPEvent:
36
+ """Single event payload."""
37
+ source: str # NRP ID or node name
38
+ name: str # "temperature_changed", "battery_low", "collision"
39
+ severity: str # Severity level
40
+ data: dict[str, Any] = field(default_factory=dict)
41
+ timestamp: float = field(default_factory=time.time)
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ return {
45
+ "source": self.source,
46
+ "name": self.name,
47
+ "severity": self.severity,
48
+ "data": self.data,
49
+ "timestamp": self.timestamp,
50
+ }
51
+
52
+ def to_json(self) -> str:
53
+ return json.dumps(self.to_dict(), default=str, ensure_ascii=False)
54
+
55
+
56
+ class EventBus:
57
+ """Central event routing. Subscribe by pattern, receive matching events."""
58
+
59
+ __slots__ = ("_handlers", "_history", "_max_history", "_queue")
60
+
61
+ def __init__(self, max_history: int = 10_000) -> None:
62
+ self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
63
+ self._history: list[NRPEvent] = []
64
+ self._max_history = max_history
65
+ self._queue: asyncio.Queue[NRPEvent] = asyncio.Queue(maxsize=50_000)
66
+
67
+ def subscribe(self, pattern: str, handler: EventHandler) -> None:
68
+ """
69
+ Subscribe to events matching a pattern.
70
+
71
+ Patterns:
72
+ "*" — all events
73
+ "battery_low" — exact event name
74
+ "nrp://farm/*" — all events from farm scope
75
+ "temperature_*" — wildcard on event name
76
+ """
77
+ self._handlers[pattern].append(handler)
78
+ log.debug("events.subscribe pattern=%s", pattern)
79
+
80
+ def unsubscribe(self, pattern: str, handler: EventHandler) -> None:
81
+ if pattern in self._handlers:
82
+ self._handlers[pattern] = [h for h in self._handlers[pattern] if h is not handler]
83
+
84
+ async def emit(self, event: NRPEvent) -> int:
85
+ """
86
+ Emit an event. Routes to all matching handlers.
87
+ Returns number of handlers that received it.
88
+
89
+ EMERGENCY events are processed synchronously (no queue).
90
+ All others go through the async queue.
91
+ """
92
+ self._record(event)
93
+
94
+ if event.severity == Severity.EMERGENCY:
95
+ return await self._dispatch_now(event)
96
+
97
+ try:
98
+ self._queue.put_nowait(event)
99
+ except asyncio.QueueFull:
100
+ log.error("events.queue_full dropping=%s", event.name)
101
+ return 0 # Will be dispatched by process_loop
102
+
103
+ async def emit_simple(
104
+ self, source: str, name: str, severity: str = Severity.INFO, **data: Any
105
+ ) -> int:
106
+ """Shorthand for emitting events."""
107
+ return await self.emit(NRPEvent(source=source, name=name, severity=severity, data=data))
108
+
109
+ async def process_loop(self) -> None:
110
+ """Background loop that processes queued events. Run as asyncio task."""
111
+ log.info("events.loop_started")
112
+ while True:
113
+ try:
114
+ event = await asyncio.wait_for(self._queue.get(), timeout=1.0)
115
+ await self._dispatch_now(event)
116
+ except asyncio.TimeoutError:
117
+ continue
118
+ except Exception as exc:
119
+ log.exception("events.loop_error: %s", exc)
120
+
121
+ def recent(self, n: int = 50, source: str = "", name: str = "", severity: str = "") -> list[NRPEvent]:
122
+ """Query recent events with optional filters."""
123
+ events = self._history
124
+ if source:
125
+ events = [e for e in events if source in e.source]
126
+ if name:
127
+ events = [e for e in events if name in e.name]
128
+ if severity:
129
+ events = [e for e in events if e.severity == severity]
130
+ return events[-n:]
131
+
132
+ @property
133
+ def pending(self) -> int:
134
+ return self._queue.qsize()
135
+
136
+ @property
137
+ def total(self) -> int:
138
+ return len(self._history)
139
+
140
+ # ─── Internal ──────────────────────────────────
141
+
142
+ async def _dispatch_now(self, event: NRPEvent) -> int:
143
+ """Dispatch to all matching handlers immediately."""
144
+ import fnmatch
145
+ count = 0
146
+ for pattern, handlers in self._handlers.items():
147
+ matched = (
148
+ pattern == "*"
149
+ or pattern == event.name
150
+ or fnmatch.fnmatch(event.name, pattern)
151
+ or fnmatch.fnmatch(event.source, pattern)
152
+ )
153
+ if matched:
154
+ for handler in handlers:
155
+ try:
156
+ result = handler(event)
157
+ if asyncio.iscoroutine(result):
158
+ await result
159
+ count += 1
160
+ except Exception as exc:
161
+ log.error("events.handler_error pattern=%s error=%s", pattern, exc)
162
+ return count
163
+
164
+ def _record(self, event: NRPEvent) -> None:
165
+ """Store in history ring buffer."""
166
+ self._history.append(event)
167
+ if len(self._history) > self._max_history:
168
+ self._history = self._history[-self._max_history // 2:]
169
+
170
+ lvl = {
171
+ Severity.DEBUG: logging.DEBUG,
172
+ Severity.INFO: logging.INFO,
173
+ Severity.WARNING: logging.WARNING,
174
+ Severity.CRITICAL: logging.ERROR,
175
+ Severity.EMERGENCY: logging.CRITICAL,
176
+ }.get(event.severity, logging.INFO)
177
+ log.log(lvl, "event.%s source=%s data=%s", event.name, event.source,
178
+ json.dumps(event.data, default=str)[:200])
179
+
180
+
181
+ class EventSSE:
182
+ """Server-Sent Events endpoint. Streams events to HTTP clients in real-time."""
183
+
184
+ def __init__(self, bus: EventBus) -> None:
185
+ self.bus = bus
186
+ self._clients: list[asyncio.Queue[str]] = []
187
+
188
+ async def handler(self, request: Any) -> Any:
189
+ """aiohttp SSE handler. Each client gets a queue."""
190
+ from aiohttp import web
191
+ from aiohttp.web import StreamResponse
192
+
193
+ response = StreamResponse()
194
+ response.content_type = "text/event-stream"
195
+ response.headers["Cache-Control"] = "no-cache"
196
+ response.headers["X-Accel-Buffering"] = "no"
197
+ await response.prepare(request)
198
+
199
+ queue: asyncio.Queue[str] = asyncio.Queue(maxsize=1000)
200
+ self._clients.append(queue)
201
+
202
+ try:
203
+ while True:
204
+ try:
205
+ data = await asyncio.wait_for(queue.get(), timeout=30.0)
206
+ await response.write(f"data: {data}\n\n".encode())
207
+ except asyncio.TimeoutError:
208
+ # Keepalive
209
+ await response.write(b": keepalive\n\n")
210
+ except (ConnectionResetError, asyncio.CancelledError):
211
+ pass
212
+ finally:
213
+ self._clients.remove(queue)
214
+
215
+ return response
216
+
217
+ async def broadcast(self, event: NRPEvent) -> None:
218
+ """Send event to all SSE clients."""
219
+ data = event.to_json()
220
+ dead: list[asyncio.Queue[str]] = []
221
+ for client in self._clients:
222
+ try:
223
+ client.put_nowait(data)
224
+ except asyncio.QueueFull:
225
+ dead.append(client)
226
+ for d in dead:
227
+ self._clients.remove(d)
228
+
229
+ def wire(self, bus: EventBus) -> None:
230
+ """Subscribe to all events and broadcast to SSE clients."""
231
+ async def _forward(event: NRPEvent) -> None:
232
+ await self.broadcast(event)
233
+ bus.subscribe("*", _forward)
234
+
@@ -0,0 +1,88 @@
1
+ # Copyright (c) 2026 Elmadani SALKA.
2
+ # Licensed under the See LICENSE file.
3
+ """
4
+ NRP Identity — Universal node addressing.
5
+
6
+ Stable addressing: nrp://scope/kind/name
7
+ Survives IP changes, network moves, device replacement.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass
14
+
15
+ # nrp://factory-3/robot/arm-7
16
+ # nrp://home/sensor/kitchen-temp
17
+ # nrp://fleet/vehicle/truck-42
18
+ # nrp://farm/drone/survey-1
19
+ _NRP_PATTERN = re.compile(
20
+ r"^nrp://(?P<scope>[a-z0-9][a-z0-9._-]*)/"
21
+ r"(?P<kind>[a-z0-9][a-z0-9_-]*)/"
22
+ r"(?P<name>[a-z0-9][a-z0-9._-]*)$"
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class NRPId:
28
+ """Universal node identifier."""
29
+ scope: str # Location/org: "factory-3", "home", "fleet", "farm"
30
+ kind: str # Type: "robot", "sensor", "server", "vehicle", "drone"
31
+ name: str # Instance: "arm-7", "kitchen-temp", "truck-42"
32
+
33
+ @property
34
+ def uri(self) -> str:
35
+ return f"nrp://{self.scope}/{self.kind}/{self.name}"
36
+
37
+ @property
38
+ def short(self) -> str:
39
+ """Short form for display: kind/name."""
40
+ return f"{self.kind}/{self.name}"
41
+
42
+ def matches(self, pattern: str) -> bool:
43
+ """Match against glob patterns. E.g. 'factory-*/robot/*'."""
44
+ import fnmatch
45
+ return fnmatch.fnmatch(self.uri, f"nrp://{pattern}")
46
+
47
+ @classmethod
48
+ def parse(cls, uri: str) -> NRPId:
49
+ """Parse 'nrp://scope/kind/name' into NRPId."""
50
+ m = _NRP_PATTERN.match(uri.lower().strip())
51
+ if not m:
52
+ raise ValueError(
53
+ f"Invalid NRP ID: {uri!r}. "
54
+ f"Format: nrp://scope/kind/name (lowercase, alphanumeric, hyphens)"
55
+ )
56
+ return cls(scope=m["scope"], kind=m["kind"], name=m["name"])
57
+
58
+ @classmethod
59
+ def create(cls, scope: str, kind: str, name: str) -> NRPId:
60
+ """Create and validate an NRP ID."""
61
+ nid = cls(scope=scope.lower(), kind=kind.lower(), name=name.lower())
62
+ # Validate by roundtripping through parse
63
+ NRPId.parse(nid.uri)
64
+ return nid
65
+
66
+
67
+ # Aliases for common naming conventions
68
+ @property
69
+ def domain(self) -> str:
70
+ """Alias for scope."""
71
+ return self.scope
72
+
73
+ @property
74
+ def device(self) -> str:
75
+ """Alias for kind."""
76
+ return self.kind
77
+
78
+ @property
79
+ def instance(self) -> str:
80
+ """Alias for name."""
81
+ return self.name
82
+
83
+ def __str__(self) -> str:
84
+ return self.uri
85
+
86
+ def __repr__(self) -> str:
87
+ return f"NRPId({self.uri!r})"
88
+
@@ -0,0 +1,166 @@
1
+ # Copyright (c) 2026 Elmadani SALKA.
2
+ # Licensed under the See LICENSE file.
3
+ """
4
+ NRP Manifest — Self-describing nodes.
5
+
6
+ Structured capability declaration for NRP nodes.
7
+ Channels (observe), actions (act), and constraints (shield)
8
+ are declared once and consumed by any control plane.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ from dataclasses import dataclass, field
16
+ from typing import Any
17
+
18
+ from .identity import NRPId
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class ChannelSpec:
23
+ """One observable channel (sensor, metric, state)."""
24
+ name: str
25
+ type: str # "float", "int", "bool", "string", "float[]", "image", "json"
26
+ unit: str = "" # "°C", "rad", "m/s", "percent", "Pa", ""
27
+ rate: str = "" # "100Hz", "1Hz", "on_change", "on_request"
28
+ description: str = ""
29
+ range: list[float] | None = None # [min, max] if applicable
30
+
31
+ def to_dict(self) -> dict[str, Any]:
32
+ d: dict[str, Any] = {"name": self.name, "type": self.type}
33
+ if self.unit: d["unit"] = self.unit
34
+ if self.rate: d["rate"] = self.rate
35
+ if self.description: d["description"] = self.description
36
+ if self.range: d["range"] = self.range
37
+ return d
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class ActionSpec:
42
+ """One action the node can perform."""
43
+ name: str
44
+ args: dict[str, str] = field(default_factory=dict) # arg_name -> "type description"
45
+ description: str = ""
46
+ dangerous: bool = False
47
+ priority: str = "normal" # "normal", "high", "critical"
48
+ returns: str = "" # What it returns
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ d: dict[str, Any] = {"name": self.name}
52
+ if self.args: d["args"] = self.args
53
+ if self.description: d["description"] = self.description
54
+ if self.dangerous: d["dangerous"] = True
55
+ if self.priority != "normal": d["priority"] = self.priority
56
+ if self.returns: d["returns"] = self.returns
57
+ return d
58
+
59
+
60
+ @dataclass(slots=True)
61
+ class ShieldSpec:
62
+ """One safety constraint."""
63
+ name: str
64
+ type: str # "limit", "zone", "threshold", "pattern", "confirm"
65
+ value: Any = None
66
+ unit: str = ""
67
+ description: str = ""
68
+
69
+ def to_dict(self) -> dict[str, Any]:
70
+ d: dict[str, Any] = {"name": self.name, "type": self.type}
71
+ if self.value is not None: d["value"] = self.value
72
+ if self.unit: d["unit"] = self.unit
73
+ if self.description: d["description"] = self.description
74
+ return d
75
+
76
+
77
+ @dataclass(slots=True)
78
+ class NRPManifest:
79
+ """Complete capability descriptor for one NRP node."""
80
+
81
+ # Identity
82
+ nrp_id: NRPId
83
+ manufacturer: str = ""
84
+ model: str = ""
85
+ firmware: str = ""
86
+
87
+ # Capabilities
88
+ observe: list[ChannelSpec] = field(default_factory=list)
89
+ act: list[ActionSpec] = field(default_factory=list)
90
+ shield: list[ShieldSpec] = field(default_factory=list)
91
+
92
+ # Metadata
93
+ tags: dict[str, str] = field(default_factory=dict)
94
+ registered_at: float = field(default_factory=time.time)
95
+
96
+ def to_dict(self) -> dict[str, Any]:
97
+ return {
98
+ "nrp_id": self.nrp_id.uri,
99
+ "manufacturer": self.manufacturer,
100
+ "model": self.model,
101
+ "firmware": self.firmware,
102
+ "observe": [c.to_dict() for c in self.observe],
103
+ "act": [a.to_dict() for a in self.act],
104
+ "shield": [s.to_dict() for s in self.shield],
105
+ "tags": self.tags,
106
+ }
107
+
108
+ def to_json(self, indent: int = 2) -> str:
109
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
110
+
111
+ def to_llm_description(self) -> str:
112
+ """Structured text summary suitable for LLM context injection."""
113
+ lines = [f"Node: {self.nrp_id.uri}"]
114
+ if self.manufacturer:
115
+ lines.append(f" Device: {self.manufacturer} {self.model}")
116
+
117
+ if self.observe:
118
+ lines.append(" Observe (read state):")
119
+ for ch in self.observe:
120
+ unit = f" ({ch.unit})" if ch.unit else ""
121
+ desc = f" — {ch.description}" if ch.description else ""
122
+ lines.append(f" {ch.name}: {ch.type}{unit}{desc}")
123
+
124
+ if self.act:
125
+ lines.append(" Act (commands):")
126
+ for a in self.act:
127
+ args_str = ", ".join(f"{k}: {v}" for k, v in a.args.items()) if a.args else "none"
128
+ danger = " [DANGEROUS]" if a.dangerous else ""
129
+ prio = f" [PRIORITY={a.priority}]" if a.priority != "normal" else ""
130
+ desc = f" — {a.description}" if a.description else ""
131
+ lines.append(f" {a.name}({args_str}){danger}{prio}{desc}")
132
+
133
+ if self.shield:
134
+ lines.append(" Shield (safety limits):")
135
+ for s in self.shield:
136
+ unit = f" {s.unit}" if s.unit else ""
137
+ lines.append(f" {s.name}: {s.type} = {s.value}{unit}")
138
+
139
+ return "\n".join(lines)
140
+
141
+ @classmethod
142
+ def from_dict(cls, data: dict[str, Any]) -> NRPManifest:
143
+ """Parse a manifest from JSON/dict."""
144
+ nrp_id = NRPId.parse(data["nrp_id"])
145
+ return cls(
146
+ nrp_id=nrp_id,
147
+ manufacturer=data.get("manufacturer", ""),
148
+ model=data.get("model", ""),
149
+ firmware=data.get("firmware", ""),
150
+ observe=[ChannelSpec(**c) for c in data.get("observe", [])],
151
+ act=[ActionSpec(
152
+ name=a["name"],
153
+ args=a.get("args", {}),
154
+ description=a.get("description", ""),
155
+ dangerous=a.get("dangerous", False),
156
+ priority=a.get("priority", "normal"),
157
+ returns=a.get("returns", ""),
158
+ ) for a in data.get("act", [])],
159
+ shield=[ShieldSpec(**s) for s in data.get("shield", [])],
160
+ tags=data.get("tags", {}),
161
+ )
162
+
163
+ @classmethod
164
+ def from_json(cls, text: str) -> NRPManifest:
165
+ return cls.from_dict(json.loads(text))
166
+
@@ -20,7 +20,7 @@ import time
20
20
  from dataclasses import dataclass, field
21
21
  from typing import Any
22
22
 
23
- from nrp import NRPDriver, NRPId, NRPManifest, EventBus, Severity
23
+ from halyn._nrp import NRPDriver, NRPId, NRPManifest, EventBus, Severity
24
24
 
25
25
  from .engine import Engine
26
26
  from .types import Action, Result, ActionStatus
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  import json
10
10
  import subprocess
11
11
  from typing import Any
12
- from nrp import NRPDriver, ShieldRule, ShieldType
12
+ from halyn._nrp import NRPDriver, ShieldRule, ShieldType
13
13
 
14
14
  class BrowserDriver(NRPDriver):
15
15
  def __init__(self, cdp_url: str = "http://localhost:9222") -> None:
@@ -17,7 +17,7 @@ import logging
17
17
  import time
18
18
  from typing import Any
19
19
 
20
- from nrp import (
20
+ from halyn._nrp import (
21
21
  NRPDriver, NRPManifest, NRPId,
22
22
  ChannelSpec, ActionSpec, ShieldSpec, ShieldRule, ShieldType,
23
23
  )
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  import json
10
10
  import subprocess
11
11
  from typing import Any
12
- from nrp import NRPDriver, ShieldRule, ShieldType
12
+ from halyn._nrp import NRPDriver, ShieldRule, ShieldType
13
13
 
14
14
  class DockerDriver(NRPDriver):
15
15
  def __init__(self, host: str = "unix:///var/run/docker.sock") -> None:
@@ -18,7 +18,7 @@ import logging
18
18
  from typing import Any
19
19
  from urllib.parse import urljoin
20
20
 
21
- from nrp import NRPDriver, ShieldRule, ShieldType
21
+ from halyn._nrp import NRPDriver, ShieldRule, ShieldType
22
22
 
23
23
  log = logging.getLogger("halyn.drivers.http_auto")
24
24
 
@@ -60,7 +60,7 @@ class HTTPAutoDriver(NRPDriver):
60
60
  self._endpoints: list[dict[str, Any]] = []
61
61
 
62
62
  def manifest(self):
63
- from nrp import NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
63
+ from halyn._nrp import NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
64
64
  nrp_id = self._nrp_id
65
65
  observe_channels = [
66
66
  ChannelSpec("health", "string", description="API health status"),
@@ -16,7 +16,7 @@ import json
16
16
  import time
17
17
  from typing import Any
18
18
 
19
- from nrp import NRPDriver, ShieldRule, ShieldType
19
+ from halyn._nrp import NRPDriver, ShieldRule, ShieldType
20
20
 
21
21
 
22
22
  class MQTTDriver(NRPDriver):
@@ -10,7 +10,7 @@ The language of factories.
10
10
  """
11
11
  from __future__ import annotations
12
12
  from typing import Any
13
- from nrp import NRPDriver, ShieldRule, ShieldType
13
+ from halyn._nrp import NRPDriver, ShieldRule, ShieldType
14
14
 
15
15
  class OPCUADriver(NRPDriver):
16
16
  def __init__(self, endpoint: str = "opc.tcp://localhost:4840",
@@ -16,7 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  from typing import Any
18
18
 
19
- from nrp import NRPDriver, ShieldRule, ShieldType
19
+ from halyn._nrp import NRPDriver, ShieldRule, ShieldType
20
20
 
21
21
 
22
22
  class ROS2Driver(NRPDriver):
@@ -15,7 +15,7 @@ import logging
15
15
  import struct
16
16
  from typing import Any
17
17
 
18
- from nrp import NRPDriver, NRPManifest, NRPId, ChannelSpec, ActionSpec, ShieldSpec, ShieldRule, ShieldType
18
+ from halyn._nrp import NRPDriver, NRPManifest, NRPId, ChannelSpec, ActionSpec, ShieldSpec, ShieldRule, ShieldType
19
19
 
20
20
  log = logging.getLogger("halyn.drivers.serial")
21
21
 
@@ -17,7 +17,7 @@ import socket
17
17
  import time
18
18
  from typing import Any
19
19
 
20
- from nrp import (
20
+ from halyn._nrp import (
21
21
  NRPDriver, NRPManifest, NRPId,
22
22
  ChannelSpec, ActionSpec, ShieldSpec, ShieldRule, ShieldType,
23
23
  )
@@ -12,9 +12,9 @@ import subprocess
12
12
  import shlex
13
13
  from typing import Any
14
14
 
15
- from nrp import NRPDriver, ShieldRule, ShieldType
16
- from nrp import NRPId
17
- from nrp import NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
15
+ from halyn._nrp import NRPDriver, ShieldRule, ShieldType
16
+ from halyn._nrp import NRPId
17
+ from halyn._nrp import NRPManifest, ChannelSpec, ActionSpec, ShieldSpec
18
18
 
19
19
 
20
20
  class SSHDriver(NRPDriver):
@@ -10,7 +10,7 @@ Falls back to HTTP API if SDK not available.
10
10
  """
11
11
  from __future__ import annotations
12
12
  from typing import Any
13
- from nrp import NRPDriver, ShieldRule, ShieldType
13
+ from halyn._nrp import NRPDriver, ShieldRule, ShieldType
14
14
 
15
15
  class UnitreeDriver(NRPDriver):
16
16
  def __init__(self, robot_ip: str = "192.168.123.161", model: str = "g1") -> None:
@@ -17,7 +17,7 @@ import logging
17
17
  import time
18
18
  from typing import Any
19
19
 
20
- from nrp import (
20
+ from halyn._nrp import (
21
21
  NRPDriver, NRPManifest, NRPId,
22
22
  ChannelSpec, ActionSpec, ShieldSpec, ShieldRule, ShieldType, Severity,
23
23
  )
@@ -20,11 +20,11 @@ from __future__ import annotations
20
20
  import logging
21
21
  from typing import Any
22
22
 
23
- from nrp import NRPDriver, ShieldRule, ShieldType
23
+ from halyn._nrp import NRPDriver, ShieldRule, ShieldType
24
24
  from .engine import Engine
25
- from nrp import NRPId
26
- from nrp import NRPManifest
27
- from nrp import EventBus, NRPEvent, Severity
25
+ from halyn._nrp import NRPId
26
+ from halyn._nrp import NRPManifest
27
+ from halyn._nrp import EventBus, NRPEvent, Severity
28
28
  from .types import Node, NodeKind, ToolCategory, ActionStatus
29
29
 
30
30
  log = logging.getLogger("halyn.nrp")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: halyn
3
- Version: 2.1.3
3
+ Version: 2.2.0
4
4
  Summary: Halyn — The governance layer for AI agents. Every action intercepted. Every decision auditable.
5
5
  Author-email: Elmadani SALKA <contact@halyn.dev>
6
6
  License: BSL-1.1
@@ -20,7 +20,6 @@ Classifier: Topic :: Security
20
20
  Classifier: Topic :: System :: Systems Administration
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
- License-File: LICENSE
24
23
  Requires-Dist: aiohttp>=3.9
25
24
  Provides-Extra: iot
26
25
  Requires-Dist: paho-mqtt; extra == "iot"
@@ -29,7 +28,6 @@ Requires-Dist: pytest; extra == "dev"
29
28
  Requires-Dist: pytest-asyncio; extra == "dev"
30
29
  Requires-Dist: mypy; extra == "dev"
31
30
  Requires-Dist: ruff; extra == "dev"
32
- Dynamic: license-file
33
31
 
34
32
  <div align="center">
35
33
 
@@ -80,19 +78,21 @@ Every action produces a cryptographic proof stored locally. Not in the cloud. No
80
78
 
81
79
  ## Install
82
80
 
81
+ **Option 1 — pip** (Python 3.10+):
82
+
83
83
  ```bash
84
- pip install halyn
84
+ pip install halyn==2.1.3
85
85
  halyn serve
86
86
  ```
87
87
 
88
- Opens the dashboard at `http://localhost:7420`. Nothing leaves your machine.
88
+ **Option 2 curl** (Linux / macOS):
89
89
 
90
90
  ```bash
91
- # Or with curl
92
91
  curl -fsSL https://halyn.dev/install | bash
93
92
  ```
94
93
 
95
- The install script tells you exactly what it will do before doing anything.
94
+ Both options open the dashboard at `http://localhost:7420`. Nothing leaves your machine.
95
+ The curl script verifies your Python version and asks permission before doing anything.
96
96
 
97
97
  ---
98
98
 
@@ -175,7 +175,7 @@ This means: if an AI agent makes an API call or accesses your system, Halyn sees
175
175
  | Provider | Models (March 2026) | API |
176
176
  |----------|---------------------|-----|
177
177
  | **Anthropic** | Claude Sonnet 4.6, Claude Opus 4.6, Claude Haiku 4.5 | api.anthropic.com |
178
- | **OpenAI** | GPT-4.1, GPT-4.1 mini, GPT-4.1 nano, o3, o4-mini | api.openai.com |
178
+ | **OpenAI** | GPT| api.openai.com |
179
179
  | **Google** | Gemini 3.1 Pro, Gemini 3.1 Flash, Gemini 3.1 Flash-Lite | generativelanguage.googleapis.com |
180
180
  | **Mistral AI** | Mistral Large 2, Mistral Small 3, Codestral | api.mistral.ai |
181
181
  | **xAI** | Grok-3, Grok-3 mini | api.x.ai |
@@ -1,4 +1,3 @@
1
- LICENSE
2
1
  README.md
3
2
  pyproject.toml
4
3
  src/halyn/__init__.py
@@ -30,6 +29,11 @@ src/halyn.egg-info/dependency_links.txt
30
29
  src/halyn.egg-info/entry_points.txt
31
30
  src/halyn.egg-info/requires.txt
32
31
  src/halyn.egg-info/top_level.txt
32
+ src/halyn/_nrp/__init__.py
33
+ src/halyn/_nrp/driver.py
34
+ src/halyn/_nrp/events.py
35
+ src/halyn/_nrp/identity.py
36
+ src/halyn/_nrp/manifest.py
33
37
  src/halyn/drivers/__init__.py
34
38
  src/halyn/drivers/browser.py
35
39
  src/halyn/drivers/dds.py
halyn-2.1.3/LICENSE DELETED
@@ -1,58 +0,0 @@
1
- Business Source License 1.1
2
-
3
- Licensor: Elmadani SALKA
4
- Licensed Work: Halyn
5
- The Licensed Work is (c) 2026 Elmadani SALKA
6
- Change Date: 2029-03-25
7
- Change License: MIT License
8
-
9
- Additional Use Grant: You may use the Licensed Work for non-commercial purposes,
10
- research, education, and personal projects free of charge.
11
- Commercial use requires a separate commercial license.
12
- Contact: contact@halyn.dev
13
-
14
- ---
15
-
16
- The Business Source License (this document, or the "License") is not an Open
17
- Source license. However, the Licensed Work will eventually be made available
18
- under an Open Source License, as stated in this License.
19
-
20
- License text copyright (c) 2017 MariaDB Corporation Ab, All Rights Reserved.
21
- "Business Source License" is a trademark of MariaDB Corporation Ab.
22
-
23
- Parameters
24
-
25
- Licensor: Elmadani SALKA
26
- Licensed Work: Halyn — Enforceable safety for AI agents
27
- Change Date: 2029-03-25
28
- Change License: MIT License
29
-
30
- For information about alternative licensing arrangements, contact:
31
- contact@halyn.dev · https://halyn.dev
32
-
33
- ---
34
-
35
- 1. Grant of Rights
36
-
37
- The Licensor hereby grants you the right to copy, modify, create derivative
38
- works, redistribute, and make non-production use of the Licensed Work.
39
-
40
- The Licensor may make an Additional Use Grant, above, permitting limited
41
- production use.
42
-
43
- 2. Change Date
44
-
45
- After the Change Date, the Grant of Rights is governed by the Change License.
46
-
47
- 3. No Other Rights
48
-
49
- The License does not grant you any right in any trademark or logo of Licensor.
50
-
51
- 4. Disclaimer of Warranty
52
-
53
- UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE EXTENT
54
- POSSIBLE, THE LICENSOR OFFERS THE LICENSED WORK AS-IS AND AS-AVAILABLE, AND
55
- MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE LICENSED WORK,
56
- WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHER.
57
-
58
- Full BSL 1.1 text: https://mariadb.com/bsl11/
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes