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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/calibration_migration.py +242 -0
- package/src/cli.py +160 -0
- package/src/events_bus.py +155 -0
- package/src/health_check.py +195 -0
- package/src/user_context.py +23 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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.
|
|
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
|
+
"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
|
package/src/user_context.py
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
self.
|
|
28
|
-
|
|
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
|
|