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.
- 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 +181 -0
- package/src/events_bus.py +155 -0
- package/src/health_check.py +195 -0
- package/src/hooks/capture-session.sh +31 -9
- 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.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.
|
|
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.
|
|
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
|
+
"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
|
-
#
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
#
|
|
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|
|
|
35
|
+
Read|Glob|Grep|LS|Skill|ToolSearch|TodoWrite) exit 0 ;;
|
|
19
36
|
esac
|
|
20
37
|
|
|
21
|
-
|
|
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"
|
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
|
|