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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/agent_runner.py +21 -8
- package/src/enforcement_engine.py +365 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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.
|
|
3
|
+
"version": "5.5.1",
|
|
4
4
|
"mcpName": "io.github.wazionapps/nexo",
|
|
5
|
-
"description": "NEXO Brain
|
|
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",
|
package/src/agent_runner.py
CHANGED
|
@@ -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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
+
)
|