nexo-brain 5.4.8 → 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.8",
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.8` is the current packaged-runtime line: tool-enforcement-map v2.0multi-dimensional enforcement with dependency chains, internal_calls, provides/requires, and 3-level enforcement (must/should/none).
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.8",
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",
@@ -61,7 +61,11 @@
61
61
  "postinstall": "node bin/postinstall.js"
62
62
  },
63
63
  "runtimeDependencies": [
64
- {"name": "@anthropic-ai/claude-code", "type": "npm-global", "optional": false}
64
+ {
65
+ "name": "@anthropic-ai/claude-code",
66
+ "type": "npm-global",
67
+ "optional": false
68
+ }
65
69
  ],
66
70
  "engines": {
67
71
  "node": ">=18"
@@ -56,7 +56,6 @@ class TerminalClientUnavailableError(AgentRunnerError):
56
56
  class AutomationBackendUnavailableError(AgentRunnerError):
57
57
  """Raised when the configured automation backend is unavailable."""
58
58
 
59
-
60
59
  def _canonical_pricing_model(model: str) -> str:
61
60
  lowered = str(model or "").strip().lower()
62
61
  lowered = lowered.split("[", 1)[0]
@@ -533,6 +532,63 @@ def _build_codex_prompt(
533
532
  return prompt
534
533
 
535
534
 
535
+ def _build_enforcement_system_prompt() -> str:
536
+ """Build a system prompt fragment from tool-enforcement-map.json for headless sessions.
537
+
538
+ Reads the map and generates concise enforcement rules that the model must follow.
539
+ Only includes 'must' and 'should' level tools — 'none' tools are omitted.
540
+ """
541
+ map_path = NEXO_HOME / "tool-enforcement-map.json"
542
+ if not map_path.exists():
543
+ for candidate in [
544
+ Path(__file__).parent.parent / "tool-enforcement-map.json",
545
+ ]:
546
+ if candidate.exists():
547
+ map_path = candidate
548
+ break
549
+ else:
550
+ return ""
551
+ try:
552
+ data = json.loads(map_path.read_text())
553
+ except Exception:
554
+ return ""
555
+
556
+ lines = ["[PROTOCOL ENFORCEMENT — these rules are mandatory]"]
557
+ must_rules = []
558
+ should_rules = []
559
+
560
+ for tool_name, tool in data.get("tools", {}).items():
561
+ enf = tool.get("enforcement", {})
562
+ level = enf.get("level", "none")
563
+ if level == "none":
564
+ continue
565
+ rules = enf.get("rules", [])
566
+ rule_descs = [r.get("description", "") for r in rules if r.get("description")]
567
+ notes = enf.get("notes", "")
568
+ if rule_descs:
569
+ desc = f"{tool_name}: {'; '.join(rule_descs)}"
570
+ elif notes:
571
+ desc = f"{tool_name}: {notes[:120]}"
572
+ else:
573
+ rule_types = [r.get("type", "") for r in rules]
574
+ desc = f"{tool_name} ({', '.join(rule_types)})" if rule_types else tool_name
575
+ if level == "must":
576
+ must_rules.append(desc)
577
+ elif level == "should":
578
+ should_rules.append(desc)
579
+
580
+ if must_rules:
581
+ lines.append("MUST (violation creates protocol debt):")
582
+ for r in must_rules:
583
+ lines.append(f" - {r}")
584
+ if should_rules:
585
+ lines.append("SHOULD (recommended, check if relevant):")
586
+ for r in should_rules:
587
+ lines.append(f" - {r}")
588
+
589
+ return "\n".join(lines) if (must_rules or should_rules) else ""
590
+
591
+
536
592
  def run_automation_prompt(
537
593
  prompt: str,
538
594
  *,
@@ -562,6 +618,13 @@ def run_automation_prompt(
562
618
  reasoning_effort = profile["reasoning_effort"]
563
619
  selected_backend = _resolve_available_backend(selected_backend, preferences=prefs)
564
620
 
621
+ enforcement_fragment = _build_enforcement_system_prompt()
622
+ if enforcement_fragment:
623
+ if append_system_prompt:
624
+ append_system_prompt = append_system_prompt + "\n\n" + enforcement_fragment
625
+ else:
626
+ append_system_prompt = enforcement_fragment
627
+
565
628
  cwd_path = Path(cwd).expanduser().resolve() if cwd else Path.cwd()
566
629
  run_env = _headless_env(env)
567
630
  extra_args = list(extra_args or [])
@@ -599,14 +662,24 @@ def run_automation_prompt(
599
662
  if allowed_tools:
600
663
  cmd.extend(["--allowedTools", allowed_tools])
601
664
  cmd.extend(extra_args)
602
- result = subprocess.run(
603
- cmd,
604
- cwd=str(cwd_path),
605
- capture_output=True,
606
- text=True,
607
- timeout=timeout,
608
- env=run_env,
609
- )
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
+ )
610
683
  final_stdout, telemetry = _extract_claude_telemetry(
611
684
  result.stdout or "",
612
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
+ )