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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.5.0",
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.0` 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.5.0",
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",
@@ -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
- # No map available fall back to simple subprocess.run
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 # Skip the prompt argument
205
+ skip_next = True
188
206
  continue
189
207
  if arg == "--output-format":
190
- skip_next = True # Skip output format
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
- except Exception:
235
- pass
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
- # After injection response, check for more injections
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
- break # No more injections, we're done
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
- pass
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: