nexo-brain 5.3.0 → 5.3.2
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 +10 -1
- package/bin/nexo-brain.js +59 -4
- package/bin/nexo.js +43 -7
- package/hooks/hooks.json +17 -0
- package/package.json +2 -1
- package/scripts/sync_release_artifacts.py +128 -0
- package/src/auto_update.py +70 -8
- package/src/bootstrap_docs.py +53 -2
- package/src/cli.py +3 -1
- package/src/client_sync.py +14 -1
- package/src/db/_personal_scripts.py +30 -8
- package/src/doctor/providers/runtime.py +37 -1
- package/src/hooks/heartbeat-enforcement.py +90 -0
- package/src/hooks/heartbeat-posttool.sh +18 -0
- package/src/hooks/heartbeat-user-msg.sh +15 -0
- package/src/hooks/session-start.sh +8 -4
- package/src/plugins/update.py +134 -4
- package/src/runtime_home.py +46 -0
- package/src/script_registry.py +47 -7
- package/src/scripts/nexo-cognitive-decay.py +1 -1
- package/src/scripts/nexo-cortex-cycle.py +2 -2
- package/src/scripts/nexo-hook-record.py +1 -1
- package/src/scripts/nexo-migrate.py +38 -10
- package/src/state_watchers_runtime.py +1 -1
- package/templates/CLAUDE.md.template +7 -1
- package/templates/CODEX.AGENTS.md.template +7 -1
- package/templates/launchagents/README.md +0 -1
- package/templates/nexo_helper.py +5 -1
- package/templates/launchagents/com.nexo.github-monitor.plist +0 -45
package/src/client_sync.py
CHANGED
|
@@ -18,6 +18,7 @@ except ModuleNotFoundError: # Python < 3.11
|
|
|
18
18
|
import tomli as tomllib
|
|
19
19
|
|
|
20
20
|
from bootstrap_docs import sync_client_bootstrap
|
|
21
|
+
from runtime_home import resolve_nexo_home
|
|
21
22
|
|
|
22
23
|
try:
|
|
23
24
|
from client_preferences import (
|
|
@@ -73,7 +74,7 @@ def _user_home() -> Path:
|
|
|
73
74
|
|
|
74
75
|
|
|
75
76
|
def _default_nexo_home() -> Path:
|
|
76
|
-
return
|
|
77
|
+
return resolve_nexo_home(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo")))
|
|
77
78
|
|
|
78
79
|
|
|
79
80
|
def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
|
|
@@ -348,6 +349,12 @@ CORE_HOOK_SPECS = [
|
|
|
348
349
|
"timeout": 5,
|
|
349
350
|
"script": "protocol-pretool-guardrail.sh",
|
|
350
351
|
},
|
|
352
|
+
{
|
|
353
|
+
"event": "UserPromptSubmit",
|
|
354
|
+
"identity": "heartbeat-user-msg.sh",
|
|
355
|
+
"timeout": 3,
|
|
356
|
+
"script": "heartbeat-user-msg.sh",
|
|
357
|
+
},
|
|
351
358
|
{
|
|
352
359
|
"event": "PostToolUse",
|
|
353
360
|
"identity": "capture-tool-logs.sh",
|
|
@@ -372,6 +379,12 @@ CORE_HOOK_SPECS = [
|
|
|
372
379
|
"timeout": 5,
|
|
373
380
|
"script": "protocol-guardrail.sh",
|
|
374
381
|
},
|
|
382
|
+
{
|
|
383
|
+
"event": "PostToolUse",
|
|
384
|
+
"identity": "heartbeat-posttool.sh",
|
|
385
|
+
"timeout": 3,
|
|
386
|
+
"script": "heartbeat-posttool.sh",
|
|
387
|
+
},
|
|
375
388
|
{
|
|
376
389
|
"event": "PreCompact",
|
|
377
390
|
"identity": "pre-compact.sh",
|
|
@@ -12,15 +12,30 @@ import os
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
14
|
from db._core import get_db
|
|
15
|
+
from runtime_home import resolve_nexo_home
|
|
15
16
|
|
|
16
17
|
|
|
17
|
-
NEXO_HOME =
|
|
18
|
+
NEXO_HOME = resolve_nexo_home()
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def _now_text() -> str:
|
|
21
22
|
return datetime.datetime.now().isoformat(timespec="seconds")
|
|
22
23
|
|
|
23
24
|
|
|
25
|
+
def _canonical_scripts_dir() -> Path:
|
|
26
|
+
home = resolve_nexo_home(os.environ.get("NEXO_HOME", str(NEXO_HOME)))
|
|
27
|
+
return home / "scripts"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize_script_path(path: str | Path) -> str:
|
|
31
|
+
candidate = Path(path).expanduser()
|
|
32
|
+
try:
|
|
33
|
+
relative = candidate.resolve(strict=False).relative_to(_canonical_scripts_dir().resolve(strict=False))
|
|
34
|
+
except Exception:
|
|
35
|
+
return str(candidate)
|
|
36
|
+
return str(_canonical_scripts_dir() / relative)
|
|
37
|
+
|
|
38
|
+
|
|
24
39
|
def _row_to_dict(row) -> dict:
|
|
25
40
|
return dict(row) if row is not None else {}
|
|
26
41
|
|
|
@@ -61,6 +76,7 @@ def _safe_slug(value: str) -> str:
|
|
|
61
76
|
|
|
62
77
|
|
|
63
78
|
def _ensure_script_id(conn, name: str, path: str) -> str:
|
|
79
|
+
path = _normalize_script_path(path)
|
|
64
80
|
existing = conn.execute(
|
|
65
81
|
"SELECT id FROM personal_scripts WHERE path = ? LIMIT 1",
|
|
66
82
|
(path,),
|
|
@@ -90,6 +106,7 @@ def upsert_personal_script(
|
|
|
90
106
|
has_inline_metadata: bool = False,
|
|
91
107
|
) -> dict:
|
|
92
108
|
conn = get_db()
|
|
109
|
+
path = _normalize_script_path(path)
|
|
93
110
|
script_id = _ensure_script_id(conn, name, path)
|
|
94
111
|
now = _now_text()
|
|
95
112
|
conn.execute(
|
|
@@ -132,11 +149,12 @@ def upsert_personal_script(
|
|
|
132
149
|
|
|
133
150
|
def delete_missing_personal_scripts(active_paths: list[str]) -> int:
|
|
134
151
|
conn = get_db()
|
|
135
|
-
|
|
136
|
-
|
|
152
|
+
normalized_paths = [_normalize_script_path(path) for path in active_paths]
|
|
153
|
+
if normalized_paths:
|
|
154
|
+
placeholders = ",".join("?" for _ in normalized_paths)
|
|
137
155
|
rows = conn.execute(
|
|
138
156
|
f"SELECT id FROM personal_scripts WHERE path NOT IN ({placeholders})",
|
|
139
|
-
tuple(
|
|
157
|
+
tuple(normalized_paths),
|
|
140
158
|
).fetchall()
|
|
141
159
|
else:
|
|
142
160
|
rows = conn.execute("SELECT id FROM personal_scripts").fetchall()
|
|
@@ -160,6 +178,7 @@ def register_personal_script_schedule(
|
|
|
160
178
|
enabled: bool = True,
|
|
161
179
|
) -> dict | None:
|
|
162
180
|
conn = get_db()
|
|
181
|
+
script_path = _normalize_script_path(script_path)
|
|
163
182
|
script = conn.execute(
|
|
164
183
|
"SELECT id FROM personal_scripts WHERE path = ?",
|
|
165
184
|
(script_path,),
|
|
@@ -342,6 +361,7 @@ def list_personal_scripts(include_disabled: bool = True) -> list[dict]:
|
|
|
342
361
|
|
|
343
362
|
def get_personal_script(name_or_path: str) -> dict | None:
|
|
344
363
|
conn = get_db()
|
|
364
|
+
normalized_path = _normalize_script_path(name_or_path)
|
|
345
365
|
row = conn.execute(
|
|
346
366
|
"""
|
|
347
367
|
SELECT * FROM personal_scripts
|
|
@@ -349,7 +369,7 @@ def get_personal_script(name_or_path: str) -> dict | None:
|
|
|
349
369
|
ORDER BY path = ? DESC
|
|
350
370
|
LIMIT 1
|
|
351
371
|
""",
|
|
352
|
-
(
|
|
372
|
+
(normalized_path, name_or_path, normalized_path),
|
|
353
373
|
).fetchone()
|
|
354
374
|
if not row:
|
|
355
375
|
return None
|
|
@@ -361,9 +381,10 @@ def get_personal_script(name_or_path: str) -> dict | None:
|
|
|
361
381
|
|
|
362
382
|
def delete_personal_script(name_or_path: str) -> int:
|
|
363
383
|
conn = get_db()
|
|
384
|
+
normalized_path = _normalize_script_path(name_or_path)
|
|
364
385
|
result = conn.execute(
|
|
365
386
|
"DELETE FROM personal_scripts WHERE path = ? OR name = ? OR id = ?",
|
|
366
|
-
(
|
|
387
|
+
(normalized_path, name_or_path, name_or_path),
|
|
367
388
|
)
|
|
368
389
|
return int(result.rowcount or 0)
|
|
369
390
|
|
|
@@ -371,13 +392,14 @@ def delete_personal_script(name_or_path: str) -> int:
|
|
|
371
392
|
def record_personal_script_run(name_or_path: str, exit_code: int, run_at: str | None = None) -> None:
|
|
372
393
|
conn = get_db()
|
|
373
394
|
run_at = run_at or _now_text()
|
|
395
|
+
normalized_path = _normalize_script_path(name_or_path)
|
|
374
396
|
conn.execute(
|
|
375
397
|
"""
|
|
376
398
|
UPDATE personal_scripts
|
|
377
399
|
SET last_run_at = ?, last_exit_code = ?, updated_at = ?
|
|
378
400
|
WHERE path = ? OR name = ?
|
|
379
401
|
""",
|
|
380
|
-
(run_at, exit_code, _now_text(),
|
|
402
|
+
(run_at, exit_code, _now_text(), normalized_path, name_or_path),
|
|
381
403
|
)
|
|
382
404
|
|
|
383
405
|
|
|
@@ -393,7 +415,7 @@ def sync_personal_scripts_registry(
|
|
|
393
415
|
scheduled = 0
|
|
394
416
|
|
|
395
417
|
for record in script_records:
|
|
396
|
-
path =
|
|
418
|
+
path = _normalize_script_path(record["path"])
|
|
397
419
|
active_paths.append(path)
|
|
398
420
|
upsert_personal_script(
|
|
399
421
|
name=record.get("name") or Path(path).stem,
|
|
@@ -84,6 +84,28 @@ def _release_root() -> Path:
|
|
|
84
84
|
return Path(NEXO_CODE)
|
|
85
85
|
|
|
86
86
|
|
|
87
|
+
def _has_release_publish_context(release_root: Path) -> bool:
|
|
88
|
+
git_dir = release_root / ".git"
|
|
89
|
+
if git_dir.exists() or git_dir.is_file():
|
|
90
|
+
return True
|
|
91
|
+
if _recorded_source_root() is not None:
|
|
92
|
+
return True
|
|
93
|
+
try:
|
|
94
|
+
release_root_resolved = release_root.resolve()
|
|
95
|
+
nexo_home_resolved = NEXO_HOME.resolve()
|
|
96
|
+
except Exception:
|
|
97
|
+
release_root_resolved = release_root
|
|
98
|
+
nexo_home_resolved = NEXO_HOME
|
|
99
|
+
if release_root_resolved == nexo_home_resolved:
|
|
100
|
+
return False
|
|
101
|
+
has_release_files = (
|
|
102
|
+
(release_root / "package.json").is_file()
|
|
103
|
+
and (release_root / "CHANGELOG.md").is_file()
|
|
104
|
+
and (release_root / "scripts" / "sync_release_artifacts.py").is_file()
|
|
105
|
+
)
|
|
106
|
+
return has_release_files
|
|
107
|
+
|
|
108
|
+
|
|
87
109
|
def _package_json_path() -> Path:
|
|
88
110
|
if PACKAGE_JSON.is_file():
|
|
89
111
|
return PACKAGE_JSON
|
|
@@ -2660,13 +2682,27 @@ def check_release_artifact_sync() -> DoctorCheck:
|
|
|
2660
2682
|
if changelog_version:
|
|
2661
2683
|
evidence.append(f"top changelog version: {changelog_version}")
|
|
2662
2684
|
|
|
2685
|
+
release_root = _release_root()
|
|
2686
|
+
if not _has_release_publish_context(release_root):
|
|
2687
|
+
if version:
|
|
2688
|
+
evidence.append(f"package version: {version}")
|
|
2689
|
+
evidence.append(f"release root: {release_root}")
|
|
2690
|
+
evidence.append("packaged runtime without source repo; release artifact audit skipped")
|
|
2691
|
+
return DoctorCheck(
|
|
2692
|
+
id="runtime.release_artifacts",
|
|
2693
|
+
tier="runtime",
|
|
2694
|
+
status="healthy",
|
|
2695
|
+
severity="info",
|
|
2696
|
+
summary="Release artifact audit skipped for packaged runtime",
|
|
2697
|
+
evidence=evidence,
|
|
2698
|
+
)
|
|
2699
|
+
|
|
2663
2700
|
if version and changelog_version and version != changelog_version:
|
|
2664
2701
|
status = "critical"
|
|
2665
2702
|
severity = "error"
|
|
2666
2703
|
evidence.append("package/changelog release version mismatch")
|
|
2667
2704
|
repair_plan.append("Bump or align CHANGELOG.md before publishing")
|
|
2668
2705
|
|
|
2669
|
-
release_root = _release_root()
|
|
2670
2706
|
sync_script = release_root / "scripts" / "sync_release_artifacts.py"
|
|
2671
2707
|
if not sync_script.is_file():
|
|
2672
2708
|
status = "critical"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Heartbeat enforcement for NEXO sessions.
|
|
3
|
+
|
|
4
|
+
Tracks user messages vs heartbeat calls. Emits a warning when more than two
|
|
5
|
+
user messages pass without a heartbeat call.
|
|
6
|
+
|
|
7
|
+
Modes:
|
|
8
|
+
- HEARTBEAT_MODE=user_msg: increment counter on UserPromptSubmit
|
|
9
|
+
- HEARTBEAT_MODE=post_tool: inspect PostToolUse payload, reset on heartbeat,
|
|
10
|
+
warn when other tools keep running without one
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
STATE_FILE = Path(os.environ.get("NEXO_HOME", Path.home() / ".nexo")) / "operations" / ".heartbeat-state.json"
|
|
22
|
+
THRESHOLD = 2
|
|
23
|
+
HEARTBEAT_TOOL = "nexo_heartbeat"
|
|
24
|
+
SKIP_TOOLS = {"nexo_startup", "nexo_stop", "nexo_smart_startup"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_state() -> dict:
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(STATE_FILE.read_text())
|
|
30
|
+
except Exception:
|
|
31
|
+
return {"user_msgs": 0, "last_heartbeat_ts": 0.0, "last_user_msg_ts": 0.0}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write_state(state: dict) -> None:
|
|
35
|
+
try:
|
|
36
|
+
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
STATE_FILE.write_text(json.dumps(state))
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def handle_user_message() -> int:
|
|
43
|
+
state = _read_state()
|
|
44
|
+
state["user_msgs"] = state.get("user_msgs", 0) + 1
|
|
45
|
+
state["last_user_msg_ts"] = time.time()
|
|
46
|
+
_write_state(state)
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def handle_post_tool(payload: dict) -> int:
|
|
51
|
+
tool_name = str(payload.get("tool_name", "")).strip()
|
|
52
|
+
short_name = tool_name.rsplit("__", 1)[-1] if "__" in tool_name else tool_name
|
|
53
|
+
state = _read_state()
|
|
54
|
+
|
|
55
|
+
if short_name == HEARTBEAT_TOOL:
|
|
56
|
+
state["user_msgs"] = 0
|
|
57
|
+
state["last_heartbeat_ts"] = time.time()
|
|
58
|
+
_write_state(state)
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
if short_name in SKIP_TOOLS:
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
user_msgs = state.get("user_msgs", 0)
|
|
65
|
+
if user_msgs > THRESHOLD:
|
|
66
|
+
print(
|
|
67
|
+
f"\nWARNING: HEARTBEAT OVERDUE ({user_msgs} user messages without nexo_heartbeat). "
|
|
68
|
+
"Call nexo_heartbeat(sid=SID, task='...') before continuing."
|
|
69
|
+
)
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main() -> int:
|
|
74
|
+
mode = os.environ.get("HEARTBEAT_MODE", "").strip()
|
|
75
|
+
if mode == "user_msg":
|
|
76
|
+
return handle_user_message()
|
|
77
|
+
if mode == "post_tool":
|
|
78
|
+
raw = sys.stdin.read()
|
|
79
|
+
if not raw.strip():
|
|
80
|
+
return 0
|
|
81
|
+
try:
|
|
82
|
+
payload = json.loads(raw)
|
|
83
|
+
except Exception:
|
|
84
|
+
return 0
|
|
85
|
+
return handle_post_tool(payload)
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# NEXO PostToolUse hook — heartbeat enforcement checker
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
|
|
5
|
+
INPUT=$(cat || true)
|
|
6
|
+
[ -z "$INPUT" ] && exit 0
|
|
7
|
+
|
|
8
|
+
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
9
|
+
HELPER=""
|
|
10
|
+
if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/hooks/heartbeat-enforcement.py" ]; then
|
|
11
|
+
HELPER="${NEXO_CODE%/}/hooks/heartbeat-enforcement.py"
|
|
12
|
+
elif [ -f "$NEXO_HOME/hooks/heartbeat-enforcement.py" ]; then
|
|
13
|
+
HELPER="$NEXO_HOME/hooks/heartbeat-enforcement.py"
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
[ -z "$HELPER" ] && exit 0
|
|
17
|
+
HEARTBEAT_MODE=post_tool python3 "$HELPER" <<< "$INPUT" 2>/dev/null || true
|
|
18
|
+
exit 0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# NEXO UserPromptSubmit hook — track user messages for heartbeat enforcement
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
|
|
5
|
+
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
6
|
+
HELPER=""
|
|
7
|
+
if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/hooks/heartbeat-enforcement.py" ]; then
|
|
8
|
+
HELPER="${NEXO_CODE%/}/hooks/heartbeat-enforcement.py"
|
|
9
|
+
elif [ -f "$NEXO_HOME/hooks/heartbeat-enforcement.py" ]; then
|
|
10
|
+
HELPER="$NEXO_HOME/hooks/heartbeat-enforcement.py"
|
|
11
|
+
fi
|
|
12
|
+
|
|
13
|
+
[ -z "$HELPER" ] && exit 0
|
|
14
|
+
HEARTBEAT_MODE=user_msg python3 "$HELPER" 2>/dev/null || true
|
|
15
|
+
exit 0
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
# Caches output for 1 hour to avoid regenerating on rapid successive sessions.
|
|
5
5
|
set -uo pipefail
|
|
6
6
|
|
|
7
|
+
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
8
|
+
|
|
7
9
|
# Fase 3 item 7: hook lifecycle observability — record duration + exit code
|
|
8
10
|
# in hook_runs on EXIT. Best-effort: a failure here must not break the hook.
|
|
9
11
|
NEXO_HOOK_START_MS=$(python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || echo 0)
|
|
@@ -18,8 +20,12 @@ _nexo_record_hook_run() {
|
|
|
18
20
|
duration_ms=$((now_ms - NEXO_HOOK_START_MS))
|
|
19
21
|
fi
|
|
20
22
|
fi
|
|
21
|
-
local recorder
|
|
22
|
-
|
|
23
|
+
local recorder=""
|
|
24
|
+
if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/scripts/nexo-hook-record.py" ]; then
|
|
25
|
+
recorder="${NEXO_CODE%/}/scripts/nexo-hook-record.py"
|
|
26
|
+
elif [ -f "$NEXO_HOME/scripts/nexo-hook-record.py" ]; then
|
|
27
|
+
recorder="$NEXO_HOME/scripts/nexo-hook-record.py"
|
|
28
|
+
fi
|
|
23
29
|
if [ -f "$recorder" ]; then
|
|
24
30
|
python3 "$recorder" record \
|
|
25
31
|
--hook "$NEXO_HOOK_NAME" \
|
|
@@ -30,8 +36,6 @@ _nexo_record_hook_run() {
|
|
|
30
36
|
fi
|
|
31
37
|
}
|
|
32
38
|
trap _nexo_record_hook_run EXIT
|
|
33
|
-
|
|
34
|
-
NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
|
|
35
39
|
BRIEFING_FILE="$NEXO_HOME/coordination/session-briefing.txt"
|
|
36
40
|
MAX_AGE_SECONDS=3600 # 1 hour cache
|
|
37
41
|
|
package/src/plugins/update.py
CHANGED
|
@@ -9,6 +9,8 @@ import sys
|
|
|
9
9
|
import time
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from runtime_home import export_resolved_nexo_home
|
|
13
|
+
|
|
12
14
|
# Code root is the parent of plugins/:
|
|
13
15
|
# - source checkout: <repo>/src
|
|
14
16
|
# - packaged runtime: <NEXO_HOME>
|
|
@@ -16,7 +18,7 @@ _THIS_DIR = Path(__file__).resolve().parent
|
|
|
16
18
|
CODE_ROOT = _THIS_DIR.parent
|
|
17
19
|
_REPO_CANDIDATE = CODE_ROOT.parent
|
|
18
20
|
|
|
19
|
-
NEXO_HOME =
|
|
21
|
+
NEXO_HOME = export_resolved_nexo_home()
|
|
20
22
|
DATA_DIR = NEXO_HOME / "data"
|
|
21
23
|
BACKUP_BASE = NEXO_HOME / "backups"
|
|
22
24
|
|
|
@@ -79,7 +81,7 @@ def _is_git_repo() -> bool:
|
|
|
79
81
|
|
|
80
82
|
|
|
81
83
|
def _refresh_installed_manifest():
|
|
82
|
-
"""
|
|
84
|
+
"""Refresh packaged crons and persist the runtime core-artifacts manifest."""
|
|
83
85
|
try:
|
|
84
86
|
src_crons = SRC_DIR / "crons"
|
|
85
87
|
dst_crons = NEXO_HOME / "crons"
|
|
@@ -88,10 +90,45 @@ def _refresh_installed_manifest():
|
|
|
88
90
|
for f in src_crons.iterdir():
|
|
89
91
|
if f.is_file():
|
|
90
92
|
shutil.copy2(str(f), str(dst_crons / f.name))
|
|
93
|
+
config_dir = NEXO_HOME / "config"
|
|
94
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
95
|
+
payload = {
|
|
96
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
97
|
+
"script_names": sorted(
|
|
98
|
+
f.name for f in (SRC_DIR / "scripts").iterdir()
|
|
99
|
+
if f.is_file()
|
|
100
|
+
) if (SRC_DIR / "scripts").is_dir() else [],
|
|
101
|
+
"hook_names": sorted(
|
|
102
|
+
f.name for f in (SRC_DIR / "hooks").iterdir()
|
|
103
|
+
if f.is_file()
|
|
104
|
+
) if (SRC_DIR / "hooks").is_dir() else [],
|
|
105
|
+
}
|
|
106
|
+
(config_dir / "runtime-core-artifacts.json").write_text(
|
|
107
|
+
json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
|
|
108
|
+
)
|
|
91
109
|
except Exception:
|
|
92
110
|
pass
|
|
93
111
|
|
|
94
112
|
|
|
113
|
+
def _cleanup_retired_runtime_files() -> list[str]:
|
|
114
|
+
removed: list[str] = []
|
|
115
|
+
retired_paths = [
|
|
116
|
+
NEXO_HOME / "scripts" / "heartbeat-enforcement.py",
|
|
117
|
+
NEXO_HOME / "scripts" / "heartbeat-posttool.sh",
|
|
118
|
+
NEXO_HOME / "scripts" / "heartbeat-user-msg.sh",
|
|
119
|
+
NEXO_HOME / "hooks" / "heartbeat-guard.sh",
|
|
120
|
+
]
|
|
121
|
+
for path in retired_paths:
|
|
122
|
+
if not path.exists():
|
|
123
|
+
continue
|
|
124
|
+
try:
|
|
125
|
+
path.unlink()
|
|
126
|
+
removed.append(str(path))
|
|
127
|
+
except Exception:
|
|
128
|
+
continue
|
|
129
|
+
return removed
|
|
130
|
+
|
|
131
|
+
|
|
95
132
|
def _read_version() -> str:
|
|
96
133
|
"""Read the installed/runtime version."""
|
|
97
134
|
if _PACKAGED_INSTALL:
|
|
@@ -330,8 +367,23 @@ def _backup_code_tree() -> tuple[str | None, str | None]:
|
|
|
330
367
|
timestamp = time.strftime("%Y-%m-%d-%H%M%S")
|
|
331
368
|
backup_dir = BACKUP_BASE / f"code-tree-{timestamp}"
|
|
332
369
|
# Directories and flat files that postinstall copies into NEXO_HOME
|
|
333
|
-
code_dirs = [
|
|
334
|
-
|
|
370
|
+
code_dirs = [
|
|
371
|
+
"bin",
|
|
372
|
+
"hooks",
|
|
373
|
+
"plugins",
|
|
374
|
+
"db",
|
|
375
|
+
"cognitive",
|
|
376
|
+
"dashboard",
|
|
377
|
+
"rules",
|
|
378
|
+
"crons",
|
|
379
|
+
"scripts",
|
|
380
|
+
"doctor",
|
|
381
|
+
"skills",
|
|
382
|
+
"skills-core",
|
|
383
|
+
"skills-runtime",
|
|
384
|
+
"templates",
|
|
385
|
+
]
|
|
386
|
+
code_files_glob = ["*.py", "requirements.txt", "package.json"]
|
|
335
387
|
try:
|
|
336
388
|
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
337
389
|
# Backup directories
|
|
@@ -372,6 +424,54 @@ def _restore_code_tree(backup_dir: str) -> str | None:
|
|
|
372
424
|
return None
|
|
373
425
|
|
|
374
426
|
|
|
427
|
+
def _normalize_preferences_for_client_sync() -> dict:
|
|
428
|
+
from client_preferences import normalize_client_preferences
|
|
429
|
+
|
|
430
|
+
schedule_path = NEXO_HOME / "config" / "schedule.json"
|
|
431
|
+
schedule_payload = json.loads(schedule_path.read_text()) if schedule_path.exists() else {}
|
|
432
|
+
normalized_preferences = normalize_client_preferences(schedule_payload)
|
|
433
|
+
if normalized_preferences != {
|
|
434
|
+
key: schedule_payload.get(key)
|
|
435
|
+
for key in normalized_preferences
|
|
436
|
+
}:
|
|
437
|
+
merged_schedule = dict(schedule_payload)
|
|
438
|
+
merged_schedule.update(normalized_preferences)
|
|
439
|
+
schedule_path.parent.mkdir(parents=True, exist_ok=True)
|
|
440
|
+
schedule_path.write_text(json.dumps(merged_schedule, indent=2, ensure_ascii=False) + "\n")
|
|
441
|
+
return normalized_preferences
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _sync_packaged_clients() -> tuple[bool, str | None]:
|
|
445
|
+
try:
|
|
446
|
+
from client_sync import sync_all_clients
|
|
447
|
+
except Exception as e:
|
|
448
|
+
return False, f"client sync import failed: {e}"
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
preferences = _normalize_preferences_for_client_sync()
|
|
452
|
+
result = sync_all_clients(
|
|
453
|
+
nexo_home=NEXO_HOME,
|
|
454
|
+
runtime_root=NEXO_HOME,
|
|
455
|
+
operator_name=os.environ.get("NEXO_NAME", ""),
|
|
456
|
+
preferences=preferences,
|
|
457
|
+
)
|
|
458
|
+
except Exception as e:
|
|
459
|
+
return False, f"client sync failed: {e}"
|
|
460
|
+
|
|
461
|
+
if result.get("ok"):
|
|
462
|
+
return True, None
|
|
463
|
+
|
|
464
|
+
clients = result.get("clients", {})
|
|
465
|
+
failures = []
|
|
466
|
+
for key, payload in clients.items():
|
|
467
|
+
if payload.get("ok") or payload.get("skipped"):
|
|
468
|
+
continue
|
|
469
|
+
failures.append(f"{key}: {payload.get('error', 'unknown error')}")
|
|
470
|
+
if not failures:
|
|
471
|
+
failures.append("unknown client sync failure")
|
|
472
|
+
return False, "; ".join(failures)
|
|
473
|
+
|
|
474
|
+
|
|
375
475
|
def _rollback_npm_package(target_version: str) -> str | None:
|
|
376
476
|
"""Rollback nexo-brain npm package to a specific version.
|
|
377
477
|
|
|
@@ -480,6 +580,22 @@ def _handle_packaged_update(progress_fn=None) -> str:
|
|
|
480
580
|
if verify_err:
|
|
481
581
|
errors.append(f"verification: {verify_err}")
|
|
482
582
|
|
|
583
|
+
hook_sync_warning = None
|
|
584
|
+
retired_runtime_files: list[str] = []
|
|
585
|
+
try:
|
|
586
|
+
_emit_progress(progress_fn, "Refreshing installed hooks and manifests...")
|
|
587
|
+
_refresh_installed_manifest()
|
|
588
|
+
_sync_hooks_to_home()
|
|
589
|
+
retired_runtime_files = _cleanup_retired_runtime_files()
|
|
590
|
+
except Exception as e:
|
|
591
|
+
hook_sync_warning = f"{e}"
|
|
592
|
+
|
|
593
|
+
client_sync_warning = None
|
|
594
|
+
_emit_progress(progress_fn, "Refreshing shared client configs...")
|
|
595
|
+
clients_ok, client_sync_error = _sync_packaged_clients()
|
|
596
|
+
if not clients_ok:
|
|
597
|
+
client_sync_warning = client_sync_error or "unknown client sync error"
|
|
598
|
+
|
|
483
599
|
if errors:
|
|
484
600
|
# 5. Full rollback: restore code tree + DBs + pip deps + rollback npm package
|
|
485
601
|
if code_backup_dir:
|
|
@@ -516,6 +632,16 @@ def _handle_packaged_update(progress_fn=None) -> str:
|
|
|
516
632
|
lines = ["UPDATE SUCCESSFUL (packaged install)"]
|
|
517
633
|
lines.append(f" Version: {old_version} -> {new_version}")
|
|
518
634
|
lines.append(f" Backup: {backup_dir}")
|
|
635
|
+
if not hook_sync_warning:
|
|
636
|
+
lines.append(" Hooks: synced to NEXO_HOME")
|
|
637
|
+
else:
|
|
638
|
+
lines.append(f" WARNING: hook sync: {hook_sync_warning}")
|
|
639
|
+
if retired_runtime_files:
|
|
640
|
+
lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
|
|
641
|
+
if not client_sync_warning:
|
|
642
|
+
lines.append(" Clients: configured client targets synced")
|
|
643
|
+
else:
|
|
644
|
+
lines.append(f" WARNING: client sync: {client_sync_warning}")
|
|
519
645
|
lines.append("")
|
|
520
646
|
lines.append("MCP server restart needed to load new code.")
|
|
521
647
|
return "\n".join(lines)
|
|
@@ -627,9 +753,11 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
|
|
|
627
753
|
cron_sync_result = f"Cron sync warning: {e}"
|
|
628
754
|
|
|
629
755
|
# Step 9: Sync hooks to NEXO_HOME
|
|
756
|
+
retired_runtime_files: list[str] = []
|
|
630
757
|
try:
|
|
631
758
|
_emit_progress(progress_fn, "Syncing core Claude hooks...")
|
|
632
759
|
_sync_hooks_to_home()
|
|
760
|
+
retired_runtime_files = _cleanup_retired_runtime_files()
|
|
633
761
|
steps_done.append("hook-sync")
|
|
634
762
|
except Exception as e:
|
|
635
763
|
pass # Non-critical, log in function
|
|
@@ -681,6 +809,8 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
|
|
|
681
809
|
lines.append(" Crons: synced with manifest")
|
|
682
810
|
if "hook-sync" in steps_done:
|
|
683
811
|
lines.append(" Hooks: synced to NEXO_HOME")
|
|
812
|
+
if retired_runtime_files:
|
|
813
|
+
lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
|
|
684
814
|
if "client-sync" in steps_done:
|
|
685
815
|
lines.append(" Clients: configured client targets synced")
|
|
686
816
|
lines.append("")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""Shared helpers to resolve the managed NEXO home path."""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def user_home() -> Path:
|
|
10
|
+
return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def managed_nexo_home(*, home: Path | None = None) -> Path:
|
|
14
|
+
return (home or user_home()) / ".nexo"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def legacy_nexo_home(*, home: Path | None = None) -> Path:
|
|
18
|
+
return (home or user_home()) / "claude"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def resolve_nexo_home(value: str | os.PathLike[str] | None = None) -> Path:
|
|
22
|
+
home = user_home()
|
|
23
|
+
managed = managed_nexo_home(home=home)
|
|
24
|
+
candidate = Path(value).expanduser() if value else Path(
|
|
25
|
+
os.environ.get("NEXO_HOME", str(managed))
|
|
26
|
+
).expanduser()
|
|
27
|
+
legacy = legacy_nexo_home(home=home)
|
|
28
|
+
|
|
29
|
+
if candidate == managed:
|
|
30
|
+
return managed
|
|
31
|
+
if candidate == legacy:
|
|
32
|
+
return managed if managed.exists() or legacy.is_symlink() else candidate
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
if managed.exists() and candidate.resolve(strict=False) == managed.resolve(strict=False):
|
|
36
|
+
return managed
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
return candidate
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def export_resolved_nexo_home(value: str | os.PathLike[str] | None = None) -> Path:
|
|
44
|
+
resolved = resolve_nexo_home(value)
|
|
45
|
+
os.environ["NEXO_HOME"] = str(resolved)
|
|
46
|
+
return resolved
|