halyn 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
halyn/memory/store.py ADDED
@@ -0,0 +1,200 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ Memory — Jarvis never forgets.
5
+
6
+ SQLite + FTS5. Persistent across sessions.
7
+ Facts (key-value), Journal (timeline), Skills (learned patterns).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sqlite3
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from ..types import ToolCategory
19
+
20
+
21
+ class Memory:
22
+ """Persistent memory. Survives reboots. Searchable."""
23
+
24
+ __slots__ = ("_db", "_path")
25
+
26
+ def __init__(self, path: str = "~/.jarvis/memory.db") -> None:
27
+ self._path = Path(path).expanduser()
28
+ self._path.parent.mkdir(parents=True, exist_ok=True)
29
+ self._db = sqlite3.connect(str(self._path), check_same_thread=False)
30
+ self._db.execute("PRAGMA journal_mode=WAL")
31
+ self._db.execute("PRAGMA synchronous=NORMAL")
32
+ self._init()
33
+
34
+ def _init(self) -> None:
35
+ self._db.executescript("""
36
+ CREATE TABLE IF NOT EXISTS facts (
37
+ key TEXT PRIMARY KEY,
38
+ value TEXT NOT NULL,
39
+ category TEXT NOT NULL DEFAULT 'general',
40
+ updated_at REAL NOT NULL
41
+ );
42
+ CREATE TABLE IF NOT EXISTS journal (
43
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ ts REAL NOT NULL,
45
+ event TEXT NOT NULL,
46
+ detail TEXT NOT NULL DEFAULT '',
47
+ node TEXT NOT NULL DEFAULT 'local'
48
+ );
49
+ CREATE TABLE IF NOT EXISTS skills (
50
+ name TEXT PRIMARY KEY,
51
+ trigger_pattern TEXT NOT NULL,
52
+ actions TEXT NOT NULL,
53
+ times_used INTEGER NOT NULL DEFAULT 0,
54
+ created_at REAL NOT NULL
55
+ );
56
+ """)
57
+ # FTS5 for full-text search (safe to call multiple times)
58
+ try:
59
+ self._db.execute(
60
+ "CREATE VIRTUAL TABLE IF NOT EXISTS facts_fts "
61
+ "USING fts5(key, value, category)"
62
+ )
63
+ except sqlite3.OperationalError:
64
+ pass # Already exists or FTS5 not available
65
+ self._db.commit()
66
+
67
+ # ─── Facts ──────────────────────────────────────
68
+
69
+ def remember(self, key: str, value: Any, category: str = "general") -> None:
70
+ v = json.dumps(value) if not isinstance(value, str) else value
71
+ now = time.time()
72
+ self._db.execute(
73
+ "INSERT OR REPLACE INTO facts (key, value, category, updated_at) VALUES (?, ?, ?, ?)",
74
+ (key, v, category, now),
75
+ )
76
+ # Sync FTS
77
+ try:
78
+ self._db.execute("DELETE FROM facts_fts WHERE key = ?", (key,))
79
+ self._db.execute("INSERT INTO facts_fts (key, value, category) VALUES (?, ?, ?)", (key, v, category))
80
+ except sqlite3.OperationalError:
81
+ pass
82
+ self._db.commit()
83
+
84
+ def recall(self, key: str) -> str | None:
85
+ row = self._db.execute("SELECT value FROM facts WHERE key = ?", (key,)).fetchone()
86
+ return row[0] if row else None
87
+
88
+ def forget(self, key: str) -> bool:
89
+ cur = self._db.execute("DELETE FROM facts WHERE key = ?", (key,))
90
+ self._db.commit()
91
+ return cur.rowcount > 0
92
+
93
+ def search(self, query: str, limit: int = 10) -> list[dict[str, str]]:
94
+ """Full-text search. Falls back to LIKE if FTS5 unavailable."""
95
+ try:
96
+ rows = self._db.execute(
97
+ "SELECT key, value, category FROM facts_fts WHERE facts_fts MATCH ? LIMIT ?",
98
+ (query, limit),
99
+ ).fetchall()
100
+ except sqlite3.OperationalError:
101
+ like = f"%{query}%"
102
+ rows = self._db.execute(
103
+ "SELECT key, value, category FROM facts WHERE key LIKE ? OR value LIKE ? LIMIT ?",
104
+ (like, like, limit),
105
+ ).fetchall()
106
+ return [{"key": r[0], "value": r[1], "category": r[2]} for r in rows]
107
+
108
+ def facts(self, category: str | None = None) -> list[dict[str, str]]:
109
+ if category:
110
+ rows = self._db.execute(
111
+ "SELECT key, value, category FROM facts WHERE category = ? ORDER BY updated_at DESC",
112
+ (category,),
113
+ ).fetchall()
114
+ else:
115
+ rows = self._db.execute(
116
+ "SELECT key, value, category FROM facts ORDER BY updated_at DESC"
117
+ ).fetchall()
118
+ return [{"key": r[0], "value": r[1], "category": r[2]} for r in rows]
119
+
120
+ # ─── Journal ────────────────────────────────────
121
+
122
+ def log(self, event: str, detail: str = "", node: str = "local") -> None:
123
+ self._db.execute(
124
+ "INSERT INTO journal (ts, event, detail, node) VALUES (?, ?, ?, ?)",
125
+ (time.time(), event, detail, node),
126
+ )
127
+ self._db.commit()
128
+
129
+ def recent(self, n: int = 20) -> list[dict[str, Any]]:
130
+ rows = self._db.execute(
131
+ "SELECT ts, event, detail, node FROM journal ORDER BY id DESC LIMIT ?", (n,)
132
+ ).fetchall()
133
+ return [{"ts": r[0], "event": r[1], "detail": r[2], "node": r[3]} for r in rows]
134
+
135
+ # ─── Skills ─────────────────────────────────────
136
+
137
+ def learn(self, name: str, trigger: str, actions: list[str]) -> None:
138
+ self._db.execute(
139
+ "INSERT OR REPLACE INTO skills (name, trigger_pattern, actions, times_used, created_at) "
140
+ "VALUES (?, ?, ?, 0, ?)",
141
+ (name, trigger, json.dumps(actions), time.time()),
142
+ )
143
+ self._db.commit()
144
+
145
+ def get_skill(self, name: str) -> dict[str, Any] | None:
146
+ row = self._db.execute(
147
+ "SELECT name, trigger_pattern, actions, times_used FROM skills WHERE name = ?",
148
+ (name,),
149
+ ).fetchone()
150
+ if row:
151
+ return {"name": row[0], "trigger": row[1], "actions": json.loads(row[2]), "used": row[3]}
152
+ return None
153
+
154
+ def close(self) -> None:
155
+ self._db.close()
156
+
157
+
158
+ # ─── Tool wrappers ──────────────────────────────────
159
+
160
+ _instance: Memory | None = None
161
+
162
+ def _mem() -> Memory:
163
+ global _instance
164
+ if _instance is None:
165
+ _instance = Memory()
166
+ return _instance
167
+
168
+
169
+ def tool_remember(args: dict[str, Any], node: Any) -> str:
170
+ _mem().remember(args["key"], args["value"], args.get("category", "general"))
171
+ return "remembered"
172
+
173
+ def tool_recall(args: dict[str, Any], node: Any) -> str | None:
174
+ return _mem().recall(args["key"])
175
+
176
+ def tool_forget(args: dict[str, Any], node: Any) -> str:
177
+ ok = _mem().forget(args["key"])
178
+ return "forgotten" if ok else "not found"
179
+
180
+ def tool_search(args: dict[str, Any], node: Any) -> list[dict[str, str]]:
181
+ return _mem().search(args["query"], args.get("limit", 10))
182
+
183
+ def tool_log(args: dict[str, Any], node: Any) -> str:
184
+ _mem().log(args["event"], args.get("detail", ""), args.get("node", "local"))
185
+ return "logged"
186
+
187
+ def tool_journal(args: dict[str, Any], node: Any) -> list[dict[str, Any]]:
188
+ return _mem().recent(args.get("n", 20))
189
+
190
+
191
+ def register_memory(engine: Any) -> None:
192
+ """Plug memory tools into the engine."""
193
+ reg = engine.registry
194
+ reg.register_tool("remember", tool_remember, ToolCategory.MEMORY, "Store a fact")
195
+ reg.register_tool("recall", tool_recall, ToolCategory.MEMORY, "Recall a fact by key")
196
+ reg.register_tool("forget", tool_forget, ToolCategory.MEMORY, "Remove a fact")
197
+ reg.register_tool("search_memory", tool_search, ToolCategory.MEMORY, "Search all memory")
198
+ reg.register_tool("log_event", tool_log, ToolCategory.MEMORY, "Log an event")
199
+ reg.register_tool("journal", tool_journal, ToolCategory.MEMORY, "Recent events")
200
+
halyn/nrp_bridge.py ADDED
@@ -0,0 +1,213 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ NRP Bridge v2 — Connects Driver + Identity + Manifest + Events to Engine.
5
+
6
+ This is the integration layer. One function does everything:
7
+ 1. Connects the driver
8
+ 2. Reads the manifest (auto-description)
9
+ 3. Generates engine tools from manifest
10
+ 4. Wires events
11
+ 5. Enforces shield rules from manifest
12
+
13
+ After registration, the AI sees the node and knows everything about it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Any
20
+
21
+ from nrp import NRPDriver, ShieldRule, ShieldType
22
+ from .engine import Engine
23
+ from nrp import NRPId
24
+ from nrp import NRPManifest
25
+ from nrp import EventBus, NRPEvent, Severity
26
+ from .types import Node, NodeKind, ToolCategory, ActionStatus
27
+
28
+ log = logging.getLogger("jarvis.nrp")
29
+
30
+ NODE_KIND_MAP: dict[str, NodeKind] = {
31
+ "ssh": NodeKind.SSH, "adb": NodeKind.ADB, "docker": NodeKind.DOCKER,
32
+ "ros2": NodeKind.LOCAL, "unitree": NodeKind.LOCAL, "mqtt": NodeKind.LOCAL,
33
+ "dji": NodeKind.LOCAL, "browser": NodeKind.LOCAL, "opcua": NodeKind.LOCAL,
34
+ "http": NodeKind.LOCAL, "serial": NodeKind.LOCAL,
35
+ }
36
+
37
+
38
+ async def register_nrp_node(
39
+ engine: Engine,
40
+ nrp_id: NRPId | str,
41
+ driver: NRPDriver,
42
+ event_bus: EventBus | None = None,
43
+ ) -> NRPManifest:
44
+ """
45
+ Register a device into the engine via NRP.
46
+
47
+ Primary registration entry point.
48
+ Returns the manifest for inspection/logging.
49
+ """
50
+ # 1. Parse ID
51
+ if isinstance(nrp_id, str):
52
+ nrp_id = NRPId.parse(nrp_id)
53
+ node_name = nrp_id.short # "robot/g1-01"
54
+
55
+ # 2. Bind events
56
+ bus = event_bus or EventBus()
57
+ driver.bind(nrp_id, bus)
58
+
59
+ # 3. Connect
60
+ alive = await driver.connect()
61
+ if not alive:
62
+ log.warning("nrp.connect_failed id=%s", nrp_id.uri)
63
+
64
+ # 4. Read manifest
65
+ manifest = driver.manifest()
66
+ log.info("nrp.manifest id=%s observe=%d act=%d shield=%d",
67
+ nrp_id.uri, len(manifest.observe), len(manifest.act), len(manifest.shield))
68
+
69
+ # 5. Register node
70
+ kind_str = nrp_id.kind
71
+ kind = NODE_KIND_MAP.get(kind_str, NodeKind.LOCAL)
72
+ node = Node(
73
+ name=node_name, kind=kind, alive=alive,
74
+ labels={"nrp_id": nrp_id.uri, "nrp_kind": kind_str,
75
+ "manufacturer": manifest.manufacturer, "model": manifest.model},
76
+ )
77
+ engine.registry.register_node(node)
78
+
79
+ # 6. Get shield rules for enforcement
80
+ shield_rules = driver.shield_rules()
81
+
82
+ # 7. Register observe tool (reads all or specific channels)
83
+ async def _observe(args: dict[str, Any], target: Any) -> dict[str, Any]:
84
+ channels = args.get("channels")
85
+ if isinstance(channels, str):
86
+ channels = [c.strip() for c in channels.split(",")]
87
+ return await driver.observe(channels)
88
+
89
+ engine.registry.register_tool(
90
+ f"{node_name}.observe", _observe, ToolCategory.OBSERVER,
91
+ f"Observe {nrp_id.uri} — {_describe_observe(manifest)}",
92
+ )
93
+
94
+ # 8. Register act tool (with shield enforcement)
95
+ async def _act(args: dict[str, Any], target: Any) -> Any:
96
+ command = args.get("command", "")
97
+ cmd_args = {k: v for k, v in args.items() if k != "command"}
98
+ # Enforce shield rules
99
+ violation = _check_shield(command, cmd_args, shield_rules)
100
+ if violation:
101
+ log.warning("nrp.shield_blocked id=%s cmd=%s rule=%s",
102
+ nrp_id.uri, command, violation)
103
+ await driver.emit("shield_blocked", Severity.WARNING,
104
+ command=command, rule=violation)
105
+ raise PermissionError(f"Shield blocked: {violation}")
106
+ result = await driver.act(command, cmd_args)
107
+ return result
108
+
109
+ engine.registry.register_tool(
110
+ f"{node_name}.act", _act, ToolCategory.EXECUTOR,
111
+ f"Act on {nrp_id.uri} — {_describe_act(manifest)}", dangerous=True,
112
+ )
113
+
114
+ # 9. Register individual action shortcuts
115
+ for action_spec in manifest.act:
116
+ aname = action_spec.name
117
+
118
+ async def _shortcut(args: dict[str, Any], target: Any, _cmd: str = aname) -> Any:
119
+ return await _act({"command": _cmd, **args}, target)
120
+
121
+ engine.registry.register_tool(
122
+ f"{node_name}.{aname}", _shortcut, ToolCategory.EXECUTOR,
123
+ action_spec.description or f"{aname} on {nrp_id.short}",
124
+ dangerous=action_spec.dangerous,
125
+ )
126
+
127
+ # 10. Register shield info tool
128
+ def _shield_info(args: dict[str, Any], target: Any) -> list[dict[str, Any]]:
129
+ return [s.to_dict() for s in manifest.shield]
130
+
131
+ engine.registry.register_tool(
132
+ f"{node_name}.shield", _shield_info, ToolCategory.OBSERVER,
133
+ f"Safety rules for {nrp_id.uri}",
134
+ )
135
+
136
+ # 11. Register manifest tool (the AI can inspect the full description)
137
+ def _manifest_info(args: dict[str, Any], target: Any) -> str:
138
+ return manifest.to_llm_description()
139
+
140
+ engine.registry.register_tool(
141
+ f"{node_name}.info", _manifest_info, ToolCategory.OBSERVER,
142
+ f"Full description of {nrp_id.uri}",
143
+ )
144
+
145
+ # 12. Heartbeat
146
+ async def _heartbeat(args: dict[str, Any], target: Any) -> dict[str, Any]:
147
+ return await driver.heartbeat()
148
+
149
+ engine.registry.register_tool(
150
+ f"{node_name}.heartbeat", _heartbeat, ToolCategory.OBSERVER,
151
+ f"Health check {nrp_id.uri}",
152
+ )
153
+
154
+ # 13. Log registration event
155
+ await bus.emit_simple(nrp_id.uri, "node_registered", Severity.INFO,
156
+ alive=alive, tools=len(manifest.act) + 4,
157
+ observe_channels=len(manifest.observe))
158
+
159
+ tool_count = len(manifest.act) + 4 # observe + act + shield + info + heartbeat + shortcuts
160
+ log.info("nrp.registered id=%s alive=%s tools=%d", nrp_id.uri, alive, tool_count)
161
+
162
+ return manifest
163
+
164
+
165
+ # ─── Shield enforcement ─────────────────────────────
166
+
167
+ def _check_shield(command: str, args: dict[str, Any], rules: list[ShieldRule]) -> str | None:
168
+ """Check if a command violates any shield rule. Returns rule name or None."""
169
+ for rule in rules:
170
+ if rule.type == ShieldType.PATTERN:
171
+ pattern = str(rule.value).lower()
172
+ cmd_lower = command.lower() + " " + " ".join(str(v) for v in args.values()).lower()
173
+ if pattern in cmd_lower:
174
+ return rule.name
175
+
176
+ elif rule.type == ShieldType.LIMIT:
177
+ # Check if any numeric arg exceeds the limit
178
+ limit_val = float(rule.value) if rule.value is not None else None
179
+ if limit_val is not None:
180
+ for v in args.values():
181
+ try:
182
+ if abs(float(v)) > limit_val:
183
+ return rule.name
184
+ except (TypeError, ValueError):
185
+ pass
186
+
187
+ elif rule.type == ShieldType.THRESHOLD:
188
+ # Threshold = minimum value. Below = blocked.
189
+ thresh = float(rule.value) if rule.value is not None else None
190
+ if thresh is not None and "battery" in rule.name.lower():
191
+ # Battery threshold checked at observe time, not act time
192
+ pass
193
+
194
+ return None
195
+
196
+
197
+ # ─── Description helpers ────────────────────────────
198
+
199
+ def _describe_observe(m: NRPManifest) -> str:
200
+ if not m.observe:
201
+ return "no channels"
202
+ names = [c.name for c in m.observe[:5]]
203
+ more = f" +{len(m.observe) - 5} more" if len(m.observe) > 5 else ""
204
+ return "channels: " + ", ".join(names) + more
205
+
206
+
207
+ def _describe_act(m: NRPManifest) -> str:
208
+ if not m.act:
209
+ return "no actions"
210
+ names = [a.name for a in m.act[:5]]
211
+ more = f" +{len(m.act) - 5} more" if len(m.act) > 5 else ""
212
+ return "commands: " + ", ".join(names) + more
213
+
halyn/py.typed ADDED
File without changes
halyn/sanitizer.py ADDED
@@ -0,0 +1,120 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ Sanitizer — Clean all inputs before execution.
5
+
6
+ Prevents: command injection, path traversal, output flooding.
7
+ Applied BEFORE the shield, BEFORE the driver.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import logging
14
+ from typing import Any
15
+
16
+ log = logging.getLogger("jarvis.sanitizer")
17
+
18
+ # Max output size to prevent OOM
19
+ MAX_OUTPUT_BYTES: int = 1_048_576 # 1MB
20
+
21
+ # Max timeout any user can request
22
+ MAX_TIMEOUT: int = 300 # 5 minutes
23
+
24
+ # Shell injection patterns (beyond what shield catches)
25
+ INJECTION_PATTERNS: tuple[str, ...] = (
26
+ "$(", "`", # Command substitution
27
+ " | ", " || ", # Pipe to another command
28
+ " ; ", # Command chaining
29
+ " && ", # Conditional chaining
30
+ "\n", # Newline injection
31
+ ">> /etc/", "> /etc/", # Write to system files
32
+ "curl ", "wget ", # Download and execute
33
+ "nc ", "ncat ", # Netcat reverse shells
34
+ "python -c", "python3 -c", # Inline code execution
35
+ "perl -e", "ruby -e",
36
+ "base64 -d", # Decode hidden payloads
37
+ )
38
+
39
+ # Path traversal patterns
40
+ PATH_TRAVERSAL: tuple[str, ...] = (
41
+ "..",
42
+ "~root",
43
+ "/etc/shadow",
44
+ "/etc/passwd",
45
+ "/proc/",
46
+ "/sys/",
47
+ )
48
+
49
+
50
+ def sanitize_action(tool: str, args: dict[str, Any]) -> dict[str, Any]:
51
+ """
52
+ Sanitize action arguments. Returns cleaned args.
53
+ Raises ValueError if dangerous.
54
+ """
55
+ args = dict(args) # Don't mutate original
56
+
57
+ # Cap timeout everywhere
58
+ if "timeout" in args:
59
+ args["timeout"] = min(int(args["timeout"]), MAX_TIMEOUT)
60
+
61
+ # Cap output limit
62
+ if "limit" in args:
63
+ args["limit"] = min(int(args["limit"]), MAX_OUTPUT_BYTES)
64
+ if "lines" in args:
65
+ args["lines"] = min(int(args["lines"]), 500)
66
+ if "n" in args and isinstance(args["n"], int):
67
+ args["n"] = min(args["n"], 500)
68
+
69
+ # Shell command sanitization
70
+ if tool in ("shell",) and "command" in args:
71
+ cmd = args["command"]
72
+ _check_injection(cmd)
73
+
74
+ # File path sanitization
75
+ if "path" in args:
76
+ path = str(args["path"])
77
+ _check_path(path)
78
+
79
+ return args
80
+
81
+
82
+ def sanitize_output(data: Any) -> Any:
83
+ """Truncate output to prevent OOM."""
84
+ if isinstance(data, str) and len(data) > MAX_OUTPUT_BYTES:
85
+ truncated = data[:MAX_OUTPUT_BYTES]
86
+ log.warning("sanitizer.output_truncated original=%d", len(data))
87
+ return truncated + f"\n... [truncated, {len(data)} bytes total]"
88
+ return data
89
+
90
+
91
+ def _check_injection(cmd: str) -> None:
92
+ """Check for shell injection patterns in non-shell tools."""
93
+ # For the "shell" tool, we ALLOW these (that's the point of shell).
94
+ # But we LOG them for the audit trail.
95
+ for pattern in INJECTION_PATTERNS:
96
+ if pattern in cmd:
97
+ log.info("sanitizer.injection_pattern cmd=%s pattern=%s", cmd[:100], pattern)
98
+ # We don't block — shield handles dangerous patterns.
99
+ # We just make sure the audit knows.
100
+ return
101
+
102
+
103
+ def _check_path(path: str) -> None:
104
+ """Block path traversal."""
105
+ for pattern in PATH_TRAVERSAL:
106
+ if pattern in path:
107
+ raise ValueError(f"path traversal blocked: {pattern}")
108
+
109
+
110
+ def redact_error(error: str) -> str:
111
+ """Remove sensitive info from error messages before sending to client."""
112
+ # Remove file paths
113
+ error = re.sub(r"/[\w/.-]+\.(?:pem|key|crt|conf|env)", "[REDACTED_PATH]", error)
114
+ # Remove IP:port patterns that might reveal internal topology
115
+ error = re.sub(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+", "[REDACTED_HOST]", error)
116
+ # Remove potential credentials
117
+ error = re.sub(r"(?:password|passwd|token|secret|key)[\s=:]+\S+", "[REDACTED]", error, flags=re.IGNORECASE)
118
+ # Limit length
119
+ return error[:300]
120
+