nexo-brain 5.5.0 → 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 +3 -0
- package/src/enforcement_engine.py +49 -32
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.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.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.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
|
@@ -663,6 +663,9 @@ def run_automation_prompt(
|
|
|
663
663
|
cmd.extend(["--allowedTools", allowed_tools])
|
|
664
664
|
cmd.extend(extra_args)
|
|
665
665
|
try:
|
|
666
|
+
import sys as _sys
|
|
667
|
+
if str(NEXO_HOME) not in _sys.path:
|
|
668
|
+
_sys.path.insert(0, str(NEXO_HOME))
|
|
666
669
|
from enforcement_engine import run_with_enforcement
|
|
667
670
|
result = run_with_enforcement(
|
|
668
671
|
cmd,
|
|
@@ -8,6 +8,7 @@ are violated. Python equivalent of Desktop's enforcement-engine.js.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import json
|
|
11
|
+
import logging
|
|
11
12
|
import os
|
|
12
13
|
import subprocess
|
|
13
14
|
import threading
|
|
@@ -16,6 +17,15 @@ from pathlib import Path
|
|
|
16
17
|
|
|
17
18
|
NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
|
|
18
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)
|
|
19
29
|
|
|
20
30
|
|
|
21
31
|
def _load_map() -> dict | None:
|
|
@@ -48,8 +58,8 @@ class HeadlessEnforcer:
|
|
|
48
58
|
self.msg_since_tool: dict[str, int] = {}
|
|
49
59
|
self.injection_queue: list[dict] = []
|
|
50
60
|
self._started_at = time.time()
|
|
61
|
+
self._injections_done = 0
|
|
51
62
|
|
|
52
|
-
# Build indexes from map
|
|
53
63
|
self._on_start: list[dict] = []
|
|
54
64
|
self._on_end: list[dict] = []
|
|
55
65
|
self._periodic_msg: list[dict] = []
|
|
@@ -58,6 +68,11 @@ class HeadlessEnforcer:
|
|
|
58
68
|
|
|
59
69
|
if self.map:
|
|
60
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")
|
|
61
76
|
|
|
62
77
|
def _build_indexes(self):
|
|
63
78
|
for tool_name, tool_def in self.map.get("tools", {}).items():
|
|
@@ -79,7 +94,6 @@ class HeadlessEnforcer:
|
|
|
79
94
|
for wt in rule.get("watch_tools", []):
|
|
80
95
|
self._after_tool.setdefault(wt, []).append(entry)
|
|
81
96
|
|
|
82
|
-
# triggers_after chains
|
|
83
97
|
for triggered in enf.get("triggers_after", []):
|
|
84
98
|
self._after_tool.setdefault(tool_name, []).append({
|
|
85
99
|
"tool": triggered,
|
|
@@ -93,8 +107,8 @@ class HeadlessEnforcer:
|
|
|
93
107
|
self.tools_called.add(name)
|
|
94
108
|
self.tool_timestamps[name] = time.time()
|
|
95
109
|
self.msg_since_tool[name] = 0
|
|
110
|
+
_logger.info("TOOL_CALL #%d: %s", self.tool_call_count, name)
|
|
96
111
|
|
|
97
|
-
# Check after_tool rules
|
|
98
112
|
for entry in self._after_tool.get(name, []):
|
|
99
113
|
target = entry["tool"]
|
|
100
114
|
if target not in self.tools_called:
|
|
@@ -103,8 +117,6 @@ class HeadlessEnforcer:
|
|
|
103
117
|
self._enqueue(prompt, f"after:{name}->{target}")
|
|
104
118
|
|
|
105
119
|
def check_periodic(self):
|
|
106
|
-
"""Called periodically to check time-based and message-based rules."""
|
|
107
|
-
# on_session_start
|
|
108
120
|
for entry in self._on_start:
|
|
109
121
|
tool = entry["tool"]
|
|
110
122
|
threshold = entry["rule"].get("threshold", 2)
|
|
@@ -113,7 +125,6 @@ class HeadlessEnforcer:
|
|
|
113
125
|
if prompt:
|
|
114
126
|
self._enqueue(prompt, f"start:{tool}")
|
|
115
127
|
|
|
116
|
-
# periodic_by_messages
|
|
117
128
|
for entry in self._periodic_msg:
|
|
118
129
|
tool = entry["tool"]
|
|
119
130
|
threshold = entry["rule"].get("threshold", 3)
|
|
@@ -123,7 +134,6 @@ class HeadlessEnforcer:
|
|
|
123
134
|
if prompt:
|
|
124
135
|
self._enqueue(prompt, f"periodic_msg:{tool}")
|
|
125
136
|
|
|
126
|
-
# periodic_by_time
|
|
127
137
|
for entry in self._periodic_time:
|
|
128
138
|
tool = entry["tool"]
|
|
129
139
|
threshold_min = entry["rule"].get("threshold", 15)
|
|
@@ -141,6 +151,7 @@ class HeadlessEnforcer:
|
|
|
141
151
|
p = entry["enf"].get("session_end_inject_prompt") or entry["enf"].get("inject_prompt", "")
|
|
142
152
|
if p:
|
|
143
153
|
prompts.append(p)
|
|
154
|
+
_logger.info("END_PROMPTS: %d prompts to inject", len(prompts))
|
|
144
155
|
return prompts
|
|
145
156
|
|
|
146
157
|
def flush(self) -> dict | None:
|
|
@@ -152,9 +163,20 @@ class HeadlessEnforcer:
|
|
|
152
163
|
if any(q["tag"] == tag for q in self.injection_queue):
|
|
153
164
|
return
|
|
154
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
|
|
155
171
|
if tool in self.tools_called and not tag.startswith("periodic_"):
|
|
172
|
+
_logger.info("SKIP: %s — already called", tag)
|
|
156
173
|
return
|
|
157
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)}")
|
|
158
180
|
|
|
159
181
|
|
|
160
182
|
def run_with_enforcement(
|
|
@@ -165,18 +187,14 @@ def run_with_enforcement(
|
|
|
165
187
|
env: dict | None = None,
|
|
166
188
|
timeout: int = 300,
|
|
167
189
|
) -> 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
190
|
enforcer = HeadlessEnforcer()
|
|
191
|
+
_logger.info("=== SESSION START === prompt=%s timeout=%d", prompt[:80], timeout)
|
|
192
|
+
|
|
174
193
|
if not enforcer.map:
|
|
175
|
-
|
|
194
|
+
_logger.warning("No map — falling back to plain subprocess.run")
|
|
176
195
|
return subprocess.run(cmd, cwd=cwd or None, capture_output=True, text=True,
|
|
177
196
|
timeout=timeout, env=env)
|
|
178
197
|
|
|
179
|
-
# Replace -p with stream-json mode
|
|
180
198
|
stream_cmd = []
|
|
181
199
|
skip_next = False
|
|
182
200
|
for i, arg in enumerate(cmd):
|
|
@@ -184,10 +202,10 @@ def run_with_enforcement(
|
|
|
184
202
|
skip_next = False
|
|
185
203
|
continue
|
|
186
204
|
if arg == "-p":
|
|
187
|
-
skip_next = True
|
|
205
|
+
skip_next = True
|
|
188
206
|
continue
|
|
189
207
|
if arg == "--output-format":
|
|
190
|
-
skip_next = True
|
|
208
|
+
skip_next = True
|
|
191
209
|
continue
|
|
192
210
|
stream_cmd.append(arg)
|
|
193
211
|
|
|
@@ -208,7 +226,6 @@ def run_with_enforcement(
|
|
|
208
226
|
text=True,
|
|
209
227
|
)
|
|
210
228
|
|
|
211
|
-
# Send initial user message
|
|
212
229
|
initial_msg = json.dumps({
|
|
213
230
|
"type": "user",
|
|
214
231
|
"message": {"role": "user", "content": [{"type": "text", "text": prompt}]}
|
|
@@ -231,8 +248,10 @@ def run_with_enforcement(
|
|
|
231
248
|
proc.stdin.write(msg + "\n")
|
|
232
249
|
proc.stdin.flush()
|
|
233
250
|
waiting_for_injection_response = True
|
|
234
|
-
|
|
235
|
-
|
|
251
|
+
enforcer._injections_done += 1
|
|
252
|
+
_logger.info("INJECTED: %s", text[:100])
|
|
253
|
+
except Exception as e:
|
|
254
|
+
_logger.error("INJECT_FAILED: %s", e)
|
|
236
255
|
|
|
237
256
|
def _read_stderr():
|
|
238
257
|
try:
|
|
@@ -244,7 +263,6 @@ def run_with_enforcement(
|
|
|
244
263
|
stderr_thread = threading.Thread(target=_read_stderr, daemon=True)
|
|
245
264
|
stderr_thread.start()
|
|
246
265
|
|
|
247
|
-
# Periodic check timer
|
|
248
266
|
last_periodic_check = time.time()
|
|
249
267
|
|
|
250
268
|
try:
|
|
@@ -253,8 +271,8 @@ def run_with_enforcement(
|
|
|
253
271
|
if not line:
|
|
254
272
|
continue
|
|
255
273
|
|
|
256
|
-
# Check timeout
|
|
257
274
|
if time.time() - start_time > timeout:
|
|
275
|
+
_logger.warning("TIMEOUT after %ds", timeout)
|
|
258
276
|
proc.kill()
|
|
259
277
|
break
|
|
260
278
|
|
|
@@ -265,7 +283,6 @@ def run_with_enforcement(
|
|
|
265
283
|
|
|
266
284
|
event_type = event.get("type", "")
|
|
267
285
|
|
|
268
|
-
# Detect tool use
|
|
269
286
|
if event_type == "assistant" and event.get("message", {}).get("content"):
|
|
270
287
|
for block in event["message"]["content"]:
|
|
271
288
|
if block.get("type") == "tool_use":
|
|
@@ -275,48 +292,44 @@ def run_with_enforcement(
|
|
|
275
292
|
if cb.get("type") == "tool_use":
|
|
276
293
|
enforcer.on_tool_call(cb.get("name", ""))
|
|
277
294
|
|
|
278
|
-
# Collect assistant text
|
|
279
295
|
if event_type == "assistant" and not waiting_for_injection_response:
|
|
280
296
|
msg = event.get("message", {})
|
|
281
297
|
for block in msg.get("content", []):
|
|
282
298
|
if block.get("type") == "text":
|
|
283
299
|
collected_text.append(block["text"])
|
|
284
300
|
|
|
285
|
-
# Detect turn end (result event with stop_reason)
|
|
286
301
|
if event_type == "result":
|
|
287
302
|
if waiting_for_injection_response:
|
|
288
303
|
waiting_for_injection_response = False
|
|
289
|
-
|
|
304
|
+
_logger.info("INJECTION_RESPONSE received")
|
|
290
305
|
item = enforcer.flush()
|
|
291
306
|
if item:
|
|
292
307
|
_inject(item["prompt"])
|
|
293
308
|
continue
|
|
294
309
|
|
|
295
|
-
# Normal turn end — check for pending enforcements
|
|
296
310
|
enforcer.check_periodic()
|
|
297
311
|
item = enforcer.flush()
|
|
298
312
|
if item:
|
|
299
313
|
_inject(item["prompt"])
|
|
300
314
|
else:
|
|
301
|
-
|
|
315
|
+
_logger.info("TURN_END — no pending enforcements, done")
|
|
316
|
+
break
|
|
302
317
|
|
|
303
|
-
# Periodic check every 30 seconds
|
|
304
318
|
if time.time() - last_periodic_check > 30:
|
|
305
319
|
enforcer.check_periodic()
|
|
306
320
|
last_periodic_check = time.time()
|
|
307
321
|
|
|
308
|
-
except Exception:
|
|
309
|
-
|
|
322
|
+
except Exception as e:
|
|
323
|
+
_logger.error("EXCEPTION: %s", e)
|
|
310
324
|
finally:
|
|
311
|
-
# End-of-session enforcement: inject diary + stop
|
|
312
325
|
end_prompts = enforcer.get_end_prompts()
|
|
313
326
|
for ep in end_prompts:
|
|
314
327
|
try:
|
|
315
328
|
_inject(ep)
|
|
316
|
-
# Wait briefly for response
|
|
317
329
|
deadline = time.time() + 15
|
|
318
330
|
for raw_line in proc.stdout:
|
|
319
331
|
if time.time() > deadline:
|
|
332
|
+
_logger.warning("END_PROMPT timeout")
|
|
320
333
|
break
|
|
321
334
|
line = raw_line.strip()
|
|
322
335
|
if not line:
|
|
@@ -324,12 +337,16 @@ def run_with_enforcement(
|
|
|
324
337
|
try:
|
|
325
338
|
event = json.loads(line)
|
|
326
339
|
if event.get("type") == "result":
|
|
340
|
+
_logger.info("END_PROMPT response received")
|
|
327
341
|
break
|
|
328
342
|
except json.JSONDecodeError:
|
|
329
343
|
continue
|
|
330
344
|
except Exception:
|
|
331
345
|
break
|
|
332
346
|
|
|
347
|
+
elapsed = time.time() - start_time
|
|
348
|
+
_logger.info("=== SESSION END === duration=%.1fs %s", elapsed, enforcer.summary())
|
|
349
|
+
|
|
333
350
|
try:
|
|
334
351
|
proc.stdin.close()
|
|
335
352
|
except Exception:
|