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.
- package/README.md +10 -5
- package/package.json +1 -1
- package/src/__pycache__/evolution_cycle.cpython-314.pyc +0 -0
- package/src/cognitive.py +45 -0
- package/src/evolution_cycle.py +266 -0
- package/src/plugins/guard.py +235 -1
- package/src/scripts/__pycache__/check-context.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-auto-update.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-catchup.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-cognitive-decay.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-daily-self-audit.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-evolution-run.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-immune.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-learning-validator.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-postmortem-consolidator.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-reflection.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-sleep.cpython-314.pyc +0 -0
- package/src/scripts/__pycache__/nexo-synthesis.cpython-314.pyc +0 -0
- package/src/scripts/check-context.py +257 -0
- package/src/scripts/nexo-catchup.py +59 -5
- package/src/scripts/nexo-cognitive-decay.py +8 -0
- package/src/scripts/nexo-daily-self-audit.py +168 -183
- package/src/scripts/nexo-evolution-run.py +584 -0
- package/src/scripts/nexo-immune.py +108 -91
- package/src/scripts/nexo-learning-validator.py +226 -0
- package/src/scripts/nexo-postmortem-consolidator.py +230 -414
- package/src/scripts/nexo-sleep.py +283 -503
- package/src/scripts/nexo-synthesis.py +141 -432
- package/src/tools_sessions.py +20 -12
|
@@ -1,31 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
NEXO Sleep System —
|
|
3
|
+
NEXO Sleep System v2 — The brain dreams.
|
|
4
4
|
|
|
5
|
-
|
|
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 —
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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() / "
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
87
|
+
log(f"Interrupted run (phase: {lock_data.get('phase', '?')}). Resuming.")
|
|
104
88
|
return True
|
|
105
89
|
except PermissionError:
|
|
106
90
|
return False
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
""
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
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:
|
|
197
|
+
if f.stat().st_size > 5 * 1024 * 1024:
|
|
232
198
|
lines = f.read_text().splitlines()
|
|
233
|
-
keep = lines[-500:]
|
|
199
|
+
keep = lines[-500:]
|
|
234
200
|
f.write_text("\n".join(keep) + "\n")
|
|
235
201
|
stats["a3_logs_rotated"] += 1
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
log(f"A6: WARN: {e}")
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
278
236
|
|
|
279
|
-
# A7: Delete daemon/logs/ dirs >14 days
|
|
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
|
-
|
|
291
|
-
|
|
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
|
|
251
|
+
# A8: Delete cortex/logs/*.log >7 days, truncate launchd >5MB
|
|
295
252
|
cutoff_7 = TODAY - timedelta(days=7)
|
|
296
|
-
cortex_logs = Path.home() / "
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
315
|
-
|
|
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
|
|
275
|
+
# ─── Stage B: Dreaming (Claude CLI) ─────────────────────────────────────────
|
|
322
276
|
|
|
323
|
-
|
|
324
|
-
"
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
return stats
|
|
318
|
+
# Feedback count
|
|
319
|
+
state["feedback_count"] = len(list(MEMORY_MD.parent.glob("feedback_*.md")))
|
|
365
320
|
|
|
366
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
#
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
576
|
-
|
|
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
|
-
|
|
358
|
+
The wrapper will execute the actual DB operations based on this JSON.
|
|
582
359
|
|
|
360
|
+
LEARNINGS:
|
|
361
|
+
{learnings_json}""")
|
|
583
362
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
|
589
|
-
tasks.append(f"""
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
622
|
-
|
|
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
|
-
|
|
625
|
-
-
|
|
626
|
-
-
|
|
627
|
-
-
|
|
628
|
-
-
|
|
629
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
639
|
-
|
|
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
|
-
|
|
645
|
-
return {"error": f"Claude CLI not found at {CLAUDE_CLI}"}
|
|
407
|
+
log("Stage B: Invoking Claude CLI (opus) — dreaming...")
|
|
646
408
|
|
|
647
|
-
|
|
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", "
|
|
657
|
-
|
|
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:
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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:
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
# Register successful run for catch-up
|
|
567
|
+
# Register for catch-up
|
|
784
568
|
try:
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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()
|