halyn 2.1.3__tar.gz → 2.2.1__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.1}/PKG-INFO +11 -9
  2. {halyn-2.1.3 → halyn-2.2.1}/README.md +9 -7
  3. {halyn-2.1.3 → halyn-2.2.1}/pyproject.toml +4 -3
  4. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/__init__.py +3 -3
  5. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/__main__.py +1 -1
  6. halyn-2.2.1/src/halyn/_nrp/__init__.py +17 -0
  7. halyn-2.2.1/src/halyn/_nrp/driver.py +112 -0
  8. halyn-2.2.1/src/halyn/_nrp/events.py +234 -0
  9. halyn-2.2.1/src/halyn/_nrp/identity.py +88 -0
  10. halyn-2.2.1/src/halyn/_nrp/manifest.py +166 -0
  11. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/audit.py +1 -1
  12. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/auth.py +1 -1
  13. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/autonomy.py +1 -1
  14. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/cli.py +3 -2
  15. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/config.py +1 -1
  16. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/consent.py +1 -1
  17. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/control_plane.py +2 -2
  18. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/dashboard.py +1 -1
  19. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/discovery.py +1 -1
  20. {halyn-2.1.3/src/halyn/memory → halyn-2.2.1/src/halyn/drivers}/__init__.py +1 -1
  21. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/browser.py +2 -2
  22. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/dds.py +2 -2
  23. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/docker.py +2 -2
  24. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/http_auto.py +3 -3
  25. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/mqtt.py +2 -2
  26. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/opcua.py +2 -2
  27. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/ros2.py +2 -2
  28. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/serial.py +2 -2
  29. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/socket_raw.py +2 -2
  30. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/ssh.py +4 -4
  31. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/unitree.py +2 -2
  32. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/drivers/websocket.py +2 -2
  33. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/engine.py +1 -1
  34. {halyn-2.1.3/src/halyn/drivers → halyn-2.2.1/src/halyn/integrations}/__init__.py +1 -1
  35. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/intent.py +1 -1
  36. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/llm.py +1 -1
  37. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/mcp.py +1 -1
  38. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/mcp_serve.py +1 -1
  39. {halyn-2.1.3/src/halyn/integrations → halyn-2.2.1/src/halyn/memory}/__init__.py +1 -1
  40. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/memory/store.py +1 -1
  41. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/nrp_bridge.py +5 -5
  42. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/sanitizer.py +1 -1
  43. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/security/__init__.py +1 -1
  44. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/security/audit_guard.py +1 -1
  45. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/security/ebpf_monitor.py +1 -1
  46. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/security/fs_watch.py +1 -1
  47. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/security/process_guard.py +1 -1
  48. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/security/proxy.py +1 -1
  49. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/server.py +1 -1
  50. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/shield.py +1 -1
  51. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/types.py +1 -1
  52. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/watchdog.py +1 -1
  53. {halyn-2.1.3 → halyn-2.2.1/src/halyn.egg-info}/PKG-INFO +11 -9
  54. {halyn-2.1.3 → halyn-2.2.1}/src/halyn.egg-info/SOURCES.txt +5 -0
  55. {halyn-2.1.3 → halyn-2.2.1}/tests/test_halyn.py +1 -1
  56. {halyn-2.1.3 → halyn-2.2.1}/LICENSE +0 -0
  57. {halyn-2.1.3 → halyn-2.2.1}/setup.cfg +0 -0
  58. {halyn-2.1.3 → halyn-2.2.1}/src/halyn/py.typed +0 -0
  59. {halyn-2.1.3 → halyn-2.2.1}/src/halyn.egg-info/dependency_links.txt +0 -0
  60. {halyn-2.1.3 → halyn-2.2.1}/src/halyn.egg-info/entry_points.txt +0 -0
  61. {halyn-2.1.3 → halyn-2.2.1}/src/halyn.egg-info/requires.txt +0 -0
  62. {halyn-2.1.3 → halyn-2.2.1}/src/halyn.egg-info/top_level.txt +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: halyn
3
- Version: 2.1.3
3
+ Version: 2.2.1
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
- License: BSL-1.1
6
+ License-Expression: BUSL-1.1
7
7
  Project-URL: Homepage, https://halyn.dev
8
8
  Project-URL: Repository, https://github.com/halyndev/halyn
9
9
  Project-URL: Issues, https://github.com/halyndev/halyn/issues
@@ -39,7 +39,7 @@ Dynamic: license-file
39
39
 
40
40
  [![PyPI](https://img.shields.io/pypi/v/halyn?style=flat-square&color=20c754)](https://pypi.org/project/halyn/)
41
41
  [![Python 3.10+](https://img.shields.io/badge/Python-3.10+-20c754?style=flat-square)](https://python.org)
42
- [![License: BSL-1.1](https://img.shields.io/badge/License-BSL--1.1-orange?style=flat-square)](LICENSE)
42
+ [![License: BSL-1.1](https://img.shields.io/badge/License-BUSL--1.1-orange?style=flat-square)](LICENSE)
43
43
  [![Website](https://img.shields.io/badge/Website-halyn.dev-20c754?style=flat-square)](https://halyn.dev)
44
44
 
45
45
  Every action intercepted. Every decision auditable. The AI cannot bypass it.
@@ -80,19 +80,21 @@ Every action produces a cryptographic proof stored locally. Not in the cloud. No
80
80
 
81
81
  ## Install
82
82
 
83
+ **Option 1 — pip** (Python 3.10+):
84
+
83
85
  ```bash
84
- pip install halyn
86
+ pip install halyn==2.1.3
85
87
  halyn serve
86
88
  ```
87
89
 
88
- Opens the dashboard at `http://localhost:7420`. Nothing leaves your machine.
90
+ **Option 2 curl** (Linux / macOS):
89
91
 
90
92
  ```bash
91
- # Or with curl
92
93
  curl -fsSL https://halyn.dev/install | bash
93
94
  ```
94
95
 
95
- The install script tells you exactly what it will do before doing anything.
96
+ Both options open the dashboard at `http://localhost:7420`. Nothing leaves your machine.
97
+ The curl script verifies your Python version and asks permission before doing anything.
96
98
 
97
99
  ---
98
100
 
@@ -175,7 +177,7 @@ This means: if an AI agent makes an API call or accesses your system, Halyn sees
175
177
  | Provider | Models (March 2026) | API |
176
178
  |----------|---------------------|-----|
177
179
  | **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 |
180
+ | **OpenAI** | GPT| api.openai.com |
179
181
  | **Google** | Gemini 3.1 Pro, Gemini 3.1 Flash, Gemini 3.1 Flash-Lite | generativelanguage.googleapis.com |
180
182
  | **Mistral AI** | Mistral Large 2, Mistral Small 3, Codestral | api.mistral.ai |
181
183
  | **xAI** | Grok-3, Grok-3 mini | api.x.ai |
@@ -287,7 +289,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
287
289
 
288
290
  ## License
289
291
 
290
- **[BSL-1.1](LICENSE)** — Business Source License 1.1
292
+ **[BUSL-1.1](LICENSE)** — Business Source License 1.1
291
293
 
292
294
  - **Free** for personal use, research, and education
293
295
  - **Commercial use** requires a license — [contact@halyn.dev](mailto:contact@halyn.dev)
@@ -6,7 +6,7 @@
6
6
 
7
7
  [![PyPI](https://img.shields.io/pypi/v/halyn?style=flat-square&color=20c754)](https://pypi.org/project/halyn/)
8
8
  [![Python 3.10+](https://img.shields.io/badge/Python-3.10+-20c754?style=flat-square)](https://python.org)
9
- [![License: BSL-1.1](https://img.shields.io/badge/License-BSL--1.1-orange?style=flat-square)](LICENSE)
9
+ [![License: BSL-1.1](https://img.shields.io/badge/License-BUSL--1.1-orange?style=flat-square)](LICENSE)
10
10
  [![Website](https://img.shields.io/badge/Website-halyn.dev-20c754?style=flat-square)](https://halyn.dev)
11
11
 
12
12
  Every action intercepted. Every decision auditable. The AI cannot bypass it.
@@ -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 |
@@ -254,7 +256,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
254
256
 
255
257
  ## License
256
258
 
257
- **[BSL-1.1](LICENSE)** — Business Source License 1.1
259
+ **[BUSL-1.1](LICENSE)** — Business Source License 1.1
258
260
 
259
261
  - **Free** for personal use, research, and education
260
262
  - **Commercial use** requires a license — [contact@halyn.dev](mailto:contact@halyn.dev)
@@ -1,9 +1,10 @@
1
1
  [project]
2
2
  name = "halyn"
3
- version = "2.1.3"
3
+ version = "2.2.1"
4
4
  description = "Halyn — The governance layer for AI agents. Every action intercepted. Every decision auditable."
5
5
  requires-python = ">=3.10"
6
- license = {text = "BSL-1.1"}
6
+ license = "BUSL-1.1"
7
+ license-files = ["LICENSE"]
7
8
  authors = [{name = "Elmadani SALKA", email = "contact@halyn.dev"}]
8
9
  readme = "README.md"
9
10
  keywords = [
@@ -45,7 +46,7 @@ where = ["src"]
45
46
  halyn = ["py.typed"]
46
47
 
47
48
  [build-system]
48
- requires = ["setuptools>=68", "wheel"]
49
+ requires = ["setuptools>=77", "wheel"]
49
50
  build-backend = "setuptools.build_meta"
50
51
 
51
52
  [tool.ruff]
@@ -1,5 +1,5 @@
1
1
  # Copyright (c) 2026 Elmadani SALKA
2
- # Licensed under BSL-1.1. See LICENSE file.
2
+ # Licensed under BUSL-1.1. See LICENSE file.
3
3
  # Commercial use requires a license — contact@halyn.dev
4
4
 
5
5
  """
@@ -9,9 +9,9 @@ Every action intercepted. Every decision auditable.
9
9
  The AI cannot bypass it.
10
10
  """
11
11
 
12
- __version__ = "2.1.3"
12
+ __version__ = "2.2.1"
13
13
  __author__ = "Elmadani SALKA"
14
- __license__ = "BSL-1.1"
14
+ __license__ = "BUSL-1.1"
15
15
  __email__ = "contact@halyn.dev"
16
16
  __url__ = "https://halyn.dev"
17
17
 
@@ -1,5 +1,5 @@
1
1
  # Copyright (c) 2026 Elmadani SALKA
2
- # Licensed under BSL-1.1. See LICENSE file.
2
+ # Licensed under BUSL-1.1. See LICENSE file.
3
3
  # Commercial use requires a license — contact@halyn.dev
4
4
 
5
5
  from .cli import main
@@ -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
+