nexo-brain 5.3.30 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.3.30",
3
+ "version": "5.4.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,9 +18,9 @@
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.3.30` is the current packaged-runtime line: four read-only CLI commands (`nexo schema`, `nexo identity`, `nexo onboard`, `nexo scan-profile`) let external UIs like NEXO Desktop auto-adapt to the editable schema, canonical identity, onboarding wizard, and profile heuristics without hardcoding fields.
21
+ Version `5.4.0` is the current packaged-runtime line: runtime event bus at `~/.nexo/runtime/events.ndjson`, `nexo notify` for one-shot proactive events, `nexo health --json` for a rolled-up subsystem snapshot, `nexo logs --tail --json` for structured log access, and a safe flat→nested migration for `calibration.json` on older installs.
22
22
 
23
- Previously in `5.3.29`: duplicate `* 2` artifacts now fail hygiene gates instead of hiding in the tree, packaged/runtime update paths converge on one canonical core, startup preflight runs synchronously, corrupt DB state no longer respawns an empty brain by default, and cron runs spool locally when SQLite is unavailable.
23
+ Previously in `5.3.30`: four read-only CLI commands (`nexo schema`, `nexo identity`, `nexo onboard`, `nexo scan-profile`) let external UIs auto-adapt to the editable schema, identity, onboarding wizard, and profile heuristics.
24
24
 
25
25
  Start here:
26
26
  - [5-minute quickstart](docs/quickstart-5-minutes.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.3.30",
3
+ "version": "5.4.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",
@@ -0,0 +1,242 @@
1
+ """Calibration migration — flat → nested schema for calibration.json.
2
+
3
+ Older NEXO installations wrote calibration.json with flat top-level keys
4
+ (user_name, autonomy, role…). The canonical shape is nested
5
+ (user.name, personality.autonomy, meta.role). This module detects the
6
+ flat shape and migrates to nested with a backup.
7
+
8
+ Design:
9
+ - Backup lives at calibration.json.pre-migrate-<version>
10
+ - Unknown fields are preserved verbatim under `legacy_unmapped`
11
+ - Revert is just: cp backup → calibration.json
12
+ - Idempotent: if already nested, returns OK without touching the file
13
+
14
+ No network, no DB. Pure file I/O so it can run from any runtime.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import shutil
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ FLAT_TO_NESTED = {
27
+ # user.*
28
+ "user_name": ("user", "name"),
29
+ "name": ("user", "name"),
30
+ "language": ("user", "language"),
31
+ "lang": ("user", "language"),
32
+ "timezone": ("user", "timezone"),
33
+ "tz": ("user", "timezone"),
34
+ "assistant_name": ("user", "assistant_name"),
35
+ # personality.*
36
+ "autonomy": ("personality", "autonomy"),
37
+ "communication": ("personality", "communication"),
38
+ "honesty": ("personality", "honesty"),
39
+ "proactivity": ("personality", "proactivity"),
40
+ "error_handling": ("personality", "error_handling"),
41
+ # preferences.*
42
+ "menu_on_demand": ("preferences", "menu_on_demand"),
43
+ "show_pending_items": ("preferences", "show_pending_items"),
44
+ "execution_first": ("preferences", "execution_first"),
45
+ "report_style": ("preferences", "report_style"),
46
+ # meta.*
47
+ "role": ("meta", "role"),
48
+ "technical_level": ("meta", "technical_level"),
49
+ }
50
+
51
+ # Keys that always live at the top level regardless of shape
52
+ TOP_LEVEL_KEYS = {"version", "created", "mood_history", "operator_name"}
53
+
54
+
55
+ def _calibration_path(nexo_home: Path | None = None) -> Path:
56
+ home = nexo_home or Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
57
+ return home / "brain" / "calibration.json"
58
+
59
+
60
+ def _is_nested(cal: dict) -> bool:
61
+ """Nested shape has at least one of {user, personality, preferences, meta} as dict."""
62
+ for key in ("user", "personality", "preferences", "meta"):
63
+ if isinstance(cal.get(key), dict):
64
+ return True
65
+ return False
66
+
67
+
68
+ def _has_flat_markers(cal: dict) -> bool:
69
+ """Flat shape has top-level keys that normally belong inside nested groups."""
70
+ for flat_key in FLAT_TO_NESTED:
71
+ if flat_key in cal:
72
+ return True
73
+ return False
74
+
75
+
76
+ def detect(cal: dict | None = None, *, path: Path | None = None) -> dict:
77
+ """Return {'shape': 'nested'|'flat'|'mixed'|'empty', 'reason': str}."""
78
+ if cal is None:
79
+ target = path or _calibration_path()
80
+ if not target.is_file():
81
+ return {"shape": "empty", "reason": "file does not exist"}
82
+ try:
83
+ cal = json.loads(target.read_text())
84
+ except Exception as exc:
85
+ return {"shape": "empty", "reason": f"unreadable: {exc}"}
86
+ if not isinstance(cal, dict) or not cal:
87
+ return {"shape": "empty", "reason": "empty or not an object"}
88
+
89
+ nested = _is_nested(cal)
90
+ flat = _has_flat_markers(cal)
91
+ if nested and flat:
92
+ return {"shape": "mixed", "reason": "both nested groups and flat keys present"}
93
+ if nested:
94
+ return {"shape": "nested", "reason": "already canonical"}
95
+ if flat:
96
+ return {"shape": "flat", "reason": "top-level flat keys, no nested groups"}
97
+ return {"shape": "empty", "reason": "no recognizable keys"}
98
+
99
+
100
+ def migrate(
101
+ cal: dict,
102
+ *,
103
+ preserve_unmapped: bool = True,
104
+ ) -> dict:
105
+ """Convert a flat calibration payload to nested. Returns a new dict."""
106
+ if _is_nested(cal) and not _has_flat_markers(cal):
107
+ return dict(cal)
108
+
109
+ result: dict[str, Any] = {}
110
+ legacy_unmapped: dict[str, Any] = {}
111
+
112
+ # Preserve nested groups already present
113
+ for group in ("user", "personality", "preferences", "meta"):
114
+ if isinstance(cal.get(group), dict):
115
+ result[group] = dict(cal[group])
116
+
117
+ # Preserve top-level metadata
118
+ for key in TOP_LEVEL_KEYS:
119
+ if key in cal:
120
+ result[key] = cal[key]
121
+
122
+ # Walk flat keys
123
+ for key, value in cal.items():
124
+ if key in ("user", "personality", "preferences", "meta"):
125
+ continue
126
+ if key in TOP_LEVEL_KEYS:
127
+ continue
128
+ if key in FLAT_TO_NESTED:
129
+ group, leaf = FLAT_TO_NESTED[key]
130
+ result.setdefault(group, {})
131
+ # Nested value wins over flat if both exist
132
+ if leaf not in result[group]:
133
+ result[group][leaf] = value
134
+ else:
135
+ legacy_unmapped[key] = value
136
+
137
+ if legacy_unmapped and preserve_unmapped:
138
+ result["legacy_unmapped"] = legacy_unmapped
139
+
140
+ # Bump version marker if present
141
+ if "version" not in result:
142
+ result["version"] = 1
143
+
144
+ return result
145
+
146
+
147
+ def backup_path(path: Path, version: str = "5.4.0") -> Path:
148
+ return path.with_name(path.name + f".pre-migrate-{version}")
149
+
150
+
151
+ def apply_migration(
152
+ path: Path | None = None,
153
+ *,
154
+ version: str = "5.4.0",
155
+ dry_run: bool = False,
156
+ ) -> dict:
157
+ """Migrate calibration.json on disk. Returns a status dict."""
158
+ target = path or _calibration_path()
159
+ if not target.is_file():
160
+ return {"status": "skipped", "reason": "calibration.json not found", "path": str(target)}
161
+
162
+ try:
163
+ original = json.loads(target.read_text())
164
+ except Exception as exc:
165
+ return {"status": "error", "reason": f"unreadable: {exc}", "path": str(target)}
166
+
167
+ shape = detect(original)
168
+ if shape["shape"] in ("nested", "empty"):
169
+ return {
170
+ "status": "noop",
171
+ "reason": f"already {shape['shape']}",
172
+ "path": str(target),
173
+ "shape": shape["shape"],
174
+ }
175
+
176
+ migrated = migrate(original)
177
+ if dry_run:
178
+ return {
179
+ "status": "preview",
180
+ "reason": "dry run",
181
+ "path": str(target),
182
+ "shape": shape["shape"],
183
+ "original": original,
184
+ "migrated": migrated,
185
+ "backup_would_be": str(backup_path(target, version)),
186
+ }
187
+
188
+ backup = backup_path(target, version)
189
+ try:
190
+ shutil.copy2(target, backup)
191
+ except Exception as exc:
192
+ return {"status": "error", "reason": f"backup failed: {exc}", "path": str(target)}
193
+
194
+ try:
195
+ target.write_text(json.dumps(migrated, ensure_ascii=False, indent=2))
196
+ except Exception as exc:
197
+ # Attempt revert
198
+ try:
199
+ shutil.copy2(backup, target)
200
+ except Exception:
201
+ pass
202
+ return {"status": "error", "reason": f"write failed: {exc}", "path": str(target)}
203
+
204
+ # Re-detect to confirm
205
+ post = detect(migrated)
206
+ if post["shape"] != "nested":
207
+ # Revert
208
+ try:
209
+ shutil.copy2(backup, target)
210
+ except Exception:
211
+ pass
212
+ return {
213
+ "status": "error",
214
+ "reason": f"post-migration shape is {post['shape']}",
215
+ "path": str(target),
216
+ }
217
+
218
+ return {
219
+ "status": "migrated",
220
+ "reason": "flat → nested",
221
+ "path": str(target),
222
+ "backup": str(backup),
223
+ "shape": post["shape"],
224
+ "migrated_at": time.time(),
225
+ }
226
+
227
+
228
+ def revert(
229
+ path: Path | None = None,
230
+ *,
231
+ version: str = "5.4.0",
232
+ ) -> dict:
233
+ """Revert calibration.json to the most recent pre-migrate backup."""
234
+ target = path or _calibration_path()
235
+ backup = backup_path(target, version)
236
+ if not backup.is_file():
237
+ return {"status": "error", "reason": f"no backup found at {backup}", "path": str(target)}
238
+ try:
239
+ shutil.copy2(backup, target)
240
+ except Exception as exc:
241
+ return {"status": "error", "reason": f"copy failed: {exc}", "path": str(target)}
242
+ return {"status": "reverted", "from": str(backup), "path": str(target)}
package/src/cli.py CHANGED
@@ -922,6 +922,19 @@ def _update(args):
922
922
  print(f" Model recommendation check skipped: {exc}", file=sys.stderr)
923
923
  else:
924
924
  print(f"UPDATE FAILED: {result.get('error', 'sync failed')}", file=sys.stderr)
925
+
926
+ # Auto-migrate calibration.json flat → nested once per user. Silent on
927
+ # no-op; logs a line if an actual migration happened.
928
+ try:
929
+ from calibration_migration import detect as _cal_detect, apply_migration as _cal_apply
930
+ if _cal_detect()["shape"] == "flat":
931
+ mig = _cal_apply()
932
+ if mig.get("status") == "migrated" and not args.json:
933
+ print(f"[NEXO] calibration.json migrated flat → nested (backup: {mig.get('backup')})",
934
+ flush=True)
935
+ except Exception:
936
+ pass
937
+
925
938
  return 0 if result.get("ok") else 1
926
939
 
927
940
 
@@ -1295,6 +1308,105 @@ def _chat(args):
1295
1308
  return int(result.returncode)
1296
1309
 
1297
1310
 
1311
+ def _notify(args):
1312
+ """Emit an event to the runtime events bus (events.ndjson)."""
1313
+ from events_bus import emit
1314
+ try:
1315
+ event = emit(
1316
+ args.type,
1317
+ text=getattr(args, "text", "") or "",
1318
+ reason=getattr(args, "reason", "") or "",
1319
+ priority=getattr(args, "priority", "normal"),
1320
+ source=getattr(args, "source", "nexo-brain"),
1321
+ )
1322
+ except ValueError as exc:
1323
+ print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
1324
+ return 2
1325
+ if args.json:
1326
+ print(json.dumps({"ok": True, "event": event}, ensure_ascii=False, indent=2))
1327
+ else:
1328
+ print(f"notify: id={event['id']} type={event['type']} priority={event['priority']}")
1329
+ return 0
1330
+
1331
+
1332
+ def _health(args):
1333
+ """Collect a health snapshot and print it."""
1334
+ from health_check import collect
1335
+ report = collect()
1336
+ if getattr(args, "json", True) is False:
1337
+ # minimal text mode
1338
+ print(f"status: {report.get('status', 'unknown')}")
1339
+ for name, sub in report.get("subsystems", {}).items():
1340
+ print(f" {name:10s} {sub.get('status', '?')}")
1341
+ return 0 if report.get("status") == "ok" else 1
1342
+ print(json.dumps(report, ensure_ascii=False, indent=2))
1343
+ return 0 if report.get("status") == "ok" else 1
1344
+
1345
+
1346
+ def _logs(args):
1347
+ """Tail recent logs: events bus + operations/*.log."""
1348
+ import glob as _glob
1349
+ lines_want = max(1, int(getattr(args, "lines", 100)))
1350
+ source = (getattr(args, "source", "all") or "all").lower()
1351
+
1352
+ results: dict = {"source": source, "lines": lines_want, "entries": []}
1353
+ home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
1354
+
1355
+ def _collect_events(n: int) -> list[dict]:
1356
+ try:
1357
+ from events_bus import tail as _tail
1358
+ return _tail(lines=n)
1359
+ except Exception as exc:
1360
+ return [{"error": f"events tail failed: {exc}"}]
1361
+
1362
+ def _collect_ops(n: int) -> list[dict]:
1363
+ ops_dir = home / "operations"
1364
+ if not ops_dir.is_dir():
1365
+ return []
1366
+ files = sorted(
1367
+ ops_dir.glob("*.log"),
1368
+ key=lambda p: p.stat().st_mtime if p.exists() else 0,
1369
+ reverse=True,
1370
+ )[:5]
1371
+ out: list[dict] = []
1372
+ for log in files:
1373
+ try:
1374
+ text = log.read_text(errors="ignore").splitlines()[-n:]
1375
+ except Exception as exc:
1376
+ out.append({"file": str(log), "error": str(exc)})
1377
+ continue
1378
+ for line in text:
1379
+ out.append({"file": log.name, "line": line})
1380
+ return out[-n:]
1381
+
1382
+ if source in ("all", "events"):
1383
+ results["entries"].extend({"kind": "event", **e} for e in _collect_events(lines_want))
1384
+ if source in ("all", "operations"):
1385
+ results["entries"].extend({"kind": "log", **e} for e in _collect_ops(lines_want))
1386
+ if source not in ("all", "events", "operations"):
1387
+ # Treat as filename match inside operations/
1388
+ specific = home / "operations" / source
1389
+ if specific.is_file():
1390
+ try:
1391
+ text = specific.read_text(errors="ignore").splitlines()[-lines_want:]
1392
+ results["entries"] = [{"kind": "log", "file": specific.name, "line": ln} for ln in text]
1393
+ except Exception as exc:
1394
+ results["error"] = str(exc)
1395
+ else:
1396
+ results["error"] = f"unknown log source: {source}"
1397
+
1398
+ if args.json:
1399
+ print(json.dumps(results, ensure_ascii=False, indent=2))
1400
+ else:
1401
+ for entry in results["entries"][-lines_want:]:
1402
+ if entry.get("kind") == "event":
1403
+ print(f"[event] id={entry.get('id')} {entry.get('type')} "
1404
+ f"{entry.get('priority')} {entry.get('text','')}")
1405
+ else:
1406
+ print(f"[{entry.get('file','?')}] {entry.get('line','')}")
1407
+ return 0
1408
+
1409
+
1298
1410
  def _doctor(args):
1299
1411
  """Run unified doctor diagnostics."""
1300
1412
  try:
@@ -1306,6 +1418,22 @@ def _doctor(args):
1306
1418
  return 1
1307
1419
 
1308
1420
  init_db()
1421
+
1422
+ # Calibration migration hook — runs before the orchestrator so the rest
1423
+ # of doctor sees the canonical nested shape.
1424
+ want_migrate = getattr(args, "migrate_calibration", False) or args.fix
1425
+ dry = getattr(args, "calibration_dry_run", False)
1426
+ if want_migrate or dry:
1427
+ from calibration_migration import detect, apply_migration
1428
+ shape = detect()
1429
+ if shape["shape"] == "flat":
1430
+ mig = apply_migration(dry_run=dry)
1431
+ if args.json:
1432
+ print(json.dumps({"calibration_migration": mig}, ensure_ascii=False, indent=2))
1433
+ else:
1434
+ print(f"[NEXO] calibration migration: {mig.get('status')} — {mig.get('reason','')}",
1435
+ file=sys.stderr, flush=True)
1436
+
1309
1437
  tier_label = getattr(args, "tier", "boot") or "boot"
1310
1438
  print(f"[NEXO] Inspecting {tier_label} diagnostics... please wait.", file=sys.stderr, flush=True)
1311
1439
  report = run_doctor(tier=args.tier, fix=args.fix, plane=getattr(args, "plane", ""))
@@ -1855,6 +1983,10 @@ def main():
1855
1983
  )
1856
1984
  doctor_parser.add_argument("--json", action="store_true", help="JSON output")
1857
1985
  doctor_parser.add_argument("--fix", action="store_true", help="Apply deterministic fixes")
1986
+ doctor_parser.add_argument("--migrate-calibration", action="store_true",
1987
+ help="Force calibration.json flat → nested migration")
1988
+ doctor_parser.add_argument("--calibration-dry-run", action="store_true",
1989
+ help="Preview the calibration migration without writing")
1858
1990
 
1859
1991
  # -- contributor --
1860
1992
  contributor_parser = sub.add_parser("contributor", help="Public Draft PR contribution mode")
@@ -1959,6 +2091,28 @@ def main():
1959
2091
  scan_profile_parser.add_argument("--apply", action="store_true", help="Write profile.json (default is preview)")
1960
2092
  scan_profile_parser.add_argument("--force", action="store_true", help="Overwrite existing profile.json on --apply")
1961
2093
 
2094
+ # -- runtime events bus + operational observability --
2095
+ notify_parser = sub.add_parser("notify", help="Emit an event to the runtime event bus")
2096
+ notify_parser.add_argument("type", choices=[
2097
+ "attention_required", "proactive_message", "followup_alert",
2098
+ "health_alert", "info",
2099
+ ], help="Event type")
2100
+ notify_parser.add_argument("--text", default="", help="Short user-facing message")
2101
+ notify_parser.add_argument("--reason", default="", help="Internal reason / trigger")
2102
+ notify_parser.add_argument("--priority", choices=["low", "normal", "high", "urgent"], default="normal")
2103
+ notify_parser.add_argument("--source", default="nexo-brain", help="Who emitted this event")
2104
+ notify_parser.add_argument("--json", action="store_true", help="JSON output")
2105
+
2106
+ health_parser = sub.add_parser("health", help="Snapshot of NEXO Brain subsystem health")
2107
+ health_parser.add_argument("--json", action="store_true", help="JSON output (default)")
2108
+
2109
+ logs_parser = sub.add_parser("logs", help="Tail recent operational logs")
2110
+ logs_parser.add_argument("--tail", action="store_true", help="Tail mode (default)")
2111
+ logs_parser.add_argument("--lines", type=int, default=100, help="How many lines to return")
2112
+ logs_parser.add_argument("--source", default="all",
2113
+ help="Log source: all | events | operations | <logname>")
2114
+ logs_parser.add_argument("--json", action="store_true", help="JSON output")
2115
+
1962
2116
  args = parser.parse_args()
1963
2117
 
1964
2118
  if args.help or (not args.command and not args.version):
@@ -2060,6 +2214,12 @@ def main():
2060
2214
  "onboard": cmd_onboard,
2061
2215
  "scan-profile": cmd_scan_profile,
2062
2216
  }[args.command](args)
2217
+ elif args.command == "notify":
2218
+ return _notify(args)
2219
+ elif args.command == "health":
2220
+ return _health(args)
2221
+ elif args.command == "logs":
2222
+ return _logs(args)
2063
2223
  else:
2064
2224
  _print_help()
2065
2225
  return 0
@@ -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
@@ -18,14 +18,32 @@ class UserContext:
18
18
  self.user_name = ""
19
19
  self.user_language = "en"
20
20
 
21
- # calibration.json has operator_name + user info
21
+ # calibration.json has operator_name + user info.
22
+ # v5.4.0+: tolerate both nested ({user:{name,language}}) and legacy flat
23
+ # ({user_name, language}) shapes. Nested wins when both exist.
22
24
  if cal_path.exists():
23
25
  try:
24
26
  cal = json.loads(cal_path.read_text())
25
- self.assistant_name = cal.get("operator_name", "") or \
26
- cal.get("user", {}).get("assistant_name", "") or "NEXO"
27
- self.user_name = cal.get("user", {}).get("name", "")
28
- self.user_language = cal.get("user", {}).get("language", "en")
27
+ user_block = cal.get("user") if isinstance(cal.get("user"), dict) else {}
28
+
29
+ self.assistant_name = (
30
+ user_block.get("assistant_name", "")
31
+ or cal.get("operator_name", "")
32
+ or cal.get("assistant_name", "")
33
+ or "NEXO"
34
+ )
35
+ self.user_name = (
36
+ user_block.get("name", "")
37
+ or cal.get("user_name", "")
38
+ or cal.get("name", "")
39
+ or ""
40
+ )
41
+ self.user_language = (
42
+ user_block.get("language", "")
43
+ or cal.get("language", "")
44
+ or cal.get("lang", "")
45
+ or "en"
46
+ )
29
47
  except Exception:
30
48
  pass
31
49