nexo-brain 5.3.13 → 5.3.15

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.
Files changed (230) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/bin/nexo-brain.js +52 -1
  3. package/package.json +1 -1
  4. package/src/crons/sync.py +18 -4
  5. package/src/dashboard/static/favicon 2.svg +32 -0
  6. package/src/dashboard/static/nexo-logo 2.png +0 -0
  7. package/src/dashboard/static/nexo-logo 2.svg +40 -0
  8. package/src/dashboard/static/style 2.css +2458 -0
  9. package/src/dashboard/templates/adaptive 2.html +118 -0
  10. package/src/dashboard/templates/artifacts 2.html +133 -0
  11. package/src/dashboard/templates/backups 2.html +136 -0
  12. package/src/dashboard/templates/base 2.html +417 -0
  13. package/src/dashboard/templates/calendar 2.html +591 -0
  14. package/src/dashboard/templates/chat 2.html +356 -0
  15. package/src/dashboard/templates/claims 2.html +259 -0
  16. package/src/dashboard/templates/cortex 2.html +321 -0
  17. package/src/dashboard/templates/credentials 2.html +128 -0
  18. package/src/dashboard/templates/crons 2.html +370 -0
  19. package/src/dashboard/templates/dashboard 2.html +494 -0
  20. package/src/dashboard/templates/dreams 2.html +252 -0
  21. package/src/dashboard/templates/email 2.html +160 -0
  22. package/src/dashboard/templates/evolution 2.html +189 -0
  23. package/src/dashboard/templates/feed 2.html +249 -0
  24. package/src/dashboard/templates/followup_health 2.html +170 -0
  25. package/src/dashboard/templates/graph 2.html +201 -0
  26. package/src/dashboard/templates/guard 2.html +259 -0
  27. package/src/dashboard/templates/inbox 2.html +251 -0
  28. package/src/dashboard/templates/memory 2.html +420 -0
  29. package/src/dashboard/templates/operations 2.html +608 -0
  30. package/src/dashboard/templates/plugins 2.html +185 -0
  31. package/src/dashboard/templates/protocol 2.html +199 -0
  32. package/src/dashboard/templates/rules 2.html +246 -0
  33. package/src/dashboard/templates/sentiment 2.html +247 -0
  34. package/src/dashboard/templates/sessions 2.html +218 -0
  35. package/src/dashboard/templates/skills 2.html +329 -0
  36. package/src/dashboard/templates/somatic 2.html +73 -0
  37. package/src/dashboard/templates/triggers 2.html +133 -0
  38. package/src/dashboard/templates/trust 2.html +360 -0
  39. package/src/db/__init__ 2.py +259 -0
  40. package/src/db/_core 2.py +437 -0
  41. package/src/db/_credentials 2.py +124 -0
  42. package/src/db/_entities.py +1 -1
  43. package/src/db/_episodic 2.py +762 -0
  44. package/src/db/_evolution 2.py +54 -0
  45. package/src/db/_fts 2.py +406 -0
  46. package/src/db/_goal_profiles 2.py +376 -0
  47. package/src/db/_hot_context 2.py +660 -0
  48. package/src/db/_outcomes 2.py +800 -0
  49. package/src/db/_personal_scripts 2.py +582 -0
  50. package/src/db/_sessions 2.py +330 -0
  51. package/src/db/_tasks 2.py +91 -0
  52. package/src/db/_watchers 2.py +173 -0
  53. package/src/doctor/formatters 2.py +52 -0
  54. package/src/doctor/models 2.py +69 -0
  55. package/src/doctor/planes 2.py +87 -0
  56. package/src/doctor/providers/__init__ 2.py +1 -0
  57. package/src/doctor/providers/deep 2.py +367 -0
  58. package/src/evolution_cycle 2.py +519 -0
  59. package/src/hooks/auto_capture 2.py +208 -0
  60. package/src/hooks/caffeinate-guard 2.sh +8 -0
  61. package/src/hooks/capture-session 2.sh +21 -0
  62. package/src/hooks/capture-tool-logs 2.sh +158 -0
  63. package/src/hooks/daily-briefing-check 2.sh +33 -0
  64. package/src/hooks/heartbeat-enforcement 2.py +90 -0
  65. package/src/hooks/heartbeat-posttool 2.sh +18 -0
  66. package/src/hooks/inbox-hook 2.sh +76 -0
  67. package/src/hooks/post-compact 2.sh +152 -0
  68. package/src/hooks/pre-compact 2.sh +169 -0
  69. package/src/hooks/protocol-guardrail 2.sh +10 -0
  70. package/src/hooks/protocol-pretool-guardrail 2.sh +9 -0
  71. package/src/hooks/session-stop 2.sh +52 -0
  72. package/src/kg_populate 2.py +292 -0
  73. package/src/maintenance 2.py +53 -0
  74. package/src/memory_backends 2.py +71 -0
  75. package/src/migrate_embeddings 2.py +124 -0
  76. package/src/nexo_sdk 2.py +103 -0
  77. package/src/observability 2.py +199 -0
  78. package/src/plugin_loader 2.py +217 -0
  79. package/src/plugins/__init__ 2.py +0 -0
  80. package/src/plugins/agents.py +10 -3
  81. package/src/plugins/artifact_registry 2.py +450 -0
  82. package/src/plugins/backup 2.py +127 -0
  83. package/src/plugins/claims_tools 2.py +119 -0
  84. package/src/plugins/cognitive_memory 2.py +609 -0
  85. package/src/plugins/core_rules 2.py +252 -0
  86. package/src/plugins/cortex 2.py +1155 -0
  87. package/src/plugins/entities 2.py +67 -0
  88. package/src/plugins/episodic_memory 2.py +560 -0
  89. package/src/plugins/evolution 2.py +167 -0
  90. package/src/plugins/goal_engine 2.py +142 -0
  91. package/src/plugins/guard 2.py +862 -0
  92. package/src/plugins/impact 2.py +29 -0
  93. package/src/plugins/knowledge_graph_tools 2.py +137 -0
  94. package/src/plugins/media_memory_tools 2.py +98 -0
  95. package/src/plugins/memory_export 2.py +196 -0
  96. package/src/plugins/outcomes 2.py +130 -0
  97. package/src/plugins/personal_scripts 2.py +117 -0
  98. package/src/plugins/preferences 2.py +47 -0
  99. package/src/plugins/protocol 2.py +1449 -0
  100. package/src/plugins/schedule.py +2 -1
  101. package/src/plugins/simple_api 2.py +106 -0
  102. package/src/plugins/skills 2.py +341 -0
  103. package/src/plugins/state_watchers 2.py +79 -0
  104. package/src/plugins/update 2.py +986 -0
  105. package/src/plugins/user_state_tools 2.py +43 -0
  106. package/src/plugins/workflow 2.py +588 -0
  107. package/src/protocol_settings 2.py +59 -0
  108. package/src/public_contribution 2.py +466 -0
  109. package/src/public_evolution_queue 2.py +241 -0
  110. package/src/requirements 2.txt +14 -0
  111. package/src/requirements.txt +1 -1
  112. package/src/retroactive_learnings 2.py +373 -0
  113. package/src/rules/__init__ 2.py +0 -0
  114. package/src/rules/core-rules 2.json +331 -0
  115. package/src/rules/migrate 2.py +207 -0
  116. package/src/runtime_power 2.py +874 -0
  117. package/src/runtime_power.py +18 -1
  118. package/src/script_registry 2.py +1559 -0
  119. package/src/scripts/check-context 2.py +272 -0
  120. package/src/scripts/deep-sleep/apply_findings 2.py +2327 -0
  121. package/src/scripts/deep-sleep/collect 2.py +928 -0
  122. package/src/scripts/deep-sleep/extract 2.py +330 -0
  123. package/src/scripts/deep-sleep/extract-prompt 2.md +285 -0
  124. package/src/scripts/deep-sleep/synthesize 2.py +312 -0
  125. package/src/scripts/deep-sleep/synthesize-prompt 2.md +336 -0
  126. package/src/scripts/nexo-agent-run 2.py +75 -0
  127. package/src/scripts/nexo-auto-update 2.py +6 -0
  128. package/src/scripts/nexo-backup 2.sh +25 -0
  129. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  130. package/src/scripts/nexo-catchup 2.py +300 -0
  131. package/src/scripts/nexo-cognitive-decay 2.py +257 -0
  132. package/src/scripts/nexo-cortex-cycle 2.py +293 -0
  133. package/src/scripts/nexo-cron-wrapper 2.sh +53 -0
  134. package/src/scripts/nexo-cron-wrapper.sh +7 -0
  135. package/src/scripts/nexo-daily-self-audit 2.py +2161 -0
  136. package/src/scripts/nexo-dashboard 2.sh +29 -0
  137. package/src/scripts/nexo-deep-sleep 2.sh +86 -0
  138. package/src/scripts/nexo-evolution-run 2.py +1664 -0
  139. package/src/scripts/nexo-followup-hygiene 2.py +139 -0
  140. package/src/scripts/nexo-hook-record 2.py +42 -0
  141. package/src/scripts/nexo-immune 2.py +936 -0
  142. package/src/scripts/nexo-impact-scorer 2.py +117 -0
  143. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  144. package/src/scripts/nexo-install 2.py +6 -0
  145. package/src/scripts/nexo-learning-housekeep 2.py +401 -0
  146. package/src/scripts/nexo-learning-validator 2.py +266 -0
  147. package/src/scripts/nexo-migrate 2.py +260 -0
  148. package/src/scripts/nexo-outcome-checker 2.py +127 -0
  149. package/src/scripts/nexo-postmortem-consolidator 2.py +456 -0
  150. package/src/scripts/nexo-pre-commit 2.py +120 -0
  151. package/src/scripts/nexo-prevent-sleep 2.sh +35 -0
  152. package/src/scripts/nexo-proactive-dashboard 2.py +354 -0
  153. package/src/scripts/nexo-reflection 2.py +256 -0
  154. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  155. package/src/scripts/nexo-sleep 2.py +631 -0
  156. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  157. package/src/scripts/nexo-sync-clients 2.py +16 -0
  158. package/src/scripts/nexo-synthesis 2.py +475 -0
  159. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  160. package/src/scripts/nexo-update 2.sh +306 -0
  161. package/src/scripts/nexo-watchdog 2.sh +1207 -0
  162. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  163. package/src/scripts/rehydrate_learnings_from_archive 2.py +245 -0
  164. package/src/server 2.py +1296 -0
  165. package/src/skills/run-nexo-audit-phase/guide 2.md +43 -0
  166. package/src/skills/run-nexo-audit-phase/skill 2.json +59 -0
  167. package/src/skills/run-nexo-core-fix-cycle/guide 2.md +17 -0
  168. package/src/skills/run-nexo-core-fix-cycle/script 2.py +276 -0
  169. package/src/skills/run-nexo-core-fix-cycle/skill 2.json +58 -0
  170. package/src/skills/run-release-final-audit/guide 2.md +16 -0
  171. package/src/skills/run-release-final-audit/script 2.py +259 -0
  172. package/src/skills/run-release-final-audit/skill 2.json +77 -0
  173. package/src/skills/run-runtime-doctor/guide 2.md +12 -0
  174. package/src/skills/run-runtime-doctor/script 2.py +21 -0
  175. package/src/skills/run-runtime-doctor/skill 2.json +25 -0
  176. package/src/skills_runtime 2.py +932 -0
  177. package/src/state_watchers_runtime 2.py +475 -0
  178. package/src/storage_router 2.py +32 -0
  179. package/src/system_catalog 2.py +786 -0
  180. package/src/tools_coordination 2.py +103 -0
  181. package/src/tools_credentials 2.py +68 -0
  182. package/src/tools_drive 2.py +487 -0
  183. package/src/tools_hot_context 2.py +163 -0
  184. package/src/tools_learnings 2.py +612 -0
  185. package/src/tools_menu 2.py +229 -0
  186. package/src/tools_reminders 2.py +88 -0
  187. package/src/tools_reminders_crud 2.py +363 -0
  188. package/src/tools_sessions 2.py +1054 -0
  189. package/src/tools_system_catalog 2.py +19 -0
  190. package/src/tools_task_history 2.py +57 -0
  191. package/src/tools_transcripts 2.py +98 -0
  192. package/src/transcript_utils 2.py +412 -0
  193. package/src/user_context 2.py +46 -0
  194. package/src/user_data_portability 2.py +328 -0
  195. package/src/user_state_model 2.py +170 -0
  196. package/templates/CLAUDE.md 2.template +108 -0
  197. package/templates/CODEX.AGENTS.md 2.template +66 -0
  198. package/templates/launchagents/README 2.md +132 -0
  199. package/templates/launchagents/com.nexo.auto-close-sessions 2.plist +39 -0
  200. package/templates/launchagents/com.nexo.auto-close-sessions.plist +1 -1
  201. package/templates/launchagents/com.nexo.catchup 2.plist +39 -0
  202. package/templates/launchagents/com.nexo.catchup.plist +1 -1
  203. package/templates/launchagents/com.nexo.cognitive-decay 2.plist +40 -0
  204. package/templates/launchagents/com.nexo.dashboard 2.plist +43 -0
  205. package/templates/launchagents/com.nexo.dashboard.plist +1 -1
  206. package/templates/launchagents/com.nexo.deep-sleep 2.plist +43 -0
  207. package/templates/launchagents/com.nexo.deep-sleep.plist +1 -1
  208. package/templates/launchagents/com.nexo.evolution 2.plist +44 -0
  209. package/templates/launchagents/com.nexo.evolution.plist +1 -1
  210. package/templates/launchagents/com.nexo.followup-hygiene 2.plist +45 -0
  211. package/templates/launchagents/com.nexo.followup-hygiene.plist +1 -1
  212. package/templates/launchagents/com.nexo.immune 2.plist +41 -0
  213. package/templates/launchagents/com.nexo.immune.plist +1 -1
  214. package/templates/launchagents/com.nexo.postmortem 2.plist +45 -0
  215. package/templates/launchagents/com.nexo.postmortem.plist +1 -1
  216. package/templates/launchagents/com.nexo.self-audit 2.plist +47 -0
  217. package/templates/launchagents/com.nexo.self-audit.plist +1 -1
  218. package/templates/launchagents/com.nexo.synthesis 2.plist +45 -0
  219. package/templates/launchagents/com.nexo.synthesis.plist +1 -1
  220. package/templates/launchagents/com.nexo.watchdog 2.plist +37 -0
  221. package/templates/launchagents/com.nexo.watchdog.plist +1 -1
  222. package/templates/nexo_helper 2.py +301 -0
  223. package/templates/openclaw 2.json +13 -0
  224. package/templates/plugin-template 2.py +40 -0
  225. package/templates/script-template 2.py +59 -0
  226. package/templates/script-template 2.sh +13 -0
  227. package/templates/script-template.py +5 -4
  228. package/templates/skill-script-template 2.py +48 -0
  229. package/templates/skill-script-template.py +2 -1
  230. package/templates/skill-template 2.md +33 -0
@@ -0,0 +1,986 @@
1
+ from __future__ import annotations
2
+ """Update plugin — pull latest code, backup DBs, run migrations, verify."""
3
+ import json
4
+ import os
5
+ import shutil
6
+ import sqlite3
7
+ import subprocess
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from runtime_home import export_resolved_nexo_home
13
+
14
+ # Code root is the parent of plugins/:
15
+ # - source checkout: <repo>/src
16
+ # - packaged runtime: <NEXO_HOME>
17
+ _THIS_DIR = Path(__file__).resolve().parent
18
+ CODE_ROOT = _THIS_DIR.parent
19
+ _REPO_CANDIDATE = CODE_ROOT.parent
20
+
21
+ NEXO_HOME = export_resolved_nexo_home()
22
+ DATA_DIR = NEXO_HOME / "data"
23
+ BACKUP_BASE = NEXO_HOME / "backups"
24
+
25
+ # In packaged installs, update.py lives at <NEXO_HOME>/plugins/update.py.
26
+ _PACKAGED_INSTALL = not (_REPO_CANDIDATE / ".git").exists() and not (_REPO_CANDIDATE / ".git").is_file()
27
+ REPO_DIR = CODE_ROOT if _PACKAGED_INSTALL else _REPO_CANDIDATE
28
+ SRC_DIR = CODE_ROOT
29
+ PACKAGE_JSON = REPO_DIR / "package.json"
30
+
31
+
32
+ def _venv_python_path(runtime_root: Path = NEXO_HOME) -> Path:
33
+ if sys.platform == "win32":
34
+ return runtime_root / ".venv" / "Scripts" / "python.exe"
35
+ return runtime_root / ".venv" / "bin" / "python3"
36
+
37
+
38
+ def _venv_pip_path(runtime_root: Path = NEXO_HOME) -> Path:
39
+ if sys.platform == "win32":
40
+ return runtime_root / ".venv" / "Scripts" / "pip.exe"
41
+ return runtime_root / ".venv" / "bin" / "pip"
42
+
43
+
44
+ def _ensure_managed_venv(runtime_root: Path = NEXO_HOME) -> str | None:
45
+ venv_python = _venv_python_path(runtime_root)
46
+ if venv_python.exists():
47
+ return None
48
+ try:
49
+ runtime_root.mkdir(parents=True, exist_ok=True)
50
+ result = subprocess.run(
51
+ [sys.executable, "-m", "venv", str(runtime_root / ".venv")],
52
+ capture_output=True,
53
+ text=True,
54
+ timeout=120,
55
+ )
56
+ except Exception as e:
57
+ return f"venv creation error: {e}"
58
+ if result.returncode != 0 or not venv_python.exists():
59
+ return f"venv creation failed: {result.stderr or result.stdout}"
60
+ return None
61
+
62
+
63
+ def _find_npm_pkg_src() -> Path | None:
64
+ """Locate the nexo-brain npm package's src/ directory for requirements.txt."""
65
+ try:
66
+ result = subprocess.run(
67
+ ["npm", "root", "-g"],
68
+ capture_output=True, text=True, timeout=10,
69
+ )
70
+ if result.returncode == 0:
71
+ npm_src = Path(result.stdout.strip()) / "nexo-brain" / "src"
72
+ if npm_src.is_dir():
73
+ return npm_src
74
+ except Exception:
75
+ pass
76
+ return None
77
+
78
+
79
+ def _core_artifact_source_dir() -> Path | None:
80
+ """Return the canonical source directory for packaged core artifacts."""
81
+ if _PACKAGED_INSTALL:
82
+ return _find_npm_pkg_src()
83
+ return SRC_DIR
84
+
85
+
86
+ def _is_git_repo() -> bool:
87
+ """Check if REPO_DIR is a valid git repository."""
88
+ return (REPO_DIR / ".git").exists() or (REPO_DIR / ".git").is_file()
89
+
90
+
91
+ def _refresh_installed_manifest():
92
+ """Refresh packaged crons and persist the runtime core-artifacts manifest."""
93
+ try:
94
+ artifact_src = _core_artifact_source_dir()
95
+ if artifact_src is None:
96
+ return
97
+
98
+ src_crons = artifact_src / "crons"
99
+ dst_crons = NEXO_HOME / "crons"
100
+ if src_crons.exists():
101
+ dst_crons.mkdir(parents=True, exist_ok=True)
102
+ for f in src_crons.iterdir():
103
+ if f.is_file():
104
+ dest = dst_crons / f.name
105
+ if _paths_match(f, dest):
106
+ continue
107
+ shutil.copy2(str(f), str(dest))
108
+ config_dir = NEXO_HOME / "config"
109
+ config_dir.mkdir(parents=True, exist_ok=True)
110
+ payload = {
111
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
112
+ "script_names": sorted(
113
+ f.name for f in (artifact_src / "scripts").iterdir()
114
+ if f.is_file()
115
+ ) if (artifact_src / "scripts").is_dir() else [],
116
+ "hook_names": sorted(
117
+ f.name for f in (artifact_src / "hooks").iterdir()
118
+ if f.is_file()
119
+ ) if (artifact_src / "hooks").is_dir() else [],
120
+ }
121
+ (config_dir / "runtime-core-artifacts.json").write_text(
122
+ json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
123
+ )
124
+ except Exception:
125
+ pass
126
+
127
+
128
+ def _cleanup_retired_runtime_files() -> list[str]:
129
+ removed: list[str] = []
130
+ retired_paths = [
131
+ NEXO_HOME / "scripts" / "heartbeat-enforcement.py",
132
+ NEXO_HOME / "scripts" / "heartbeat-posttool.sh",
133
+ NEXO_HOME / "scripts" / "heartbeat-user-msg.sh",
134
+ NEXO_HOME / "hooks" / "heartbeat-guard.sh",
135
+ ]
136
+ for path in retired_paths:
137
+ if not path.exists():
138
+ continue
139
+ try:
140
+ path.unlink()
141
+ removed.append(str(path))
142
+ except Exception:
143
+ continue
144
+ return removed
145
+
146
+
147
+ def _read_version() -> str:
148
+ """Read the installed/runtime version."""
149
+ if _PACKAGED_INSTALL:
150
+ # version.json is the runtime truth for packaged installs.
151
+ try:
152
+ version_file = NEXO_HOME / "version.json"
153
+ if version_file.exists():
154
+ return json.loads(version_file.read_text()).get("version", "unknown")
155
+ except Exception:
156
+ pass
157
+ try:
158
+ package_file = NEXO_HOME / "package.json"
159
+ if package_file.exists():
160
+ return json.loads(package_file.read_text()).get("version", "unknown")
161
+ except Exception:
162
+ pass
163
+
164
+ try:
165
+ if PACKAGE_JSON.exists():
166
+ return json.loads(PACKAGE_JSON.read_text()).get("version", "unknown")
167
+ except Exception:
168
+ pass
169
+ try:
170
+ version_file = NEXO_HOME / "version.json"
171
+ if version_file.exists():
172
+ return json.loads(version_file.read_text()).get("version", "unknown")
173
+ except Exception:
174
+ pass
175
+ return "unknown"
176
+
177
+
178
+ def _git(*args, cwd=None) -> tuple[int, str, str]:
179
+ """Run a git command and return (returncode, stdout, stderr)."""
180
+ result = subprocess.run(
181
+ ["git"] + list(args),
182
+ cwd=cwd or str(REPO_DIR),
183
+ capture_output=True,
184
+ text=True,
185
+ timeout=60,
186
+ )
187
+ return result.returncode, result.stdout.strip(), result.stderr.strip()
188
+
189
+
190
+ def _requirements_hash() -> str:
191
+ """Return a content hash of requirements.txt, or empty string if missing."""
192
+ import hashlib
193
+ req_file = SRC_DIR / "requirements.txt"
194
+ if not req_file.exists() and _PACKAGED_INSTALL:
195
+ npm_src = _find_npm_pkg_src()
196
+ if npm_src:
197
+ req_file = npm_src / "requirements.txt"
198
+ if req_file.exists():
199
+ return hashlib.sha256(req_file.read_bytes()).hexdigest()
200
+ return ""
201
+
202
+
203
+ def _check_dirty() -> str | None:
204
+ """Return error message if worktree has uncommitted changes, else None."""
205
+ if not _is_git_repo():
206
+ return None # Not a git repo, skip dirty check
207
+ rc, out, _ = _git("status", "--porcelain")
208
+ if rc != 0:
209
+ return "Failed to check git status."
210
+ if out:
211
+ return f"Uncommitted changes:\n{out}\nCommit or stash before updating."
212
+ return None
213
+
214
+
215
+ def _backup_databases() -> tuple[str, str | None]:
216
+ """Backup all .db files from NEXO_HOME/data/. Returns (backup_dir, error)."""
217
+ timestamp = time.strftime("%Y-%m-%d-%H%M")
218
+ backup_dir = BACKUP_BASE / f"pre-update-{timestamp}"
219
+
220
+ db_files = list(DATA_DIR.glob("*.db")) if DATA_DIR.is_dir() else []
221
+ # Also check NEXO_HOME root for legacy db location
222
+ db_files += [f for f in NEXO_HOME.glob("*.db") if f.is_file()]
223
+ # And check src/ dir for nexo.db (dev mode)
224
+ src_db = SRC_DIR / "nexo.db"
225
+ if src_db.is_file() and src_db not in db_files:
226
+ db_files.append(src_db)
227
+
228
+ if not db_files:
229
+ return str(backup_dir), None # No DBs to backup, not an error
230
+
231
+ backup_dir.mkdir(parents=True, exist_ok=True)
232
+
233
+ for db_file in db_files:
234
+ dest = backup_dir / db_file.name
235
+ src_conn = None
236
+ dst_conn = None
237
+ try:
238
+ src_conn = sqlite3.connect(str(db_file))
239
+ dst_conn = sqlite3.connect(str(dest))
240
+ src_conn.backup(dst_conn)
241
+ except Exception as e:
242
+ return str(backup_dir), f"Failed to backup {db_file.name}: {e}"
243
+ finally:
244
+ for conn in (dst_conn, src_conn):
245
+ if conn is not None:
246
+ try:
247
+ conn.close()
248
+ except Exception:
249
+ pass
250
+
251
+ return str(backup_dir), None
252
+
253
+
254
+ def _restore_databases(backup_dir: str):
255
+ """Restore .db files from a backup directory."""
256
+ bdir = Path(backup_dir)
257
+ if not bdir.is_dir():
258
+ return
259
+ for db_backup in bdir.glob("*.db"):
260
+ # Try to find original location
261
+ for candidate in [DATA_DIR / db_backup.name, NEXO_HOME / db_backup.name, SRC_DIR / db_backup.name]:
262
+ if candidate.is_file():
263
+ src_conn = None
264
+ dst_conn = None
265
+ try:
266
+ src_conn = sqlite3.connect(str(db_backup))
267
+ dst_conn = sqlite3.connect(str(candidate))
268
+ src_conn.backup(dst_conn)
269
+ except Exception:
270
+ pass
271
+ finally:
272
+ for conn in (dst_conn, src_conn):
273
+ if conn is not None:
274
+ try:
275
+ conn.close()
276
+ except Exception:
277
+ pass
278
+ break
279
+
280
+
281
+ def _reinstall_pip_deps() -> str | None:
282
+ """Reinstall Python dependencies from requirements.txt into the managed venv."""
283
+ req_file = SRC_DIR / "requirements.txt"
284
+ if not req_file.exists() and _PACKAGED_INSTALL:
285
+ # In packaged mode, requirements.txt lives in the npm package's src/ dir
286
+ npm_src = _find_npm_pkg_src()
287
+ if npm_src:
288
+ req_file = npm_src / "requirements.txt"
289
+ if not req_file.exists():
290
+ return None # No requirements file, skip
291
+ venv_error = _ensure_managed_venv(NEXO_HOME)
292
+ if venv_error is not None:
293
+ return venv_error
294
+ venv_pip = _venv_pip_path(NEXO_HOME)
295
+ if not venv_pip.exists() and sys.platform != "win32":
296
+ alt_pip = NEXO_HOME / ".venv" / "bin" / "pip3"
297
+ if alt_pip.exists():
298
+ venv_pip = alt_pip
299
+ if not venv_pip.exists():
300
+ # No venv, try system pip with --break-system-packages
301
+ try:
302
+ result = subprocess.run(
303
+ [sys.executable, "-m", "pip", "install", "--quiet", "-r", str(req_file), "--break-system-packages"],
304
+ capture_output=True, text=True, timeout=120,
305
+ )
306
+ if result.returncode != 0:
307
+ return f"pip install failed: {result.stderr or result.stdout}"
308
+ except Exception as e:
309
+ return f"pip install error: {e}"
310
+ return None
311
+ try:
312
+ result = subprocess.run(
313
+ [str(venv_pip), "install", "--quiet", "-r", str(req_file)],
314
+ capture_output=True, text=True, timeout=120,
315
+ )
316
+ if result.returncode != 0:
317
+ return f"pip install failed: {result.stderr or result.stdout}"
318
+ except Exception as e:
319
+ return f"pip install error: {e}"
320
+ return None
321
+
322
+
323
+ def _run_migrations() -> str | None:
324
+ """Run init_db() to apply pending migrations. Returns error or None."""
325
+ # In packaged mode, db/ lives in NEXO_HOME; in dev mode, in SRC_DIR
326
+ cwd = str(NEXO_HOME) if _PACKAGED_INSTALL else str(SRC_DIR)
327
+ try:
328
+ result = subprocess.run(
329
+ [sys.executable, "-c", "import db; db.init_db()"],
330
+ cwd=cwd,
331
+ capture_output=True,
332
+ text=True,
333
+ timeout=30,
334
+ )
335
+ if result.returncode != 0:
336
+ return f"Migration failed: {result.stderr or result.stdout}"
337
+ except Exception as e:
338
+ return f"Migration error: {e}"
339
+ return None
340
+
341
+
342
+ def _verify_import() -> str | None:
343
+ """Verify server.py can be imported successfully."""
344
+ # In packaged mode, server.py lives in NEXO_HOME; in dev mode, in SRC_DIR
345
+ cwd = str(NEXO_HOME) if _PACKAGED_INSTALL else str(SRC_DIR)
346
+ try:
347
+ result = subprocess.run(
348
+ [sys.executable, "-c", "import server"],
349
+ cwd=cwd,
350
+ capture_output=True,
351
+ text=True,
352
+ timeout=15,
353
+ )
354
+ if result.returncode != 0:
355
+ return f"Import verification failed: {result.stderr or result.stdout}"
356
+ except Exception as e:
357
+ return f"Import verification error: {e}"
358
+ return None
359
+
360
+
361
+ def _sync_hooks_to_home():
362
+ """Copy hook scripts from src/hooks/ to NEXO_HOME/hooks/ after update."""
363
+ import shutil
364
+ hooks_src = SRC_DIR / "hooks"
365
+ hooks_dest = NEXO_HOME / "hooks"
366
+ if not hooks_src.is_dir():
367
+ return
368
+ hooks_dest.mkdir(parents=True, exist_ok=True)
369
+ synced = 0
370
+ for f in hooks_src.iterdir():
371
+ if f.is_file() and f.suffix == ".sh":
372
+ dest = hooks_dest / f.name
373
+ if not _paths_match(f, dest):
374
+ shutil.copy2(str(f), str(dest))
375
+ os.chmod(str(dest), 0o755)
376
+ synced += 1
377
+ if synced:
378
+ print(f"[NEXO update] Synced {synced} hook(s) to {hooks_dest}", file=sys.stderr)
379
+
380
+
381
+ def _backup_code_tree() -> tuple[str | None, str | None]:
382
+ """Snapshot NEXO_HOME code dirs before npm update. Returns (backup_dir, error)."""
383
+ timestamp = time.strftime("%Y-%m-%d-%H%M%S")
384
+ backup_dir = BACKUP_BASE / f"code-tree-{timestamp}"
385
+ # Directories and flat files that postinstall copies into NEXO_HOME
386
+ code_dirs = [
387
+ "bin",
388
+ "hooks",
389
+ "plugins",
390
+ "db",
391
+ "cognitive",
392
+ "dashboard",
393
+ "rules",
394
+ "crons",
395
+ "scripts",
396
+ "doctor",
397
+ "skills",
398
+ "skills-core",
399
+ "skills-runtime",
400
+ "templates",
401
+ ]
402
+ code_files_glob = ["*.py", "requirements.txt", "package.json"]
403
+ try:
404
+ backup_dir.mkdir(parents=True, exist_ok=True)
405
+ # Backup directories
406
+ for d in code_dirs:
407
+ src = NEXO_HOME / d
408
+ if src.is_dir():
409
+ shutil.copytree(src, backup_dir / d, dirs_exist_ok=True)
410
+ # Backup flat code files in NEXO_HOME root
411
+ for pattern in code_files_glob:
412
+ for f in NEXO_HOME.glob(pattern):
413
+ if f.is_file():
414
+ shutil.copy2(f, backup_dir / f.name)
415
+ # Backup version.json
416
+ vf = NEXO_HOME / "version.json"
417
+ if vf.is_file():
418
+ shutil.copy2(vf, backup_dir / "version.json")
419
+ except Exception as e:
420
+ return None, f"Code tree backup failed: {e}"
421
+ return str(backup_dir), None
422
+
423
+
424
+ def _restore_code_tree(backup_dir: str) -> str | None:
425
+ """Restore NEXO_HOME code dirs from a backup snapshot. Returns error or None."""
426
+ bdir = Path(backup_dir)
427
+ if not bdir.is_dir():
428
+ return f"Code tree backup dir not found: {backup_dir}"
429
+ try:
430
+ for item in bdir.iterdir():
431
+ dest = NEXO_HOME / item.name
432
+ if item.is_dir():
433
+ if dest.is_dir():
434
+ shutil.rmtree(dest)
435
+ shutil.copytree(item, dest)
436
+ elif item.is_file():
437
+ shutil.copy2(item, dest)
438
+ except Exception as e:
439
+ return f"Code tree restore failed: {e}"
440
+ return None
441
+
442
+
443
+ def _normalize_preferences_for_client_sync() -> dict:
444
+ from client_preferences import normalize_client_preferences
445
+
446
+ schedule_path = NEXO_HOME / "config" / "schedule.json"
447
+ schedule_payload = json.loads(schedule_path.read_text()) if schedule_path.exists() else {}
448
+ normalized_preferences = normalize_client_preferences(schedule_payload)
449
+ if normalized_preferences != {
450
+ key: schedule_payload.get(key)
451
+ for key in normalized_preferences
452
+ }:
453
+ merged_schedule = dict(schedule_payload)
454
+ merged_schedule.update(normalized_preferences)
455
+ schedule_path.parent.mkdir(parents=True, exist_ok=True)
456
+ schedule_path.write_text(json.dumps(merged_schedule, indent=2, ensure_ascii=False) + "\n")
457
+ return normalized_preferences
458
+
459
+
460
+ def _sync_packaged_clients() -> tuple[bool, str | None]:
461
+ try:
462
+ from client_sync import sync_all_clients
463
+ except Exception as e:
464
+ return False, f"client sync import failed: {e}"
465
+
466
+ try:
467
+ preferences = _normalize_preferences_for_client_sync()
468
+ result = sync_all_clients(
469
+ nexo_home=NEXO_HOME,
470
+ runtime_root=NEXO_HOME,
471
+ operator_name=os.environ.get("NEXO_NAME", ""),
472
+ preferences=preferences,
473
+ )
474
+ except Exception as e:
475
+ return False, f"client sync failed: {e}"
476
+
477
+ if result.get("ok"):
478
+ return True, None
479
+
480
+ clients = result.get("clients", {})
481
+ failures = []
482
+ for key, payload in clients.items():
483
+ if payload.get("ok") or payload.get("skipped"):
484
+ continue
485
+ failures.append(f"{key}: {payload.get('error', 'unknown error')}")
486
+ if not failures:
487
+ failures.append("unknown client sync failure")
488
+ return False, "; ".join(failures)
489
+
490
+
491
+ def _rollback_npm_package(target_version: str) -> str | None:
492
+ """Rollback nexo-brain npm package to a specific version.
493
+
494
+ Uses NEXO_SKIP_POSTINSTALL because we restore the code tree
495
+ from our own pre-update backup — no need for postinstall migration.
496
+ """
497
+ try:
498
+ result = subprocess.run(
499
+ ["npm", "install", "-g", f"nexo-brain@{target_version}"],
500
+ capture_output=True, text=True, timeout=120,
501
+ env={**os.environ, "NEXO_SKIP_POSTINSTALL": "1", "NEXO_HOME": str(NEXO_HOME)},
502
+ )
503
+ if result.returncode != 0:
504
+ return f"npm rollback failed: {result.stderr or result.stdout}"
505
+ except Exception as e:
506
+ return f"npm rollback error: {e}"
507
+ return None
508
+
509
+
510
+ def _emit_progress(progress_fn, message: str) -> None:
511
+ if callable(progress_fn):
512
+ try:
513
+ progress_fn(message)
514
+ except Exception:
515
+ pass
516
+
517
+
518
+ def _paths_match(src: Path, dest: Path) -> bool:
519
+ try:
520
+ return src.exists() and dest.exists() and src.samefile(dest)
521
+ except Exception:
522
+ return False
523
+
524
+
525
+ def _sync_packaged_crons(progress_fn=None) -> tuple[bool, str | None]:
526
+ sync_path = NEXO_HOME / "crons" / "sync.py"
527
+ if not sync_path.is_file():
528
+ _refresh_installed_manifest()
529
+ return True, None
530
+ try:
531
+ _emit_progress(progress_fn, "Syncing core cron definitions...")
532
+ result = subprocess.run(
533
+ [sys.executable, str(sync_path)],
534
+ cwd=str(NEXO_HOME),
535
+ capture_output=True,
536
+ text=True,
537
+ timeout=30,
538
+ env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(NEXO_HOME)},
539
+ )
540
+ if result.returncode != 0:
541
+ return False, result.stderr.strip() or result.stdout.strip() or "cron sync failed"
542
+ _refresh_installed_manifest()
543
+ return True, None
544
+ except Exception as e:
545
+ return False, f"cron sync error: {e}"
546
+
547
+
548
+ def _reload_launch_agents_after_bump() -> dict:
549
+ result: dict = {
550
+ "scanned": 0,
551
+ "reloaded": 0,
552
+ "skipped_missing": 0,
553
+ "errors": [],
554
+ "platform": sys.platform,
555
+ }
556
+
557
+ if sys.platform != "darwin":
558
+ return result
559
+
560
+ launch_agents_dir = Path.home() / "Library" / "LaunchAgents"
561
+ if not launch_agents_dir.is_dir():
562
+ return result
563
+
564
+ try:
565
+ plists = sorted(launch_agents_dir.glob("com.nexo.*.plist"))
566
+ except Exception as e:
567
+ result["errors"].append({"plist": "*", "stderr": f"glob failed: {e}"})
568
+ return result
569
+
570
+ result["scanned"] = len(plists)
571
+ for plist in plists:
572
+ try:
573
+ if not plist.is_file():
574
+ result["skipped_missing"] += 1
575
+ continue
576
+ subprocess.run(
577
+ ["launchctl", "unload", str(plist)],
578
+ capture_output=True,
579
+ text=True,
580
+ timeout=10,
581
+ )
582
+ load_proc = subprocess.run(
583
+ ["launchctl", "load", "-w", str(plist)],
584
+ capture_output=True,
585
+ text=True,
586
+ timeout=10,
587
+ )
588
+ if load_proc.returncode == 0:
589
+ result["reloaded"] += 1
590
+ else:
591
+ result["errors"].append(
592
+ {
593
+ "plist": plist.name,
594
+ "stderr": (load_proc.stderr or load_proc.stdout or "load failed")[:300],
595
+ }
596
+ )
597
+ except subprocess.TimeoutExpired:
598
+ result["errors"].append({"plist": plist.name, "stderr": "launchctl timeout"})
599
+ except Exception as e:
600
+ result["errors"].append({"plist": plist.name, "stderr": str(e)[:300]})
601
+
602
+ return result
603
+
604
+
605
+ def _handle_packaged_update(progress_fn=None) -> str:
606
+ """Update a packaged (npm) install — no git repo available."""
607
+ old_version = _read_version()
608
+
609
+ # 1. Backup databases BEFORE any changes
610
+ _emit_progress(progress_fn, "Backing up runtime databases...")
611
+ backup_dir, backup_err = _backup_databases()
612
+ if backup_err:
613
+ return f"ABORTED at backup: {backup_err}"
614
+
615
+ # 2. Backup NEXO_HOME code tree BEFORE npm update
616
+ # postinstall copies hooks/core/plugins/scripts into NEXO_HOME,
617
+ # so we need a full snapshot to restore on failure.
618
+ _emit_progress(progress_fn, "Backing up runtime files...")
619
+ code_backup_dir, code_err = _backup_code_tree()
620
+ if code_err:
621
+ return f"ABORTED at code tree backup: {code_err}"
622
+
623
+ # 3. Run npm update (postinstall.js will migrate NEXO_HOME in-place)
624
+ try:
625
+ _emit_progress(progress_fn, "Downloading and applying the latest npm package...")
626
+ result = subprocess.run(
627
+ ["npm", "update", "-g", "nexo-brain"],
628
+ capture_output=True, text=True, timeout=120,
629
+ env={**os.environ, "NEXO_HOME": str(NEXO_HOME)},
630
+ )
631
+ if result.returncode != 0:
632
+ # npm failed (including postinstall failures) — full rollback
633
+ if backup_dir:
634
+ _restore_databases(backup_dir)
635
+ if code_backup_dir:
636
+ _restore_code_tree(code_backup_dir)
637
+ # Reinstall pip deps from restored old requirements.txt
638
+ _reinstall_pip_deps()
639
+ rollback_err = _rollback_npm_package(old_version)
640
+ msg = f"ABORTED: npm update failed: {result.stderr or result.stdout}"
641
+ if rollback_err:
642
+ msg += f"\n WARNING: npm rollback also failed: {rollback_err}"
643
+ msg += f"\n Manual rollback: npm install -g nexo-brain@{old_version}"
644
+ return msg
645
+ except FileNotFoundError:
646
+ return "ABORTED: npm not found. Install Node.js to update packaged installs."
647
+ except Exception as e:
648
+ if backup_dir:
649
+ _restore_databases(backup_dir)
650
+ if code_backup_dir:
651
+ _restore_code_tree(code_backup_dir)
652
+ # Reinstall pip deps from restored old requirements.txt
653
+ _reinstall_pip_deps()
654
+ rollback_err = _rollback_npm_package(old_version)
655
+ msg = f"ABORTED: npm update error: {e}"
656
+ if rollback_err:
657
+ msg += f"\n WARNING: npm rollback also failed: {rollback_err}"
658
+ msg += f"\n Manual rollback: npm install -g nexo-brain@{old_version}"
659
+ return msg
660
+
661
+ new_version = _read_version()
662
+ if old_version == new_version:
663
+ return f"Already up to date (v{old_version}). No changes."
664
+
665
+ # 4. Post-npm verification steps
666
+ errors = []
667
+
668
+ # Reinstall pip deps for new version
669
+ _emit_progress(progress_fn, "Reconciling Python dependencies...")
670
+ pip_err = _reinstall_pip_deps()
671
+ if pip_err:
672
+ errors.append(f"pip deps: {pip_err}")
673
+
674
+ # Run migrations
675
+ _emit_progress(progress_fn, "Running runtime migrations...")
676
+ mig_err = _run_migrations()
677
+ if mig_err:
678
+ errors.append(f"migrations: {mig_err}")
679
+
680
+ # Verify server can still import
681
+ _emit_progress(progress_fn, "Verifying runtime import health...")
682
+ verify_err = _verify_import()
683
+ if verify_err:
684
+ errors.append(f"verification: {verify_err}")
685
+
686
+ hook_sync_warning = None
687
+ cron_sync_warning = None
688
+ retired_runtime_files: list[str] = []
689
+ launchagent_reload_warning = None
690
+ launchagent_reload_summary = None
691
+ cron_sync_ok, cron_sync_error = _sync_packaged_crons(progress_fn=progress_fn)
692
+ if not cron_sync_ok:
693
+ errors.append(f"cron sync: {cron_sync_error}")
694
+ cron_sync_warning = cron_sync_error
695
+ try:
696
+ _emit_progress(progress_fn, "Refreshing installed hooks and manifests...")
697
+ _sync_hooks_to_home()
698
+ retired_runtime_files = _cleanup_retired_runtime_files()
699
+ except Exception as e:
700
+ hook_sync_warning = f"{e}"
701
+
702
+ client_sync_warning = None
703
+ _emit_progress(progress_fn, "Refreshing shared client configs...")
704
+ clients_ok, client_sync_error = _sync_packaged_clients()
705
+ if not clients_ok:
706
+ client_sync_warning = client_sync_error or "unknown client sync error"
707
+
708
+ if old_version != new_version:
709
+ _emit_progress(progress_fn, "Reloading LaunchAgents after version bump...")
710
+ try:
711
+ launchagent_reload_summary = _reload_launch_agents_after_bump()
712
+ if launchagent_reload_summary.get("errors"):
713
+ launchagent_reload_warning = (
714
+ f"reloaded {launchagent_reload_summary['reloaded']}/"
715
+ f"{launchagent_reload_summary['scanned']} with "
716
+ f"{len(launchagent_reload_summary['errors'])} error(s)"
717
+ )
718
+ except Exception as e:
719
+ launchagent_reload_warning = f"launchagent reload error: {e}"
720
+
721
+ if errors:
722
+ # 5. Full rollback: restore code tree + DBs + pip deps + rollback npm package
723
+ if code_backup_dir:
724
+ tree_err = _restore_code_tree(code_backup_dir)
725
+ else:
726
+ tree_err = "no code tree backup available"
727
+ if backup_dir:
728
+ _restore_databases(backup_dir)
729
+ # Reinstall pip deps from the restored (old) requirements.txt
730
+ # so the venv matches the rolled-back code tree
731
+ pip_rollback_err = _reinstall_pip_deps() if not tree_err else None
732
+ rollback_err = _rollback_npm_package(old_version)
733
+ lines = [f"UPDATE FAILED (packaged install, v{old_version} -> v{new_version})"]
734
+ for err in errors:
735
+ lines.append(f" ERROR: {err}")
736
+ lines.append(f" Databases restored from: {backup_dir}")
737
+ if tree_err:
738
+ lines.append(f" WARNING: code tree restore failed: {tree_err}")
739
+ else:
740
+ lines.append(f" Code tree restored from: {code_backup_dir}")
741
+ if pip_rollback_err:
742
+ lines.append(f" WARNING: pip deps rollback failed: {pip_rollback_err}")
743
+ elif not tree_err:
744
+ lines.append(" Python deps: reinstalled from old requirements.txt")
745
+ if rollback_err:
746
+ lines.append(f" WARNING: npm rollback failed: {rollback_err}")
747
+ lines.append(f" Manual rollback: npm install -g nexo-brain@{old_version}")
748
+ else:
749
+ lines.append(f" npm package rolled back to v{old_version}")
750
+ lines.append("")
751
+ lines.append("Fix the errors above, then run nexo_update again.")
752
+ return "\n".join(lines)
753
+
754
+ lines = ["UPDATE SUCCESSFUL (packaged install)"]
755
+ lines.append(f" Version: {old_version} -> {new_version}")
756
+ lines.append(f" Backup: {backup_dir}")
757
+ if not cron_sync_warning:
758
+ lines.append(" Crons: synced with manifest")
759
+ else:
760
+ lines.append(f" WARNING: cron sync: {cron_sync_warning}")
761
+ if not hook_sync_warning:
762
+ lines.append(" Hooks: synced to NEXO_HOME")
763
+ else:
764
+ lines.append(f" WARNING: hook sync: {hook_sync_warning}")
765
+ if retired_runtime_files:
766
+ lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
767
+ if not client_sync_warning:
768
+ lines.append(" Clients: configured client targets synced")
769
+ else:
770
+ lines.append(f" WARNING: client sync: {client_sync_warning}")
771
+ if launchagent_reload_summary and launchagent_reload_summary.get("scanned"):
772
+ if not launchagent_reload_warning:
773
+ lines.append(
774
+ " LaunchAgents: reloaded "
775
+ f"{launchagent_reload_summary['reloaded']}/"
776
+ f"{launchagent_reload_summary['scanned']}"
777
+ )
778
+ else:
779
+ lines.append(f" WARNING: launchagent reload: {launchagent_reload_warning}")
780
+ lines.append("")
781
+ lines.append("MCP server restart needed to load new code.")
782
+ return "\n".join(lines)
783
+
784
+
785
+ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None) -> str:
786
+ """Pull latest NEXO code, backup databases, run migrations, and verify.
787
+
788
+ Supports both git checkouts and packaged (npm) installs.
789
+
790
+ Full update flow (git):
791
+ 1. Check for uncommitted changes in entire worktree
792
+ 2. Backup all .db files
793
+ 3. git pull
794
+ 4. Reinstall Python dependencies if version changed
795
+ 5. Run migrations if version changed
796
+ 6. Verify server.py imports
797
+ 7. Rollback on failure (git reset --hard to saved commit)
798
+
799
+ Args:
800
+ remote: Git remote name (default: origin)
801
+ branch: Git branch to pull (default: main)
802
+ """
803
+ # Packaged install — no git repo
804
+ if not _is_git_repo():
805
+ return _handle_packaged_update(progress_fn=progress_fn)
806
+
807
+ steps_done = []
808
+ old_commit = None
809
+ backup_dir = None
810
+
811
+ try:
812
+ # Step 1: Check dirty (full worktree)
813
+ _emit_progress(progress_fn, "Checking repository state...")
814
+ dirty_err = _check_dirty()
815
+ if dirty_err:
816
+ return f"ABORTED: {dirty_err}"
817
+ steps_done.append("clean-check")
818
+
819
+ # Record current state
820
+ old_version = _read_version()
821
+ old_req_hash = _requirements_hash()
822
+ rc, old_commit, _ = _git("rev-parse", "HEAD")
823
+ if rc != 0:
824
+ return "ABORTED: Not a git repository or git not available."
825
+
826
+ # Step 2: Backup databases
827
+ _emit_progress(progress_fn, "Backing up runtime databases...")
828
+ backup_dir, backup_err = _backup_databases()
829
+ if backup_err:
830
+ return f"ABORTED at backup: {backup_err}"
831
+ steps_done.append("backup")
832
+
833
+ # Step 3: git pull
834
+ _emit_progress(progress_fn, "Pulling latest source changes...")
835
+ rc, pull_out, pull_err = _git("pull", remote, branch)
836
+ if rc != 0:
837
+ return f"ABORTED at git pull: {pull_err or pull_out}"
838
+ steps_done.append("git-pull")
839
+
840
+ # Step 4: Check version and dependency changes
841
+ new_version = _read_version()
842
+ version_changed = old_version != new_version
843
+ new_req_hash = _requirements_hash()
844
+ deps_changed = old_req_hash != new_req_hash
845
+
846
+ # Step 5: Reinstall pip dependencies if requirements.txt changed
847
+ if deps_changed or version_changed:
848
+ _emit_progress(progress_fn, "Reconciling Python dependencies...")
849
+ pip_err = _reinstall_pip_deps()
850
+ if pip_err:
851
+ raise RuntimeError(f"Pip install failed: {pip_err}")
852
+ steps_done.append("pip-deps")
853
+
854
+ # Step 6: Run migrations if version changed
855
+ if version_changed:
856
+ _emit_progress(progress_fn, "Running runtime migrations...")
857
+ mig_err = _run_migrations()
858
+ if mig_err:
859
+ raise RuntimeError(f"Migration failed: {mig_err}")
860
+ steps_done.append("migrations")
861
+
862
+ # Step 7: Verify import
863
+ _emit_progress(progress_fn, "Verifying runtime import health...")
864
+ verify_err = _verify_import()
865
+ if verify_err:
866
+ raise RuntimeError(f"Verification failed: {verify_err}")
867
+ steps_done.append("verify")
868
+
869
+ # Step 8: Sync crons with manifest
870
+ cron_sync_result = ""
871
+ try:
872
+ cron_sync_path = SRC_DIR / "crons" / "sync.py"
873
+ if cron_sync_path.exists():
874
+ _emit_progress(progress_fn, "Syncing core cron definitions...")
875
+ r = subprocess.run(
876
+ [sys.executable, str(cron_sync_path)],
877
+ capture_output=True, text=True, timeout=30,
878
+ env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(SRC_DIR)},
879
+ )
880
+ cron_sync_result = r.stdout.strip()
881
+ if r.returncode == 0:
882
+ steps_done.append("cron-sync")
883
+ # Refresh installed manifest only after successful sync
884
+ _refresh_installed_manifest()
885
+ else:
886
+ cron_sync_result = f"Cron sync failed (exit {r.returncode}): {r.stderr or r.stdout}"
887
+ except Exception as e:
888
+ cron_sync_result = f"Cron sync warning: {e}"
889
+
890
+ # Step 9: Sync hooks to NEXO_HOME
891
+ retired_runtime_files: list[str] = []
892
+ try:
893
+ _emit_progress(progress_fn, "Syncing core Claude hooks...")
894
+ _sync_hooks_to_home()
895
+ retired_runtime_files = _cleanup_retired_runtime_files()
896
+ steps_done.append("hook-sync")
897
+ except Exception as e:
898
+ pass # Non-critical, log in function
899
+
900
+ # Step 10: Sync shared client configs
901
+ try:
902
+ _emit_progress(progress_fn, "Refreshing shared client configs...")
903
+ from client_sync import sync_all_clients
904
+ from client_preferences import normalize_client_preferences
905
+
906
+ schedule_path = NEXO_HOME / "config" / "schedule.json"
907
+ schedule_payload = json.loads(schedule_path.read_text()) if schedule_path.exists() else {}
908
+ normalized_preferences = normalize_client_preferences(schedule_payload)
909
+ if normalized_preferences != {
910
+ key: schedule_payload.get(key)
911
+ for key in normalized_preferences
912
+ }:
913
+ merged_schedule = dict(schedule_payload)
914
+ merged_schedule.update(normalized_preferences)
915
+ schedule_path.parent.mkdir(parents=True, exist_ok=True)
916
+ schedule_path.write_text(json.dumps(merged_schedule, indent=2, ensure_ascii=False) + "\n")
917
+ client_sync_result = sync_all_clients(
918
+ nexo_home=NEXO_HOME,
919
+ runtime_root=SRC_DIR,
920
+ operator_name=os.environ.get("NEXO_NAME", ""),
921
+ preferences=normalized_preferences,
922
+ )
923
+ if client_sync_result.get("ok"):
924
+ steps_done.append("client-sync")
925
+ except Exception:
926
+ pass # Non-critical, configs can be re-synced later
927
+
928
+ # Build result
929
+ if pull_out == "Already up to date.":
930
+ return f"Already up to date (v{old_version}). No changes pulled."
931
+
932
+ lines = ["UPDATE SUCCESSFUL"]
933
+ if version_changed:
934
+ lines.append(f" Version: {old_version} -> {new_version}")
935
+ else:
936
+ lines.append(f" Version: {old_version} (unchanged)")
937
+ lines.append(f" Branch: {remote}/{branch}")
938
+ lines.append(f" Backup: {backup_dir}")
939
+ if "pip-deps" in steps_done:
940
+ lines.append(" Python deps: reinstalled")
941
+ if version_changed:
942
+ lines.append(" Migrations: applied")
943
+ if "cron-sync" in steps_done:
944
+ lines.append(" Crons: synced with manifest")
945
+ if "hook-sync" in steps_done:
946
+ lines.append(" Hooks: synced to NEXO_HOME")
947
+ if retired_runtime_files:
948
+ lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
949
+ if "client-sync" in steps_done:
950
+ lines.append(" Clients: configured client targets synced")
951
+ lines.append("")
952
+ lines.append("MCP server restart needed to load new code.")
953
+ return "\n".join(lines)
954
+
955
+ except Exception as e:
956
+ # Rollback — use git checkout to saved commit (safer than reset --hard)
957
+ rollback_lines = [f"UPDATE FAILED: {e}", "", "Rolling back..."]
958
+
959
+ if old_commit and "git-pull" in steps_done:
960
+ # Full rollback: reset HEAD + index + worktree to old commit
961
+ rc, _, err = _git("reset", "--hard", old_commit)
962
+ if rc == 0:
963
+ rollback_lines.append(f" Git: restored files to {old_commit[:8]}")
964
+ # Reinstall pip deps from the restored old requirements.txt
965
+ # so the venv matches the rolled-back code
966
+ if "pip-deps" in steps_done:
967
+ pip_rb_err = _reinstall_pip_deps()
968
+ if pip_rb_err:
969
+ rollback_lines.append(f" WARNING: pip deps rollback failed: {pip_rb_err}")
970
+ else:
971
+ rollback_lines.append(" Python deps: reinstalled from old requirements.txt")
972
+ else:
973
+ rollback_lines.append(f" Git rollback FAILED: {err}")
974
+
975
+ if backup_dir and "backup" in steps_done:
976
+ _restore_databases(backup_dir)
977
+ rollback_lines.append(f" DBs: restored from {backup_dir}")
978
+
979
+ rollback_lines.append("")
980
+ rollback_lines.append("System restored to previous state.")
981
+ return "\n".join(rollback_lines)
982
+
983
+
984
+ TOOLS = [
985
+ (handle_update, "nexo_update", "Pull latest NEXO code, backup DBs, run migrations, verify. Rolls back on failure."),
986
+ ]