nexo-brain 5.3.28 → 5.4.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.
@@ -0,0 +1,155 @@
1
+ """Runtime events bus — append-only NDJSON stream at ~/.nexo/runtime/events.ndjson.
2
+
3
+ NEXO Brain writes events that external UIs (NEXO Desktop, mobile, web)
4
+ can tail for real-time attention signals, proactive messages, health
5
+ alerts, and general notifications.
6
+
7
+ Contract:
8
+ - One JSON object per line. No multi-line JSON.
9
+ - Monotonic `id` (integer) and `ts` (unix seconds, float).
10
+ - Stable event envelope keys: id, ts, type, priority, text, reason,
11
+ source, extra. Unknown keys are preserved.
12
+ - File is append-only. Rotation happens at 5 MB: current file is
13
+ renamed to events-<ts>.ndjson and a fresh empty file is created.
14
+ - Readers tail the current file; rotation is transparent because the
15
+ file is reopened after rename detection.
16
+
17
+ Event types (stable):
18
+ attention_required — user should look at something
19
+ proactive_message — Brain wants to initiate dialogue
20
+ followup_alert — overdue or urgent followup
21
+ health_alert — a core system is degraded
22
+ info — general update, no attention needed
23
+
24
+ Priorities: "low" | "normal" | "high" | "urgent"
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import fcntl
29
+ import json
30
+ import os
31
+ import time
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ EVENT_TYPES = {
36
+ "attention_required",
37
+ "proactive_message",
38
+ "followup_alert",
39
+ "health_alert",
40
+ "info",
41
+ }
42
+ PRIORITIES = {"low", "normal", "high", "urgent"}
43
+ ROTATION_BYTES = 5 * 1024 * 1024 # 5 MB
44
+
45
+
46
+ def _nexo_home() -> Path:
47
+ return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
48
+
49
+
50
+ def events_path() -> Path:
51
+ return _nexo_home() / "runtime" / "events.ndjson"
52
+
53
+
54
+ def _next_id(path: Path) -> int:
55
+ """Return the next monotonic id by reading the last line's id, or 1."""
56
+ if not path.is_file():
57
+ return 1
58
+ try:
59
+ # Read last 4 KB — more than enough for the tail line
60
+ with path.open("rb") as fh:
61
+ fh.seek(0, os.SEEK_END)
62
+ size = fh.tell()
63
+ fh.seek(max(0, size - 4096))
64
+ tail = fh.read().decode("utf-8", errors="ignore")
65
+ lines = [ln for ln in tail.splitlines() if ln.strip()]
66
+ if not lines:
67
+ return 1
68
+ last = json.loads(lines[-1])
69
+ return int(last.get("id", 0)) + 1
70
+ except Exception:
71
+ return int(time.time())
72
+
73
+
74
+ def _rotate_if_needed(path: Path) -> None:
75
+ try:
76
+ if path.is_file() and path.stat().st_size > ROTATION_BYTES:
77
+ stamp = int(time.time())
78
+ rotated = path.with_name(f"events-{stamp}.ndjson")
79
+ path.rename(rotated)
80
+ except Exception:
81
+ # Rotation failure is non-fatal; worst case the file grows
82
+ pass
83
+
84
+
85
+ def emit(
86
+ event_type: str,
87
+ *,
88
+ text: str = "",
89
+ reason: str = "",
90
+ priority: str = "normal",
91
+ source: str = "nexo-brain",
92
+ extra: dict[str, Any] | None = None,
93
+ ) -> dict:
94
+ """Append a new event to the bus. Returns the full event dict."""
95
+ if event_type not in EVENT_TYPES:
96
+ raise ValueError(f"unknown event_type: {event_type}")
97
+ if priority not in PRIORITIES:
98
+ raise ValueError(f"unknown priority: {priority}")
99
+
100
+ path = events_path()
101
+ path.parent.mkdir(parents=True, exist_ok=True)
102
+ _rotate_if_needed(path)
103
+
104
+ event = {
105
+ "id": _next_id(path),
106
+ "ts": time.time(),
107
+ "type": event_type,
108
+ "priority": priority,
109
+ "text": text,
110
+ "reason": reason,
111
+ "source": source,
112
+ "extra": extra or {},
113
+ }
114
+
115
+ line = json.dumps(event, ensure_ascii=False) + "\n"
116
+ # fcntl flock for cross-process safety on macOS/Linux
117
+ with path.open("a", encoding="utf-8") as fh:
118
+ try:
119
+ fcntl.flock(fh, fcntl.LOCK_EX)
120
+ fh.write(line)
121
+ fh.flush()
122
+ finally:
123
+ try:
124
+ fcntl.flock(fh, fcntl.LOCK_UN)
125
+ except Exception:
126
+ pass
127
+
128
+ return event
129
+
130
+
131
+ def tail(lines: int = 50, since_id: int | None = None) -> list[dict]:
132
+ """Return the most recent events, newest last. Optionally filter by id."""
133
+ path = events_path()
134
+ if not path.is_file():
135
+ return []
136
+ try:
137
+ with path.open("r", encoding="utf-8", errors="ignore") as fh:
138
+ raw = fh.readlines()
139
+ except Exception:
140
+ return []
141
+
142
+ events: list[dict] = []
143
+ for ln in raw[-max(lines, 1) * 4:]: # generous buffer for malformed lines
144
+ ln = ln.strip()
145
+ if not ln:
146
+ continue
147
+ try:
148
+ evt = json.loads(ln)
149
+ except Exception:
150
+ continue
151
+ if since_id is not None and int(evt.get("id", 0)) <= since_id:
152
+ continue
153
+ events.append(evt)
154
+
155
+ return events[-lines:]
@@ -0,0 +1,195 @@
1
+ """Health check — one-shot snapshot of NEXO Brain subsystems.
2
+
3
+ Output is stable JSON consumable by any UI or monitoring tool.
4
+ No side effects, no network, no mutation.
5
+
6
+ Subsystems reported:
7
+ - runtime : NEXO_HOME exists, version.json readable, version string
8
+ - database : SQLite reachable, integrity check, basic row counts
9
+ - crons : count of active personal LaunchAgents (macOS) or unknown
10
+ - mcp : Claude Code MCP config present and mentions nexo-brain
11
+ - errors : count of recent errors in ~/.nexo/operations/*.log (24h)
12
+ - events : count of events emitted in last 24h
13
+
14
+ Top-level `status` is "ok" | "degraded" | "error".
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import re
21
+ import sqlite3
22
+ import subprocess
23
+ import time
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+
28
+ def _nexo_home() -> Path:
29
+ return Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
30
+
31
+
32
+ def _check_runtime() -> dict:
33
+ home = _nexo_home()
34
+ ver_file = home / "version.json"
35
+ out: dict[str, Any] = {"nexo_home": str(home), "exists": home.is_dir()}
36
+ if ver_file.is_file():
37
+ try:
38
+ payload = json.loads(ver_file.read_text())
39
+ out["version"] = payload.get("version", "unknown")
40
+ except Exception as exc:
41
+ out["version"] = "unreadable"
42
+ out["error"] = str(exc)
43
+ else:
44
+ out["version"] = "missing"
45
+ out["status"] = "ok" if out["exists"] and out.get("version") not in ("missing", "unreadable") else "degraded"
46
+ return out
47
+
48
+
49
+ def _check_database() -> dict:
50
+ db_path = _nexo_home() / "data" / "nexo.db"
51
+ out: dict[str, Any] = {"path": str(db_path), "exists": db_path.is_file()}
52
+ if not out["exists"]:
53
+ out["status"] = "error"
54
+ return out
55
+ try:
56
+ conn = sqlite3.connect(str(db_path), timeout=2.0)
57
+ try:
58
+ cur = conn.execute("PRAGMA integrity_check")
59
+ row = cur.fetchone()
60
+ out["integrity"] = row[0] if row else "unknown"
61
+ finally:
62
+ conn.close()
63
+ out["status"] = "ok" if out["integrity"] == "ok" else "degraded"
64
+ except Exception as exc:
65
+ out["status"] = "error"
66
+ out["error"] = str(exc)
67
+ return out
68
+
69
+
70
+ def _check_crons() -> dict:
71
+ out: dict[str, Any] = {}
72
+ # macOS LaunchAgents
73
+ agents_dir = Path.home() / "Library" / "LaunchAgents"
74
+ if agents_dir.is_dir():
75
+ try:
76
+ plists = [p for p in agents_dir.glob("com.nexo.*.plist")]
77
+ out["launch_agents"] = len(plists)
78
+ out["platform"] = "macos"
79
+ except Exception as exc:
80
+ out["error"] = str(exc)
81
+ else:
82
+ out["platform"] = "unknown"
83
+ out["status"] = "ok"
84
+ return out
85
+
86
+
87
+ def _check_mcp() -> dict:
88
+ out: dict[str, Any] = {}
89
+ candidates = [
90
+ Path.home() / ".claude.json",
91
+ Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json",
92
+ ]
93
+ found = []
94
+ for path in candidates:
95
+ if not path.is_file():
96
+ continue
97
+ try:
98
+ text = path.read_text(errors="ignore")
99
+ except Exception:
100
+ continue
101
+ if "nexo" in text.lower():
102
+ found.append(str(path))
103
+ out["configs_with_nexo"] = found
104
+ out["status"] = "ok" if found else "degraded"
105
+ if not found:
106
+ out["reason"] = "no client config mentions nexo-brain"
107
+ return out
108
+
109
+
110
+ def _check_errors(hours: int = 24) -> dict:
111
+ ops_dir = _nexo_home() / "operations"
112
+ out: dict[str, Any] = {"dir": str(ops_dir)}
113
+ if not ops_dir.is_dir():
114
+ out["recent_errors"] = 0
115
+ out["status"] = "ok"
116
+ return out
117
+
118
+ cutoff = time.time() - hours * 3600
119
+ recent = 0
120
+ sample: list[str] = []
121
+ error_re = re.compile(r"(?i)\b(error|traceback|exception|fail(ed)?)\b")
122
+
123
+ for log in ops_dir.glob("*.log"):
124
+ try:
125
+ if log.stat().st_mtime < cutoff:
126
+ continue
127
+ with log.open("r", errors="ignore") as fh:
128
+ for line in fh:
129
+ if error_re.search(line):
130
+ recent += 1
131
+ if len(sample) < 5:
132
+ sample.append(line.strip()[:200])
133
+ except Exception:
134
+ continue
135
+
136
+ out["recent_errors"] = recent
137
+ out["sample"] = sample
138
+ out["status"] = "ok" if recent < 20 else "degraded"
139
+ return out
140
+
141
+
142
+ def _check_events(hours: int = 24) -> dict:
143
+ events_file = _nexo_home() / "runtime" / "events.ndjson"
144
+ out: dict[str, Any] = {"path": str(events_file), "exists": events_file.is_file()}
145
+ if not events_file.is_file():
146
+ out["recent_events"] = 0
147
+ out["status"] = "ok"
148
+ return out
149
+ cutoff = time.time() - hours * 3600
150
+ count = 0
151
+ urgent = 0
152
+ try:
153
+ with events_file.open("r", errors="ignore") as fh:
154
+ for line in fh:
155
+ line = line.strip()
156
+ if not line:
157
+ continue
158
+ try:
159
+ evt = json.loads(line)
160
+ except Exception:
161
+ continue
162
+ if float(evt.get("ts", 0)) < cutoff:
163
+ continue
164
+ count += 1
165
+ if evt.get("priority") == "urgent":
166
+ urgent += 1
167
+ except Exception as exc:
168
+ out["error"] = str(exc)
169
+ out["recent_events"] = count
170
+ out["urgent"] = urgent
171
+ out["status"] = "degraded" if urgent > 0 else "ok"
172
+ return out
173
+
174
+
175
+ def collect() -> dict:
176
+ """Run every subsystem check and return a unified report."""
177
+ report: dict[str, Any] = {
178
+ "ts": time.time(),
179
+ "subsystems": {
180
+ "runtime": _check_runtime(),
181
+ "database": _check_database(),
182
+ "crons": _check_crons(),
183
+ "mcp": _check_mcp(),
184
+ "errors": _check_errors(),
185
+ "events": _check_events(),
186
+ },
187
+ }
188
+ statuses = [sub.get("status", "unknown") for sub in report["subsystems"].values()]
189
+ if "error" in statuses:
190
+ report["status"] = "error"
191
+ elif "degraded" in statuses:
192
+ report["status"] = "degraded"
193
+ else:
194
+ report["status"] = "ok"
195
+ return report
@@ -10,6 +10,7 @@ import time
10
10
 
11
11
  from db import get_db
12
12
  from fastmcp.tools import Tool
13
+ from tree_hygiene import is_duplicate_artifact_name
13
14
 
14
15
  SERVER_DIR = os.path.dirname(os.path.abspath(__file__))
15
16
  PLUGINS_DIR = os.path.join(SERVER_DIR, "plugins")
@@ -47,12 +48,16 @@ def load_all_plugins(mcp) -> int:
47
48
  if os.path.isdir(PLUGINS_DIR):
48
49
  for f in sorted(os.listdir(PLUGINS_DIR)):
49
50
  if f.endswith(".py") and f != "__init__.py":
51
+ if is_duplicate_artifact_name(os.path.join(PLUGINS_DIR, f)):
52
+ continue
50
53
  plugin_map[f] = (PLUGINS_DIR, "repo")
51
54
 
52
55
  # 2. Personal plugins (override if same filename)
53
56
  if os.path.isdir(PERSONAL_PLUGINS_DIR):
54
57
  for f in sorted(os.listdir(PERSONAL_PLUGINS_DIR)):
55
58
  if f.endswith(".py") and f != "__init__.py":
59
+ if is_duplicate_artifact_name(os.path.join(PERSONAL_PLUGINS_DIR, f)):
60
+ continue
56
61
  source = "personal (override)" if f in plugin_map else "personal"
57
62
  plugin_map[f] = (PERSONAL_PLUGINS_DIR, source)
58
63
 
@@ -10,6 +10,7 @@ import time
10
10
  from pathlib import Path
11
11
 
12
12
  from runtime_home import export_resolved_nexo_home
13
+ from tree_hygiene import is_duplicate_artifact_name
13
14
 
14
15
  # Code root is the parent of plugins/:
15
16
  # - source checkout: <repo>/src
@@ -100,7 +101,7 @@ def _refresh_installed_manifest():
100
101
  if src_crons.exists():
101
102
  dst_crons.mkdir(parents=True, exist_ok=True)
102
103
  for f in src_crons.iterdir():
103
- if f.is_file():
104
+ if f.is_file() and not is_duplicate_artifact_name(f):
104
105
  dest = dst_crons / f.name
105
106
  if _paths_match(f, dest):
106
107
  continue
@@ -111,11 +112,11 @@ def _refresh_installed_manifest():
111
112
  "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
112
113
  "script_names": sorted(
113
114
  f.name for f in (artifact_src / "scripts").iterdir()
114
- if f.is_file()
115
+ if f.is_file() and not is_duplicate_artifact_name(f)
115
116
  ) if (artifact_src / "scripts").is_dir() else [],
116
117
  "hook_names": sorted(
117
118
  f.name for f in (artifact_src / "hooks").iterdir()
118
- if f.is_file()
119
+ if f.is_file() and not is_duplicate_artifact_name(f)
119
120
  ) if (artifact_src / "hooks").is_dir() else [],
120
121
  }
121
122
  (config_dir / "runtime-core-artifacts.json").write_text(
@@ -368,7 +369,7 @@ def _sync_hooks_to_home():
368
369
  hooks_dest.mkdir(parents=True, exist_ok=True)
369
370
  synced = 0
370
371
  for f in hooks_src.iterdir():
371
- if f.is_file() and f.suffix == ".sh":
372
+ if f.is_file() and f.suffix == ".sh" and not is_duplicate_artifact_name(f):
372
373
  dest = hooks_dest / f.name
373
374
  if not _paths_match(f, dest):
374
375
  shutil.copy2(str(f), str(dest))
@@ -13,6 +13,7 @@ shift
13
13
 
14
14
  NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
15
15
  DB="$NEXO_HOME/data/nexo.db"
16
+ SPOOL_DIR="$NEXO_HOME/operations/cron-spool"
16
17
 
17
18
  # Unlock macOS Keychain so headless Claude Code can read auth tokens.
18
19
  # Claude Code stores its API key in the login keychain which auto-locks.
@@ -21,40 +22,95 @@ if [ -f "$KEYCHAIN_PASS_FILE" ] && [ "$(uname)" = "Darwin" ]; then
21
22
  security unlock-keychain -p "$(cat "$KEYCHAIN_PASS_FILE")" ~/Library/Keychains/login.keychain-db 2>/dev/null || true
22
23
  fi
23
24
 
24
- # Record start
25
- RUN_ID=$(sqlite3 "$DB" "INSERT INTO cron_runs (cron_id) VALUES ('$CRON_ID'); SELECT last_insert_rowid();" 2>/dev/null)
26
-
27
- if [ -z "$RUN_ID" ]; then
28
- # DB not ready — run without tracking
29
- exec "$@"
30
- fi
25
+ START_EPOCH=$(python3 - <<'PY'
26
+ import time
27
+ print(f"{time.time():.6f}")
28
+ PY
29
+ )
30
+ STARTED_AT=$(python3 - <<'PY'
31
+ from datetime import datetime, timezone
32
+ print(datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"))
33
+ PY
34
+ )
31
35
 
32
36
  # Run the actual command, capture output
33
37
  OUTPUT_FILE=$(mktemp)
38
+ trap 'rm -f "$OUTPUT_FILE"' EXIT
34
39
  "$@" > "$OUTPUT_FILE" 2>&1
35
40
  EXIT_CODE=$?
41
+ ENDED_AT=$(python3 - <<'PY'
42
+ from datetime import datetime, timezone
43
+ print(datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"))
44
+ PY
45
+ )
46
+ DURATION_SECS=$(python3 - <<PY
47
+ start = float("$START_EPOCH")
48
+ import time
49
+ print(round(time.time() - start, 1))
50
+ PY
51
+ )
36
52
 
37
53
  # Extract summary (last meaningful line, max 500 chars)
38
- SUMMARY=$(tail -5 "$OUTPUT_FILE" | grep -v "^$" | tail -1 | head -c 500 | sed "s/'/''/g")
54
+ SUMMARY=$(tail -5 "$OUTPUT_FILE" | grep -v "^$" | tail -1 | head -c 500)
39
55
 
40
56
  # Extract error if failed
41
57
  ERROR=""
42
58
  if [ $EXIT_CODE -ne 0 ]; then
43
- ERROR=$(grep -i "error\|exception\|fail\|traceback" "$OUTPUT_FILE" | tail -1 | head -c 500 | sed "s/'/''/g")
59
+ ERROR=$(grep -i "error\|exception\|fail\|traceback" "$OUTPUT_FILE" | tail -1 | head -c 500)
44
60
  fi
45
61
 
46
- # Record end
47
- sqlite3 "$DB" "
48
- UPDATE cron_runs SET
49
- ended_at = datetime('now'),
50
- exit_code = $EXIT_CODE,
51
- summary = '$SUMMARY',
52
- error = '$ERROR',
53
- duration_secs = ROUND((julianday(datetime('now')) - julianday(started_at)) * 86400, 1)
54
- WHERE id = $RUN_ID;
55
- " 2>/dev/null
56
-
57
- # Clean output
58
- rm -f "$OUTPUT_FILE"
62
+ if ! python3 - "$DB" "$CRON_ID" "$STARTED_AT" "$ENDED_AT" "$EXIT_CODE" "$SUMMARY" "$ERROR" "$DURATION_SECS" <<'PY'
63
+ from __future__ import annotations
64
+
65
+ import sqlite3
66
+ import sys
67
+
68
+ db_path, cron_id, started_at, ended_at, exit_code, summary, error, duration_secs = sys.argv[1:]
69
+ conn = sqlite3.connect(db_path)
70
+ try:
71
+ conn.execute(
72
+ """
73
+ INSERT INTO cron_runs (
74
+ cron_id, started_at, ended_at, exit_code, summary, error, duration_secs
75
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
76
+ """,
77
+ (
78
+ cron_id,
79
+ started_at,
80
+ ended_at,
81
+ int(exit_code),
82
+ summary,
83
+ error,
84
+ float(duration_secs),
85
+ ),
86
+ )
87
+ conn.commit()
88
+ finally:
89
+ conn.close()
90
+ PY
91
+ then
92
+ mkdir -p "$SPOOL_DIR"
93
+ SPOOL_FILE="$SPOOL_DIR/${CRON_ID}-$(date +%Y%m%d-%H%M%S)-$$.json"
94
+ python3 - "$SPOOL_FILE" "$CRON_ID" "$STARTED_AT" "$ENDED_AT" "$EXIT_CODE" "$SUMMARY" "$ERROR" "$DURATION_SECS" <<'PY'
95
+ from __future__ import annotations
96
+
97
+ import json
98
+ import sys
99
+ from pathlib import Path
100
+
101
+ spool_file, cron_id, started_at, ended_at, exit_code, summary, error, duration_secs = sys.argv[1:]
102
+ payload = {
103
+ "cron_id": cron_id,
104
+ "started_at": started_at,
105
+ "ended_at": ended_at,
106
+ "exit_code": int(exit_code),
107
+ "summary": summary,
108
+ "error": error,
109
+ "duration_secs": float(duration_secs),
110
+ }
111
+ Path(spool_file).write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
112
+ PY
113
+ echo "[nexo-cron-wrapper] DB write failed; spooled run to $SPOOL_FILE" >&2
114
+ fi
59
115
 
60
116
  exit $EXIT_CODE