nexo-brain 5.4.9 → 5.5.1

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.1",
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.1` 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,8 +1,8 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.4.9",
3
+ "version": "5.5.1",
4
4
  "mcpName": "io.github.wazionapps/nexo",
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.",
5
+ "description": "NEXO Brain \u2014 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",
7
7
  "bin": {
8
8
  "nexo-brain": "./bin/nexo-brain.js",
@@ -662,14 +662,27 @@ 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
+ import sys as _sys
667
+ if str(NEXO_HOME) not in _sys.path:
668
+ _sys.path.insert(0, str(NEXO_HOME))
669
+ from enforcement_engine import run_with_enforcement
670
+ result = run_with_enforcement(
671
+ cmd,
672
+ prompt=prompt,
673
+ cwd=str(cwd_path),
674
+ env=run_env,
675
+ timeout=timeout,
676
+ )
677
+ except ImportError:
678
+ result = subprocess.run(
679
+ cmd,
680
+ cwd=str(cwd_path),
681
+ capture_output=True,
682
+ text=True,
683
+ timeout=timeout,
684
+ env=run_env,
685
+ )
673
686
  final_stdout, telemetry = _extract_claude_telemetry(
674
687
  result.stdout or "",
675
688
  requested_output_format=requested_output_format,
@@ -0,0 +1,365 @@
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 logging
12
+ import os
13
+ import subprocess
14
+ import threading
15
+ import time
16
+ from pathlib import Path
17
+
18
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
19
+ MAP_FILENAME = "tool-enforcement-map.json"
20
+ LOG_DIR = NEXO_HOME / "logs"
21
+
22
+ _logger = logging.getLogger("nexo.enforcer")
23
+ if not _logger.handlers:
24
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
25
+ _fh = logging.FileHandler(LOG_DIR / "enforcer-headless.log")
26
+ _fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
27
+ _logger.addHandler(_fh)
28
+ _logger.setLevel(logging.INFO)
29
+
30
+
31
+ def _load_map() -> dict | None:
32
+ for candidate in [
33
+ NEXO_HOME / MAP_FILENAME,
34
+ NEXO_HOME / "brain" / MAP_FILENAME,
35
+ Path(__file__).parent.parent / MAP_FILENAME,
36
+ ]:
37
+ if candidate.exists():
38
+ try:
39
+ return json.loads(candidate.read_text(encoding="utf-8"))
40
+ except Exception:
41
+ continue
42
+ return None
43
+
44
+
45
+ def _normalize(name: str) -> str:
46
+ return name.replace("mcp__nexo__", "")
47
+
48
+
49
+ class HeadlessEnforcer:
50
+ """Monitor a Claude Code stream-json process and enforce protocol rules."""
51
+
52
+ def __init__(self):
53
+ self.map = _load_map()
54
+ self.tools_called: set[str] = set()
55
+ self.tool_call_count = 0
56
+ self.user_message_count = 0
57
+ self.tool_timestamps: dict[str, float] = {}
58
+ self.msg_since_tool: dict[str, int] = {}
59
+ self.injection_queue: list[dict] = []
60
+ self._started_at = time.time()
61
+ self._injections_done = 0
62
+
63
+ self._on_start: list[dict] = []
64
+ self._on_end: list[dict] = []
65
+ self._periodic_msg: list[dict] = []
66
+ self._periodic_time: list[dict] = []
67
+ self._after_tool: dict[str, list[dict]] = {}
68
+
69
+ if self.map:
70
+ self._build_indexes()
71
+ _logger.info("Map v%s loaded: %d on_start, %d on_end, %d periodic_msg, %d periodic_time, %d after_tool",
72
+ self.map.get("version", "?"), len(self._on_start), len(self._on_end),
73
+ len(self._periodic_msg), len(self._periodic_time), len(self._after_tool))
74
+ else:
75
+ _logger.warning("No enforcement map found")
76
+
77
+ def _build_indexes(self):
78
+ for tool_name, tool_def in self.map.get("tools", {}).items():
79
+ enf = tool_def.get("enforcement")
80
+ if not enf or enf.get("level") == "none":
81
+ continue
82
+ for rule in enf.get("rules", []):
83
+ rtype = rule.get("type", "")
84
+ entry = {"tool": tool_name, "rule": rule, "enf": enf}
85
+ if rtype == "on_session_start":
86
+ self._on_start.append(entry)
87
+ elif rtype == "on_session_end":
88
+ self._on_end.append(entry)
89
+ elif rtype == "periodic_by_messages":
90
+ self._periodic_msg.append(entry)
91
+ elif rtype == "periodic_by_time":
92
+ self._periodic_time.append(entry)
93
+ elif rtype == "after_tool":
94
+ for wt in rule.get("watch_tools", []):
95
+ self._after_tool.setdefault(wt, []).append(entry)
96
+
97
+ for triggered in enf.get("triggers_after", []):
98
+ self._after_tool.setdefault(tool_name, []).append({
99
+ "tool": triggered,
100
+ "rule": {"type": "after_tool"},
101
+ "enf": self.map["tools"].get(triggered, {}).get("enforcement", {}),
102
+ })
103
+
104
+ def on_tool_call(self, raw_name: str):
105
+ name = _normalize(raw_name)
106
+ self.tool_call_count += 1
107
+ self.tools_called.add(name)
108
+ self.tool_timestamps[name] = time.time()
109
+ self.msg_since_tool[name] = 0
110
+ _logger.info("TOOL_CALL #%d: %s", self.tool_call_count, name)
111
+
112
+ for entry in self._after_tool.get(name, []):
113
+ target = entry["tool"]
114
+ if target not in self.tools_called:
115
+ prompt = entry["enf"].get("inject_prompt", "")
116
+ if prompt:
117
+ self._enqueue(prompt, f"after:{name}->{target}")
118
+
119
+ def check_periodic(self):
120
+ for entry in self._on_start:
121
+ tool = entry["tool"]
122
+ threshold = entry["rule"].get("threshold", 2)
123
+ if tool not in self.tools_called and self.tool_call_count >= threshold:
124
+ prompt = entry["enf"].get("inject_prompt", "")
125
+ if prompt:
126
+ self._enqueue(prompt, f"start:{tool}")
127
+
128
+ for entry in self._periodic_msg:
129
+ tool = entry["tool"]
130
+ threshold = entry["rule"].get("threshold", 3)
131
+ count = self.msg_since_tool.get(tool, self.user_message_count)
132
+ if count >= threshold:
133
+ prompt = entry["enf"].get("inject_prompt", "")
134
+ if prompt:
135
+ self._enqueue(prompt, f"periodic_msg:{tool}")
136
+
137
+ for entry in self._periodic_time:
138
+ tool = entry["tool"]
139
+ threshold_min = entry["rule"].get("threshold", 15)
140
+ last = self.tool_timestamps.get(tool, self._started_at)
141
+ elapsed_min = (time.time() - last) / 60
142
+ if elapsed_min >= threshold_min:
143
+ prompt = entry["enf"].get("inject_prompt", "")
144
+ if prompt:
145
+ self._enqueue(prompt, f"periodic_time:{tool}")
146
+
147
+ def get_end_prompts(self) -> list[str]:
148
+ prompts = []
149
+ for entry in self._on_end:
150
+ if entry["enf"].get("level") == "must":
151
+ p = entry["enf"].get("session_end_inject_prompt") or entry["enf"].get("inject_prompt", "")
152
+ if p:
153
+ prompts.append(p)
154
+ _logger.info("END_PROMPTS: %d prompts to inject", len(prompts))
155
+ return prompts
156
+
157
+ def flush(self) -> dict | None:
158
+ if not self.injection_queue:
159
+ return None
160
+ return self.injection_queue.pop(0)
161
+
162
+ def _enqueue(self, prompt: str, tag: str):
163
+ if any(q["tag"] == tag for q in self.injection_queue):
164
+ return
165
+ tool = tag.split(":")[-1].split("->")[-1]
166
+ last_called = self.tool_timestamps.get(tool)
167
+ if last_called and tool in self.tools_called:
168
+ if time.time() - last_called < 60:
169
+ _logger.info("DEDUP_SKIP: %s — %s called %ds ago", tag, tool, int(time.time() - last_called))
170
+ return
171
+ if tool in self.tools_called and not tag.startswith("periodic_"):
172
+ _logger.info("SKIP: %s — already called", tag)
173
+ return
174
+ self.injection_queue.append({"prompt": prompt, "tag": tag, "at": time.time()})
175
+ _logger.info("ENQUEUED: %s (queue size: %d)", tag, len(self.injection_queue))
176
+
177
+ def summary(self) -> str:
178
+ return (f"tools_called={len(self.tools_called)} tool_calls={self.tool_call_count} "
179
+ f"injections={self._injections_done} tools={sorted(self.tools_called)}")
180
+
181
+
182
+ def run_with_enforcement(
183
+ cmd: list[str],
184
+ *,
185
+ prompt: str,
186
+ cwd: str = "",
187
+ env: dict | None = None,
188
+ timeout: int = 300,
189
+ ) -> subprocess.CompletedProcess:
190
+ enforcer = HeadlessEnforcer()
191
+ _logger.info("=== SESSION START === prompt=%s timeout=%d", prompt[:80], timeout)
192
+
193
+ if not enforcer.map:
194
+ _logger.warning("No map — falling back to plain subprocess.run")
195
+ return subprocess.run(cmd, cwd=cwd or None, capture_output=True, text=True,
196
+ timeout=timeout, env=env)
197
+
198
+ stream_cmd = []
199
+ skip_next = False
200
+ for i, arg in enumerate(cmd):
201
+ if skip_next:
202
+ skip_next = False
203
+ continue
204
+ if arg == "-p":
205
+ skip_next = True
206
+ continue
207
+ if arg == "--output-format":
208
+ skip_next = True
209
+ continue
210
+ stream_cmd.append(arg)
211
+
212
+ stream_cmd.extend([
213
+ "--print",
214
+ "--input-format", "stream-json",
215
+ "--output-format", "stream-json",
216
+ "--verbose",
217
+ ])
218
+
219
+ proc = subprocess.Popen(
220
+ stream_cmd,
221
+ stdin=subprocess.PIPE,
222
+ stdout=subprocess.PIPE,
223
+ stderr=subprocess.PIPE,
224
+ cwd=cwd or None,
225
+ env=env,
226
+ text=True,
227
+ )
228
+
229
+ initial_msg = json.dumps({
230
+ "type": "user",
231
+ "message": {"role": "user", "content": [{"type": "text", "text": prompt}]}
232
+ })
233
+ proc.stdin.write(initial_msg + "\n")
234
+ proc.stdin.flush()
235
+
236
+ collected_text = []
237
+ stderr_lines = []
238
+ start_time = time.time()
239
+ waiting_for_injection_response = False
240
+
241
+ def _inject(text: str):
242
+ nonlocal waiting_for_injection_response
243
+ msg = json.dumps({
244
+ "type": "user",
245
+ "message": {"role": "user", "content": [{"type": "text", "text": text}]}
246
+ })
247
+ try:
248
+ proc.stdin.write(msg + "\n")
249
+ proc.stdin.flush()
250
+ waiting_for_injection_response = True
251
+ enforcer._injections_done += 1
252
+ _logger.info("INJECTED: %s", text[:100])
253
+ except Exception as e:
254
+ _logger.error("INJECT_FAILED: %s", e)
255
+
256
+ def _read_stderr():
257
+ try:
258
+ for line in proc.stderr:
259
+ stderr_lines.append(line)
260
+ except Exception:
261
+ pass
262
+
263
+ stderr_thread = threading.Thread(target=_read_stderr, daemon=True)
264
+ stderr_thread.start()
265
+
266
+ last_periodic_check = time.time()
267
+
268
+ try:
269
+ for raw_line in proc.stdout:
270
+ line = raw_line.strip()
271
+ if not line:
272
+ continue
273
+
274
+ if time.time() - start_time > timeout:
275
+ _logger.warning("TIMEOUT after %ds", timeout)
276
+ proc.kill()
277
+ break
278
+
279
+ try:
280
+ event = json.loads(line)
281
+ except json.JSONDecodeError:
282
+ continue
283
+
284
+ event_type = event.get("type", "")
285
+
286
+ if event_type == "assistant" and event.get("message", {}).get("content"):
287
+ for block in event["message"]["content"]:
288
+ if block.get("type") == "tool_use":
289
+ enforcer.on_tool_call(block.get("name", ""))
290
+ elif event_type == "content_block_start":
291
+ cb = event.get("content_block", {})
292
+ if cb.get("type") == "tool_use":
293
+ enforcer.on_tool_call(cb.get("name", ""))
294
+
295
+ if event_type == "assistant" and not waiting_for_injection_response:
296
+ msg = event.get("message", {})
297
+ for block in msg.get("content", []):
298
+ if block.get("type") == "text":
299
+ collected_text.append(block["text"])
300
+
301
+ if event_type == "result":
302
+ if waiting_for_injection_response:
303
+ waiting_for_injection_response = False
304
+ _logger.info("INJECTION_RESPONSE received")
305
+ item = enforcer.flush()
306
+ if item:
307
+ _inject(item["prompt"])
308
+ continue
309
+
310
+ enforcer.check_periodic()
311
+ item = enforcer.flush()
312
+ if item:
313
+ _inject(item["prompt"])
314
+ else:
315
+ _logger.info("TURN_END — no pending enforcements, done")
316
+ break
317
+
318
+ if time.time() - last_periodic_check > 30:
319
+ enforcer.check_periodic()
320
+ last_periodic_check = time.time()
321
+
322
+ except Exception as e:
323
+ _logger.error("EXCEPTION: %s", e)
324
+ finally:
325
+ end_prompts = enforcer.get_end_prompts()
326
+ for ep in end_prompts:
327
+ try:
328
+ _inject(ep)
329
+ deadline = time.time() + 15
330
+ for raw_line in proc.stdout:
331
+ if time.time() > deadline:
332
+ _logger.warning("END_PROMPT timeout")
333
+ break
334
+ line = raw_line.strip()
335
+ if not line:
336
+ continue
337
+ try:
338
+ event = json.loads(line)
339
+ if event.get("type") == "result":
340
+ _logger.info("END_PROMPT response received")
341
+ break
342
+ except json.JSONDecodeError:
343
+ continue
344
+ except Exception:
345
+ break
346
+
347
+ elapsed = time.time() - start_time
348
+ _logger.info("=== SESSION END === duration=%.1fs %s", elapsed, enforcer.summary())
349
+
350
+ try:
351
+ proc.stdin.close()
352
+ except Exception:
353
+ pass
354
+ try:
355
+ proc.wait(timeout=10)
356
+ except subprocess.TimeoutExpired:
357
+ proc.kill()
358
+
359
+ stderr_thread.join(timeout=2)
360
+ final_text = "\n".join(collected_text)
361
+ final_stderr = "".join(stderr_lines)
362
+
363
+ return subprocess.CompletedProcess(
364
+ stream_cmd, proc.returncode or 0, final_text, final_stderr
365
+ )