halyn 2.1.2__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.
- {halyn-2.1.2/src/halyn.egg-info → halyn-2.2.0}/PKG-INFO +8 -8
- {halyn-2.1.2 → halyn-2.2.0}/README.md +7 -5
- {halyn-2.1.2 → halyn-2.2.0}/pyproject.toml +4 -1
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/__init__.py +1 -1
- halyn-2.2.0/src/halyn/_nrp/__init__.py +17 -0
- halyn-2.2.0/src/halyn/_nrp/driver.py +112 -0
- halyn-2.2.0/src/halyn/_nrp/events.py +234 -0
- halyn-2.2.0/src/halyn/_nrp/identity.py +88 -0
- halyn-2.2.0/src/halyn/_nrp/manifest.py +166 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/control_plane.py +1 -1
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/drivers/browser.py +1 -1
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/drivers/dds.py +1 -1
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/drivers/docker.py +1 -1
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/drivers/http_auto.py +2 -2
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/drivers/mqtt.py +1 -1
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/drivers/opcua.py +1 -1
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/drivers/ros2.py +1 -1
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/drivers/serial.py +1 -1
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/drivers/socket_raw.py +1 -1
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/drivers/ssh.py +3 -3
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/drivers/unitree.py +1 -1
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/drivers/websocket.py +1 -1
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/nrp_bridge.py +4 -4
- {halyn-2.1.2 → halyn-2.2.0/src/halyn.egg-info}/PKG-INFO +8 -8
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn.egg-info/SOURCES.txt +5 -1
- {halyn-2.1.2 → halyn-2.2.0}/tests/test_halyn.py +4 -1
- halyn-2.1.2/LICENSE +0 -58
- {halyn-2.1.2 → halyn-2.2.0}/setup.cfg +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/__main__.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/audit.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/auth.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/autonomy.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/cli.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/config.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/consent.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/dashboard.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/discovery.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/drivers/__init__.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/engine.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/integrations/__init__.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/intent.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/llm.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/mcp.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/mcp_serve.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/memory/__init__.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/memory/store.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/py.typed +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/sanitizer.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/security/__init__.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/security/audit_guard.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/security/ebpf_monitor.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/security/fs_watch.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/security/process_guard.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/security/proxy.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/server.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/shield.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/types.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn/watchdog.py +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn.egg-info/dependency_links.txt +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn.egg-info/entry_points.txt +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn.egg-info/requires.txt +0 -0
- {halyn-2.1.2 → halyn-2.2.0}/src/halyn.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: halyn
|
|
3
|
-
Version: 2.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
@@ -9,7 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
import json
|
|
10
10
|
import subprocess
|
|
11
11
|
from typing import Any
|
|
12
|
-
from
|
|
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
|
|
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
|
|
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"),
|
|
@@ -10,7 +10,7 @@ The language of factories.
|
|
|
10
10
|
"""
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
from typing import Any
|
|
13
|
-
from
|
|
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",
|
|
@@ -15,7 +15,7 @@ import logging
|
|
|
15
15
|
import struct
|
|
16
16
|
from typing import Any
|
|
17
17
|
|
|
18
|
-
from
|
|
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
|
|
|
@@ -12,9 +12,9 @@ import subprocess
|
|
|
12
12
|
import shlex
|
|
13
13
|
from typing import Any
|
|
14
14
|
|
|
15
|
-
from
|
|
16
|
-
from
|
|
17
|
-
from
|
|
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
|
|
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:
|
|
@@ -20,11 +20,11 @@ from __future__ import annotations
|
|
|
20
20
|
import logging
|
|
21
21
|
from typing import Any
|
|
22
22
|
|
|
23
|
-
from
|
|
23
|
+
from halyn._nrp import NRPDriver, ShieldRule, ShieldType
|
|
24
24
|
from .engine import Engine
|
|
25
|
-
from
|
|
26
|
-
from
|
|
27
|
-
from
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.2/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
|
|
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
|