nexo-brain 2.3.1 → 2.4.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.
@@ -18,10 +18,32 @@ import sys
18
18
  from datetime import datetime, timedelta
19
19
  from pathlib import Path
20
20
 
21
- CLAUDE_CLI = Path.home() / ".local" / "bin" / "claude"
22
-
23
21
  HOME = Path.home()
24
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
22
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(HOME / ".nexo")))
23
+
24
+
25
+ def _resolve_claude_cli() -> Path:
26
+ """Find claude CLI: saved path > PATH > common locations."""
27
+ saved = NEXO_HOME / "config" / "claude-cli-path"
28
+ if saved.exists():
29
+ p = Path(saved.read_text().strip())
30
+ if p.exists():
31
+ return p
32
+ import shutil
33
+ found = shutil.which("claude")
34
+ if found:
35
+ return Path(found)
36
+ for candidate in [
37
+ HOME / ".local" / "bin" / "claude",
38
+ HOME / ".npm-global" / "bin" / "claude",
39
+ Path("/usr/local/bin/claude"),
40
+ ]:
41
+ if candidate.exists():
42
+ return candidate
43
+ return HOME / ".local" / "bin" / "claude" # last resort
44
+
45
+
46
+ CLAUDE_CLI = _resolve_claude_cli()
25
47
  LOG_DIR = NEXO_HOME / "logs"
26
48
  LOG_DIR.mkdir(parents=True, exist_ok=True)
27
49
  LOG_FILE = LOG_DIR / "catchup.log"
@@ -47,7 +69,10 @@ def _resolve_python() -> str:
47
69
 
48
70
  NEXO_PYTHON = _resolve_python()
49
71
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent.parent)))
50
- MANIFEST = NEXO_CODE / "crons" / "manifest.json"
72
+ # Look for manifest in NEXO_HOME first (packaged install), then NEXO_CODE (dev/repo)
73
+ _manifest_home = NEXO_HOME / "crons" / "manifest.json"
74
+ _manifest_code = NEXO_CODE / "crons" / "manifest.json"
75
+ MANIFEST = _manifest_home if _manifest_home.exists() else _manifest_code
51
76
 
52
77
 
53
78
  def _load_tasks_from_manifest() -> list[tuple]:
@@ -43,7 +43,27 @@ RUNTIME_PREFLIGHT_SUMMARY = LOG_DIR / "runtime-preflight-summary.json"
43
43
  WATCHDOG_SMOKE_SUMMARY = LOG_DIR / "watchdog-smoke-summary.json"
44
44
  RESTORE_LOG = LOG_DIR / "snapshot-restores.log"
45
45
  CORTEX_LOG_DIR = NEXO_HOME / "brain" / "logs"
46
- CLAUDE_CLI = Path.home() / ".local" / "bin" / "claude"
46
+ def _resolve_claude_cli() -> Path:
47
+ """Find claude CLI: saved path > PATH > common locations."""
48
+ import shutil as _shutil
49
+ saved = NEXO_HOME / "config" / "claude-cli-path"
50
+ if saved.exists():
51
+ p = Path(saved.read_text().strip())
52
+ if p.exists():
53
+ return p
54
+ found = _shutil.which("claude")
55
+ if found:
56
+ return Path(found)
57
+ for candidate in [
58
+ Path.home() / ".local" / "bin" / "claude",
59
+ Path.home() / ".npm-global" / "bin" / "claude",
60
+ Path("/usr/local/bin/claude"),
61
+ ]:
62
+ if candidate.exists():
63
+ return candidate
64
+ return Path.home() / ".local" / "bin" / "claude"
65
+
66
+ CLAUDE_CLI = _resolve_claude_cli()
47
67
 
48
68
  findings = []
49
69
 
@@ -64,7 +64,27 @@ IMMUTABLE_FILES = {
64
64
  }
65
65
 
66
66
  # ── Claude CLI path ──────────────────────────────────────────────────────
67
- CLAUDE_CLI = Path.home() / ".local" / "bin" / "claude"
67
+ def _resolve_claude_cli() -> Path:
68
+ """Find claude CLI: saved path > PATH > common locations."""
69
+ import shutil as _shutil
70
+ saved = NEXO_HOME / "config" / "claude-cli-path"
71
+ if saved.exists():
72
+ p = Path(saved.read_text().strip())
73
+ if p.exists():
74
+ return p
75
+ found = _shutil.which("claude")
76
+ if found:
77
+ return Path(found)
78
+ for candidate in [
79
+ Path.home() / ".local" / "bin" / "claude",
80
+ Path.home() / ".npm-global" / "bin" / "claude",
81
+ Path("/usr/local/bin/claude"),
82
+ ]:
83
+ if candidate.exists():
84
+ return candidate
85
+ return Path.home() / ".local" / "bin" / "claude"
86
+
87
+ CLAUDE_CLI = _resolve_claude_cli()
68
88
 
69
89
  # ── Logging ──────────────────────────────────────────────────────────────
70
90
  LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -42,7 +42,27 @@ MEMORY_DIR = NEXO_HOME / "memory"
42
42
  MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
43
43
  HISTORY_FILE = NEXO_HOME / "coordination" / "postmortem-history.json"
44
44
  CONSOLIDATION_LOG = NEXO_HOME / "logs" / "postmortem-consolidation.log"
45
- CLAUDE_CLI = HOME / ".local" / "bin" / "claude"
45
+ def _resolve_claude_cli() -> Path:
46
+ """Find claude CLI: saved path > PATH > common locations."""
47
+ import shutil as _shutil
48
+ saved = NEXO_HOME / "config" / "claude-cli-path"
49
+ if saved.exists():
50
+ p = Path(saved.read_text().strip())
51
+ if p.exists():
52
+ return p
53
+ found = _shutil.which("claude")
54
+ if found:
55
+ return Path(found)
56
+ for candidate in [
57
+ HOME / ".local" / "bin" / "claude",
58
+ HOME / ".npm-global" / "bin" / "claude",
59
+ Path("/usr/local/bin/claude"),
60
+ ]:
61
+ if candidate.exists():
62
+ return candidate
63
+ return HOME / ".local" / "bin" / "claude"
64
+
65
+ CLAUDE_CLI = _resolve_claude_cli()
46
66
  SESSION_BUFFER = NEXO_HOME / "brain" / "session_buffer.jsonl"
47
67
 
48
68
  TODAY = date.today()
@@ -379,6 +399,7 @@ def main():
379
399
  return
380
400
 
381
401
  log("=== NEXO Post-Mortem Consolidator v2 starting ===")
402
+ had_errors = False
382
403
 
383
404
  # Stage 1: Collect data
384
405
  data = collect_data()
@@ -392,27 +413,31 @@ def main():
392
413
  if not success:
393
414
  log("Stage 2 failed (CLI unavailable or error). "
394
415
  "Skipping intelligent consolidation. Stage 3 (sensory + force) will still run.")
416
+ had_errors = True
395
417
 
396
418
  # Stage 3: Sensory Register (mechanical, kept from v1)
397
419
  try:
398
420
  process_sensory_register()
399
421
  except Exception as e:
400
422
  log(f"Sensory register failed: {e}")
423
+ had_errors = True
401
424
 
402
425
  # Stage 3b: Force analysis (mechanical, kept from v1)
403
426
  try:
404
427
  analyze_force_events()
405
428
  except Exception as e:
406
429
  log(f"Force analysis failed: {e}")
430
+ had_errors = True
407
431
 
408
- # Register successful run
409
- try:
410
- state_file = NEXO_HOME / "operations" / ".catchup-state.json"
411
- state = json.loads(state_file.read_text()) if state_file.exists() else {}
412
- state["postmortem"] = datetime.now().isoformat()
413
- state_file.write_text(json.dumps(state, indent=2))
414
- except Exception:
415
- pass
432
+ # Register successful run only if no stages failed
433
+ if not had_errors:
434
+ try:
435
+ state_file = NEXO_HOME / "operations" / ".catchup-state.json"
436
+ state = json.loads(state_file.read_text()) if state_file.exists() else {}
437
+ state["postmortem"] = datetime.now().isoformat()
438
+ state_file.write_text(json.dumps(state, indent=2))
439
+ except Exception:
440
+ pass
416
441
 
417
442
  mark_done()
418
443
  log("=== Consolidation v2 complete ===")
@@ -49,7 +49,26 @@ SLEEP_LOG = COORD_DIR / "sleep-log.json"
49
49
  MEMORY_MD = NEXO_HOME / "memory" / "MEMORY.md"
50
50
  NEXO_DB = NEXO_HOME / "data" / "nexo.db"
51
51
  CLAUDE_MEM_DB = Path.home() / ".claude-mem" / "claude-mem.db"
52
- CLAUDE_CLI = Path.home() / ".local" / "bin" / "claude"
52
+ def _resolve_claude_cli() -> Path:
53
+ """Find claude CLI: saved path > PATH > common locations."""
54
+ saved = NEXO_HOME / "config" / "claude-cli-path"
55
+ if saved.exists():
56
+ p = Path(saved.read_text().strip())
57
+ if p.exists():
58
+ return p
59
+ found = shutil.which("claude")
60
+ if found:
61
+ return Path(found)
62
+ for candidate in [
63
+ Path.home() / ".local" / "bin" / "claude",
64
+ Path.home() / ".npm-global" / "bin" / "claude",
65
+ Path("/usr/local/bin/claude"),
66
+ ]:
67
+ if candidate.exists():
68
+ return candidate
69
+ return Path.home() / ".local" / "bin" / "claude"
70
+
71
+ CLAUDE_CLI = _resolve_claude_cli()
53
72
 
54
73
  LAST_RUN_FILE = COORD_DIR / "sleep-last-run"
55
74
  LOCK_FILE = COORD_DIR / "sleep.lock"
@@ -534,6 +553,7 @@ def main():
534
553
 
535
554
  run_log = {"date": str(TODAY), "started": TIMESTAMP,
536
555
  "stage_a": None, "stage_b": None, "completed": None}
556
+ sleep_had_errors = False
537
557
 
538
558
  # Stage A: Housekeeping (mechanical)
539
559
  if start_phase == "stage_a":
@@ -555,7 +575,8 @@ def main():
555
575
 
556
576
  if "error" in dream_result:
557
577
  log(f"Stage B: Dreaming failed ({dream_result['error']}). "
558
- "Stage A cleanup completed successfully. Marking done to avoid retry loop.")
578
+ "Stage A cleanup completed successfully. Not marking catchup to allow retry.")
579
+ sleep_had_errors = True
559
580
  else:
560
581
  # Stage B2: Execute actions from CLI output
561
582
  actions_file = COORD_DIR / "sleep-actions.json"
@@ -575,14 +596,15 @@ def main():
575
596
  append_sleep_log(run_log)
576
597
  log(f"NEXO Sleep v2 complete at {run_log['completed']}")
577
598
 
578
- # Register for catch-up
579
- try:
580
- state_file = NEXO_HOME / "operations" / ".catchup-state.json"
581
- st = json.loads(state_file.read_text()) if state_file.exists() else {}
582
- st["sleep"] = datetime.now().isoformat()
583
- state_file.write_text(json.dumps(st, indent=2))
584
- except Exception:
585
- pass
599
+ # Register for catch-up only if all stages succeeded
600
+ if not sleep_had_errors:
601
+ try:
602
+ state_file = NEXO_HOME / "operations" / ".catchup-state.json"
603
+ st = json.loads(state_file.read_text()) if state_file.exists() else {}
604
+ st["sleep"] = datetime.now().isoformat()
605
+ state_file.write_text(json.dumps(st, indent=2))
606
+ except Exception:
607
+ pass
586
608
 
587
609
  finally:
588
610
  try:
@@ -26,7 +26,27 @@ NEXO_DB = NEXO_HOME / "data" / "nexo.db"
26
26
  OUTPUT_FILE = COORD_DIR / "daily-synthesis.md"
27
27
  LAST_RUN_FILE = COORD_DIR / "synthesis-last-run"
28
28
  LOCK_FILE = COORD_DIR / "synthesis.lock"
29
- CLAUDE_CLI = HOME / ".local" / "bin" / "claude"
29
+ def _resolve_claude_cli() -> Path:
30
+ """Find claude CLI: saved path > PATH > common locations."""
31
+ import shutil as _shutil
32
+ saved = NEXO_HOME / "config" / "claude-cli-path"
33
+ if saved.exists():
34
+ p = Path(saved.read_text().strip())
35
+ if p.exists():
36
+ return p
37
+ found = _shutil.which("claude")
38
+ if found:
39
+ return Path(found)
40
+ for candidate in [
41
+ HOME / ".local" / "bin" / "claude",
42
+ HOME / ".npm-global" / "bin" / "claude",
43
+ Path("/usr/local/bin/claude"),
44
+ ]:
45
+ if candidate.exists():
46
+ return candidate
47
+ return HOME / ".local" / "bin" / "claude"
48
+
49
+ CLAUDE_CLI = _resolve_claude_cli()
30
50
 
31
51
  TODAY = date.today()
32
52
  TODAY_STR = TODAY.isoformat()
@@ -109,17 +129,17 @@ def collect_data() -> dict:
109
129
  (TODAY_STR,)
110
130
  )
111
131
 
112
- # Overdue reminders
132
+ # Overdue reminders (schema: description, date, status uppercase)
113
133
  data["overdue_reminders"] = safe_query(
114
- "SELECT id, title, due_date FROM reminders "
115
- "WHERE status='PENDING' AND due_date <= ? ORDER BY due_date",
134
+ "SELECT id, description, date FROM reminders "
135
+ "WHERE status='PENDING' AND date <= ? ORDER BY date",
116
136
  (TODAY_STR,)
117
137
  )
118
138
 
119
- # Pending followups
139
+ # Pending followups (schema: description, date, status uppercase)
120
140
  data["pending_followups"] = safe_query(
121
- "SELECT id, title, description, due_date FROM followups "
122
- "WHERE status='pending' ORDER BY due_date"
141
+ "SELECT id, description, date FROM followups "
142
+ "WHERE status='PENDING' ORDER BY date"
123
143
  )
124
144
 
125
145
  # Guard stats
@@ -240,13 +260,13 @@ def fallback_synthesis(data: dict):
240
260
  if data.get("overdue_reminders"):
241
261
  lines.append("## Overdue Reminders")
242
262
  for r in data["overdue_reminders"][:10]:
243
- lines.append(f"- #{r.get('id', '?')} {r.get('title', '')} (due {r.get('due_date', '?')})")
263
+ lines.append(f"- #{r.get('id', '?')} {r.get('description', '')} (due {r.get('date', '?')})")
244
264
  lines.append("")
245
265
 
246
266
  if data.get("pending_followups"):
247
267
  lines.append("## Pending Followups")
248
268
  for f in data["pending_followups"][:10]:
249
- lines.append(f"- #{f.get('id', '?')} {f.get('title', '')} (due {f.get('due_date', '?')})")
269
+ lines.append(f"- #{f.get('id', '?')} {f.get('description', '')} (due {f.get('date', '?')})")
250
270
  lines.append("")
251
271
 
252
272
  OUTPUT_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -34,13 +34,20 @@ read_version() {
34
34
  python3 -c "import json; print(json.load(open('$PACKAGE_JSON')).get('version','unknown'))" 2>/dev/null || echo "unknown"
35
35
  }
36
36
 
37
- # --- Step 1: Check for uncommitted changes in src/ ---
38
- log "Checking for uncommitted changes in src/..."
37
+ # --- Check if this is a git repo ---
38
+ if [ ! -d "$REPO_DIR/.git" ] && [ ! -f "$REPO_DIR/.git" ]; then
39
+ err "ABORTED: Not a git repository at $REPO_DIR"
40
+ err "For packaged installs, use: npm update -g nexo-brain"
41
+ exit 1
42
+ fi
43
+
44
+ # --- Step 1: Check for uncommitted changes in entire worktree ---
45
+ log "Checking for uncommitted changes..."
39
46
  cd "$REPO_DIR"
40
47
 
41
- if [ -n "$(git status --porcelain -- src/ 2>/dev/null)" ]; then
42
- err "ABORTED: Uncommitted changes in src/"
43
- git status --short -- src/
48
+ if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
49
+ err "ABORTED: Uncommitted changes in worktree"
50
+ git status --short
44
51
  exit 1
45
52
  fi
46
53
  log "Working tree clean."
@@ -48,6 +55,11 @@ log "Working tree clean."
48
55
  # Record current state
49
56
  OLD_VERSION="$(read_version)"
50
57
  OLD_COMMIT="$(git rev-parse HEAD)"
58
+ REQ_FILE="$SRC_DIR/requirements.txt"
59
+ OLD_REQ_HASH=""
60
+ if [ -f "$REQ_FILE" ]; then
61
+ OLD_REQ_HASH="$(shasum -a 256 "$REQ_FILE" | cut -d' ' -f1)"
62
+ fi
51
63
  log "Current: v${OLD_VERSION} (${OLD_COMMIT:0:8})"
52
64
 
53
65
  # --- Step 2: Backup databases ---
@@ -94,6 +106,54 @@ fi
94
106
  NEW_VERSION="$(read_version)"
95
107
  log "New version: v${NEW_VERSION}"
96
108
 
109
+ # --- Step 4b: Reinstall Python dependencies if requirements.txt changed ---
110
+ NEW_REQ_HASH=""
111
+ if [ -f "$REQ_FILE" ]; then
112
+ NEW_REQ_HASH="$(shasum -a 256 "$REQ_FILE" | cut -d' ' -f1)"
113
+ fi
114
+
115
+ DEPS_CHANGED=false
116
+ if [ "$OLD_REQ_HASH" != "$NEW_REQ_HASH" ]; then
117
+ DEPS_CHANGED=true
118
+ fi
119
+
120
+ reinstall_pip_deps() {
121
+ local VENV_PIP="$NEXO_HOME/.venv/bin/pip"
122
+ if [ -f "$REQ_FILE" ]; then
123
+ if [ -x "$VENV_PIP" ]; then
124
+ "$VENV_PIP" install --quiet -r "$REQ_FILE" || return 1
125
+ else
126
+ python3 -m pip install --quiet -r "$REQ_FILE" --break-system-packages 2>/dev/null || return 1
127
+ fi
128
+ fi
129
+ return 0
130
+ }
131
+
132
+ if [ "$DEPS_CHANGED" = true ] || [ "$OLD_VERSION" != "$NEW_VERSION" ]; then
133
+ log "Reinstalling Python dependencies..."
134
+ if ! reinstall_pip_deps; then
135
+ err "pip install failed! Rolling back..."
136
+ git reset --hard "$OLD_COMMIT"
137
+ reinstall_pip_deps || warn "pip rollback also had issues"
138
+ if [ -d "$BACKUP_DIR" ]; then
139
+ for db in "$BACKUP_DIR"/*.db; do
140
+ [ -f "$db" ] || continue
141
+ BASENAME="$(basename "$db")"
142
+ for candidate in "$NEXO_HOME/data/$BASENAME" "$NEXO_HOME/$BASENAME" "$SRC_DIR/$BASENAME"; do
143
+ if [ -f "$candidate" ]; then
144
+ cp "$db" "$candidate"
145
+ warn " Restored: $BASENAME"
146
+ break
147
+ fi
148
+ done
149
+ done
150
+ fi
151
+ err "Rolled back to ${OLD_COMMIT:0:8}. Databases restored."
152
+ exit 1
153
+ fi
154
+ log "Python dependencies updated."
155
+ fi
156
+
97
157
  # --- Step 5: Run migrations if version changed ---
98
158
  if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then
99
159
  log "Version changed: ${OLD_VERSION} -> ${NEW_VERSION}"
@@ -101,6 +161,8 @@ if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then
101
161
  if ! (cd "$SRC_DIR" && python3 -c "import db; db.init_db()" 2>&1); then
102
162
  err "Migration failed! Rolling back..."
103
163
  git reset --hard "$OLD_COMMIT"
164
+ # Reinstall pip deps from restored old requirements.txt
165
+ reinstall_pip_deps || warn "pip rollback also had issues"
104
166
  # Restore DB backups
105
167
  if [ -d "$BACKUP_DIR" ]; then
106
168
  for db in "$BACKUP_DIR"/*.db; do
@@ -115,7 +177,7 @@ if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then
115
177
  done
116
178
  done
117
179
  fi
118
- err "Rolled back to ${OLD_COMMIT:0:8}. Databases restored."
180
+ err "Rolled back to ${OLD_COMMIT:0:8}. Databases and deps restored."
119
181
  exit 1
120
182
  fi
121
183
  log "Migrations applied."
@@ -128,6 +190,8 @@ log "Verifying server.py import..."
128
190
  if ! (cd "$SRC_DIR" && python3 -c "import server" 2>&1); then
129
191
  err "Import verification failed! Rolling back..."
130
192
  git reset --hard "$OLD_COMMIT"
193
+ # Reinstall pip deps from restored old requirements.txt
194
+ reinstall_pip_deps || warn "pip rollback also had issues"
131
195
  if [ -d "$BACKUP_DIR" ]; then
132
196
  for db in "$BACKUP_DIR"/*.db; do
133
197
  [ -f "$db" ] || continue
@@ -141,10 +205,48 @@ if ! (cd "$SRC_DIR" && python3 -c "import server" 2>&1); then
141
205
  done
142
206
  done
143
207
  fi
144
- err "Rolled back to ${OLD_COMMIT:0:8}. Databases restored."
208
+ err "Rolled back to ${OLD_COMMIT:0:8}. Databases and deps restored."
145
209
  exit 1
146
210
  fi
147
211
 
212
+ # --- Step 7: Sync hooks to NEXO_HOME ---
213
+ HOOKS_SRC="$SRC_DIR/hooks"
214
+ HOOKS_DEST="$NEXO_HOME/hooks"
215
+ if [ -d "$HOOKS_SRC" ]; then
216
+ mkdir -p "$HOOKS_DEST"
217
+ SYNCED=0
218
+ for hook in "$HOOKS_SRC"/*.sh; do
219
+ [ -f "$hook" ] || continue
220
+ cp "$hook" "$HOOKS_DEST/$(basename "$hook")"
221
+ chmod 755 "$HOOKS_DEST/$(basename "$hook")"
222
+ SYNCED=$((SYNCED + 1))
223
+ done
224
+ if [ "$SYNCED" -gt 0 ]; then
225
+ log "Synced $SYNCED hook(s) to $HOOKS_DEST"
226
+ fi
227
+ fi
228
+
229
+ # --- Step 8: Sync cron definitions with manifest ---
230
+ CRON_SYNC="$SRC_DIR/crons/sync.py"
231
+ CRON_SYNC_OK=false
232
+ if [ -f "$CRON_SYNC" ]; then
233
+ log "Syncing cron definitions..."
234
+ if NEXO_HOME="$NEXO_HOME" NEXO_CODE="$SRC_DIR" python3 "$CRON_SYNC" 2>&1; then
235
+ log "Cron definitions synced."
236
+ CRON_SYNC_OK=true
237
+ else
238
+ warn "Cron sync failed (non-fatal). Installed manifest NOT refreshed to avoid divergence."
239
+ fi
240
+ fi
241
+
242
+ # --- Step 8b: Refresh installed manifest for catchup/watchdog (only if sync succeeded) ---
243
+ if $CRON_SYNC_OK && [ -d "$SRC_DIR/crons" ]; then
244
+ mkdir -p "$NEXO_HOME/crons"
245
+ cp -f "$SRC_DIR/crons/"*.json "$NEXO_HOME/crons/" 2>/dev/null
246
+ cp -f "$SRC_DIR/crons/"*.py "$NEXO_HOME/crons/" 2>/dev/null
247
+ log "Refreshed installed crons manifest."
248
+ fi
249
+
148
250
  # --- Done ---
149
251
  echo ""
150
252
  log "========================================="