nexo-brain 2.2.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 (98) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -1
  3. package/scripts/migrate-v1.7-to-v1.8.py +2 -2
  4. package/scripts/nexo-preflight.sh +236 -0
  5. package/src/__pycache__/auto_close_sessions.cpython-314.pyc +0 -0
  6. package/src/__pycache__/auto_update.cpython-310.pyc +0 -0
  7. package/src/__pycache__/hnsw_index.cpython-314.pyc +0 -0
  8. package/src/__pycache__/plugin_loader.cpython-314.pyc +0 -0
  9. package/src/__pycache__/tools_reminders_crud.cpython-310.pyc +0 -0
  10. package/src/auto_update.py +25 -0
  11. package/src/cognitive/__pycache__/__init__.cpython-312.pyc +0 -0
  12. package/src/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  13. package/src/cognitive/__pycache__/_core.cpython-312.pyc +0 -0
  14. package/src/cognitive/__pycache__/_core.cpython-314.pyc +0 -0
  15. package/src/cognitive/__pycache__/_decay.cpython-312.pyc +0 -0
  16. package/src/cognitive/__pycache__/_decay.cpython-314.pyc +0 -0
  17. package/src/cognitive/__pycache__/_ingest.cpython-312.pyc +0 -0
  18. package/src/cognitive/__pycache__/_ingest.cpython-314.pyc +0 -0
  19. package/src/cognitive/__pycache__/_memory.cpython-312.pyc +0 -0
  20. package/src/cognitive/__pycache__/_memory.cpython-314.pyc +0 -0
  21. package/src/cognitive/__pycache__/_search.cpython-312.pyc +0 -0
  22. package/src/cognitive/__pycache__/_search.cpython-314.pyc +0 -0
  23. package/src/cognitive/__pycache__/_trust.cpython-310.pyc +0 -0
  24. package/src/cognitive/__pycache__/_trust.cpython-312.pyc +0 -0
  25. package/src/cognitive/__pycache__/_trust.cpython-314.pyc +0 -0
  26. package/src/crons/__pycache__/sync.cpython-314.pyc +0 -0
  27. package/src/crons/manifest.json +6 -13
  28. package/src/crons/sync.py +151 -6
  29. package/src/db/__init__.py +13 -0
  30. package/src/db/__pycache__/__init__.cpython-310.pyc +0 -0
  31. package/src/db/__pycache__/__init__.cpython-312.pyc +0 -0
  32. package/src/db/__pycache__/__init__.cpython-314.pyc +0 -0
  33. package/src/db/__pycache__/_cron_runs.cpython-310.pyc +0 -0
  34. package/src/db/__pycache__/_cron_runs.cpython-314.pyc +0 -0
  35. package/src/db/__pycache__/_episodic.cpython-310.pyc +0 -0
  36. package/src/db/__pycache__/_episodic.cpython-312.pyc +0 -0
  37. package/src/db/__pycache__/_episodic.cpython-314.pyc +0 -0
  38. package/src/db/__pycache__/_schema.cpython-310.pyc +0 -0
  39. package/src/db/__pycache__/_schema.cpython-312.pyc +0 -0
  40. package/src/db/__pycache__/_schema.cpython-314.pyc +0 -0
  41. package/src/db/__pycache__/_skills.cpython-310.pyc +0 -0
  42. package/src/db/__pycache__/_skills.cpython-312.pyc +0 -0
  43. package/src/db/__pycache__/_skills.cpython-314.pyc +0 -0
  44. package/src/db/_cron_runs.py +74 -0
  45. package/src/db/_episodic.py +40 -6
  46. package/src/db/_schema.py +64 -0
  47. package/src/db/_skills.py +514 -0
  48. package/src/hooks/session-stop.sh +13 -101
  49. package/src/plugins/__pycache__/__init__.cpython-314.pyc +0 -0
  50. package/src/plugins/__pycache__/adaptive_mode.cpython-314.pyc +0 -0
  51. package/src/plugins/__pycache__/episodic_memory.cpython-310.pyc +0 -0
  52. package/src/plugins/__pycache__/schedule.cpython-310.pyc +0 -0
  53. package/src/plugins/__pycache__/schedule.cpython-314.pyc +0 -0
  54. package/src/plugins/__pycache__/skills.cpython-310.pyc +0 -0
  55. package/src/plugins/__pycache__/skills.cpython-314.pyc +0 -0
  56. package/src/plugins/episodic_memory.py +5 -3
  57. package/src/plugins/schedule.py +212 -0
  58. package/src/plugins/skills.py +264 -0
  59. package/src/scripts/__pycache__/nexo-auto-update.cpython-314.pyc +0 -0
  60. package/src/scripts/__pycache__/nexo-catchup.cpython-314.pyc +0 -0
  61. package/src/scripts/__pycache__/nexo-cognitive-decay.cpython-314.pyc +0 -0
  62. package/src/scripts/__pycache__/nexo-daily-self-audit.cpython-314.pyc +0 -0
  63. package/src/scripts/__pycache__/nexo-evolution-run.cpython-314.pyc +0 -0
  64. package/src/scripts/__pycache__/nexo-followup-hygiene.cpython-314.pyc +0 -0
  65. package/src/scripts/__pycache__/nexo-immune.cpython-314.pyc +0 -0
  66. package/src/scripts/__pycache__/nexo-install.cpython-314.pyc +0 -0
  67. package/src/scripts/__pycache__/nexo-learning-housekeep.cpython-314.pyc +0 -0
  68. package/src/scripts/__pycache__/nexo-learning-validator.cpython-314.pyc +0 -0
  69. package/src/scripts/__pycache__/nexo-migrate.cpython-314.pyc +0 -0
  70. package/src/scripts/__pycache__/nexo-postmortem-consolidator.cpython-314.pyc +0 -0
  71. package/src/scripts/__pycache__/nexo-pre-commit.cpython-314.pyc +0 -0
  72. package/src/scripts/__pycache__/nexo-proactive-dashboard.cpython-314.pyc +0 -0
  73. package/src/scripts/__pycache__/nexo-reflection.cpython-314.pyc +0 -0
  74. package/src/scripts/__pycache__/nexo-runtime-preflight.cpython-314.pyc +0 -0
  75. package/src/scripts/__pycache__/nexo-send-email.cpython-314.pyc +0 -0
  76. package/src/scripts/__pycache__/nexo-send-reply.cpython-314.pyc +0 -0
  77. package/src/scripts/__pycache__/nexo-sleep.cpython-314.pyc +0 -0
  78. package/src/scripts/__pycache__/nexo-synthesis.cpython-314.pyc +0 -0
  79. package/src/scripts/__pycache__/nexo-watchdog-smoke.cpython-314.pyc +0 -0
  80. package/src/scripts/deep-sleep/apply_findings.py +110 -8
  81. package/src/scripts/deep-sleep/collect.py +33 -11
  82. package/src/scripts/deep-sleep/extract-prompt.md +38 -0
  83. package/src/scripts/deep-sleep/extract.py +80 -8
  84. package/src/scripts/deep-sleep/synthesize-prompt.md +29 -1
  85. package/src/scripts/deep-sleep/synthesize.py +3 -1
  86. package/src/scripts/nexo-catchup.py +65 -29
  87. package/src/scripts/nexo-cron-wrapper.sh +53 -0
  88. package/src/scripts/nexo-daily-self-audit.py +4 -2
  89. package/src/scripts/nexo-deep-sleep.sh +66 -77
  90. package/src/scripts/nexo-evolution-run.py +13 -0
  91. package/src/scripts/nexo-learning-housekeep.py +156 -1
  92. package/src/scripts/nexo-learning-validator.py +19 -0
  93. package/src/scripts/nexo-postmortem-consolidator.py +3 -2
  94. package/src/scripts/nexo-sleep.py +16 -11
  95. package/src/scripts/nexo-synthesis.py +46 -3
  96. package/src/scripts/nexo-watchdog.sh +72 -19
  97. package/src/server.py +5 -1
  98. package/src/scripts/nexo-github-monitor.py +0 -256
package/README.md CHANGED
@@ -283,13 +283,13 @@ NEXO Brain doesn't just respond — it runs 15 autonomous processes in the backg
283
283
  | **prevent-sleep** | Always (daemon) | Keeps machine awake for nocturnal processes (caffeinate/systemd-inhibit) |
284
284
  | **evolution** | Weekly (Sun) | Self-improvement proposals — NEXO suggests and applies enhancements |
285
285
  | **followup-hygiene** | Weekly (Sun) | Normalizes statuses, flags stale followups, cleans orphans |
286
+ | **learning-housekeep** | 03:15 daily | Dedup learnings, adjust weights by usage, process overdue reviews, reconcile decision outcomes |
286
287
  | **immune** | Every 30 min | Quarantine processing, memory promotion/rejection, synaptic pruning |
287
- | **synthesis** | Every 2 hours | Memory synthesis — discovers cross-memory patterns |
288
- | **backup** | Every hour | SQLite database backups |
289
- | **watchdog** | Every 5 min | Monitors services, LaunchAgents, and infrastructure health |
288
+ | **synthesis** | 06:00 daily | Memory synthesis — discovers cross-memory patterns |
289
+ | **watchdog** | Every 30 min | Monitors services, LaunchAgents, and infrastructure health |
290
290
  | **auto-close-sessions** | Every 5 min | Cleans stale sessions |
291
291
 
292
- All processes are defined in `src/crons/manifest.json` and auto-synced to your system by `nexo_update`. On macOS they run via LaunchAgents; on Linux via systemd user timers (or crontab fallback). Personal crons (your own scripts) are never touched by the sync. If your Mac was asleep during a scheduled process, the catch-up script re-runs everything in order when it wakes.
292
+ Core processes are defined in `src/crons/manifest.json` and auto-synced to your system by `nexo_update`. On macOS they run via LaunchAgents; on Linux via systemd user timers. `tcc-approve`, `prevent-sleep`, and `backup` are platform/personal helpers — not in the manifest but listed above for completeness. Personal crons (your own scripts) are never touched by the sync. If your Mac was asleep during a scheduled process, the catch-up script re-runs everything in order when it wakes.
293
293
 
294
294
  ## Deep Sleep v2 — Overnight Learning (v2.1.0)
295
295
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO — Cognitive co-operator for Claude Code. Memory, emotional intelligence, overnight learning (Deep Sleep), cron management, trust scoring, and adaptive calibration.",
6
6
  "bin": {
@@ -39,7 +39,7 @@ MCP_OWNED_SECTIONS = [
39
39
  "Dissonance",
40
40
  "Disonancia",
41
41
  "Observe the User",
42
- "Observar a Francisco", # legacy personal CLAUDE.md files
42
+ "Observar a {{user}}", # legacy personal CLAUDE.md files
43
43
  "Observar al Usuario",
44
44
  "Change Log",
45
45
  "Session Diary",
@@ -51,7 +51,7 @@ MCP_OWNED_SECTIONS = [
51
51
  BOOTSTRAP_SECTIONS = [
52
52
  "Startup",
53
53
  "User Profile",
54
- "Francisco", # legacy personal CLAUDE.md files
54
+ "{{user_name}}", # legacy personal CLAUDE.md files
55
55
  "Formato",
56
56
  "Format",
57
57
  "Autonomy",
@@ -0,0 +1,236 @@
1
+ #!/bin/bash
2
+ # ============================================================================
3
+ # NEXO Preflight — CI / manual verification script
4
+ # Checks: Python syntax, shell syntax, manifest<->file consistency,
5
+ # manifest<->watchdog consistency
6
+ # Exit code: 0 if all PASS, 1 if any FAIL
7
+ # Usage: bash scripts/nexo-preflight.sh
8
+ # ============================================================================
9
+ set -uo pipefail
10
+
11
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
12
+ SRC="$REPO_ROOT/src"
13
+ MANIFEST="$SRC/crons/manifest.json"
14
+ WATCHDOG="$SRC/scripts/nexo-watchdog.sh"
15
+
16
+ PASS=0
17
+ FAIL=0
18
+ WARN=0
19
+
20
+ pass() { echo " PASS $1"; ((PASS++)); }
21
+ fail() { echo " FAIL $1"; ((FAIL++)); }
22
+ warn() { echo " WARN $1"; ((WARN++)); }
23
+
24
+ echo "============================================================"
25
+ echo "NEXO Preflight — $(date '+%Y-%m-%d %H:%M:%S')"
26
+ echo "============================================================"
27
+
28
+ # ── 1. py_compile for all Python scripts in src/scripts/ ──────────────────
29
+ echo ""
30
+ # Non-core scripts to exclude from compilation checks
31
+ NON_CORE="check-context.py"
32
+
33
+ echo "--- Check 1: Python syntax (src/scripts/*.py) ---"
34
+ for pyfile in "$SRC"/scripts/*.py; do
35
+ # Skip " 2" duplicate files (backup copies)
36
+ [[ "$pyfile" == *" 2"* ]] && continue
37
+ [ -f "$pyfile" ] || continue
38
+ name=$(basename "$pyfile")
39
+ # Skip non-core scripts
40
+ for skip in $NON_CORE; do
41
+ [[ "$name" == "$skip" ]] && continue 2
42
+ done
43
+ if python3 -m py_compile "$pyfile" 2>/dev/null; then
44
+ pass "$name"
45
+ else
46
+ fail "$name — py_compile error"
47
+ fi
48
+ done
49
+
50
+ # ── 2. py_compile for auto_close_sessions.py ──────────────────────────────
51
+ echo ""
52
+ echo "--- Check 2: Python syntax (auto_close_sessions.py) ---"
53
+ ACS="$SRC/auto_close_sessions.py"
54
+ if [ -f "$ACS" ]; then
55
+ if python3 -m py_compile "$ACS" 2>/dev/null; then
56
+ pass "auto_close_sessions.py"
57
+ else
58
+ fail "auto_close_sessions.py — py_compile error"
59
+ fi
60
+ else
61
+ fail "auto_close_sessions.py — file not found"
62
+ fi
63
+
64
+ # ── 3. bash -n for all shell scripts in src/scripts/ ─────────────────────
65
+ echo ""
66
+ echo "--- Check 3: Shell syntax (src/scripts/*.sh) ---"
67
+ for shfile in "$SRC"/scripts/*.sh; do
68
+ # Skip " 2" duplicate files (backup copies)
69
+ [[ "$shfile" == *" 2"* ]] && continue
70
+ [ -f "$shfile" ] || continue
71
+ name=$(basename "$shfile")
72
+ if bash -n "$shfile" 2>/dev/null; then
73
+ pass "$name"
74
+ else
75
+ fail "$name — bash -n syntax error"
76
+ fi
77
+ done
78
+
79
+ # ── 4. Manifest<->file consistency ────────────────────────────────────────
80
+ echo ""
81
+ echo "--- Check 4: Manifest crons have existing script files ---"
82
+ if [ ! -f "$MANIFEST" ]; then
83
+ fail "manifest.json not found at $MANIFEST"
84
+ else
85
+ # Extract script paths from manifest crons
86
+ cron_scripts=$(python3 -c "
87
+ import json, sys
88
+ try:
89
+ m = json.load(open('$MANIFEST'))
90
+ for c in m.get('crons', []):
91
+ print(c.get('id', '?') + '|' + c.get('script', ''))
92
+ except Exception as e:
93
+ print(f'ERROR|{e}', file=sys.stderr)
94
+ sys.exit(1)
95
+ " 2>/dev/null)
96
+
97
+ if [ $? -ne 0 ]; then
98
+ fail "manifest.json — cannot parse JSON"
99
+ else
100
+ while IFS='|' read -r cron_id script_path; do
101
+ [ -z "$script_path" ] && continue
102
+ full_path="$SRC/$script_path"
103
+ if [ -f "$full_path" ]; then
104
+ pass "cron '$cron_id' -> $script_path exists"
105
+ else
106
+ fail "cron '$cron_id' -> $script_path NOT FOUND"
107
+ fi
108
+ done <<< "$cron_scripts"
109
+ fi
110
+ fi
111
+
112
+ # ── 5. Manifest<->watchdog MONITORS consistency ──────────────────────────
113
+ echo ""
114
+ echo "--- Check 5: Manifest crons present in watchdog MONITORS ---"
115
+ if [ ! -f "$WATCHDOG" ]; then
116
+ fail "nexo-watchdog.sh not found at $WATCHDOG"
117
+ else
118
+ # The watchdog dynamically builds MONITORS from manifest.json via
119
+ # _build_monitors_from_manifest(). Verify that function exists and
120
+ # references the manifest, plus check that any hardcoded PERSONAL_MONITORS
121
+ # use valid com.nexo.* plist IDs.
122
+
123
+ if grep -q "_build_monitors_from_manifest" "$WATCHDOG"; then
124
+ pass "watchdog dynamically loads MONITORS from manifest.json"
125
+ else
126
+ fail "watchdog does NOT reference _build_monitors_from_manifest"
127
+ fi
128
+
129
+ if grep -q 'MANIFEST_FILE' "$WATCHDOG"; then
130
+ pass "watchdog references MANIFEST_FILE"
131
+ else
132
+ fail "watchdog does NOT reference MANIFEST_FILE for dynamic loading"
133
+ fi
134
+
135
+ # Check that any hardcoded personal monitors have valid format
136
+ personal_count=$(grep -c '|com\.nexo\.' "$WATCHDOG" 2>/dev/null || echo 0)
137
+ if [ "$personal_count" -gt 0 ]; then
138
+ pass "watchdog has $personal_count personal monitor entries"
139
+ else
140
+ pass "watchdog has no hardcoded personal monitors (all from manifest)"
141
+ fi
142
+ fi
143
+
144
+ # ── 6. Manifest<->README consistency ─────────────────────────────────────
145
+ echo ""
146
+ echo "--- Check 6: Manifest crons mentioned in README ---"
147
+ README="$REPO_ROOT/README.md"
148
+ if [ -f "$README" ] && [ -f "$MANIFEST" ]; then
149
+ manifest_ids=$(python3 -c "
150
+ import json
151
+ m = json.load(open('$MANIFEST'))
152
+ for c in m.get('crons', []):
153
+ print(c['id'])
154
+ " 2>/dev/null)
155
+
156
+ for cid in $manifest_ids; do
157
+ if grep -qE "\*\*${cid}\*\*|${cid}" "$README" 2>/dev/null; then
158
+ pass "cron '$cid' documented in README"
159
+ else
160
+ fail "cron '$cid' NOT in README"
161
+ fi
162
+ done
163
+ else
164
+ warn "README.md or manifest.json not found, skipping"
165
+ fi
166
+
167
+ # ── 7. Smoke tests ──────────────────────────────────────────────────────
168
+ echo ""
169
+ echo "--- Check 7: Smoke tests ---"
170
+
171
+ # 7a: catchup weekday conversion (manifest 0=Sunday -> python 6)
172
+ WEEKDAY_TEST=$(python3 -c "
173
+ # Simulate the conversion from catchup.py
174
+ manifest_weekday = 0 # Sunday in cron/launchd
175
+ py_weekday = (manifest_weekday - 1) % 7 # Should be 6 (Sunday in Python)
176
+ assert py_weekday == 6, f'Expected 6 (Sunday), got {py_weekday}'
177
+ # Also test Monday
178
+ assert (1 - 1) % 7 == 0, 'Monday should be 0'
179
+ # Saturday
180
+ assert (6 - 1) % 7 == 5, 'Saturday should be 5'
181
+ print('OK')
182
+ " 2>&1)
183
+ if [ "$WEEKDAY_TEST" = "OK" ]; then
184
+ pass "catchup weekday conversion (manifest 0=Sun -> python 6)"
185
+ else
186
+ fail "catchup weekday conversion: $WEEKDAY_TEST"
187
+ fi
188
+
189
+ # 7b: change_log schema uses what_changed (not description)
190
+ SCHEMA_TEST=$(python3 -c "
191
+ import sys
192
+ sys.path.insert(0, '$SRC')
193
+ # Verify change_log columns match what learning-housekeep uses
194
+ with open('$SRC/db/_core.py') as f:
195
+ core = f.read()
196
+ if 'what_changed' in core:
197
+ print('OK')
198
+ else:
199
+ print('FAIL: what_changed not found in _core.py')
200
+ " 2>&1)
201
+ if [ "$SCHEMA_TEST" = "OK" ]; then
202
+ pass "change_log schema uses what_changed (matches reconciler)"
203
+ else
204
+ fail "change_log schema: $SCHEMA_TEST"
205
+ fi
206
+
207
+ # 7c: reconciler queries use correct columns for change_log
208
+ RECONCILER_TEST=$(python3 -c "
209
+ with open('$SRC/scripts/nexo-learning-housekeep.py') as f:
210
+ code = f.read()
211
+ # Find the change_log section of _reconcile_decision_outcome
212
+ cl_section = code[code.index('# Check change_log'):code.index('return None', code.index('# Check change_log'))]
213
+ if 'what_changed LIKE' in cl_section:
214
+ print('OK')
215
+ else:
216
+ print('FAIL: change_log section does not use what_changed')
217
+ " 2>&1)
218
+ if [ "$RECONCILER_TEST" = "OK" ]; then
219
+ pass "reconciler uses correct change_log columns"
220
+ else
221
+ fail "reconciler columns: $RECONCILER_TEST"
222
+ fi
223
+
224
+ # ── Summary ───────────────────────────────────────────────────────────────
225
+ echo ""
226
+ echo "============================================================"
227
+ echo "Results: $PASS PASS, $FAIL FAIL, $WARN WARN"
228
+ echo "============================================================"
229
+
230
+ if [ "$FAIL" -gt 0 ]; then
231
+ echo "PREFLIGHT FAILED"
232
+ exit 1
233
+ else
234
+ echo "PREFLIGHT OK"
235
+ exit 0
236
+ fi
@@ -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
@@ -70,35 +70,28 @@
70
70
  {
71
71
  "id": "followup-hygiene",
72
72
  "script": "scripts/nexo-followup-hygiene.py",
73
- "schedule": {"hour": 5, "minute": 0},
73
+ "schedule": {"hour": 5, "minute": 0, "weekday": 0},
74
74
  "description": "Clean stale followups, archive completed, validate dates",
75
75
  "core": true
76
76
  },
77
77
  {
78
78
  "id": "synthesis",
79
79
  "script": "scripts/nexo-synthesis.py",
80
- "interval_seconds": 7200,
81
- "description": "Periodic synthesis — cross-reference learnings, decisions, changes",
80
+ "schedule": {"hour": 6, "minute": 0},
81
+ "description": "Daily synthesis — cross-reference learnings, decisions, changes",
82
82
  "core": true
83
83
  },
84
84
  {
85
85
  "id": "auto-close-sessions",
86
- "script": "scripts/nexo-auto-close-sessions.py",
86
+ "script": "auto_close_sessions.py",
87
87
  "interval_seconds": 300,
88
88
  "description": "Close stale sessions that lost their parent process",
89
89
  "core": true
90
90
  },
91
- {
92
- "id": "github-monitor",
93
- "script": "scripts/nexo-github-monitor.py",
94
- "schedule": {"hour": 8, "minute": 0},
95
- "description": "Monitor GitHub repo — issues, PRs, stars, auto-respond",
96
- "core": true
97
- },
98
- {
91
+ {
99
92
  "id": "catchup",
100
93
  "script": "scripts/nexo-catchup.py",
101
- "schedule": {"hour": 8, "minute": 30},
94
+ "run_at_load": true,
102
95
  "description": "Morning catchup briefing for the user",
103
96
  "core": true
104
97
  }
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:
@@ -87,3 +87,16 @@ from db._evolution import (
87
87
  insert_evolution_metric, get_latest_metrics,
88
88
  insert_evolution_log, get_evolution_history, update_evolution_log_status,
89
89
  )
90
+
91
+ # Cron execution history
92
+ from db._cron_runs import (
93
+ cron_run_start, cron_run_end, cron_runs_recent, cron_runs_summary,
94
+ )
95
+
96
+ # Skills
97
+ from db._skills import (
98
+ create_skill, get_skill, list_skills, search_skills,
99
+ update_skill, delete_skill,
100
+ record_usage as record_skill_usage,
101
+ match_skills, merge_skills, get_skill_stats, decay_unused_skills,
102
+ )