nexo-brain 5.3.30 → 5.4.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.3.30",
3
+ "version": "5.4.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,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.1` is the current packaged-runtime line: hook hygiene fix the PostToolUse `capture-session.sh` hook had been reading a nonexistent env var since 2026-04-12, silently writing `"tool":"unknown"` to the Sensory Register for 48 hours. v5.4.1 parses the tool name from stdin JSON, removes the filter that was hiding `Bash`, and purges pre-fix entries from the buffer on update.
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.4.0`: runtime event bus at `~/.nexo/runtime/events.ndjson`, `nexo notify`, `nexo health --json`, `nexo logs --tail --json`, and a safe flat→nested migration for `calibration.json`.
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.1",
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,40 @@ 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
+
938
+ # v5.4.1 one-time hygiene: purge pre-fix "tool":"unknown" entries from
939
+ # the sensory-register buffer. Keeps a .pre-v5.4.1.bak backup the first
940
+ # time it runs on a given host.
941
+ try:
942
+ buf = NEXO_HOME / "brain" / "session_buffer.jsonl"
943
+ marker = buf.with_suffix(".jsonl.pre-v5.4.1.bak")
944
+ if buf.is_file() and not marker.is_file():
945
+ raw = buf.read_text(errors="ignore").splitlines()
946
+ unknown = sum(1 for ln in raw if '"tool":"unknown"' in ln)
947
+ if unknown > 0:
948
+ buf.rename(marker)
949
+ cleaned = "\n".join(ln for ln in raw if '"tool":"unknown"' not in ln)
950
+ if cleaned:
951
+ cleaned += "\n"
952
+ buf.write_text(cleaned)
953
+ if not args.json:
954
+ print(f"[NEXO] session_buffer.jsonl: purged {unknown} legacy "
955
+ f"\"tool\":\"unknown\" entries (backup: {marker.name})", flush=True)
956
+ except Exception:
957
+ pass
958
+
925
959
  return 0 if result.get("ok") else 1
926
960
 
927
961
 
@@ -1295,6 +1329,105 @@ def _chat(args):
1295
1329
  return int(result.returncode)
1296
1330
 
1297
1331
 
1332
+ def _notify(args):
1333
+ """Emit an event to the runtime events bus (events.ndjson)."""
1334
+ from events_bus import emit
1335
+ try:
1336
+ event = emit(
1337
+ args.type,
1338
+ text=getattr(args, "text", "") or "",
1339
+ reason=getattr(args, "reason", "") or "",
1340
+ priority=getattr(args, "priority", "normal"),
1341
+ source=getattr(args, "source", "nexo-brain"),
1342
+ )
1343
+ except ValueError as exc:
1344
+ print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
1345
+ return 2
1346
+ if args.json:
1347
+ print(json.dumps({"ok": True, "event": event}, ensure_ascii=False, indent=2))
1348
+ else:
1349
+ print(f"notify: id={event['id']} type={event['type']} priority={event['priority']}")
1350
+ return 0
1351
+
1352
+
1353
+ def _health(args):
1354
+ """Collect a health snapshot and print it."""
1355
+ from health_check import collect
1356
+ report = collect()
1357
+ if getattr(args, "json", True) is False:
1358
+ # minimal text mode
1359
+ print(f"status: {report.get('status', 'unknown')}")
1360
+ for name, sub in report.get("subsystems", {}).items():
1361
+ print(f" {name:10s} {sub.get('status', '?')}")
1362
+ return 0 if report.get("status") == "ok" else 1
1363
+ print(json.dumps(report, ensure_ascii=False, indent=2))
1364
+ return 0 if report.get("status") == "ok" else 1
1365
+
1366
+
1367
+ def _logs(args):
1368
+ """Tail recent logs: events bus + operations/*.log."""
1369
+ import glob as _glob
1370
+ lines_want = max(1, int(getattr(args, "lines", 100)))
1371
+ source = (getattr(args, "source", "all") or "all").lower()
1372
+
1373
+ results: dict = {"source": source, "lines": lines_want, "entries": []}
1374
+ home = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
1375
+
1376
+ def _collect_events(n: int) -> list[dict]:
1377
+ try:
1378
+ from events_bus import tail as _tail
1379
+ return _tail(lines=n)
1380
+ except Exception as exc:
1381
+ return [{"error": f"events tail failed: {exc}"}]
1382
+
1383
+ def _collect_ops(n: int) -> list[dict]:
1384
+ ops_dir = home / "operations"
1385
+ if not ops_dir.is_dir():
1386
+ return []
1387
+ files = sorted(
1388
+ ops_dir.glob("*.log"),
1389
+ key=lambda p: p.stat().st_mtime if p.exists() else 0,
1390
+ reverse=True,
1391
+ )[:5]
1392
+ out: list[dict] = []
1393
+ for log in files:
1394
+ try:
1395
+ text = log.read_text(errors="ignore").splitlines()[-n:]
1396
+ except Exception as exc:
1397
+ out.append({"file": str(log), "error": str(exc)})
1398
+ continue
1399
+ for line in text:
1400
+ out.append({"file": log.name, "line": line})
1401
+ return out[-n:]
1402
+
1403
+ if source in ("all", "events"):
1404
+ results["entries"].extend({"kind": "event", **e} for e in _collect_events(lines_want))
1405
+ if source in ("all", "operations"):
1406
+ results["entries"].extend({"kind": "log", **e} for e in _collect_ops(lines_want))
1407
+ if source not in ("all", "events", "operations"):
1408
+ # Treat as filename match inside operations/
1409
+ specific = home / "operations" / source
1410
+ if specific.is_file():
1411
+ try:
1412
+ text = specific.read_text(errors="ignore").splitlines()[-lines_want:]
1413
+ results["entries"] = [{"kind": "log", "file": specific.name, "line": ln} for ln in text]
1414
+ except Exception as exc:
1415
+ results["error"] = str(exc)
1416
+ else:
1417
+ results["error"] = f"unknown log source: {source}"
1418
+
1419
+ if args.json:
1420
+ print(json.dumps(results, ensure_ascii=False, indent=2))
1421
+ else:
1422
+ for entry in results["entries"][-lines_want:]:
1423
+ if entry.get("kind") == "event":
1424
+ print(f"[event] id={entry.get('id')} {entry.get('type')} "
1425
+ f"{entry.get('priority')} {entry.get('text','')}")
1426
+ else:
1427
+ print(f"[{entry.get('file','?')}] {entry.get('line','')}")
1428
+ return 0
1429
+
1430
+
1298
1431
  def _doctor(args):
1299
1432
  """Run unified doctor diagnostics."""
1300
1433
  try:
@@ -1306,6 +1439,22 @@ def _doctor(args):
1306
1439
  return 1
1307
1440
 
1308
1441
  init_db()
1442
+
1443
+ # Calibration migration hook — runs before the orchestrator so the rest
1444
+ # of doctor sees the canonical nested shape.
1445
+ want_migrate = getattr(args, "migrate_calibration", False) or args.fix
1446
+ dry = getattr(args, "calibration_dry_run", False)
1447
+ if want_migrate or dry:
1448
+ from calibration_migration import detect, apply_migration
1449
+ shape = detect()
1450
+ if shape["shape"] == "flat":
1451
+ mig = apply_migration(dry_run=dry)
1452
+ if args.json:
1453
+ print(json.dumps({"calibration_migration": mig}, ensure_ascii=False, indent=2))
1454
+ else:
1455
+ print(f"[NEXO] calibration migration: {mig.get('status')} — {mig.get('reason','')}",
1456
+ file=sys.stderr, flush=True)
1457
+
1309
1458
  tier_label = getattr(args, "tier", "boot") or "boot"
1310
1459
  print(f"[NEXO] Inspecting {tier_label} diagnostics... please wait.", file=sys.stderr, flush=True)
1311
1460
  report = run_doctor(tier=args.tier, fix=args.fix, plane=getattr(args, "plane", ""))
@@ -1855,6 +2004,10 @@ def main():
1855
2004
  )
1856
2005
  doctor_parser.add_argument("--json", action="store_true", help="JSON output")
1857
2006
  doctor_parser.add_argument("--fix", action="store_true", help="Apply deterministic fixes")
2007
+ doctor_parser.add_argument("--migrate-calibration", action="store_true",
2008
+ help="Force calibration.json flat → nested migration")
2009
+ doctor_parser.add_argument("--calibration-dry-run", action="store_true",
2010
+ help="Preview the calibration migration without writing")
1858
2011
 
1859
2012
  # -- contributor --
1860
2013
  contributor_parser = sub.add_parser("contributor", help="Public Draft PR contribution mode")
@@ -1959,6 +2112,28 @@ def main():
1959
2112
  scan_profile_parser.add_argument("--apply", action="store_true", help="Write profile.json (default is preview)")
1960
2113
  scan_profile_parser.add_argument("--force", action="store_true", help="Overwrite existing profile.json on --apply")
1961
2114
 
2115
+ # -- runtime events bus + operational observability --
2116
+ notify_parser = sub.add_parser("notify", help="Emit an event to the runtime event bus")
2117
+ notify_parser.add_argument("type", choices=[
2118
+ "attention_required", "proactive_message", "followup_alert",
2119
+ "health_alert", "info",
2120
+ ], help="Event type")
2121
+ notify_parser.add_argument("--text", default="", help="Short user-facing message")
2122
+ notify_parser.add_argument("--reason", default="", help="Internal reason / trigger")
2123
+ notify_parser.add_argument("--priority", choices=["low", "normal", "high", "urgent"], default="normal")
2124
+ notify_parser.add_argument("--source", default="nexo-brain", help="Who emitted this event")
2125
+ notify_parser.add_argument("--json", action="store_true", help="JSON output")
2126
+
2127
+ health_parser = sub.add_parser("health", help="Snapshot of NEXO Brain subsystem health")
2128
+ health_parser.add_argument("--json", action="store_true", help="JSON output (default)")
2129
+
2130
+ logs_parser = sub.add_parser("logs", help="Tail recent operational logs")
2131
+ logs_parser.add_argument("--tail", action="store_true", help="Tail mode (default)")
2132
+ logs_parser.add_argument("--lines", type=int, default=100, help="How many lines to return")
2133
+ logs_parser.add_argument("--source", default="all",
2134
+ help="Log source: all | events | operations | <logname>")
2135
+ logs_parser.add_argument("--json", action="store_true", help="JSON output")
2136
+
1962
2137
  args = parser.parse_args()
1963
2138
 
1964
2139
  if args.help or (not args.command and not args.version):
@@ -2060,6 +2235,12 @@ def main():
2060
2235
  "onboard": cmd_onboard,
2061
2236
  "scan-profile": cmd_scan_profile,
2062
2237
  }[args.command](args)
2238
+ elif args.command == "notify":
2239
+ return _notify(args)
2240
+ elif args.command == "health":
2241
+ return _health(args)
2242
+ elif args.command == "logs":
2243
+ return _logs(args)
2063
2244
  else:
2064
2245
  _print_help()
2065
2246
  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
@@ -1,21 +1,43 @@
1
1
  #!/bin/bash
2
2
  # NEXO PostToolUse hook — captures tool usage to session_buffer.jsonl
3
- # This feeds the Sensory Register (Atkinson-Shiffrin Layer 1)
3
+ # Feeds the Sensory Register (Atkinson-Shiffrin Layer 1).
4
+ #
5
+ # IMPORTANT: Claude Code passes the tool name in a JSON payload over stdin,
6
+ # NOT as the $CLAUDE_TOOL_NAME env var. Earlier revisions of this hook
7
+ # assumed the env var existed and always wrote "unknown", masking the
8
+ # entire sensory-register stream. Do not reintroduce that pattern.
9
+
10
+ set -uo pipefail
4
11
 
5
12
  NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
6
13
  BUFFER="$NEXO_HOME/brain/session_buffer.jsonl"
7
-
8
14
  mkdir -p "$NEXO_HOME/brain"
9
15
 
10
- # Capture basic event: timestamp + tool name
11
- # Read stdin (Claude Code passes JSON via stdin for PostToolUse hooks)
12
16
  INPUT=$(cat 2>/dev/null || true)
13
- TOOL_NAME="${CLAUDE_TOOL_NAME:-unknown}"
14
- TS=$(date -u +"%Y-%m-%dT%H:%M:%S")
17
+ [ -z "$INPUT" ] && exit 0
18
+
19
+ # Extract tool_name from the stdin JSON payload. Fall back to env var for
20
+ # compatibility with any platform that still sets it; final fallback is an
21
+ # empty string, in which case we exit without writing so "unknown" noise
22
+ # never reaches the buffer.
23
+ TOOL_NAME=$(echo "$INPUT" \
24
+ | python3 -c "import sys,json; print(json.load(sys.stdin).get('tool_name',''))" 2>/dev/null \
25
+ || true)
26
+ if [ -z "$TOOL_NAME" ]; then
27
+ TOOL_NAME="${CLAUDE_TOOL_NAME:-}"
28
+ fi
29
+ [ -z "$TOOL_NAME" ] && exit 0
15
30
 
16
- # Only log meaningful tool calls (skip reads, globs, greps)
31
+ # Skip high-frequency read-only tools: they add noise without signal.
32
+ # Bash / Write / Edit / MultiEdit / Task / MCP tools ARE kept — that is
33
+ # where real state change happens and where the sensory register matters.
17
34
  case "$TOOL_NAME" in
18
- Read|Glob|Grep|LS|Bash) exit 0 ;;
35
+ Read|Glob|Grep|LS|Skill|ToolSearch|TodoWrite) exit 0 ;;
19
36
  esac
20
37
 
21
- echo "{\"ts\":\"$TS\",\"tool\":\"$TOOL_NAME\",\"source\":\"hook\"}" >> "$BUFFER"
38
+ TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
39
+ # Escape embedded quotes / special chars in tool names (MCP names can be
40
+ # long and contain colons, underscores, and dashes).
41
+ ESCAPED_NAME=$(printf '%s' "$TOOL_NAME" \
42
+ | python3 -c "import sys,json; print(json.dumps(sys.stdin.read().strip()))")
43
+ echo "{\"ts\":\"$TS\",\"tool\":$ESCAPED_NAME,\"source\":\"hook\"}" >> "$BUFFER"
@@ -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