nexo-brain 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (297) hide show
  1. package/README.md +7 -7
  2. package/bin/nexo-brain.js +53 -26
  3. package/package.json +1 -1
  4. package/scripts/migrate-to-unified 2.sh +813 -0
  5. package/scripts/migrate-v1.5-to-v1.6 2.py +778 -0
  6. package/scripts/migrate-v1.7-to-v1.8 2.py +214 -0
  7. package/scripts/migrate-v1.7-to-v1.8.py +2 -2
  8. package/scripts/nexo-preflight.sh +236 -0
  9. package/scripts/pre-commit-check 2.sh +55 -0
  10. package/src/__pycache__/auto_close_sessions.cpython-314.pyc +0 -0
  11. package/src/__pycache__/auto_update.cpython-310.pyc +0 -0
  12. package/src/__pycache__/hnsw_index.cpython-310.pyc +0 -0
  13. package/src/__pycache__/hnsw_index.cpython-314.pyc +0 -0
  14. package/src/__pycache__/kg_populate.cpython-310.pyc +0 -0
  15. package/src/__pycache__/knowledge_graph.cpython-310.pyc +0 -0
  16. package/src/__pycache__/plugin_loader.cpython-310.pyc +0 -0
  17. package/src/__pycache__/plugin_loader.cpython-314.pyc +0 -0
  18. package/src/__pycache__/tools_coordination.cpython-310.pyc +0 -0
  19. package/src/__pycache__/tools_credentials.cpython-310.pyc +0 -0
  20. package/src/__pycache__/tools_learnings.cpython-310.pyc +0 -0
  21. package/src/__pycache__/tools_menu.cpython-310.pyc +0 -0
  22. package/src/__pycache__/tools_reminders.cpython-310.pyc +0 -0
  23. package/src/__pycache__/tools_reminders_crud.cpython-310.pyc +0 -0
  24. package/src/__pycache__/tools_sessions.cpython-310.pyc +0 -0
  25. package/src/__pycache__/tools_task_history.cpython-310.pyc +0 -0
  26. package/src/auto_close_sessions 2.py +159 -0
  27. package/src/auto_update 2.py +634 -0
  28. package/src/auto_update.py +25 -0
  29. package/src/claim_graph 2.py +323 -0
  30. package/src/cognitive/__init__ 2.py +62 -0
  31. package/src/cognitive/__pycache__/__init__.cpython-310.pyc +0 -0
  32. package/src/cognitive/__pycache__/__init__.cpython-312.pyc +0 -0
  33. package/src/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  34. package/src/cognitive/__pycache__/_core.cpython-310.pyc +0 -0
  35. package/src/cognitive/__pycache__/_core.cpython-312.pyc +0 -0
  36. package/src/cognitive/__pycache__/_core.cpython-314.pyc +0 -0
  37. package/src/cognitive/__pycache__/_decay.cpython-310.pyc +0 -0
  38. package/src/cognitive/__pycache__/_decay.cpython-312.pyc +0 -0
  39. package/src/cognitive/__pycache__/_decay.cpython-314.pyc +0 -0
  40. package/src/cognitive/__pycache__/_ingest.cpython-310.pyc +0 -0
  41. package/src/cognitive/__pycache__/_ingest.cpython-312.pyc +0 -0
  42. package/src/cognitive/__pycache__/_ingest.cpython-314.pyc +0 -0
  43. package/src/cognitive/__pycache__/_memory.cpython-310.pyc +0 -0
  44. package/src/cognitive/__pycache__/_memory.cpython-312.pyc +0 -0
  45. package/src/cognitive/__pycache__/_memory.cpython-314.pyc +0 -0
  46. package/src/cognitive/__pycache__/_search.cpython-310.pyc +0 -0
  47. package/src/cognitive/__pycache__/_search.cpython-312.pyc +0 -0
  48. package/src/cognitive/__pycache__/_search.cpython-314.pyc +0 -0
  49. package/src/cognitive/__pycache__/_trust.cpython-310.pyc +0 -0
  50. package/src/cognitive/__pycache__/_trust.cpython-312.pyc +0 -0
  51. package/src/cognitive/__pycache__/_trust.cpython-314.pyc +0 -0
  52. package/src/cognitive/_core 2.py +567 -0
  53. package/src/cognitive/_decay 2.py +382 -0
  54. package/src/cognitive/_ingest 2.py +892 -0
  55. package/src/cognitive/_memory 2.py +912 -0
  56. package/src/cognitive/_search 2.py +949 -0
  57. package/src/cognitive/_trust 2.py +464 -0
  58. package/src/cognitive/_trust.py +10 -36
  59. package/src/crons/__pycache__/sync.cpython-314.pyc +0 -0
  60. package/src/crons/manifest 2.json +106 -0
  61. package/src/crons/manifest.json +6 -13
  62. package/src/crons/sync 2.py +217 -0
  63. package/src/crons/sync.py +151 -6
  64. package/src/dashboard/__init__ 2.py +0 -0
  65. package/src/dashboard/__pycache__/__init__.cpython-310.pyc +0 -0
  66. package/src/dashboard/__pycache__/app.cpython-310.pyc +0 -0
  67. package/src/dashboard/app 2.py +789 -0
  68. package/src/db/__init__ 2.py +89 -0
  69. package/src/db/__init__.py +13 -0
  70. package/src/db/__pycache__/__init__.cpython-310.pyc +0 -0
  71. package/src/db/__pycache__/__init__.cpython-312.pyc +0 -0
  72. package/src/db/__pycache__/__init__.cpython-314.pyc +0 -0
  73. package/src/db/__pycache__/_core.cpython-310.pyc +0 -0
  74. package/src/db/__pycache__/_core.cpython-312.pyc +0 -0
  75. package/src/db/__pycache__/_core.cpython-314.pyc +0 -0
  76. package/src/db/__pycache__/_credentials.cpython-310.pyc +0 -0
  77. package/src/db/__pycache__/_credentials.cpython-312.pyc +0 -0
  78. package/src/db/__pycache__/_credentials.cpython-314.pyc +0 -0
  79. package/src/db/__pycache__/_cron_runs.cpython-310.pyc +0 -0
  80. package/src/db/__pycache__/_cron_runs.cpython-314.pyc +0 -0
  81. package/src/db/__pycache__/_entities.cpython-310.pyc +0 -0
  82. package/src/db/__pycache__/_entities.cpython-312.pyc +0 -0
  83. package/src/db/__pycache__/_entities.cpython-314.pyc +0 -0
  84. package/src/db/__pycache__/_episodic.cpython-310.pyc +0 -0
  85. package/src/db/__pycache__/_episodic.cpython-312.pyc +0 -0
  86. package/src/db/__pycache__/_episodic.cpython-314.pyc +0 -0
  87. package/src/db/__pycache__/_evolution.cpython-310.pyc +0 -0
  88. package/src/db/__pycache__/_evolution.cpython-312.pyc +0 -0
  89. package/src/db/__pycache__/_evolution.cpython-314.pyc +0 -0
  90. package/src/db/__pycache__/_fts.cpython-310.pyc +0 -0
  91. package/src/db/__pycache__/_fts.cpython-312.pyc +0 -0
  92. package/src/db/__pycache__/_fts.cpython-314.pyc +0 -0
  93. package/src/db/__pycache__/_learnings.cpython-310.pyc +0 -0
  94. package/src/db/__pycache__/_learnings.cpython-312.pyc +0 -0
  95. package/src/db/__pycache__/_learnings.cpython-314.pyc +0 -0
  96. package/src/db/__pycache__/_reminders.cpython-310.pyc +0 -0
  97. package/src/db/__pycache__/_reminders.cpython-312.pyc +0 -0
  98. package/src/db/__pycache__/_reminders.cpython-314.pyc +0 -0
  99. package/src/db/__pycache__/_schema.cpython-310.pyc +0 -0
  100. package/src/db/__pycache__/_schema.cpython-312.pyc +0 -0
  101. package/src/db/__pycache__/_schema.cpython-314.pyc +0 -0
  102. package/src/db/__pycache__/_sessions.cpython-310.pyc +0 -0
  103. package/src/db/__pycache__/_sessions.cpython-312.pyc +0 -0
  104. package/src/db/__pycache__/_sessions.cpython-314.pyc +0 -0
  105. package/src/db/__pycache__/_skills.cpython-310.pyc +0 -0
  106. package/src/db/__pycache__/_skills.cpython-312.pyc +0 -0
  107. package/src/db/__pycache__/_skills.cpython-314.pyc +0 -0
  108. package/src/db/__pycache__/_tasks.cpython-310.pyc +0 -0
  109. package/src/db/__pycache__/_tasks.cpython-312.pyc +0 -0
  110. package/src/db/__pycache__/_tasks.cpython-314.pyc +0 -0
  111. package/src/db/_core 2.py +417 -0
  112. package/src/db/_credentials 2.py +124 -0
  113. package/src/db/_cron_runs.py +74 -0
  114. package/src/db/_entities 2.py +178 -0
  115. package/src/db/_episodic 2.py +738 -0
  116. package/src/db/_episodic.py +40 -6
  117. package/src/db/_evolution 2.py +54 -0
  118. package/src/db/_fts 2.py +406 -0
  119. package/src/db/_learnings 2.py +168 -0
  120. package/src/db/_reminders 2.py +338 -0
  121. package/src/db/_schema 2.py +364 -0
  122. package/src/db/_schema.py +64 -0
  123. package/src/db/_sessions 2.py +300 -0
  124. package/src/db/_skills.py +514 -0
  125. package/src/db/_tasks 2.py +91 -0
  126. package/src/evolution_cycle 2.py +266 -0
  127. package/src/hnsw_index 2.py +254 -0
  128. package/src/hooks/auto_capture 2.py +208 -0
  129. package/src/hooks/caffeinate-guard 2.sh +8 -0
  130. package/src/hooks/capture-session 2.sh +21 -0
  131. package/src/hooks/capture-session.sh +2 -0
  132. package/src/hooks/capture-tool-logs 2.sh +127 -0
  133. package/src/hooks/capture-tool-logs.sh +3 -2
  134. package/src/hooks/daily-briefing-check 2.sh +33 -0
  135. package/src/hooks/inbox-hook 2.sh +76 -0
  136. package/src/hooks/inbox-hook.sh +3 -2
  137. package/src/hooks/post-compact 2.sh +148 -0
  138. package/src/hooks/post-compact.sh +1 -1
  139. package/src/hooks/pre-compact 2.sh +151 -0
  140. package/src/hooks/pre-compact.sh +1 -1
  141. package/src/hooks/session-start 2.sh +268 -0
  142. package/src/hooks/session-start.sh +6 -3
  143. package/src/hooks/session-stop 2.sh +140 -0
  144. package/src/hooks/session-stop.sh +14 -102
  145. package/src/kg_populate 2.py +290 -0
  146. package/src/knowledge_graph 2.py +257 -0
  147. package/src/maintenance 2.py +59 -0
  148. package/src/migrate_embeddings 2.py +122 -0
  149. package/src/plugin_loader 2.py +202 -0
  150. package/src/plugins/__init__ 2.py +0 -0
  151. package/src/plugins/__pycache__/__init__ 2.cpython-310.pyc +0 -0
  152. package/src/plugins/__pycache__/__init__.cpython-310.pyc +0 -0
  153. package/src/plugins/__pycache__/__init__.cpython-314.pyc +0 -0
  154. package/src/plugins/__pycache__/adaptive_mode 2.cpython-310.pyc +0 -0
  155. package/src/plugins/__pycache__/adaptive_mode.cpython-310.pyc +0 -0
  156. package/src/plugins/__pycache__/adaptive_mode.cpython-314.pyc +0 -0
  157. package/src/plugins/__pycache__/agents 2.cpython-310.pyc +0 -0
  158. package/src/plugins/__pycache__/agents.cpython-310.pyc +0 -0
  159. package/src/plugins/__pycache__/artifact_registry 2.cpython-310.pyc +0 -0
  160. package/src/plugins/__pycache__/artifact_registry.cpython-310.pyc +0 -0
  161. package/src/plugins/__pycache__/backup 2.cpython-310.pyc +0 -0
  162. package/src/plugins/__pycache__/backup.cpython-310.pyc +0 -0
  163. package/src/plugins/__pycache__/cognitive_memory 2.cpython-310.pyc +0 -0
  164. package/src/plugins/__pycache__/cognitive_memory.cpython-310.pyc +0 -0
  165. package/src/plugins/__pycache__/core_rules 2.cpython-310.pyc +0 -0
  166. package/src/plugins/__pycache__/core_rules.cpython-310.pyc +0 -0
  167. package/src/plugins/__pycache__/cortex 2.cpython-310.pyc +0 -0
  168. package/src/plugins/__pycache__/cortex.cpython-310.pyc +0 -0
  169. package/src/plugins/__pycache__/entities 2.cpython-310.pyc +0 -0
  170. package/src/plugins/__pycache__/entities.cpython-310.pyc +0 -0
  171. package/src/plugins/__pycache__/episodic_memory 2.cpython-310.pyc +0 -0
  172. package/src/plugins/__pycache__/episodic_memory.cpython-310.pyc +0 -0
  173. package/src/plugins/__pycache__/evolution 2.cpython-310.pyc +0 -0
  174. package/src/plugins/__pycache__/evolution.cpython-310.pyc +0 -0
  175. package/src/plugins/__pycache__/guard 2.cpython-310.pyc +0 -0
  176. package/src/plugins/__pycache__/guard.cpython-310.pyc +0 -0
  177. package/src/plugins/__pycache__/knowledge_graph_tools 2.cpython-310.pyc +0 -0
  178. package/src/plugins/__pycache__/knowledge_graph_tools.cpython-310.pyc +0 -0
  179. package/src/plugins/__pycache__/preferences 2.cpython-310.pyc +0 -0
  180. package/src/plugins/__pycache__/preferences.cpython-310.pyc +0 -0
  181. package/src/plugins/__pycache__/schedule.cpython-310.pyc +0 -0
  182. package/src/plugins/__pycache__/schedule.cpython-314.pyc +0 -0
  183. package/src/plugins/__pycache__/skills.cpython-310.pyc +0 -0
  184. package/src/plugins/__pycache__/skills.cpython-314.pyc +0 -0
  185. package/src/plugins/__pycache__/update 2.cpython-310.pyc +0 -0
  186. package/src/plugins/__pycache__/update.cpython-310.pyc +0 -0
  187. package/src/plugins/adaptive_mode 2.py +805 -0
  188. package/src/plugins/agents 2.py +52 -0
  189. package/src/plugins/artifact_registry 2.py +450 -0
  190. package/src/plugins/backup 2.py +104 -0
  191. package/src/plugins/cognitive_memory 2.py +564 -0
  192. package/src/plugins/core_rules 2.py +252 -0
  193. package/src/plugins/cortex 2.py +299 -0
  194. package/src/plugins/entities 2.py +67 -0
  195. package/src/plugins/episodic_memory 2.py +533 -0
  196. package/src/plugins/episodic_memory.py +5 -3
  197. package/src/plugins/evolution 2.py +115 -0
  198. package/src/plugins/guard 2.py +746 -0
  199. package/src/plugins/knowledge_graph_tools 2.py +105 -0
  200. package/src/plugins/preferences 2.py +47 -0
  201. package/src/plugins/schedule.py +212 -0
  202. package/src/plugins/skills.py +264 -0
  203. package/src/plugins/update 2.py +256 -0
  204. package/src/requirements 2.txt +12 -0
  205. package/src/rules/__init__ 2.py +0 -0
  206. package/src/rules/core-rules 2.json +331 -0
  207. package/src/rules/migrate 2.py +207 -0
  208. package/src/scripts/__pycache__/nexo-auto-update.cpython-314.pyc +0 -0
  209. package/src/scripts/__pycache__/nexo-catchup.cpython-314.pyc +0 -0
  210. package/src/scripts/__pycache__/nexo-cognitive-decay.cpython-314.pyc +0 -0
  211. package/src/scripts/__pycache__/nexo-daily-self-audit.cpython-314.pyc +0 -0
  212. package/src/scripts/__pycache__/nexo-evolution-run.cpython-314.pyc +0 -0
  213. package/src/scripts/__pycache__/nexo-followup-hygiene.cpython-314.pyc +0 -0
  214. package/src/scripts/__pycache__/nexo-immune.cpython-314.pyc +0 -0
  215. package/src/scripts/__pycache__/nexo-install.cpython-314.pyc +0 -0
  216. package/src/scripts/__pycache__/nexo-learning-housekeep.cpython-314.pyc +0 -0
  217. package/src/scripts/__pycache__/nexo-learning-validator.cpython-314.pyc +0 -0
  218. package/src/scripts/__pycache__/nexo-migrate.cpython-314.pyc +0 -0
  219. package/src/scripts/__pycache__/nexo-postmortem-consolidator.cpython-314.pyc +0 -0
  220. package/src/scripts/__pycache__/nexo-pre-commit.cpython-314.pyc +0 -0
  221. package/src/scripts/__pycache__/nexo-proactive-dashboard.cpython-314.pyc +0 -0
  222. package/src/scripts/__pycache__/nexo-reflection.cpython-314.pyc +0 -0
  223. package/src/scripts/__pycache__/nexo-runtime-preflight.cpython-314.pyc +0 -0
  224. package/src/scripts/__pycache__/nexo-send-email.cpython-314.pyc +0 -0
  225. package/src/scripts/__pycache__/nexo-send-reply.cpython-314.pyc +0 -0
  226. package/src/scripts/__pycache__/nexo-sleep.cpython-314.pyc +0 -0
  227. package/src/scripts/__pycache__/nexo-synthesis.cpython-314.pyc +0 -0
  228. package/src/scripts/__pycache__/nexo-watchdog-smoke.cpython-314.pyc +0 -0
  229. package/src/scripts/check-context 2.py +264 -0
  230. package/src/scripts/deep-sleep/apply_findings.py +168 -8
  231. package/src/scripts/deep-sleep/collect.py +33 -11
  232. package/src/scripts/deep-sleep/extract-prompt.md +38 -0
  233. package/src/scripts/deep-sleep/extract.py +80 -8
  234. package/src/scripts/deep-sleep/synthesize-prompt.md +59 -2
  235. package/src/scripts/deep-sleep/synthesize.py +3 -1
  236. package/src/scripts/nexo-auto-update 2.py +6 -0
  237. package/src/scripts/nexo-backup 2.sh +25 -0
  238. package/src/scripts/nexo-brain-activation 2.sh +140 -0
  239. package/src/scripts/nexo-catchup 2.py +242 -0
  240. package/src/scripts/nexo-catchup.py +65 -29
  241. package/src/scripts/nexo-cognitive-decay 2.py +182 -0
  242. package/src/scripts/nexo-cron-wrapper.sh +53 -0
  243. package/src/scripts/nexo-daily-self-audit 2.py +552 -0
  244. package/src/scripts/nexo-daily-self-audit.py +4 -2
  245. package/src/scripts/nexo-deep-sleep 2.sh +97 -0
  246. package/src/scripts/nexo-deep-sleep.sh +66 -77
  247. package/src/scripts/nexo-evolution-run 2.py +597 -0
  248. package/src/scripts/nexo-evolution-run.py +13 -0
  249. package/src/scripts/nexo-followup-hygiene 2.py +112 -0
  250. package/src/scripts/nexo-immune 2.py +927 -0
  251. package/src/scripts/nexo-inbox-hook 2.sh +74 -0
  252. package/src/scripts/nexo-install 2.py +6 -0
  253. package/src/scripts/nexo-learning-housekeep 2.py +245 -0
  254. package/src/scripts/nexo-learning-housekeep.py +156 -1
  255. package/src/scripts/nexo-learning-validator 2.py +207 -0
  256. package/src/scripts/nexo-learning-validator.py +19 -0
  257. package/src/scripts/nexo-migrate 2.py +232 -0
  258. package/src/scripts/nexo-postmortem-consolidator 2.py +421 -0
  259. package/src/scripts/nexo-postmortem-consolidator.py +3 -2
  260. package/src/scripts/nexo-pre-commit 2.py +120 -0
  261. package/src/scripts/nexo-prevent-sleep 2.sh +29 -0
  262. package/src/scripts/nexo-proactive-dashboard 2.py +345 -0
  263. package/src/scripts/nexo-reflection 2.py +253 -0
  264. package/src/scripts/nexo-runtime-preflight 2.py +274 -0
  265. package/src/scripts/nexo-send-email 2.py +25 -0
  266. package/src/scripts/nexo-send-reply 2.py +178 -0
  267. package/src/scripts/nexo-sleep 2.py +592 -0
  268. package/src/scripts/nexo-sleep.py +16 -11
  269. package/src/scripts/nexo-snapshot-restore 2.sh +35 -0
  270. package/src/scripts/nexo-synthesis 2.py +253 -0
  271. package/src/scripts/nexo-synthesis.py +46 -3
  272. package/src/scripts/nexo-tcc-approve 2.sh +79 -0
  273. package/src/scripts/nexo-update 2.sh +161 -0
  274. package/src/scripts/nexo-watchdog 2.sh +878 -0
  275. package/src/scripts/nexo-watchdog-smoke 2.py +119 -0
  276. package/src/scripts/nexo-watchdog.sh +72 -19
  277. package/src/server 2.py +733 -0
  278. package/src/server.py +11 -2
  279. package/src/storage_router 2.py +32 -0
  280. package/src/tools_coordination 2.py +102 -0
  281. package/src/tools_credentials 2.py +68 -0
  282. package/src/tools_learnings 2.py +220 -0
  283. package/src/tools_menu 2.py +227 -0
  284. package/src/tools_reminders 2.py +86 -0
  285. package/src/tools_reminders_crud 2.py +159 -0
  286. package/src/tools_reminders_crud.py +7 -0
  287. package/src/tools_sessions 2.py +476 -0
  288. package/src/tools_task_history 2.py +57 -0
  289. package/templates/CLAUDE.md 2.template +63 -0
  290. package/templates/openclaw 2.json +13 -0
  291. package/tests/__init__ 2.py +0 -0
  292. package/tests/conftest 2.py +71 -0
  293. package/tests/test_cognitive 2.py +205 -0
  294. package/tests/test_knowledge_graph 2.py +140 -0
  295. package/tests/test_migrations 2.py +137 -0
  296. package/src/scripts/deep-sleep/__pycache__/extract.cpython-314.pyc +0 -0
  297. /package/src/scripts/{nexo-github-monitor.py → nexo-github-monitor 2.py} +0 -0
@@ -0,0 +1,634 @@
1
+ """NEXO Auto-Update — lightweight startup check for git updates and file-based migrations.
2
+
3
+ Called once per server startup. Respects a 1-hour cooldown to avoid redundant checks.
4
+ Never blocks startup for more than 5 seconds. Logs errors and continues on failure.
5
+
6
+ This is separate from plugins/update.py which handles MANUAL updates with rollback.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import re
12
+ import subprocess
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+
17
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
18
+ DATA_DIR = NEXO_HOME / "data"
19
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
20
+
21
+ # Repo root: go up from src/
22
+ SRC_DIR = Path(__file__).resolve().parent
23
+ REPO_DIR = SRC_DIR.parent
24
+
25
+ LAST_CHECK_FILE = DATA_DIR / "auto_update_last_check.json"
26
+ MIGRATION_VERSION_FILE = DATA_DIR / "migration_version"
27
+ CLAUDE_MD_VERSION_FILE = DATA_DIR / "claude_md_version.txt"
28
+ MIGRATIONS_DIR = REPO_DIR / "migrations"
29
+ TEMPLATE_FILE = REPO_DIR / "templates" / "CLAUDE.md.template"
30
+
31
+ CHECK_COOLDOWN_SECONDS = 3600 # 1 hour
32
+ GIT_TIMEOUT_SECONDS = 4 # stay well under the 5s total budget
33
+
34
+
35
+ def _log(msg: str):
36
+ """Log to stderr with prefix."""
37
+ print(f"[NEXO auto-update] {msg}", file=sys.stderr)
38
+
39
+
40
+ def _read_last_check() -> dict:
41
+ """Read last check state from disk."""
42
+ try:
43
+ if LAST_CHECK_FILE.exists():
44
+ return json.loads(LAST_CHECK_FILE.read_text())
45
+ except Exception:
46
+ pass
47
+ return {}
48
+
49
+
50
+ def _write_last_check(data: dict):
51
+ """Persist last check state."""
52
+ try:
53
+ LAST_CHECK_FILE.write_text(json.dumps(data))
54
+ except Exception as e:
55
+ _log(f"Failed to write last-check file: {e}")
56
+
57
+
58
+ def _is_git_repo() -> bool:
59
+ """Check if REPO_DIR is inside a git repository."""
60
+ try:
61
+ result = subprocess.run(
62
+ ["git", "rev-parse", "--is-inside-work-tree"],
63
+ cwd=str(REPO_DIR),
64
+ capture_output=True,
65
+ text=True,
66
+ timeout=GIT_TIMEOUT_SECONDS,
67
+ )
68
+ return result.returncode == 0 and result.stdout.strip() == "true"
69
+ except Exception:
70
+ return False
71
+
72
+
73
+ def _git(*args) -> tuple[int, str, str]:
74
+ """Run a git command in REPO_DIR. Returns (returncode, stdout, stderr)."""
75
+ result = subprocess.run(
76
+ ["git"] + list(args),
77
+ cwd=str(REPO_DIR),
78
+ capture_output=True,
79
+ text=True,
80
+ timeout=GIT_TIMEOUT_SECONDS,
81
+ )
82
+ return result.returncode, result.stdout.strip(), result.stderr.strip()
83
+
84
+
85
+ def _read_package_version() -> str:
86
+ """Read version from package.json."""
87
+ try:
88
+ pkg = REPO_DIR / "package.json"
89
+ if pkg.exists():
90
+ return json.loads(pkg.read_text()).get("version", "unknown")
91
+ except Exception:
92
+ pass
93
+ return "unknown"
94
+
95
+
96
+ # ── Git-based auto-update ────────────────────────────────────────────
97
+
98
+ def _check_git_updates() -> str | None:
99
+ """Fetch remote, compare HEAD, pull if behind. Returns status message or None."""
100
+ # Fetch (allow it to fail silently on network issues)
101
+ rc, _, fetch_err = _git("fetch", "--quiet")
102
+ if rc != 0:
103
+ _log(f"git fetch failed (network?): {fetch_err}")
104
+ return None # Can't check, skip silently
105
+
106
+ # Compare local HEAD vs remote tracking branch
107
+ rc, local_head, _ = _git("rev-parse", "HEAD")
108
+ if rc != 0:
109
+ return None
110
+ rc, remote_head, _ = _git("rev-parse", "@{u}")
111
+ if rc != 0:
112
+ # No upstream configured — skip
113
+ return None
114
+
115
+ if local_head == remote_head:
116
+ return None # Already up to date
117
+
118
+ # Check if we're behind (remote has commits we don't)
119
+ rc, merge_base, _ = _git("merge-base", "HEAD", "@{u}")
120
+ if rc != 0:
121
+ return None
122
+
123
+ if merge_base == remote_head:
124
+ # Local is AHEAD — don't pull
125
+ return None
126
+ if merge_base != local_head and merge_base != remote_head:
127
+ # Diverged — don't auto-pull, too risky
128
+ _log("Local and remote have diverged. Skipping auto-pull.")
129
+ return "diverged"
130
+
131
+ # We're behind — safe to fast-forward pull
132
+ old_version = _read_package_version()
133
+ rc, pull_out, pull_err = _git("pull", "--ff-only")
134
+ if rc != 0:
135
+ _log(f"git pull --ff-only failed: {pull_err}")
136
+ return None # Don't break anything
137
+
138
+ new_version = _read_package_version()
139
+
140
+ # Run DB migrations after pull
141
+ _run_db_migrations()
142
+
143
+ msg = f"Auto-updated: {old_version} -> {new_version}" if old_version != new_version else f"Auto-updated (v{new_version}, new commits)"
144
+ _log(msg)
145
+ return msg
146
+
147
+
148
+ def _run_db_migrations():
149
+ """Run NEXO's DB schema migrations (from db._schema) after a pull."""
150
+ try:
151
+ from db._schema import run_migrations
152
+ from db._core import get_db
153
+ conn = get_db()
154
+ applied = run_migrations(conn)
155
+ if applied > 0:
156
+ _log(f"Applied {applied} DB migration(s)")
157
+ except Exception as e:
158
+ _log(f"DB migration error (continuing): {e}")
159
+
160
+
161
+ # ── npm version check (notify only) ─────────────────────────────────
162
+
163
+ def _check_npm_version() -> str | None:
164
+ """For non-git installs: check npm registry for a newer version. Returns notification or None."""
165
+ current = _read_package_version()
166
+ if current == "unknown":
167
+ return None
168
+
169
+ pkg_name = "nexo-brain"
170
+ try:
171
+ pkg = REPO_DIR / "package.json"
172
+ if pkg.exists():
173
+ data = json.loads(pkg.read_text())
174
+ pkg_name = data.get("name", pkg_name)
175
+ except Exception:
176
+ pass
177
+
178
+ try:
179
+ result = subprocess.run(
180
+ ["npm", "view", pkg_name, "version"],
181
+ capture_output=True,
182
+ text=True,
183
+ timeout=GIT_TIMEOUT_SECONDS,
184
+ )
185
+ if result.returncode != 0:
186
+ return None
187
+ latest = result.stdout.strip()
188
+ if not latest:
189
+ return None
190
+ if latest != current and not current.endswith(latest):
191
+ return f"NEXO update available: {current} -> {latest}. Run: npm update -g {pkg_name}"
192
+ except Exception:
193
+ pass
194
+ return None
195
+
196
+
197
+ # ── File-based migrations (migrations/ directory) ────────────────────
198
+
199
+ def _get_applied_migration_version() -> int:
200
+ """Read the last applied file-migration version from disk."""
201
+ try:
202
+ if MIGRATION_VERSION_FILE.exists():
203
+ return int(MIGRATION_VERSION_FILE.read_text().strip())
204
+ except (ValueError, OSError):
205
+ pass
206
+ return 0
207
+
208
+
209
+ def _set_migration_version(version: int):
210
+ """Write the current file-migration version to disk."""
211
+ try:
212
+ MIGRATION_VERSION_FILE.write_text(str(version))
213
+ except Exception as e:
214
+ _log(f"Failed to write migration version: {e}")
215
+
216
+
217
+ def _discover_migrations() -> list[tuple[int, Path]]:
218
+ """Find numbered migration files in migrations/ directory.
219
+
220
+ Expected naming: NNN_description.ext where ext is .sql, .py, or .sh
221
+ Example: 001_add_index.sql, 002_backfill_data.py, 003_cleanup.sh
222
+ """
223
+ if not MIGRATIONS_DIR.is_dir():
224
+ return []
225
+
226
+ migrations = []
227
+ for f in MIGRATIONS_DIR.iterdir():
228
+ if f.is_file() and f.suffix in (".sql", ".py", ".sh"):
229
+ # Extract leading number from filename
230
+ parts = f.stem.split("_", 1)
231
+ if parts and parts[0].isdigit():
232
+ migrations.append((int(parts[0]), f))
233
+
234
+ migrations.sort(key=lambda x: x[0])
235
+ return migrations
236
+
237
+
238
+ def _run_file_migration(path: Path) -> tuple[bool, str]:
239
+ """Execute a single migration file. Returns (success, message)."""
240
+ ext = path.suffix
241
+
242
+ try:
243
+ if ext == ".sql":
244
+ sql = path.read_text()
245
+ from db._core import get_db
246
+ conn = get_db()
247
+ conn.executescript(sql)
248
+ conn.commit()
249
+ return True, "OK"
250
+
251
+ elif ext == ".py":
252
+ result = subprocess.run(
253
+ [sys.executable, str(path)],
254
+ cwd=str(SRC_DIR),
255
+ capture_output=True,
256
+ text=True,
257
+ timeout=30,
258
+ env={**os.environ, "NEXO_HOME": str(NEXO_HOME)},
259
+ )
260
+ if result.returncode != 0:
261
+ return False, result.stderr or result.stdout or "non-zero exit"
262
+ return True, "OK"
263
+
264
+ elif ext == ".sh":
265
+ result = subprocess.run(
266
+ ["bash", str(path)],
267
+ cwd=str(REPO_DIR),
268
+ capture_output=True,
269
+ text=True,
270
+ timeout=30,
271
+ env={**os.environ, "NEXO_HOME": str(NEXO_HOME)},
272
+ )
273
+ if result.returncode != 0:
274
+ return False, result.stderr or result.stdout or "non-zero exit"
275
+ return True, "OK"
276
+
277
+ else:
278
+ return False, f"unknown extension: {ext}"
279
+
280
+ except Exception as e:
281
+ return False, str(e)
282
+
283
+
284
+ def run_file_migrations() -> list[dict]:
285
+ """Run any pending file-based migrations from the migrations/ directory.
286
+
287
+ Returns list of results: [{"version": N, "file": "...", "status": "ok"|"failed", "message": "..."}]
288
+ """
289
+ current_version = _get_applied_migration_version()
290
+ migrations = _discover_migrations()
291
+ results = []
292
+
293
+ for version, path in migrations:
294
+ if version <= current_version:
295
+ continue
296
+
297
+ success, message = _run_file_migration(path)
298
+
299
+ if success:
300
+ _set_migration_version(version)
301
+ results.append({
302
+ "version": version,
303
+ "file": path.name,
304
+ "status": "ok",
305
+ "message": message,
306
+ })
307
+ _log(f"Migration {path.name}: OK")
308
+ else:
309
+ results.append({
310
+ "version": version,
311
+ "file": path.name,
312
+ "status": "failed",
313
+ "message": message,
314
+ })
315
+ _log(f"Migration {path.name}: FAILED — {message}")
316
+ # Don't advance version past a failure, but continue trying others
317
+ # so independent migrations still run. Version stays at last success.
318
+
319
+ return results
320
+
321
+
322
+ # ── CLAUDE.md version-tracked migration ─────────────────────────────
323
+
324
+
325
+ def _read_template_version() -> str | None:
326
+ """Extract version from the <!-- nexo-claude-md-version: X.Y.Z --> comment in the template."""
327
+ if not TEMPLATE_FILE.exists():
328
+ return None
329
+ first_line = TEMPLATE_FILE.read_text().split("\n", 1)[0]
330
+ m = re.search(r"nexo-claude-md-version:\s*([\d.]+)", first_line)
331
+ return m.group(1) if m else None
332
+
333
+
334
+ def _read_installed_claude_md_version() -> str | None:
335
+ """Read the CLAUDE.md version currently installed for this user."""
336
+ try:
337
+ if CLAUDE_MD_VERSION_FILE.exists():
338
+ return CLAUDE_MD_VERSION_FILE.read_text().strip()
339
+ except OSError:
340
+ pass
341
+ return None
342
+
343
+
344
+ def _write_installed_claude_md_version(version: str):
345
+ """Persist the installed CLAUDE.md version."""
346
+ try:
347
+ CLAUDE_MD_VERSION_FILE.write_text(version)
348
+ except Exception as e:
349
+ _log(f"Failed to write CLAUDE.md version file: {e}")
350
+
351
+
352
+ def _find_user_claude_md() -> Path | None:
353
+ """Locate the user's CLAUDE.md (typically ~/.claude/CLAUDE.md)."""
354
+ candidate = Path.home() / ".claude" / "CLAUDE.md"
355
+ if candidate.exists():
356
+ return candidate
357
+ # Fallback: check NEXO_HOME
358
+ candidate2 = NEXO_HOME / "CLAUDE.md"
359
+ if candidate2.exists():
360
+ return candidate2
361
+ return None
362
+
363
+
364
+ def _resolve_placeholders(template_text: str) -> str:
365
+ """Fill {{NAME}} and {{NEXO_HOME}} from the user's existing CLAUDE.md or config."""
366
+ # Try to read operator name from version.json
367
+ name = "NEXO"
368
+ try:
369
+ vf = NEXO_HOME / "version.json"
370
+ if vf.exists():
371
+ data = json.loads(vf.read_text())
372
+ name = data.get("operator_name", name)
373
+ except Exception:
374
+ pass
375
+
376
+ return (
377
+ template_text
378
+ .replace("{{NAME}}", name)
379
+ .replace("{{NEXO_HOME}}", str(NEXO_HOME))
380
+ )
381
+
382
+
383
+ def _extract_section(text: str, section_id: str) -> str | None:
384
+ """Extract content between <!-- nexo:start:ID --> and <!-- nexo:end:ID --> markers (inclusive)."""
385
+ pattern = re.compile(
386
+ rf"(<!-- nexo:start:{re.escape(section_id)} -->.*?<!-- nexo:end:{re.escape(section_id)} -->)",
387
+ re.DOTALL,
388
+ )
389
+ m = pattern.search(text)
390
+ return m.group(1) if m else None
391
+
392
+
393
+ def _list_section_ids(text: str) -> list[str]:
394
+ """Return all section IDs found in the text, in order."""
395
+ return re.findall(r"<!-- nexo:start:(\w+) -->", text)
396
+
397
+
398
+ def _migrate_claude_md() -> str | None:
399
+ """Compare template version vs installed version. If newer, update core sections in user's CLAUDE.md.
400
+
401
+ Returns a status message or None if no migration was needed.
402
+ """
403
+ template_version = _read_template_version()
404
+ if not template_version:
405
+ return None
406
+
407
+ installed_version = _read_installed_claude_md_version()
408
+ if installed_version == template_version:
409
+ return None # Already up to date
410
+
411
+ user_md_path = _find_user_claude_md()
412
+ if not user_md_path:
413
+ _log("CLAUDE.md migration: no user CLAUDE.md found, skipping")
414
+ return None
415
+
416
+ # Read both files
417
+ user_md = user_md_path.read_text()
418
+ template_raw = TEMPLATE_FILE.read_text()
419
+ template_resolved = _resolve_placeholders(template_raw)
420
+
421
+ # Get all section IDs from the template
422
+ section_ids = _list_section_ids(template_resolved)
423
+ if not section_ids:
424
+ _log("CLAUDE.md migration: no section markers in template, skipping")
425
+ _write_installed_claude_md_version(template_version)
426
+ return None
427
+
428
+ updated = user_md
429
+ sections_replaced = 0
430
+ sections_added = 0
431
+
432
+ for sid in section_ids:
433
+ new_section = _extract_section(template_resolved, sid)
434
+ if not new_section:
435
+ continue
436
+
437
+ old_section = _extract_section(updated, sid)
438
+ if old_section:
439
+ if old_section != new_section:
440
+ updated = updated.replace(old_section, new_section)
441
+ sections_replaced += 1
442
+ else:
443
+ # Section doesn't exist in user's file — append before the end
444
+ # (new sections added by template updates)
445
+ updated = updated.rstrip() + "\n\n" + new_section + "\n"
446
+ sections_added += 1
447
+
448
+ # Update the version comment if present in user's file
449
+ updated = re.sub(
450
+ r"<!-- nexo-claude-md-version: [\d.]+ -->",
451
+ f"<!-- nexo-claude-md-version: {template_version} -->",
452
+ updated,
453
+ )
454
+ # If no version comment existed, add one at the top
455
+ if "nexo-claude-md-version:" not in updated:
456
+ updated = f"<!-- nexo-claude-md-version: {template_version} -->\n" + updated
457
+
458
+ if sections_replaced > 0 or sections_added > 0:
459
+ # Backup before writing
460
+ backup_path = user_md_path.with_suffix(".md.bak")
461
+ try:
462
+ backup_path.write_text(user_md)
463
+ except Exception:
464
+ pass # Non-critical
465
+
466
+ user_md_path.write_text(updated)
467
+
468
+ _write_installed_claude_md_version(template_version)
469
+
470
+ if sections_replaced == 0 and sections_added == 0:
471
+ return f"CLAUDE.md v{template_version}: already current (version file updated)"
472
+
473
+ msg = f"CLAUDE.md migrated to v{template_version}: {sections_replaced} section(s) updated, {sections_added} new section(s) added"
474
+ _log(msg)
475
+ return msg
476
+
477
+
478
+ # ── Main entry point ─────────────────────────────────────────────────
479
+
480
+ def auto_update_check() -> dict:
481
+ """Run the full auto-update check at server startup.
482
+
483
+ NEVER raises an exception — always returns a dict.
484
+
485
+ Phase 1 (local, safe, no network):
486
+ - DB schema migrations
487
+ - File-based migrations
488
+ - CLAUDE.md version migration
489
+
490
+ Phase 2 (network, wrapped in try/except):
491
+ - git fetch/pull (if git repo)
492
+ - npm version check (if non-git install)
493
+
494
+ Returns a dict with:
495
+ - checked: bool — whether a network check was actually performed
496
+ - git_update: str|None — git update status message
497
+ - npm_notice: str|None — npm upgrade notice for non-git installs
498
+ - claude_md_update: str|None — CLAUDE.md migration status
499
+ - migrations: list — file-based migration results
500
+ - db_migrations: int — number of DB schema migrations applied
501
+ - skipped_reason: str|None — why the network check was skipped (cooldown, etc.)
502
+ - error: str|None — error message if something failed (informational only)
503
+ """
504
+ result = {
505
+ "checked": False,
506
+ "git_update": None,
507
+ "npm_notice": None,
508
+ "claude_md_update": None,
509
+ "migrations": [],
510
+ "db_migrations": 0,
511
+ "skipped_reason": None,
512
+ "error": None,
513
+ }
514
+
515
+ # ── Read auto_update flag from schedule.json ────────────────────
516
+ auto_update_enabled = True
517
+ try:
518
+ schedule_file = NEXO_HOME / "config" / "schedule.json"
519
+ if schedule_file.exists():
520
+ schedule_data = json.loads(schedule_file.read_text())
521
+ auto_update_enabled = schedule_data.get("auto_update", True)
522
+ except Exception:
523
+ pass # Default to enabled on any read error
524
+
525
+ # ── Phase 1: Local migrations (safe, no network) ────────────────
526
+ # These ALWAYS run, regardless of cooldown, network state, or auto_update flag.
527
+
528
+ # DB schema migrations
529
+ try:
530
+ _run_db_migrations()
531
+ except Exception as e:
532
+ _log(f"DB migration error (continuing): {e}")
533
+
534
+ # File-based migrations
535
+ try:
536
+ result["migrations"] = run_file_migrations()
537
+ except Exception as e:
538
+ _log(f"File migration runner error: {e}")
539
+
540
+ # Backfill evolution-objective.json for existing installs
541
+ try:
542
+ evo_obj_path = NEXO_HOME / "brain" / "evolution-objective.json"
543
+ if not evo_obj_path.exists():
544
+ (NEXO_HOME / "brain").mkdir(parents=True, exist_ok=True)
545
+ default_objective = {
546
+ "objective": "Improve operational excellence and reduce repeated errors",
547
+ "focus_areas": ["error_prevention", "proactivity", "memory_quality"],
548
+ "evolution_enabled": True,
549
+ "evolution_mode": "review",
550
+ "dimensions": {
551
+ "episodic_memory": {"current": 0, "target": 90},
552
+ "autonomy": {"current": 0, "target": 80},
553
+ "proactivity": {"current": 0, "target": 70},
554
+ "self_improvement": {"current": 0, "target": 60},
555
+ "agi": {"current": 0, "target": 20},
556
+ },
557
+ "total_evolutions": 0,
558
+ "consecutive_failures": 0,
559
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
560
+ }
561
+ evo_obj_path.write_text(json.dumps(default_objective, indent=2))
562
+ _log("Backfilled evolution-objective.json for existing install")
563
+ except Exception as e:
564
+ _log(f"evolution-objective.json backfill error: {e}")
565
+
566
+ # Backfill NEXO_HOME/scripts/ for existing installs
567
+ try:
568
+ scripts_dest = NEXO_HOME / "scripts"
569
+ # Deduce NEXO_CODE: env var first, then from __file__ (auto_update.py is in src/)
570
+ nexo_code = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
571
+ scripts_src = nexo_code / "scripts" if (nexo_code / "scripts").is_dir() else None
572
+ if scripts_src and not scripts_dest.is_dir():
573
+ import shutil
574
+ scripts_dest.mkdir(parents=True, exist_ok=True)
575
+ for f in scripts_src.iterdir():
576
+ if f.name.startswith('.') or f.name == '__pycache__':
577
+ continue
578
+ dest = scripts_dest / f.name
579
+ if f.is_file() and not dest.exists():
580
+ shutil.copy2(str(f), str(dest))
581
+ _log("Backfilled NEXO_HOME/scripts/ from NEXO_CODE for existing install")
582
+ except Exception as e:
583
+ _log(f"scripts backfill error: {e}")
584
+
585
+ # CLAUDE.md version migration
586
+ try:
587
+ result["claude_md_update"] = _migrate_claude_md()
588
+ except Exception as e:
589
+ _log(f"CLAUDE.md migration error: {e}")
590
+
591
+ # ── Phase 2: Network operations (wrapped, never fatal) ──────────
592
+ # Skip entirely if auto_update is disabled in schedule.json
593
+ if not auto_update_enabled:
594
+ result["skipped_reason"] = "auto_update disabled in schedule.json"
595
+ _log("Network updates disabled (auto_update: false in schedule.json)")
596
+ return result
597
+
598
+ # Check cooldown for git/npm checks
599
+ try:
600
+ last_check = _read_last_check()
601
+ last_ts = last_check.get("timestamp", 0)
602
+ now = time.time()
603
+
604
+ if now - last_ts < CHECK_COOLDOWN_SECONDS:
605
+ result["skipped_reason"] = "cooldown"
606
+ return result
607
+
608
+ result["checked"] = True
609
+
610
+ is_git = _is_git_repo()
611
+
612
+ if is_git:
613
+ result["git_update"] = _check_git_updates()
614
+ else:
615
+ # Non-git install — check npm for newer version
616
+ version_json = REPO_DIR / "version.json"
617
+ pkg_json = REPO_DIR / "package.json"
618
+ if version_json.exists() or pkg_json.exists():
619
+ result["npm_notice"] = _check_npm_version()
620
+
621
+ # Save timestamp
622
+ _write_last_check({
623
+ "timestamp": now,
624
+ "is_git": is_git,
625
+ "git_update": result["git_update"],
626
+ "npm_notice": result["npm_notice"],
627
+ })
628
+
629
+ except Exception as e:
630
+ error_msg = f"Update check failed: {e}. Running current version."
631
+ _log(error_msg)
632
+ result["error"] = error_msg
633
+
634
+ return result
@@ -93,6 +93,27 @@ def _read_package_version() -> str:
93
93
  return "unknown"
94
94
 
95
95
 
96
+ # ── Hook sync ────────────────────────────────────────────────────────
97
+
98
+ def _sync_hooks():
99
+ """Copy hook scripts from src/hooks/ to NEXO_HOME/hooks/ after a git pull."""
100
+ import shutil
101
+ hooks_src = SRC_DIR / "hooks"
102
+ hooks_dest = NEXO_HOME / "hooks"
103
+ if not hooks_src.is_dir():
104
+ return
105
+ hooks_dest.mkdir(parents=True, exist_ok=True)
106
+ synced = 0
107
+ for f in hooks_src.iterdir():
108
+ if f.is_file() and f.suffix == ".sh":
109
+ dest = hooks_dest / f.name
110
+ shutil.copy2(str(f), str(dest))
111
+ os.chmod(str(dest), 0o755)
112
+ synced += 1
113
+ if synced:
114
+ _log(f"Synced {synced} hook(s) to {hooks_dest}")
115
+
116
+
96
117
  # ── Git-based auto-update ────────────────────────────────────────────
97
118
 
98
119
  def _check_git_updates() -> str | None:
@@ -140,6 +161,10 @@ def _check_git_updates() -> str | None:
140
161
  # Run DB migrations after pull
141
162
  _run_db_migrations()
142
163
 
164
+ # Sync hooks to NEXO_HOME (nexo-brain.js copies them on install,
165
+ # but auto-update via git pull bypasses nexo-brain.js)
166
+ _sync_hooks()
167
+
143
168
  msg = f"Auto-updated: {old_version} -> {new_version}" if old_version != new_version else f"Auto-updated (v{new_version}, new commits)"
144
169
  _log(msg)
145
170
  return msg