nexo-brain 5.4.9 → 5.5.0

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.4.9",
3
+ "version": "5.5.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `5.4.9` is the current packaged-runtime line: headless Protocol Enforcer — all crons get enforcement rules automatically.
21
+ Version `5.5.0` is the current packaged-runtime line: headless Protocol Enforcer — all crons get enforcement rules automatically.
22
22
 
23
23
  Previously in `5.4.6`: runtime dependency management in `nexo update` + daily auto-update cron.
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.4.9",
3
+ "version": "5.5.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -662,14 +662,24 @@ def run_automation_prompt(
662
662
  if allowed_tools:
663
663
  cmd.extend(["--allowedTools", allowed_tools])
664
664
  cmd.extend(extra_args)
665
- result = subprocess.run(
666
- cmd,
667
- cwd=str(cwd_path),
668
- capture_output=True,
669
- text=True,
670
- timeout=timeout,
671
- env=run_env,
672
- )
665
+ try:
666
+ from enforcement_engine import run_with_enforcement
667
+ result = run_with_enforcement(
668
+ cmd,
669
+ prompt=prompt,
670
+ cwd=str(cwd_path),
671
+ env=run_env,
672
+ timeout=timeout,
673
+ )
674
+ except ImportError:
675
+ result = subprocess.run(
676
+ cmd,
677
+ cwd=str(cwd_path),
678
+ capture_output=True,
679
+ text=True,
680
+ timeout=timeout,
681
+ env=run_env,
682
+ )
673
683
  final_stdout, telemetry = _extract_claude_telemetry(
674
684
  result.stdout or "",
675
685
  requested_output_format=requested_output_format,
@@ -0,0 +1,348 @@
1
+ """Headless Protocol Enforcement Engine for NEXO Brain.
2
+
3
+ Wraps a Claude Code subprocess with stream-json I/O, monitors tool calls,
4
+ and injects enforcement prompts when rules from tool-enforcement-map.json
5
+ are violated. Python equivalent of Desktop's enforcement-engine.js.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import subprocess
13
+ import threading
14
+ import time
15
+ from pathlib import Path
16
+
17
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
18
+ MAP_FILENAME = "tool-enforcement-map.json"
19
+
20
+
21
+ def _load_map() -> dict | None:
22
+ for candidate in [
23
+ NEXO_HOME / MAP_FILENAME,
24
+ NEXO_HOME / "brain" / MAP_FILENAME,
25
+ Path(__file__).parent.parent / MAP_FILENAME,
26
+ ]:
27
+ if candidate.exists():
28
+ try:
29
+ return json.loads(candidate.read_text(encoding="utf-8"))
30
+ except Exception:
31
+ continue
32
+ return None
33
+
34
+
35
+ def _normalize(name: str) -> str:
36
+ return name.replace("mcp__nexo__", "")
37
+
38
+
39
+ class HeadlessEnforcer:
40
+ """Monitor a Claude Code stream-json process and enforce protocol rules."""
41
+
42
+ def __init__(self):
43
+ self.map = _load_map()
44
+ self.tools_called: set[str] = set()
45
+ self.tool_call_count = 0
46
+ self.user_message_count = 0
47
+ self.tool_timestamps: dict[str, float] = {}
48
+ self.msg_since_tool: dict[str, int] = {}
49
+ self.injection_queue: list[dict] = []
50
+ self._started_at = time.time()
51
+
52
+ # Build indexes from map
53
+ self._on_start: list[dict] = []
54
+ self._on_end: list[dict] = []
55
+ self._periodic_msg: list[dict] = []
56
+ self._periodic_time: list[dict] = []
57
+ self._after_tool: dict[str, list[dict]] = {}
58
+
59
+ if self.map:
60
+ self._build_indexes()
61
+
62
+ def _build_indexes(self):
63
+ for tool_name, tool_def in self.map.get("tools", {}).items():
64
+ enf = tool_def.get("enforcement")
65
+ if not enf or enf.get("level") == "none":
66
+ continue
67
+ for rule in enf.get("rules", []):
68
+ rtype = rule.get("type", "")
69
+ entry = {"tool": tool_name, "rule": rule, "enf": enf}
70
+ if rtype == "on_session_start":
71
+ self._on_start.append(entry)
72
+ elif rtype == "on_session_end":
73
+ self._on_end.append(entry)
74
+ elif rtype == "periodic_by_messages":
75
+ self._periodic_msg.append(entry)
76
+ elif rtype == "periodic_by_time":
77
+ self._periodic_time.append(entry)
78
+ elif rtype == "after_tool":
79
+ for wt in rule.get("watch_tools", []):
80
+ self._after_tool.setdefault(wt, []).append(entry)
81
+
82
+ # triggers_after chains
83
+ for triggered in enf.get("triggers_after", []):
84
+ self._after_tool.setdefault(tool_name, []).append({
85
+ "tool": triggered,
86
+ "rule": {"type": "after_tool"},
87
+ "enf": self.map["tools"].get(triggered, {}).get("enforcement", {}),
88
+ })
89
+
90
+ def on_tool_call(self, raw_name: str):
91
+ name = _normalize(raw_name)
92
+ self.tool_call_count += 1
93
+ self.tools_called.add(name)
94
+ self.tool_timestamps[name] = time.time()
95
+ self.msg_since_tool[name] = 0
96
+
97
+ # Check after_tool rules
98
+ for entry in self._after_tool.get(name, []):
99
+ target = entry["tool"]
100
+ if target not in self.tools_called:
101
+ prompt = entry["enf"].get("inject_prompt", "")
102
+ if prompt:
103
+ self._enqueue(prompt, f"after:{name}->{target}")
104
+
105
+ def check_periodic(self):
106
+ """Called periodically to check time-based and message-based rules."""
107
+ # on_session_start
108
+ for entry in self._on_start:
109
+ tool = entry["tool"]
110
+ threshold = entry["rule"].get("threshold", 2)
111
+ if tool not in self.tools_called and self.tool_call_count >= threshold:
112
+ prompt = entry["enf"].get("inject_prompt", "")
113
+ if prompt:
114
+ self._enqueue(prompt, f"start:{tool}")
115
+
116
+ # periodic_by_messages
117
+ for entry in self._periodic_msg:
118
+ tool = entry["tool"]
119
+ threshold = entry["rule"].get("threshold", 3)
120
+ count = self.msg_since_tool.get(tool, self.user_message_count)
121
+ if count >= threshold:
122
+ prompt = entry["enf"].get("inject_prompt", "")
123
+ if prompt:
124
+ self._enqueue(prompt, f"periodic_msg:{tool}")
125
+
126
+ # periodic_by_time
127
+ for entry in self._periodic_time:
128
+ tool = entry["tool"]
129
+ threshold_min = entry["rule"].get("threshold", 15)
130
+ last = self.tool_timestamps.get(tool, self._started_at)
131
+ elapsed_min = (time.time() - last) / 60
132
+ if elapsed_min >= threshold_min:
133
+ prompt = entry["enf"].get("inject_prompt", "")
134
+ if prompt:
135
+ self._enqueue(prompt, f"periodic_time:{tool}")
136
+
137
+ def get_end_prompts(self) -> list[str]:
138
+ prompts = []
139
+ for entry in self._on_end:
140
+ if entry["enf"].get("level") == "must":
141
+ p = entry["enf"].get("session_end_inject_prompt") or entry["enf"].get("inject_prompt", "")
142
+ if p:
143
+ prompts.append(p)
144
+ return prompts
145
+
146
+ def flush(self) -> dict | None:
147
+ if not self.injection_queue:
148
+ return None
149
+ return self.injection_queue.pop(0)
150
+
151
+ def _enqueue(self, prompt: str, tag: str):
152
+ if any(q["tag"] == tag for q in self.injection_queue):
153
+ return
154
+ tool = tag.split(":")[-1].split("->")[-1]
155
+ if tool in self.tools_called and not tag.startswith("periodic_"):
156
+ return
157
+ self.injection_queue.append({"prompt": prompt, "tag": tag, "at": time.time()})
158
+
159
+
160
+ def run_with_enforcement(
161
+ cmd: list[str],
162
+ *,
163
+ prompt: str,
164
+ cwd: str = "",
165
+ env: dict | None = None,
166
+ timeout: int = 300,
167
+ ) -> subprocess.CompletedProcess:
168
+ """Run a Claude Code process with real-time enforcement monitoring.
169
+
170
+ Uses stream-json mode instead of -p mode. Monitors stdout for tool calls,
171
+ injects enforcement prompts via stdin when rules are violated.
172
+ """
173
+ enforcer = HeadlessEnforcer()
174
+ if not enforcer.map:
175
+ # No map available — fall back to simple subprocess.run
176
+ return subprocess.run(cmd, cwd=cwd or None, capture_output=True, text=True,
177
+ timeout=timeout, env=env)
178
+
179
+ # Replace -p with stream-json mode
180
+ stream_cmd = []
181
+ skip_next = False
182
+ for i, arg in enumerate(cmd):
183
+ if skip_next:
184
+ skip_next = False
185
+ continue
186
+ if arg == "-p":
187
+ skip_next = True # Skip the prompt argument
188
+ continue
189
+ if arg == "--output-format":
190
+ skip_next = True # Skip output format
191
+ continue
192
+ stream_cmd.append(arg)
193
+
194
+ stream_cmd.extend([
195
+ "--print",
196
+ "--input-format", "stream-json",
197
+ "--output-format", "stream-json",
198
+ "--verbose",
199
+ ])
200
+
201
+ proc = subprocess.Popen(
202
+ stream_cmd,
203
+ stdin=subprocess.PIPE,
204
+ stdout=subprocess.PIPE,
205
+ stderr=subprocess.PIPE,
206
+ cwd=cwd or None,
207
+ env=env,
208
+ text=True,
209
+ )
210
+
211
+ # Send initial user message
212
+ initial_msg = json.dumps({
213
+ "type": "user",
214
+ "message": {"role": "user", "content": [{"type": "text", "text": prompt}]}
215
+ })
216
+ proc.stdin.write(initial_msg + "\n")
217
+ proc.stdin.flush()
218
+
219
+ collected_text = []
220
+ stderr_lines = []
221
+ start_time = time.time()
222
+ waiting_for_injection_response = False
223
+
224
+ def _inject(text: str):
225
+ nonlocal waiting_for_injection_response
226
+ msg = json.dumps({
227
+ "type": "user",
228
+ "message": {"role": "user", "content": [{"type": "text", "text": text}]}
229
+ })
230
+ try:
231
+ proc.stdin.write(msg + "\n")
232
+ proc.stdin.flush()
233
+ waiting_for_injection_response = True
234
+ except Exception:
235
+ pass
236
+
237
+ def _read_stderr():
238
+ try:
239
+ for line in proc.stderr:
240
+ stderr_lines.append(line)
241
+ except Exception:
242
+ pass
243
+
244
+ stderr_thread = threading.Thread(target=_read_stderr, daemon=True)
245
+ stderr_thread.start()
246
+
247
+ # Periodic check timer
248
+ last_periodic_check = time.time()
249
+
250
+ try:
251
+ for raw_line in proc.stdout:
252
+ line = raw_line.strip()
253
+ if not line:
254
+ continue
255
+
256
+ # Check timeout
257
+ if time.time() - start_time > timeout:
258
+ proc.kill()
259
+ break
260
+
261
+ try:
262
+ event = json.loads(line)
263
+ except json.JSONDecodeError:
264
+ continue
265
+
266
+ event_type = event.get("type", "")
267
+
268
+ # Detect tool use
269
+ if event_type == "assistant" and event.get("message", {}).get("content"):
270
+ for block in event["message"]["content"]:
271
+ if block.get("type") == "tool_use":
272
+ enforcer.on_tool_call(block.get("name", ""))
273
+ elif event_type == "content_block_start":
274
+ cb = event.get("content_block", {})
275
+ if cb.get("type") == "tool_use":
276
+ enforcer.on_tool_call(cb.get("name", ""))
277
+
278
+ # Collect assistant text
279
+ if event_type == "assistant" and not waiting_for_injection_response:
280
+ msg = event.get("message", {})
281
+ for block in msg.get("content", []):
282
+ if block.get("type") == "text":
283
+ collected_text.append(block["text"])
284
+
285
+ # Detect turn end (result event with stop_reason)
286
+ if event_type == "result":
287
+ if waiting_for_injection_response:
288
+ waiting_for_injection_response = False
289
+ # After injection response, check for more injections
290
+ item = enforcer.flush()
291
+ if item:
292
+ _inject(item["prompt"])
293
+ continue
294
+
295
+ # Normal turn end — check for pending enforcements
296
+ enforcer.check_periodic()
297
+ item = enforcer.flush()
298
+ if item:
299
+ _inject(item["prompt"])
300
+ else:
301
+ break # No more injections, we're done
302
+
303
+ # Periodic check every 30 seconds
304
+ if time.time() - last_periodic_check > 30:
305
+ enforcer.check_periodic()
306
+ last_periodic_check = time.time()
307
+
308
+ except Exception:
309
+ pass
310
+ finally:
311
+ # End-of-session enforcement: inject diary + stop
312
+ end_prompts = enforcer.get_end_prompts()
313
+ for ep in end_prompts:
314
+ try:
315
+ _inject(ep)
316
+ # Wait briefly for response
317
+ deadline = time.time() + 15
318
+ for raw_line in proc.stdout:
319
+ if time.time() > deadline:
320
+ break
321
+ line = raw_line.strip()
322
+ if not line:
323
+ continue
324
+ try:
325
+ event = json.loads(line)
326
+ if event.get("type") == "result":
327
+ break
328
+ except json.JSONDecodeError:
329
+ continue
330
+ except Exception:
331
+ break
332
+
333
+ try:
334
+ proc.stdin.close()
335
+ except Exception:
336
+ pass
337
+ try:
338
+ proc.wait(timeout=10)
339
+ except subprocess.TimeoutExpired:
340
+ proc.kill()
341
+
342
+ stderr_thread.join(timeout=2)
343
+ final_text = "\n".join(collected_text)
344
+ final_stderr = "".join(stderr_lines)
345
+
346
+ return subprocess.CompletedProcess(
347
+ stream_cmd, proc.returncode or 0, final_text, final_stderr
348
+ )