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,217 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ NEXO Cron Sync — Synchronize crons/manifest.json with system LaunchAgents (macOS).
4
+
5
+ Called by nexo_update after pulling new code. Ensures:
6
+ - New crons in manifest → installed
7
+ - Removed crons from manifest → unloaded + deleted
8
+ - Changed schedule/interval → plist updated + reloaded
9
+ - Personal (non-core) crons → left untouched
10
+
11
+ Usage:
12
+ python3 crons/sync.py [--dry-run]
13
+
14
+ Environment:
15
+ NEXO_HOME — root of NEXO installation
16
+ NEXO_CODE — path to NEXO source (defaults to script parent's parent)
17
+ """
18
+
19
+ import json
20
+ import os
21
+ import platform
22
+ import plistlib
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
28
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
29
+ MANIFEST = Path(__file__).resolve().parent / "manifest.json"
30
+ LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
31
+ LABEL_PREFIX = "com.nexo."
32
+ LOG_DIR = NEXO_HOME / "logs"
33
+
34
+
35
+ def log(msg: str):
36
+ print(f"[cron-sync] {msg}", flush=True)
37
+
38
+
39
+ def load_manifest() -> list[dict]:
40
+ with open(MANIFEST) as f:
41
+ data = json.load(f)
42
+ return data.get("crons", [])
43
+
44
+
45
+ def build_plist(cron: dict) -> dict:
46
+ """Build a macOS LaunchAgent plist dict from a manifest entry."""
47
+ cron_id = cron["id"]
48
+ label = f"{LABEL_PREFIX}{cron_id}"
49
+ script_path = str(NEXO_CODE / cron["script"])
50
+ script_type = cron.get("type", "python")
51
+
52
+ if script_type == "shell":
53
+ program_args = ["/bin/bash", script_path]
54
+ else:
55
+ # Find python3
56
+ python_candidates = [
57
+ "/opt/homebrew/bin/python3",
58
+ "/usr/local/bin/python3",
59
+ "/Library/Frameworks/Python.framework/Versions/3.12/bin/python3",
60
+ "/usr/bin/python3",
61
+ ]
62
+ python_bin = "python3"
63
+ for p in python_candidates:
64
+ if Path(p).exists():
65
+ python_bin = p
66
+ break
67
+ program_args = [python_bin, script_path]
68
+
69
+ plist = {
70
+ "Label": label,
71
+ "ProgramArguments": program_args,
72
+ "StandardOutPath": str(LOG_DIR / f"{cron_id}-stdout.log"),
73
+ "StandardErrorPath": str(LOG_DIR / f"{cron_id}-stderr.log"),
74
+ "EnvironmentVariables": {
75
+ "PATH": "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:"
76
+ + str(Path.home() / ".local" / "bin") + ":"
77
+ + str(Path.home() / ".nvm/versions/node/v22.14.0/bin") + ":"
78
+ + "/Library/Frameworks/Python.framework/Versions/3.12/bin",
79
+ "HOME": str(Path.home()),
80
+ "NEXO_HOME": str(NEXO_HOME),
81
+ "NEXO_CODE": str(NEXO_CODE),
82
+ "PYTHONUNBUFFERED": "1",
83
+ },
84
+ }
85
+
86
+ # Schedule
87
+ if "interval_seconds" in cron:
88
+ plist["StartInterval"] = cron["interval_seconds"]
89
+ elif "schedule" in cron:
90
+ cal = {}
91
+ s = cron["schedule"]
92
+ if "hour" in s:
93
+ cal["Hour"] = s["hour"]
94
+ if "minute" in s:
95
+ cal["Minute"] = s["minute"]
96
+ if "weekday" in s:
97
+ cal["Weekday"] = s["weekday"]
98
+ plist["StartCalendarInterval"] = cal
99
+
100
+ return plist
101
+
102
+
103
+ def get_installed_nexo_crons() -> dict[str, Path]:
104
+ """Return dict of cron_id → plist_path for installed NEXO crons."""
105
+ installed = {}
106
+ if not LAUNCH_AGENTS_DIR.exists():
107
+ return installed
108
+ for f in LAUNCH_AGENTS_DIR.glob(f"{LABEL_PREFIX}*.plist"):
109
+ cron_id = f.stem.replace(LABEL_PREFIX, "")
110
+ installed[cron_id] = f
111
+ return installed
112
+
113
+
114
+ def plist_needs_update(existing_path: Path, new_plist: dict) -> bool:
115
+ """Check if the installed plist differs from what we'd generate."""
116
+ try:
117
+ with open(existing_path, "rb") as f:
118
+ existing = plistlib.load(f)
119
+ except Exception:
120
+ return True
121
+
122
+ # Compare key fields
123
+ if existing.get("ProgramArguments") != new_plist.get("ProgramArguments"):
124
+ return True
125
+ if existing.get("StartInterval") != new_plist.get("StartInterval"):
126
+ return True
127
+ if existing.get("StartCalendarInterval") != new_plist.get("StartCalendarInterval"):
128
+ return True
129
+ return False
130
+
131
+
132
+ def install_plist(label: str, plist: dict, plist_path: Path, dry_run: bool):
133
+ """Write plist and load it."""
134
+ if dry_run:
135
+ log(f" DRY-RUN: would install {plist_path.name}")
136
+ return
137
+
138
+ # Unload if already loaded
139
+ subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
140
+
141
+ with open(plist_path, "wb") as f:
142
+ plistlib.dump(plist, f)
143
+
144
+ subprocess.run(["launchctl", "load", str(plist_path)], capture_output=True)
145
+ log(f" Installed + loaded: {plist_path.name}")
146
+
147
+
148
+ def unload_plist(plist_path: Path, dry_run: bool):
149
+ """Unload and remove a plist."""
150
+ if dry_run:
151
+ log(f" DRY-RUN: would remove {plist_path.name}")
152
+ return
153
+
154
+ subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True)
155
+ plist_path.unlink(missing_ok=True)
156
+ log(f" Removed: {plist_path.name}")
157
+
158
+
159
+ def sync(dry_run: bool = False):
160
+ if platform.system() != "Darwin":
161
+ log("Not macOS — cron sync only supports LaunchAgents. Skipping.")
162
+ return
163
+
164
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
165
+ LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
166
+
167
+ manifest_crons = load_manifest()
168
+ manifest_ids = {c["id"] for c in manifest_crons}
169
+ installed = get_installed_nexo_crons()
170
+
171
+ log(f"Manifest: {len(manifest_crons)} core crons")
172
+ log(f"Installed: {len(installed)} NEXO crons")
173
+
174
+ # 1. Install or update crons from manifest
175
+ for cron in manifest_crons:
176
+ cron_id = cron["id"]
177
+ label = f"{LABEL_PREFIX}{cron_id}"
178
+ plist_path = LAUNCH_AGENTS_DIR / f"{label}.plist"
179
+ new_plist = build_plist(cron)
180
+
181
+ if cron_id not in installed:
182
+ log(f" NEW: {cron_id}")
183
+ install_plist(label, new_plist, plist_path, dry_run)
184
+ elif plist_needs_update(installed[cron_id], new_plist):
185
+ log(f" UPDATE: {cron_id}")
186
+ install_plist(label, new_plist, plist_path, dry_run)
187
+ else:
188
+ log(f" OK: {cron_id}")
189
+
190
+ # 2. Remove crons that are in installed but NOT in manifest and ARE core
191
+ # (personal crons like shopify-backup, email-monitor are left alone)
192
+ for cron_id, plist_path in installed.items():
193
+ if cron_id not in manifest_ids:
194
+ # Check if this was previously a core cron by reading the plist
195
+ # If it points to NEXO_CODE scripts → it's core, safe to remove
196
+ try:
197
+ with open(plist_path, "rb") as f:
198
+ existing = plistlib.load(f)
199
+ args = existing.get("ProgramArguments", [])
200
+ is_core = any(str(NEXO_CODE) in str(a) for a in args)
201
+ except Exception:
202
+ is_core = False
203
+
204
+ if is_core:
205
+ log(f" REMOVE (no longer in manifest): {cron_id}")
206
+ unload_plist(plist_path, dry_run)
207
+ else:
208
+ log(f" SKIP (personal): {cron_id}")
209
+
210
+ log("Sync complete.")
211
+
212
+
213
+ if __name__ == "__main__":
214
+ dry_run = "--dry-run" in sys.argv
215
+ if dry_run:
216
+ log("DRY RUN MODE — no changes will be made")
217
+ sync(dry_run=dry_run)
package/src/crons/sync.py CHANGED
@@ -42,15 +42,56 @@ def load_manifest() -> list[dict]:
42
42
  return data.get("crons", [])
43
43
 
44
44
 
45
+ def _copy_script_to_nexo_home(src: Path) -> Path:
46
+ """Copy a script from NEXO_CODE to NEXO_HOME/scripts/ for Sandbox compatibility.
47
+
48
+ macOS Sandbox blocks LaunchAgents from executing scripts in ~/Documents/.
49
+ We copy scripts to NEXO_HOME/scripts/ which is typically ~/claude/scripts/
50
+ or ~/.nexo/scripts/ — both outside the Sandbox restricted paths.
51
+ """
52
+ dest_dir = NEXO_HOME / "scripts"
53
+ dest_dir.mkdir(parents=True, exist_ok=True)
54
+
55
+ if src.is_dir():
56
+ import shutil
57
+ dest = dest_dir / src.name
58
+ if dest.exists():
59
+ shutil.rmtree(dest)
60
+ shutil.copytree(src, dest)
61
+ return dest
62
+ else:
63
+ dest = dest_dir / src.name
64
+ import shutil
65
+ shutil.copy2(src, dest)
66
+ dest.chmod(0o755)
67
+ return dest
68
+
69
+
45
70
  def build_plist(cron: dict) -> dict:
46
71
  """Build a macOS LaunchAgent plist dict from a manifest entry."""
47
72
  cron_id = cron["id"]
48
73
  label = f"{LABEL_PREFIX}{cron_id}"
49
- script_path = str(NEXO_CODE / cron["script"])
74
+ script_src = NEXO_CODE / cron["script"]
50
75
  script_type = cron.get("type", "python")
51
76
 
77
+ # Copy scripts to NEXO_HOME/scripts/ to avoid macOS Sandbox restrictions
78
+ script_dest = _copy_script_to_nexo_home(script_src)
79
+ script_path = str(script_dest)
80
+
81
+ # Also copy the wrapper and any subdirectories (e.g., deep-sleep/)
82
+ wrapper_src = NEXO_CODE / "scripts" / "nexo-cron-wrapper.sh"
83
+ wrapper_dest = _copy_script_to_nexo_home(wrapper_src)
84
+ wrapper_path = str(wrapper_dest)
85
+
86
+ # Copy script subdirectories if they exist (e.g., deep-sleep/ for nexo-deep-sleep.sh)
87
+ script_name = script_src.stem # e.g., "nexo-deep-sleep"
88
+ subdir_name = script_name.replace("nexo-", "") # e.g., "deep-sleep"
89
+ subdir_src = NEXO_CODE / "scripts" / subdir_name
90
+ if subdir_src.is_dir():
91
+ _copy_script_to_nexo_home(subdir_src)
92
+
52
93
  if script_type == "shell":
53
- program_args = ["/bin/bash", script_path]
94
+ program_args = ["/bin/bash", wrapper_path, cron_id, "/bin/bash", script_path]
54
95
  else:
55
96
  # Find python3
56
97
  python_candidates = [
@@ -64,7 +105,7 @@ def build_plist(cron: dict) -> dict:
64
105
  if Path(p).exists():
65
106
  python_bin = p
66
107
  break
67
- program_args = [python_bin, script_path]
108
+ program_args = ["/bin/bash", wrapper_path, cron_id, python_bin, script_path]
68
109
 
69
110
  plist = {
70
111
  "Label": label,
@@ -84,7 +125,9 @@ def build_plist(cron: dict) -> dict:
84
125
  }
85
126
 
86
127
  # Schedule
87
- if "interval_seconds" in cron:
128
+ if cron.get("run_at_load"):
129
+ plist["RunAtLoad"] = True
130
+ elif "interval_seconds" in cron:
88
131
  plist["StartInterval"] = cron["interval_seconds"]
89
132
  elif "schedule" in cron:
90
133
  cal = {}
@@ -126,6 +169,8 @@ def plist_needs_update(existing_path: Path, new_plist: dict) -> bool:
126
169
  return True
127
170
  if existing.get("StartCalendarInterval") != new_plist.get("StartCalendarInterval"):
128
171
  return True
172
+ if existing.get("RunAtLoad") != new_plist.get("RunAtLoad"):
173
+ return True
129
174
  return False
130
175
 
131
176
 
@@ -157,8 +202,12 @@ def unload_plist(plist_path: Path, dry_run: bool):
157
202
 
158
203
 
159
204
  def sync(dry_run: bool = False):
160
- if platform.system() != "Darwin":
161
- log("Not macOS cron sync only supports LaunchAgents. Skipping.")
205
+ system = platform.system()
206
+ if system == "Linux":
207
+ sync_linux(dry_run)
208
+ return
209
+ if system != "Darwin":
210
+ log(f"Unsupported platform: {system}. Skipping.")
162
211
  return
163
212
 
164
213
  LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -210,6 +259,102 @@ def sync(dry_run: bool = False):
210
259
  log("Sync complete.")
211
260
 
212
261
 
262
+ def sync_linux(dry_run: bool = False):
263
+ """Sync manifest to systemd user timers (Linux)."""
264
+ unit_dir = Path.home() / ".config" / "systemd" / "user"
265
+ unit_dir.mkdir(parents=True, exist_ok=True)
266
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
267
+
268
+ manifest_crons = load_manifest()
269
+ wrapper_src = NEXO_CODE / "scripts" / "nexo-cron-wrapper.sh"
270
+ wrapper_dest = _copy_script_to_nexo_home(wrapper_src)
271
+
272
+ log(f"Manifest: {len(manifest_crons)} core crons")
273
+
274
+ python_bin = "/usr/bin/python3"
275
+ for p in ["/usr/bin/python3", "/usr/local/bin/python3"]:
276
+ if Path(p).exists():
277
+ python_bin = p
278
+ break
279
+
280
+ for cron in manifest_crons:
281
+ cron_id = cron["id"]
282
+ script_src = NEXO_CODE / cron["script"]
283
+ script_dest = _copy_script_to_nexo_home(script_src)
284
+ script_type = cron.get("type", "python")
285
+
286
+ # Copy subdirectories
287
+ subdir_name = script_src.stem.replace("nexo-", "")
288
+ subdir_src = NEXO_CODE / "scripts" / subdir_name
289
+ if subdir_src.is_dir():
290
+ _copy_script_to_nexo_home(subdir_src)
291
+
292
+ if script_type == "shell":
293
+ exec_cmd = f"/bin/bash {wrapper_dest} {cron_id} /bin/bash {script_dest}"
294
+ else:
295
+ exec_cmd = f"/bin/bash {wrapper_dest} {cron_id} {python_bin} {script_dest}"
296
+
297
+ service_path = unit_dir / f"nexo-{cron_id}.service"
298
+ timer_path = unit_dir / f"nexo-{cron_id}.timer"
299
+
300
+ service_content = f"""[Unit]
301
+ Description=NEXO: {cron.get('description', cron_id)}
302
+
303
+ [Service]
304
+ Type=oneshot
305
+ ExecStart={exec_cmd}
306
+ Environment=NEXO_HOME={NEXO_HOME}
307
+ Environment=NEXO_CODE={NEXO_CODE}
308
+ Environment=HOME={Path.home()}
309
+ """
310
+
311
+ if cron.get("run_at_load"):
312
+ timer_spec = "OnBootSec=0"
313
+ elif "interval_seconds" in cron:
314
+ timer_spec = f"OnUnitActiveSec={cron['interval_seconds']}s\nOnBootSec=60s"
315
+ elif "schedule" in cron:
316
+ s = cron["schedule"]
317
+ h, m = s.get("hour", 0), s.get("minute", 0)
318
+ if "weekday" in s:
319
+ days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
320
+ timer_spec = f"OnCalendar={days[s['weekday']]} *-*-* {h:02d}:{m:02d}:00"
321
+ else:
322
+ timer_spec = f"OnCalendar=*-*-* {h:02d}:{m:02d}:00"
323
+ else:
324
+ log(f" SKIP {cron_id}: no schedule or interval")
325
+ continue
326
+
327
+ timer_content = f"""[Unit]
328
+ Description=NEXO timer: {cron.get('description', cron_id)}
329
+
330
+ [Timer]
331
+ {timer_spec}
332
+ Persistent=true
333
+
334
+ [Install]
335
+ WantedBy=timers.target
336
+ """
337
+
338
+ if dry_run:
339
+ log(f" DRY-RUN: would install {cron_id}")
340
+ continue
341
+
342
+ service_path.write_text(service_content)
343
+ timer_path.write_text(timer_content)
344
+ log(f" Installed: {cron_id}")
345
+
346
+ if not dry_run:
347
+ subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
348
+ for cron in manifest_crons:
349
+ subprocess.run(
350
+ ["systemctl", "--user", "enable", "--now", f"nexo-{cron['id']}.timer"],
351
+ capture_output=True
352
+ )
353
+ log("systemd timers enabled.")
354
+
355
+ log("Sync complete.")
356
+
357
+
213
358
  if __name__ == "__main__":
214
359
  dry_run = "--dry-run" in sys.argv
215
360
  if dry_run:
File without changes