nexo-brain 1.2.3 → 1.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.
Files changed (29) hide show
  1. package/README.md +10 -5
  2. package/package.json +1 -1
  3. package/src/__pycache__/evolution_cycle.cpython-314.pyc +0 -0
  4. package/src/cognitive.py +45 -0
  5. package/src/evolution_cycle.py +266 -0
  6. package/src/plugins/guard.py +235 -1
  7. package/src/scripts/__pycache__/check-context.cpython-314.pyc +0 -0
  8. package/src/scripts/__pycache__/nexo-auto-update.cpython-314.pyc +0 -0
  9. package/src/scripts/__pycache__/nexo-catchup.cpython-314.pyc +0 -0
  10. package/src/scripts/__pycache__/nexo-cognitive-decay.cpython-314.pyc +0 -0
  11. package/src/scripts/__pycache__/nexo-daily-self-audit.cpython-314.pyc +0 -0
  12. package/src/scripts/__pycache__/nexo-evolution-run.cpython-314.pyc +0 -0
  13. package/src/scripts/__pycache__/nexo-immune.cpython-314.pyc +0 -0
  14. package/src/scripts/__pycache__/nexo-learning-validator.cpython-314.pyc +0 -0
  15. package/src/scripts/__pycache__/nexo-postmortem-consolidator.cpython-314.pyc +0 -0
  16. package/src/scripts/__pycache__/nexo-reflection.cpython-314.pyc +0 -0
  17. package/src/scripts/__pycache__/nexo-sleep.cpython-314.pyc +0 -0
  18. package/src/scripts/__pycache__/nexo-synthesis.cpython-314.pyc +0 -0
  19. package/src/scripts/check-context.py +257 -0
  20. package/src/scripts/nexo-catchup.py +59 -5
  21. package/src/scripts/nexo-cognitive-decay.py +8 -0
  22. package/src/scripts/nexo-daily-self-audit.py +168 -183
  23. package/src/scripts/nexo-evolution-run.py +584 -0
  24. package/src/scripts/nexo-immune.py +108 -91
  25. package/src/scripts/nexo-learning-validator.py +226 -0
  26. package/src/scripts/nexo-postmortem-consolidator.py +230 -414
  27. package/src/scripts/nexo-sleep.py +283 -503
  28. package/src/scripts/nexo-synthesis.py +141 -432
  29. package/src/tools_sessions.py +20 -12
@@ -1,31 +1,21 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- NEXO Sleep System — Daily memory cleanup and pruning.
3
+ NEXO Sleep System v2 The brain dreams.
4
4
 
5
- Triggered hourly via LaunchAgent. Runs ONCE per day, first time the Mac is awake.
5
+ Before: 834 lines with word-overlap "intelligence" for learning consolidation.
6
+ Now: Stage A (mechanical cleanup) stays pure Python. Stage B (dreaming) uses
7
+ Claude CLI (opus) to understand, deduplicate, and prune with real intelligence.
8
+
9
+ Triggered hourly via LaunchAgent. Runs ONCE per day, first time Mac is awake.
6
10
  If interrupted (power loss, crash), resumes on next trigger.
7
11
 
8
- Stage A — Mechanical cleanup (Python pure, always runs):
9
- A1: Delete daily_summaries >90 days
10
- A2: Delete session_archive >30 days
11
- A3: Rotate coordination stdout logs >5MB
12
- A4: Delete compressed_memories/week_*.md >180 days
13
- A5: Trim heartbeat-log.json to 200 entries
14
- A6: Trim reflection-log.json to 60 entries
15
- A7: Delete daemon/logs/ dirs >14 days
16
-
17
- Stage C — Learning Consolidation (Python pure, always runs):
18
- C1: Duplicate detection (>80% word overlap in titles)
19
- C2: Age distribution of learnings
20
- C3: Category health (counts, hottest last 7d, categories >20)
21
- C4: Contradiction detection (NUNCA pairs in same category)
22
-
23
- Stage B — Intelligent pruning (Claude CLI, conditional):
24
- Only activates if MEMORY.md >170 lines, nexo.db preferences table has >5 rows,
25
- or claude-mem.db has >500 observations >60 days.
26
- Uses Claude CLI (sonnet) to compress and prune.
27
-
28
- Zero external dependencies beyond stdlib + sqlite3. Claude CLI for Stage B only.
12
+ Stage A — Housekeeping (Python pure):
13
+ Delete old logs, rotate files, trim JSON. No intelligence needed.
14
+
15
+ Stage B Dreaming (Claude CLI opus):
16
+ Review learnings for duplicates and contradictions with UNDERSTANDING.
17
+ Prune MEMORY.md if over limit. Clean preferences. Compress old observations.
18
+ One CLI call that does what 500 lines of word-overlap couldn't.
29
19
  """
30
20
 
31
21
  import fcntl
@@ -40,7 +30,7 @@ from datetime import datetime, date, timedelta
40
30
  from pathlib import Path
41
31
 
42
32
  # ─── Paths ────────────────────────────────────────────────────────────────────
43
- CLAUDE_DIR = Path.home() / "claude"
33
+ CLAUDE_DIR = Path.home() / ".nexo"
44
34
  BRAIN_DIR = CLAUDE_DIR / "brain"
45
35
  COORD_DIR = CLAUDE_DIR / "coordination"
46
36
  MEMORY_DIR = CLAUDE_DIR / "memory"
@@ -54,9 +44,8 @@ HEARTBEAT_LOG = COORD_DIR / "heartbeat-log.json"
54
44
  REFLECTION_LOG = COORD_DIR / "reflection-log.json"
55
45
  SLEEP_LOG = COORD_DIR / "sleep-log.json"
56
46
 
57
- NEXO_HOME = os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))
58
- MEMORY_MD = Path.home() / ".claude" / "projects" / f"-Users-{os.environ.get('USER', 'user')}" / "memory" / "MEMORY.md"
59
- NEXO_DB = Path(NEXO_HOME) / "nexo.db"
47
+ MEMORY_MD = Path.home() / ".nexo" / "memory" / "MEMORY.md"
48
+ NEXO_DB = Path.home() / ".nexo" / "nexo.db"
60
49
  CLAUDE_MEM_DB = Path.home() / ".claude-mem" / "claude-mem.db"
61
50
  CLAUDE_CLI = Path.home() / ".local" / "bin" / "claude"
62
51
 
@@ -69,30 +58,25 @@ NOW = datetime.now()
69
58
  TIMESTAMP = NOW.strftime("%Y-%m-%d %H:%M")
70
59
 
71
60
 
72
- # ─── Run-once & resume logic ────────────────────────────────────────────────
61
+ # ─── Run-once & resume logic (unchanged from v1) ──────────────────────────────
73
62
 
74
63
  def already_ran_today() -> bool:
75
- """Check if sleep already completed today."""
76
64
  if not LAST_RUN_FILE.exists():
77
65
  return False
78
66
  try:
79
- last_date = LAST_RUN_FILE.read_text().strip()
80
- return last_date == str(TODAY)
67
+ return LAST_RUN_FILE.read_text().strip() == str(TODAY)
81
68
  except Exception:
82
69
  return False
83
70
 
84
71
 
85
72
  def was_interrupted() -> bool:
86
- """Check if a previous run was interrupted (lock file exists with dead PID)."""
87
73
  if not LOCK_FILE.exists():
88
74
  return False
89
75
  try:
90
76
  lock_data = json.loads(LOCK_FILE.read_text())
91
- lock_date = lock_data.get("date", "")
92
- if lock_date != str(TODAY):
77
+ if lock_data.get("date") != str(TODAY):
93
78
  LOCK_FILE.unlink()
94
79
  return False
95
-
96
80
  lock_pid = lock_data.get("pid")
97
81
  if lock_pid:
98
82
  try:
@@ -100,39 +84,29 @@ def was_interrupted() -> bool:
100
84
  log(f"Another instance running (PID {lock_pid}). Exiting.")
101
85
  return False
102
86
  except ProcessLookupError:
103
- log(f"Interrupted run detected (phase: {lock_data.get('phase', '?')}, dead PID {lock_pid}). Resuming.")
87
+ log(f"Interrupted run (phase: {lock_data.get('phase', '?')}). Resuming.")
104
88
  return True
105
89
  except PermissionError:
106
90
  return False
107
- else:
108
- LOCK_FILE.unlink()
109
- return False
91
+ LOCK_FILE.unlink()
92
+ return False
110
93
  except Exception:
111
94
  LOCK_FILE.unlink(missing_ok=True)
112
95
  return False
113
96
 
114
97
 
115
98
  def get_interrupted_phase() -> str:
116
- """Get which phase was interrupted."""
117
99
  try:
118
- lock_data = json.loads(LOCK_FILE.read_text())
119
- return lock_data.get("phase", "stage_a")
100
+ return json.loads(LOCK_FILE.read_text()).get("phase", "stage_a")
120
101
  except Exception:
121
102
  return "stage_a"
122
103
 
123
104
 
124
105
  def set_lock(phase: str):
125
- """Set lock file indicating current phase with PID for race detection."""
126
- save_json(LOCK_FILE, {
127
- "date": str(TODAY),
128
- "phase": phase,
129
- "started": TIMESTAMP,
130
- "pid": os.getpid()
131
- })
106
+ save_json(LOCK_FILE, {"date": str(TODAY), "phase": phase, "started": TIMESTAMP, "pid": os.getpid()})
132
107
 
133
108
 
134
109
  def mark_complete():
135
- """Mark today's run as complete."""
136
110
  LAST_RUN_FILE.write_text(str(TODAY))
137
111
  LOCK_FILE.unlink(missing_ok=True)
138
112
 
@@ -140,7 +114,8 @@ def mark_complete():
140
114
  # ─── Helpers ──────────────────────────────────────────────────────────────────
141
115
 
142
116
  def log(msg: str):
143
- print(f"[{TIMESTAMP}] {msg}")
117
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M")
118
+ print(f"[{ts}] {msg}")
144
119
 
145
120
 
146
121
  def load_json(path: Path, default=None):
@@ -148,8 +123,7 @@ def load_json(path: Path, default=None):
148
123
  return default if default is not None else {}
149
124
  try:
150
125
  return json.loads(path.read_text())
151
- except Exception as e:
152
- log(f"WARN: Failed to load {path}: {e}")
126
+ except Exception:
153
127
  return default if default is not None else {}
154
128
 
155
129
 
@@ -158,8 +132,7 @@ def save_json(path: Path, data):
158
132
  path.write_text(json.dumps(data, indent=2, ensure_ascii=False))
159
133
 
160
134
 
161
- def parse_date_from_stem(stem: str) -> date | None:
162
- """Extract YYYY-MM-DD date from a filename stem."""
135
+ def parse_date_from_stem(stem: str):
163
136
  m = re.search(r'(\d{4}-\d{2}-\d{2})', stem)
164
137
  if m:
165
138
  try:
@@ -170,24 +143,19 @@ def parse_date_from_stem(stem: str) -> date | None:
170
143
 
171
144
 
172
145
  def append_sleep_log(entry: dict):
173
- """Append entry to sleep-log.json, keeping last 90 entries."""
174
146
  entries = load_json(SLEEP_LOG, [])
175
147
  if not isinstance(entries, list):
176
148
  entries = []
177
149
  entries.append(entry)
178
- # Keep last 90
179
150
  if len(entries) > 90:
180
151
  entries = entries[-90:]
181
152
  save_json(SLEEP_LOG, entries)
182
153
 
183
154
 
184
- # ─── Stage A: Mechanical cleanup ─────────────────────────────────────────────
155
+ # ─── Stage A: Mechanical cleanup (UNCHANGED from v1) ─────────────────────────
185
156
 
186
157
  def stage_a_cleanup() -> dict:
187
- """
188
- Pure Python cleanup. No LLM calls.
189
- Returns stats dict with counts per sub-task.
190
- """
158
+ """Pure Python cleanup. No LLM calls."""
191
159
  stats = {
192
160
  "a1_daily_summaries_deleted": 0,
193
161
  "a2_session_archives_deleted": 0,
@@ -207,9 +175,8 @@ def stage_a_cleanup() -> dict:
207
175
  try:
208
176
  f.unlink()
209
177
  stats["a1_daily_summaries_deleted"] += 1
210
- log(f"A1: Deleted {f.name} (>{90}d)")
211
- except Exception as e:
212
- log(f"A1: WARN: Could not delete {f.name}: {e}")
178
+ except Exception:
179
+ pass
213
180
 
214
181
  # A2: Delete session_archive/*.jsonl >30 days
215
182
  cutoff_30 = TODAY - timedelta(days=30)
@@ -220,22 +187,20 @@ def stage_a_cleanup() -> dict:
220
187
  try:
221
188
  f.unlink()
222
189
  stats["a2_session_archives_deleted"] += 1
223
- log(f"A2: Deleted {f.name} (>{30}d)")
224
- except Exception as e:
225
- log(f"A2: WARN: Could not delete {f.name}: {e}")
190
+ except Exception:
191
+ pass
226
192
 
227
- # A3: Rotate coordination/*-stdout.log if >5MB (keep last 500 lines)
193
+ # A3: Rotate coordination/*-stdout.log if >5MB
228
194
  if COORD_DIR.exists():
229
195
  for f in COORD_DIR.glob("*-stdout.log"):
230
196
  try:
231
- if f.stat().st_size > 5 * 1024 * 1024: # >5MB
197
+ if f.stat().st_size > 5 * 1024 * 1024:
232
198
  lines = f.read_text().splitlines()
233
- keep = lines[-500:] if len(lines) > 500 else lines
199
+ keep = lines[-500:]
234
200
  f.write_text("\n".join(keep) + "\n")
235
201
  stats["a3_logs_rotated"] += 1
236
- log(f"A3: Rotated {f.name} ({len(lines)}→{len(keep)} lines)")
237
- except Exception as e:
238
- log(f"A3: WARN: Could not rotate {f.name}: {e}")
202
+ except Exception:
203
+ pass
239
204
 
240
205
  # A4: Delete compressed_memories/week_*.md >180 days
241
206
  cutoff_180 = TODAY - timedelta(days=180)
@@ -246,37 +211,30 @@ def stage_a_cleanup() -> dict:
246
211
  try:
247
212
  f.unlink()
248
213
  stats["a4_compressed_memories_deleted"] += 1
249
- log(f"A4: Deleted {f.name} (>{180}d)")
250
- except Exception as e:
251
- log(f"A4: WARN: Could not delete {f.name}: {e}")
214
+ except Exception:
215
+ pass
252
216
 
253
217
  # A5: Trim heartbeat-log.json to 200 entries
254
218
  if HEARTBEAT_LOG.exists():
255
219
  try:
256
220
  data = load_json(HEARTBEAT_LOG, [])
257
221
  if isinstance(data, list) and len(data) > 200:
258
- before = len(data)
259
- data = data[-200:]
260
- save_json(HEARTBEAT_LOG, data)
222
+ save_json(HEARTBEAT_LOG, data[-200:])
261
223
  stats["a5_heartbeat_trimmed"] = True
262
- log(f"A5: Trimmed heartbeat-log.json {before}→200 entries")
263
- except Exception as e:
264
- log(f"A5: WARN: {e}")
224
+ except Exception:
225
+ pass
265
226
 
266
227
  # A6: Trim reflection-log.json to 60 entries
267
228
  if REFLECTION_LOG.exists():
268
229
  try:
269
230
  data = load_json(REFLECTION_LOG, [])
270
231
  if isinstance(data, list) and len(data) > 60:
271
- before = len(data)
272
- data = data[-60:]
273
- save_json(REFLECTION_LOG, data)
232
+ save_json(REFLECTION_LOG, data[-60:])
274
233
  stats["a6_reflection_trimmed"] = True
275
- log(f"A6: Trimmed reflection-log.json {before}→60 entries")
276
- except Exception as e:
277
- log(f"A6: WARN: {e}")
234
+ except Exception:
235
+ pass
278
236
 
279
- # A7: Delete daemon/logs/ dirs >14 days (subdirs named YYYY-MM-DD)
237
+ # A7: Delete daemon/logs/ dirs >14 days
280
238
  cutoff_14 = TODAY - timedelta(days=14)
281
239
  if DAEMON_LOGS_DIR.exists():
282
240
  for d_path in sorted(DAEMON_LOGS_DIR.iterdir()):
@@ -287,513 +245,335 @@ def stage_a_cleanup() -> dict:
287
245
  try:
288
246
  shutil.rmtree(d_path)
289
247
  stats["a7_daemon_logs_deleted"] += 1
290
- log(f"A7: Deleted daemon/logs/{d_path.name}/ (>{14}d)")
291
- except Exception as e:
292
- log(f"A7: WARN: Could not delete {d_path.name}: {e}")
248
+ except Exception:
249
+ pass
293
250
 
294
- # A8: Delete cortex/logs/*.log >7 days, truncate launchd logs >5MB
251
+ # A8: Delete cortex/logs/*.log >7 days, truncate launchd >5MB
295
252
  cutoff_7 = TODAY - timedelta(days=7)
296
- cortex_logs = Path.home() / "claude" / "cortex" / "logs"
253
+ cortex_logs = Path.home() / ".nexo" / "cortex" / "logs"
297
254
  if cortex_logs.exists():
298
255
  for f in cortex_logs.glob("*.log"):
299
256
  if f.name.startswith("launchd-"):
300
257
  try:
301
258
  if f.stat().st_size > 5 * 1024 * 1024:
302
259
  lines = f.read_text().splitlines()
303
- keep = lines[-500:] if len(lines) > 500 else lines
304
- f.write_text("\n".join(keep) + "\n")
260
+ f.write_text("\n".join(lines[-500:]) + "\n")
305
261
  stats["a3_logs_rotated"] += 1
306
- log(f"A8: Truncated cortex {f.name}")
307
- except Exception as e:
308
- log(f"A8: WARN: {e}")
262
+ except Exception:
263
+ pass
309
264
  continue
310
265
  d = parse_date_from_stem(f.stem)
311
266
  if d and d < cutoff_7:
312
267
  try:
313
268
  f.unlink()
314
- log(f"A8: Deleted cortex log {f.name} (>7d)")
315
- except Exception as e:
316
- log(f"A8: WARN: Could not delete {f.name}: {e}")
269
+ except Exception:
270
+ pass
317
271
 
318
272
  return stats
319
273
 
320
274
 
321
- # ─── Stage C: Learning Consolidation ─────────────────────────────────────────
275
+ # ─── Stage B: Dreaming (Claude CLI) ─────────────────────────────────────────
322
276
 
323
- STOPWORDS = {
324
- "el", "la", "los", "las", "un", "una", "unos", "unas",
325
- "de", "del", "al", "en", "y", "o", "a", "con", "por", "para",
326
- "que", "es", "se", "no", "si", "lo", "le", "su", "sus",
327
- "the", "a", "an", "of", "in", "and", "or", "to", "for", "is",
328
- "it", "on", "at", "by", "from", "with", "not", "be", "as",
329
- "this", "that", "are", "was", "were",
330
- }
277
+ def collect_brain_state() -> dict:
278
+ """Collect all data the CLI needs to dream."""
279
+ state = {"learnings": [], "preferences": [], "memory_md_lines": 0,
280
+ "claude_mem_old": 0, "feedback_count": 0}
331
281
 
282
+ if NEXO_DB.exists():
283
+ try:
284
+ conn = sqlite3.connect(str(NEXO_DB))
285
+ conn.row_factory = sqlite3.Row
332
286
 
333
- def _title_words(title: str) -> set:
334
- """Lowercase, tokenize, remove stopwords from a title."""
335
- words = re.findall(r'[a-záéíóúüñA-ZÁÉÍÓÚÜÑ\w]+', title.lower())
336
- return {w for w in words if w not in STOPWORDS and len(w) > 2}
287
+ # Learnings
288
+ rows = conn.execute(
289
+ "SELECT id, title, content, category, created_at FROM learnings "
290
+ "WHERE status='active' ORDER BY id"
291
+ ).fetchall()
292
+ state["learnings"] = [dict(r) for r in rows]
337
293
 
294
+ # Preferences
295
+ rows = conn.execute("SELECT key, value, category, updated_at FROM preferences").fetchall()
296
+ state["preferences"] = [dict(r) for r in rows]
338
297
 
339
- def _word_overlap(words_a: set, words_b: set) -> float:
340
- """Jaccard-like overlap: intersection / union."""
341
- if not words_a or not words_b:
342
- return 0.0
343
- return len(words_a & words_b) / len(words_a | words_b)
298
+ conn.close()
299
+ except Exception as e:
300
+ log(f"DB error: {e}")
344
301
 
302
+ # MEMORY.md
303
+ if MEMORY_MD.exists():
304
+ state["memory_md_lines"] = len(MEMORY_MD.read_text().splitlines())
345
305
 
346
- def stage_c_learning_consolidation() -> dict:
347
- """
348
- Pure Python analysis of the learnings table in nexo.db.
349
- Reads only no deletions.
350
- Returns stats dict stored under run_log['stage_c'].
351
- """
352
- stats = {
353
- "total_learnings": 0,
354
- "potential_duplicates": [], # max 10
355
- "age_distribution": {"<7d": 0, "7-30d": 0, "30-90d": 0, ">90d": 0},
356
- "category_counts": {},
357
- "hottest_category_7d": None,
358
- "categories_over_20": [],
359
- "potential_contradictions": [], # max 5
360
- }
306
+ # claude-mem.db old observations
307
+ if CLAUDE_MEM_DB.exists():
308
+ try:
309
+ cutoff = int((datetime.now() - timedelta(days=60)).timestamp() * 1000)
310
+ conn = sqlite3.connect(str(CLAUDE_MEM_DB))
311
+ state["claude_mem_old"] = conn.execute(
312
+ "SELECT COUNT(*) FROM observations WHERE created_at_epoch < ?", (cutoff,)
313
+ ).fetchone()[0]
314
+ conn.close()
315
+ except Exception:
316
+ pass
361
317
 
362
- if not NEXO_DB.exists():
363
- log("Stage C: nexo.db not found, skipping.")
364
- return stats
318
+ # Feedback count
319
+ state["feedback_count"] = len(list(MEMORY_MD.parent.glob("feedback_*.md")))
365
320
 
366
- try:
367
- conn = sqlite3.connect(str(NEXO_DB))
368
- conn.row_factory = sqlite3.Row
369
- cursor = conn.cursor()
321
+ return state
370
322
 
371
- # Check table exists
372
- cursor.execute(
373
- "SELECT name FROM sqlite_master WHERE type='table' AND name='learnings'"
374
- )
375
- if not cursor.fetchone():
376
- log("Stage C: learnings table not found, skipping.")
377
- conn.close()
378
- return stats
379
323
 
380
- cursor.execute(
381
- "SELECT id, title, content, category, created_at FROM learnings ORDER BY id"
382
- )
383
- rows = cursor.fetchall()
384
- conn.close()
385
- except Exception as e:
386
- log(f"Stage C: DB error: {e}")
387
- return stats
388
-
389
- if not rows:
390
- log("Stage C: No learnings found.")
391
- return stats
392
-
393
- stats["total_learnings"] = len(rows)
394
- now_dt = datetime.now()
395
- cutoff_7 = now_dt - timedelta(days=7)
396
- cutoff_30 = now_dt - timedelta(days=30)
397
- cutoff_90 = now_dt - timedelta(days=90)
398
-
399
- # Pre-compute per-row data
400
- parsed = []
401
- category_7d_counts: dict[str, int] = {}
402
-
403
- for row in rows:
404
- # Parse created_at (stored as epoch float or ISO string)
405
- created_dt = None
406
- raw_ts = row["created_at"]
407
- if raw_ts:
408
- # Try epoch first (nexo.db uses epoch floats)
409
- try:
410
- ts_float = float(raw_ts)
411
- if ts_float > 1_000_000_000: # reasonable epoch
412
- created_dt = datetime.fromtimestamp(ts_float)
413
- except (ValueError, TypeError, OSError):
414
- pass
415
- # Fallback to ISO string formats
416
- if created_dt is None:
417
- for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
418
- try:
419
- created_dt = datetime.strptime(str(raw_ts)[:19], fmt)
420
- break
421
- except ValueError:
422
- continue
423
-
424
- words = _title_words(row["title"] or "")
425
- cat = (row["category"] or "uncategorized").strip()
426
-
427
- parsed.append({
428
- "id": row["id"],
429
- "title": row["title"] or "",
430
- "words": words,
431
- "category": cat,
432
- "created_dt": created_dt,
433
- })
434
-
435
- # C2: age distribution
436
- if created_dt:
437
- if created_dt >= cutoff_7:
438
- stats["age_distribution"]["<7d"] += 1
439
- category_7d_counts[cat] = category_7d_counts.get(cat, 0) + 1
440
- elif created_dt >= cutoff_30:
441
- stats["age_distribution"]["7-30d"] += 1
442
- elif created_dt >= cutoff_90:
443
- stats["age_distribution"]["30-90d"] += 1
444
- else:
445
- stats["age_distribution"][">90d"] += 1
446
- else:
447
- # Unknown age → bucket as >90d
448
- stats["age_distribution"][">90d"] += 1
449
-
450
- # C3: category counts
451
- stats["category_counts"][cat] = stats["category_counts"].get(cat, 0) + 1
452
-
453
- # C3: hottest category last 7d + categories over 20
454
- if category_7d_counts:
455
- stats["hottest_category_7d"] = max(category_7d_counts, key=lambda k: category_7d_counts[k])
456
- stats["categories_over_20"] = [
457
- cat for cat, cnt in stats["category_counts"].items() if cnt > 20
458
- ]
459
-
460
- # C1: Duplicate detection — O(n²) but learnings table is small
461
- duplicates = []
462
- for i in range(len(parsed)):
463
- if len(duplicates) >= 10:
464
- break
465
- for j in range(i + 1, len(parsed)):
466
- if len(duplicates) >= 10:
467
- break
468
- overlap = _word_overlap(parsed[i]["words"], parsed[j]["words"])
469
- if overlap >= 0.80:
470
- duplicates.append({
471
- "id1": parsed[i]["id"],
472
- "id2": parsed[j]["id"],
473
- "title1": parsed[i]["title"],
474
- "title2": parsed[j]["title"],
475
- "overlap": round(overlap, 2),
476
- })
477
- stats["potential_duplicates"] = duplicates
478
-
479
- # C4: Contradiction detection — NUNCA pairs in same category
480
- nunca_entries = [p for p in parsed if "nunca" in p["title"].lower()]
481
- contradictions = []
482
- for nunca in nunca_entries:
483
- if len(contradictions) >= 5:
484
- break
485
- # Look for same-category entries that don't contain NUNCA
486
- # and whose remaining words overlap significantly (same subject, opposite stance)
487
- nunca_words_no_nunca = nunca["words"] - {"nunca"}
488
- for other in parsed:
489
- if len(contradictions) >= 5:
490
- break
491
- if other["id"] == nunca["id"]:
492
- continue
493
- if other["category"] != nunca["category"]:
494
- continue
495
- if "nunca" in other["title"].lower():
496
- continue
497
- # Check if they share meaningful subject words
498
- overlap = _word_overlap(nunca_words_no_nunca, other["words"])
499
- if overlap >= 0.50:
500
- contradictions.append({
501
- "id1": nunca["id"],
502
- "id2": other["id"],
503
- "title1": nunca["title"],
504
- "title2": other["title"],
505
- })
506
- stats["potential_contradictions"] = contradictions
507
-
508
- log(f"Stage C: {stats['total_learnings']} learnings analyzed. "
509
- f"Potential duplicates: {len(duplicates)}. "
510
- f"Categories over 20: {len(stats['categories_over_20'])}. "
511
- f"Potential contradictions: {len(contradictions)}.")
512
- if stats["hottest_category_7d"]:
513
- log(f"Stage C: Hottest category last 7d: {stats['hottest_category_7d']} "
514
- f"({category_7d_counts.get(stats['hottest_category_7d'], 0)} new).")
324
+ def should_dream(state: dict) -> bool:
325
+ """Check if there's enough to justify a CLI call."""
326
+ return (
327
+ len(state["learnings"]) > 10
328
+ or state["memory_md_lines"] > 170
329
+ or len(state["preferences"]) > 5
330
+ or state["claude_mem_old"] > 500
331
+ )
515
332
 
516
- return stats
517
333
 
334
+ def dream(state: dict) -> dict:
335
+ """The brain dreams — CLI does the intelligent work."""
518
336
 
519
- # ─── Stage B: Intelligent pruning (Claude CLI) ──────────────────────────────
520
-
521
- def check_stage_b_conditions() -> dict:
522
- """
523
- Check if Stage B should activate.
524
- Returns dict with condition results and whether to trigger.
525
- """
526
- conditions = {
527
- "memory_md_lines": 0,
528
- "memory_md_over_limit": False,
529
- "preferences_auto_sections": 0,
530
- "preferences_over_limit": False,
531
- "claude_mem_old_observations": 0,
532
- "claude_mem_over_limit": False,
533
- "should_trigger": False,
534
- }
337
+ # Truncate learnings JSON if too large
338
+ learnings_json = json.dumps(state["learnings"], ensure_ascii=False, indent=1)
339
+ if len(learnings_json) > 15000:
340
+ learnings_json = learnings_json[:15000] + "\n... (truncated)"
535
341
 
536
- # Check MEMORY.md line count
537
- if MEMORY_MD.exists():
538
- try:
539
- lines = MEMORY_MD.read_text().splitlines()
540
- conditions["memory_md_lines"] = len(lines)
541
- conditions["memory_md_over_limit"] = len(lines) > 170
542
- except Exception as e:
543
- log(f"Stage B check: WARN reading MEMORY.md: {e}")
342
+ tasks = []
544
343
 
545
- # Check preferences count in SQLite
546
- if NEXO_DB.exists():
547
- try:
548
- conn = sqlite3.connect(str(NEXO_DB))
549
- cursor = conn.cursor()
550
- cursor.execute("SELECT COUNT(*) FROM preferences")
551
- count = cursor.fetchone()[0]
552
- conn.close()
553
- conditions["preferences_auto_sections"] = count
554
- conditions["preferences_over_limit"] = count > 5
555
- except Exception as e:
556
- log(f"Stage B check: WARN reading nexo.db preferences: {e}")
344
+ tasks.append(f"""TASK 1: LEARNING CONSOLIDATION ({len(state['learnings'])} active)
345
+ Review these learnings and identify:
346
+ a) DUPLICATES: learnings that say the same thing differently.
347
+ b) CONTRADICTIONS: learnings that contradict each other.
348
+ c) STALE: learnings about bugs/issues fixed >60 days ago that are never referenced.
557
349
 
558
- # Check claude-mem.db observations >60 days
559
- if CLAUDE_MEM_DB.exists():
560
- try:
561
- cutoff_epoch = int((datetime.now() - timedelta(days=60)).timestamp() * 1000)
562
- conn = sqlite3.connect(str(CLAUDE_MEM_DB))
563
- cursor = conn.cursor()
564
- cursor.execute(
565
- "SELECT COUNT(*) FROM observations WHERE created_at_epoch < ?",
566
- (cutoff_epoch,)
567
- )
568
- count = cursor.fetchone()[0]
569
- conn.close()
570
- conditions["claude_mem_old_observations"] = count
571
- conditions["claude_mem_over_limit"] = count > 500
572
- except Exception as e:
573
- log(f"Stage B check: WARN reading claude-mem.db: {e}")
350
+ Write your findings to {COORD_DIR}/sleep-report.md with sections:
351
+ - "## Duplicates to archive" — list learning IDs to archive and why
352
+ - "## Contradictions" — pairs of conflicting learnings
353
+ - "## Stale candidates" IDs of learnings that may be obsolete
574
354
 
575
- conditions["should_trigger"] = (
576
- conditions["memory_md_over_limit"]
577
- or conditions["preferences_over_limit"]
578
- or conditions["claude_mem_over_limit"]
579
- )
355
+ Also write a machine-readable file {COORD_DIR}/sleep-actions.json:
356
+ {{"archive_ids": [1, 2, 3], "contradiction_pairs": [[4, 5]], "stale_ids": [6, 7]}}
580
357
 
581
- return conditions
358
+ The wrapper will execute the actual DB operations based on this JSON.
582
359
 
360
+ LEARNINGS:
361
+ {learnings_json}""")
583
362
 
584
- def build_stage_b_prompt(conditions: dict) -> str:
585
- """Build the prompt for Claude CLI based on which conditions triggered."""
586
- tasks = []
363
+ if state["memory_md_lines"] > 170:
364
+ tasks.append(f"""TASK 2: MEMORY.MD COMPRESSION ({state['memory_md_lines']} lines, limit 200)
365
+ File: {MEMORY_MD}
366
+ Read it, compress resolved incidents >21 days, merge duplicates.
367
+ NEVER delete: credentials, legal entity info, CRITICAL rules, infrastructure.
368
+ Target: <180 lines.""")
369
+
370
+ if len(state["preferences"]) > 5:
371
+ tasks.append(f"""TASK 3: PREFERENCES CLEANUP ({len(state['preferences'])} entries)
372
+ Review the preferences and identify duplicate keys.
373
+ Add to sleep-actions.json: "duplicate_preference_keys": ["key1", "key2", ...]
374
+ The wrapper will handle the actual DB cleanup safely.""")
587
375
 
588
- if conditions["memory_md_over_limit"]:
589
- tasks.append(f"""TAREA 1: MEMORY.md ({conditions['memory_md_lines']} lineas, limite 200)
590
- Archivo: {MEMORY_MD}
591
- Lee con Read tool, comprime incidentes resueltos >21 dias, fusiona duplicados, mantener <180 lineas.
592
- PRESERVA toda la estructura de secciones existente. No elimines secciones enteras.""")
593
-
594
- if conditions["preferences_over_limit"]:
595
- tasks.append(f"""TAREA 2: preferences en SQLite ({conditions['preferences_auto_sections']} registros)
596
- DB: {NEXO_DB}, tabla: preferences (columnas: key, value, category, updated_at)
597
- Conecta con sqlite3. Elimina preferencias duplicadas (mismo key) manteniendo la mas reciente.
598
- Elimina preferencias con updated_at mas antiguo de 30 dias si hay un duplicado mas reciente.
599
- Reporta cuantos registros eliminaste.""")
600
-
601
- if conditions["claude_mem_over_limit"]:
602
- tasks.append(f"""TAREA 3: claude-mem observations ({conditions['claude_mem_old_observations']} registros >60d)
603
- DB: {CLAUDE_MEM_DB}
604
- Conecta con sqlite3. Ejecuta:
605
- DELETE FROM observations WHERE created_at_epoch < {int((datetime.now() - timedelta(days=60)).timestamp() * 1000)}
606
- AND discovery_tokens < 300
607
- AND id NOT IN (SELECT id FROM observations WHERE
608
- title LIKE '%CRITICO%' OR title LIKE '%MAXIMA%'
609
- OR title LIKE '%credential%' OR title LIKE '%token%' OR title LIKE '%API%'
610
- OR narrative LIKE '%CRITICO%' OR narrative LIKE '%MAXIMA%')
611
- LIMIT 200;
612
- Luego: DELETE FROM observations_fts WHERE rowid NOT IN (SELECT id FROM observations);
613
- Luego: VACUUM;
614
- Reporta cuantos registros eliminaste.""")
615
-
616
- if not tasks:
617
- return ""
376
+ if state["claude_mem_old"] > 500:
377
+ tasks.append(f"""TASK 4: OLD OBSERVATIONS ({state['claude_mem_old']} entries >60d)
378
+ Note in sleep-report.md that old observations should be cleaned.
379
+ Add to sleep-actions.json: "clean_old_observations": true
380
+ The wrapper will handle the actual DB cleanup safely.""")
618
381
 
619
382
  tasks_str = "\n\n".join(tasks)
620
383
 
621
- return f"""You are NEXO Sleep System. Your job is to PRUNE memory.
622
- You are NOT interactive. Do NOT wait for input. Execute the following tasks and exit.
384
+ prompt = f"""You are NEXO Sleep the nightly brain maintenance process.
385
+ Like a human brain during sleep: consolidate important memories, discard noise,
386
+ detect conflicts, prepare state for tomorrow.
623
387
 
624
- ABSOLUTE RULES:
625
- - NEVER delete credentials, tokens, account IDs, API endpoints, keys, secrets.
626
- - NEVER delete operational rules marked as "CRITICAL" or "HIGHEST PRIORITY".
627
- - NEVER delete information about infrastructure (servers, repos, deploys).
628
- - You CAN merge redundant sections.
629
- - You CAN remove obsolete technical information (fixed >30 days ago and never referenced since).
630
- - You CAN compress long paragraphs into concise bullets.
631
- - Every line you remove must have a clear reason. When in doubt, do NOT delete.
388
+ BRAIN STATE:
389
+ - {len(state['learnings'])} active learnings
390
+ - {state['memory_md_lines']} lines in MEMORY.md (limit: 200)
391
+ - {len(state['preferences'])} preferences
392
+ - {state['feedback_count']} feedback files
393
+ - {state['claude_mem_old']} old observations (>60d)
632
394
 
633
395
  {tasks_str}
634
396
 
635
- Al terminar, imprime un resumen JSON con las acciones realizadas."""
636
-
397
+ ABSOLUTE RULES:
398
+ - NEVER delete legal entity info (LLC, SLU, EIN, NIF, project)
399
+ - NEVER delete credentials, tokens, API keys, secrets
400
+ - NEVER delete rules marked CRITICAL or MAX PRIORITY
401
+ - NEVER delete infrastructure info (servers, repos, deploys)
402
+ - When in doubt, DON'T delete
637
403
 
638
- def run_stage_b(conditions: dict) -> dict:
639
- """Run Stage B using Claude CLI."""
640
- prompt = build_stage_b_prompt(conditions)
641
- if not prompt:
642
- return {"skipped": True, "reason": "No tasks to run"}
404
+ Write a summary to {COORD_DIR}/sleep-report.md when done.
405
+ Execute without asking."""
643
406
 
644
- if not CLAUDE_CLI.exists():
645
- return {"error": f"Claude CLI not found at {CLAUDE_CLI}"}
407
+ log("Stage B: Invoking Claude CLI (opus) — dreaming...")
646
408
 
647
- log("Stage B: Invoking Claude CLI (sonnet)...")
409
+ env = os.environ.copy()
410
+ env.pop("CLAUDECODE", None)
411
+ env.pop("CLAUDE_CODE", None)
648
412
 
649
413
  try:
650
- env = os.environ.copy()
651
- # Remove env vars that would cause Claude CLI to think it's inside Claude Code
652
- env.pop("CLAUDECODE", None)
653
- env.pop("CLAUDE_CODE", None)
654
-
655
414
  result = subprocess.run(
656
- [str(CLAUDE_CLI), "-p", prompt, "--model", "sonnet"],
657
- capture_output=True,
658
- text=True,
659
- timeout=600,
660
- env=env
415
+ [str(CLAUDE_CLI), "-p", prompt, "--model", "opus",
416
+ "--allowedTools", "Read,Write,Edit,Glob,Grep"],
417
+ capture_output=True, text=True, timeout=600, env=env
661
418
  )
662
419
 
663
- stdout = result.stdout.strip() if result.stdout else ""
664
- stderr = result.stderr.strip() if result.stderr else ""
665
-
666
420
  if result.returncode != 0:
667
- log(f"Stage B: Claude CLI returned code {result.returncode}")
668
- if stderr:
669
- log(f"Stage B: stderr: {stderr[:500]}")
670
- return {
671
- "returncode": result.returncode,
672
- "stderr": stderr[:500],
673
- "stdout": stdout[:500],
674
- }
675
-
676
- log(f"Stage B: Completed. Output length: {len(stdout)} chars")
677
- return {
678
- "returncode": 0,
679
- "output_length": len(stdout),
680
- "output_preview": stdout[:800],
681
- }
421
+ log(f"Stage B: CLI error ({result.returncode}): {(result.stderr or '')[:300]}")
422
+ return {"error": result.returncode}
423
+
424
+ log(f"Stage B: Dreaming complete. Output: {len(result.stdout or '')} chars")
425
+ return {"ok": True, "output_len": len(result.stdout or "")}
682
426
 
683
427
  except subprocess.TimeoutExpired:
684
- log("Stage B: Claude CLI timed out (600s)")
428
+ log("Stage B: CLI timed out (600s)")
685
429
  return {"error": "timeout"}
686
430
  except Exception as e:
687
431
  log(f"Stage B: Exception: {e}")
688
432
  return {"error": str(e)}
689
433
 
690
434
 
691
- # ─── Main ─────────────────────────────────────────────────────────────────────
435
+ def execute_dream_actions(actions: dict, state: dict):
436
+ """Execute the DB actions decided by CLI, safely in Python."""
437
+ log("Stage B2: Executing dream actions...")
438
+
439
+ # Archive duplicate/stale learnings
440
+ archive_ids = actions.get("archive_ids", []) + actions.get("stale_ids", [])
441
+ if archive_ids and NEXO_DB.exists():
442
+ try:
443
+ conn = sqlite3.connect(str(NEXO_DB))
444
+ for lid in archive_ids:
445
+ if isinstance(lid, int):
446
+ conn.execute(
447
+ "UPDATE learnings SET status='archived' WHERE id=? AND status='active'",
448
+ (lid,)
449
+ )
450
+ conn.commit()
451
+ conn.close()
452
+ log(f" Archived {len(archive_ids)} learnings: {archive_ids}")
453
+ except Exception as e:
454
+ log(f" Error archiving learnings: {e}")
455
+
456
+ # Clean duplicate preferences
457
+ dup_keys = actions.get("duplicate_preference_keys", [])
458
+ if dup_keys and NEXO_DB.exists():
459
+ try:
460
+ conn = sqlite3.connect(str(NEXO_DB))
461
+ for key in dup_keys:
462
+ if isinstance(key, str):
463
+ # Keep newest, delete older duplicates
464
+ conn.execute(
465
+ "DELETE FROM preferences WHERE key = ? AND rowid NOT IN "
466
+ "(SELECT rowid FROM preferences WHERE key = ? ORDER BY updated_at DESC LIMIT 1)",
467
+ (key, key)
468
+ )
469
+ conn.commit()
470
+ conn.close()
471
+ log(f" Cleaned {len(dup_keys)} duplicate preference keys")
472
+ except Exception as e:
473
+ log(f" Error cleaning preferences: {e}")
474
+
475
+ # Clean old observations
476
+ if actions.get("clean_old_observations") and CLAUDE_MEM_DB.exists():
477
+ try:
478
+ cutoff_ms = int((datetime.now() - timedelta(days=60)).timestamp() * 1000)
479
+ conn = sqlite3.connect(str(CLAUDE_MEM_DB))
480
+ deleted = conn.execute(
481
+ "DELETE FROM observations WHERE created_at_epoch < ? "
482
+ "AND discovery_tokens < 300 "
483
+ "AND id NOT IN (SELECT id FROM observations WHERE "
484
+ "title LIKE '%CRITICO%' OR title LIKE '%credential%' "
485
+ "OR title LIKE '%token%' OR title LIKE '%API%' "
486
+ "OR title LIKE '%LLC%' OR title LIKE '%SLU%') "
487
+ "LIMIT 200",
488
+ (cutoff_ms,)
489
+ ).rowcount
490
+ conn.execute(
491
+ "DELETE FROM observations_fts WHERE rowid NOT IN "
492
+ "(SELECT id FROM observations)"
493
+ )
494
+ conn.execute("VACUUM")
495
+ conn.commit()
496
+ conn.close()
497
+ log(f" Cleaned {deleted} old observations")
498
+ except Exception as e:
499
+ log(f" Error cleaning observations: {e}")
500
+
501
+ log("Stage B2: Actions complete.")
502
+
503
+
504
+ # ─── Main ────────────────────────────────────────────────────────────────────
692
505
 
693
506
  def main():
694
507
  log("=" * 60)
695
- log("NEXO Sleep System starting")
508
+ log("NEXO Sleep System v2 starting")
696
509
 
697
- # Process lock via fcntl to prevent concurrent instances
510
+ # Process lock
698
511
  try:
699
512
  lock_fd = open(PROCESS_LOCK, "w")
700
513
  fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
701
514
  lock_fd.write(str(os.getpid()))
702
515
  lock_fd.flush()
703
516
  except (IOError, OSError):
704
- log("Another sleep instance is already running. Exiting.")
517
+ log("Another sleep instance running. Exiting.")
705
518
  sys.exit(0)
706
519
 
707
520
  try:
708
- # Check if already completed today
709
521
  if already_ran_today():
710
522
  log("Already ran today. Exiting.")
711
523
  sys.exit(0)
712
524
 
713
- # Determine start phase (for resume after interruption)
714
525
  start_phase = "stage_a"
715
526
  if was_interrupted():
716
527
  start_phase = get_interrupted_phase()
717
- log(f"Resuming from phase: {start_phase}")
718
-
719
- run_log = {
720
- "date": str(TODAY),
721
- "started": TIMESTAMP,
722
- "stage_a": None,
723
- "stage_c": None,
724
- "stage_b_conditions": None,
725
- "stage_b": None,
726
- "completed": None,
727
- }
728
-
729
- # Stage A: Mechanical cleanup
730
- if start_phase in ("stage_a",):
528
+
529
+ run_log = {"date": str(TODAY), "started": TIMESTAMP,
530
+ "stage_a": None, "stage_b": None, "completed": None}
531
+
532
+ # Stage A: Housekeeping (mechanical)
533
+ if start_phase == "stage_a":
731
534
  set_lock("stage_a")
732
- log("─── Stage A: Mechanical cleanup ───")
733
- stage_a_stats = stage_a_cleanup()
734
- run_log["stage_a"] = stage_a_stats
735
-
736
- total_cleaned = (
737
- stage_a_stats["a1_daily_summaries_deleted"]
738
- + stage_a_stats["a2_session_archives_deleted"]
739
- + stage_a_stats["a3_logs_rotated"]
740
- + stage_a_stats["a4_compressed_memories_deleted"]
741
- + stage_a_stats["a7_daemon_logs_deleted"]
742
- )
743
- log(f"Stage A complete: {total_cleaned} items cleaned, "
744
- f"heartbeat trimmed={stage_a_stats['a5_heartbeat_trimmed']}, "
745
- f"reflection trimmed={stage_a_stats['a6_reflection_trimmed']}")
746
-
747
- # Stage C: Learning Consolidation (always runs, pure Python)
748
- if start_phase in ("stage_a", "stage_c", "stage_b"):
749
- set_lock("stage_c")
750
- log("─── Stage C: Learning Consolidation ───")
751
- stage_c_stats = stage_c_learning_consolidation()
752
- run_log["stage_c"] = stage_c_stats
753
-
754
- # Stage B: Intelligent pruning (conditional)
755
- if start_phase in ("stage_a", "stage_c", "stage_b"):
756
- set_lock("stage_b")
757
- log("─── Stage B: Checking conditions ───")
758
- conditions = check_stage_b_conditions()
759
- run_log["stage_b_conditions"] = conditions
760
-
761
- log(f" MEMORY.md: {conditions['memory_md_lines']} lines "
762
- f"(trigger={conditions['memory_md_over_limit']})")
763
- log(f" nexo.db preferences: {conditions['preferences_auto_sections']} rows "
764
- f"(trigger={conditions['preferences_over_limit']})")
765
- log(f" claude-mem old observations: {conditions['claude_mem_old_observations']} "
766
- f"(trigger={conditions['claude_mem_over_limit']})")
767
-
768
- if conditions["should_trigger"]:
769
- log("Stage B: Conditions met, running intelligent pruning...")
770
- stage_b_result = run_stage_b(conditions)
771
- run_log["stage_b"] = stage_b_result
772
- else:
773
- log("Stage B: No conditions met, skipping.")
774
- run_log["stage_b"] = {"skipped": True, "reason": "No conditions met"}
775
-
776
- # Mark complete
535
+ log("─── Stage A: Housekeeping ───")
536
+ run_log["stage_a"] = stage_a_cleanup()
537
+
538
+ # Stage B: Dreaming (intelligent)
539
+ set_lock("stage_b")
540
+ log("─── Stage B: Dreaming ───")
541
+ state = collect_brain_state()
542
+
543
+ if should_dream(state):
544
+ log(f"Brain state: {len(state['learnings'])} learnings, "
545
+ f"{state['memory_md_lines']} MEMORY lines, "
546
+ f"{state['claude_mem_old']} old observations")
547
+ run_log["stage_b"] = dream(state)
548
+
549
+ # Stage B2: Execute actions from CLI output
550
+ actions_file = COORD_DIR / "sleep-actions.json"
551
+ if actions_file.exists():
552
+ try:
553
+ actions = json.loads(actions_file.read_text())
554
+ execute_dream_actions(actions, state)
555
+ except Exception as e:
556
+ log(f"Stage B2: Error executing actions: {e}")
557
+ else:
558
+ log("Brain is clean no dreaming needed.")
559
+ run_log["stage_b"] = {"skipped": True}
560
+
561
+ # Done
777
562
  run_log["completed"] = datetime.now().strftime("%Y-%m-%d %H:%M")
778
563
  mark_complete()
779
564
  append_sleep_log(run_log)
565
+ log(f"NEXO Sleep v2 complete at {run_log['completed']}")
780
566
 
781
- log(f"NEXO Sleep complete at {run_log['completed']}")
782
-
783
- # Register successful run for catch-up
567
+ # Register for catch-up
784
568
  try:
785
- import json as _json
786
- _state_file = Path.home() / "claude" / "operations" / ".catchup-state.json"
787
- _state = _json.loads(_state_file.read_text()) if _state_file.exists() else {}
788
- _state["sleep"] = datetime.now().isoformat()
789
- _state_file.write_text(_json.dumps(_state, indent=2))
569
+ state_file = Path.home() / ".nexo" / "operations" / ".catchup-state.json"
570
+ st = json.loads(state_file.read_text()) if state_file.exists() else {}
571
+ st["sleep"] = datetime.now().isoformat()
572
+ state_file.write_text(json.dumps(st, indent=2))
790
573
  except Exception:
791
574
  pass
792
575
 
793
- log("=" * 60)
794
-
795
576
  finally:
796
- # Release process lock
797
577
  try:
798
578
  fcntl.flock(lock_fd, fcntl.LOCK_UN)
799
579
  lock_fd.close()