nexo-brain 1.3.0 → 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__/db.cpython-314.pyc +0 -0
- package/src/__pycache__/evolution_cycle.cpython-314.pyc +0 -0
- package/src/__pycache__/tools_credentials.cpython-314.pyc +0 -0
- package/src/dashboard/__pycache__/__init__.cpython-314.pyc +0 -0
- package/src/dashboard/__pycache__/app.cpython-314.pyc +0 -0
- package/src/plugins/__pycache__/episodic_memory.cpython-314.pyc +0 -0
- package/src/rules/__init__ 2.py +0 -0
- package/src/rules/__pycache__/migrate.cpython-314.pyc +0 -0
- package/src/rules/core-rules 2.json +329 -0
- package/src/rules/migrate 2.py +207 -0
- 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-daily-self-audit.py +168 -183
- package/src/scripts/nexo-evolution-run.py +24 -32
- 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 -527
- package/src/scripts/nexo-synthesis.py +141 -432
|
@@ -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,537 +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 + auto-archive — O(n²) but learnings table is small
|
|
461
|
-
duplicates = []
|
|
462
|
-
archived_ids = set()
|
|
463
|
-
for i in range(len(parsed)):
|
|
464
|
-
if parsed[i]["id"] in archived_ids:
|
|
465
|
-
continue
|
|
466
|
-
for j in range(i + 1, len(parsed)):
|
|
467
|
-
if parsed[j]["id"] in archived_ids:
|
|
468
|
-
continue
|
|
469
|
-
if parsed[i]["category"] != parsed[j]["category"]:
|
|
470
|
-
continue # only dedup within same category
|
|
471
|
-
overlap = _word_overlap(parsed[i]["words"], parsed[j]["words"])
|
|
472
|
-
if overlap >= 0.80:
|
|
473
|
-
# Keep the newer one (higher ID = more recent), archive the older
|
|
474
|
-
keep = parsed[j] if parsed[j]["id"] > parsed[i]["id"] else parsed[i]
|
|
475
|
-
drop = parsed[i] if keep == parsed[j] else parsed[j]
|
|
476
|
-
duplicates.append({
|
|
477
|
-
"keep_id": keep["id"],
|
|
478
|
-
"drop_id": drop["id"],
|
|
479
|
-
"title_keep": keep["title"],
|
|
480
|
-
"title_drop": drop["title"],
|
|
481
|
-
"overlap": round(overlap, 2),
|
|
482
|
-
})
|
|
483
|
-
archived_ids.add(drop["id"])
|
|
484
|
-
|
|
485
|
-
# Auto-archive detected duplicates
|
|
486
|
-
if archived_ids:
|
|
487
|
-
try:
|
|
488
|
-
conn = sqlite3.connect(str(NEXO_DB))
|
|
489
|
-
for aid in archived_ids:
|
|
490
|
-
conn.execute(
|
|
491
|
-
"UPDATE learnings SET status='archived' WHERE id=? AND status='active'",
|
|
492
|
-
(aid,)
|
|
493
|
-
)
|
|
494
|
-
conn.commit()
|
|
495
|
-
conn.close()
|
|
496
|
-
log(f"Stage C: Auto-archived {len(archived_ids)} duplicate learnings.")
|
|
497
|
-
except Exception as e:
|
|
498
|
-
log(f"Stage C: WARN: Failed to archive duplicates: {e}")
|
|
499
|
-
|
|
500
|
-
stats["potential_duplicates"] = duplicates[:10] # log max 10 for readability
|
|
501
|
-
stats["auto_archived"] = len(archived_ids)
|
|
502
|
-
|
|
503
|
-
# C4: Contradiction detection — NUNCA pairs in same category
|
|
504
|
-
nunca_entries = [p for p in parsed if "nunca" in p["title"].lower()]
|
|
505
|
-
contradictions = []
|
|
506
|
-
for nunca in nunca_entries:
|
|
507
|
-
if len(contradictions) >= 5:
|
|
508
|
-
break
|
|
509
|
-
# Look for same-category entries that don't contain NUNCA
|
|
510
|
-
# and whose remaining words overlap significantly (same subject, opposite stance)
|
|
511
|
-
nunca_words_no_nunca = nunca["words"] - {"nunca"}
|
|
512
|
-
for other in parsed:
|
|
513
|
-
if len(contradictions) >= 5:
|
|
514
|
-
break
|
|
515
|
-
if other["id"] == nunca["id"]:
|
|
516
|
-
continue
|
|
517
|
-
if other["category"] != nunca["category"]:
|
|
518
|
-
continue
|
|
519
|
-
if "nunca" in other["title"].lower():
|
|
520
|
-
continue
|
|
521
|
-
# Check if they share meaningful subject words
|
|
522
|
-
overlap = _word_overlap(nunca_words_no_nunca, other["words"])
|
|
523
|
-
if overlap >= 0.50:
|
|
524
|
-
contradictions.append({
|
|
525
|
-
"id1": nunca["id"],
|
|
526
|
-
"id2": other["id"],
|
|
527
|
-
"title1": nunca["title"],
|
|
528
|
-
"title2": other["title"],
|
|
529
|
-
})
|
|
530
|
-
stats["potential_contradictions"] = contradictions
|
|
531
|
-
|
|
532
|
-
log(f"Stage C: {stats['total_learnings']} learnings analyzed. "
|
|
533
|
-
f"Duplicates found: {len(duplicates)}, archived: {len(archived_ids)}. "
|
|
534
|
-
f"Categories over 20: {len(stats['categories_over_20'])}. "
|
|
535
|
-
f"Potential contradictions: {len(contradictions)}.")
|
|
536
|
-
if stats["hottest_category_7d"]:
|
|
537
|
-
log(f"Stage C: Hottest category last 7d: {stats['hottest_category_7d']} "
|
|
538
|
-
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
|
+
)
|
|
539
332
|
|
|
540
|
-
return stats
|
|
541
333
|
|
|
334
|
+
def dream(state: dict) -> dict:
|
|
335
|
+
"""The brain dreams — CLI does the intelligent work."""
|
|
542
336
|
|
|
543
|
-
#
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
Check if Stage B should activate.
|
|
548
|
-
Returns dict with condition results and whether to trigger.
|
|
549
|
-
"""
|
|
550
|
-
conditions = {
|
|
551
|
-
"memory_md_lines": 0,
|
|
552
|
-
"memory_md_over_limit": False,
|
|
553
|
-
"preferences_auto_sections": 0,
|
|
554
|
-
"preferences_over_limit": False,
|
|
555
|
-
"claude_mem_old_observations": 0,
|
|
556
|
-
"claude_mem_over_limit": False,
|
|
557
|
-
"should_trigger": False,
|
|
558
|
-
}
|
|
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)"
|
|
559
341
|
|
|
560
|
-
|
|
561
|
-
if MEMORY_MD.exists():
|
|
562
|
-
try:
|
|
563
|
-
lines = MEMORY_MD.read_text().splitlines()
|
|
564
|
-
conditions["memory_md_lines"] = len(lines)
|
|
565
|
-
conditions["memory_md_over_limit"] = len(lines) > 170
|
|
566
|
-
except Exception as e:
|
|
567
|
-
log(f"Stage B check: WARN reading MEMORY.md: {e}")
|
|
342
|
+
tasks = []
|
|
568
343
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
cursor.execute("SELECT COUNT(*) FROM preferences")
|
|
575
|
-
count = cursor.fetchone()[0]
|
|
576
|
-
conn.close()
|
|
577
|
-
conditions["preferences_auto_sections"] = count
|
|
578
|
-
conditions["preferences_over_limit"] = count > 5
|
|
579
|
-
except Exception as e:
|
|
580
|
-
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.
|
|
581
349
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
conn = sqlite3.connect(str(CLAUDE_MEM_DB))
|
|
587
|
-
cursor = conn.cursor()
|
|
588
|
-
cursor.execute(
|
|
589
|
-
"SELECT COUNT(*) FROM observations WHERE created_at_epoch < ?",
|
|
590
|
-
(cutoff_epoch,)
|
|
591
|
-
)
|
|
592
|
-
count = cursor.fetchone()[0]
|
|
593
|
-
conn.close()
|
|
594
|
-
conditions["claude_mem_old_observations"] = count
|
|
595
|
-
conditions["claude_mem_over_limit"] = count > 500
|
|
596
|
-
except Exception as e:
|
|
597
|
-
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
|
|
598
354
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
or conditions["preferences_over_limit"]
|
|
602
|
-
or conditions["claude_mem_over_limit"]
|
|
603
|
-
)
|
|
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]}}
|
|
604
357
|
|
|
605
|
-
|
|
358
|
+
The wrapper will execute the actual DB operations based on this JSON.
|
|
606
359
|
|
|
360
|
+
LEARNINGS:
|
|
361
|
+
{learnings_json}""")
|
|
607
362
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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.""")
|
|
611
369
|
|
|
612
|
-
if
|
|
613
|
-
tasks.append(f"""
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
if
|
|
619
|
-
tasks.append(f"""
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
Reporta cuantos registros eliminaste.""")
|
|
624
|
-
|
|
625
|
-
if conditions["claude_mem_over_limit"]:
|
|
626
|
-
tasks.append(f"""TAREA 3: claude-mem observations ({conditions['claude_mem_old_observations']} registros >60d)
|
|
627
|
-
DB: {CLAUDE_MEM_DB}
|
|
628
|
-
Conecta con sqlite3. Ejecuta:
|
|
629
|
-
DELETE FROM observations WHERE created_at_epoch < {int((datetime.now() - timedelta(days=60)).timestamp() * 1000)}
|
|
630
|
-
AND discovery_tokens < 300
|
|
631
|
-
AND id NOT IN (SELECT id FROM observations WHERE
|
|
632
|
-
title LIKE '%CRITICO%' OR title LIKE '%MAXIMA%'
|
|
633
|
-
OR title LIKE '%credential%' OR title LIKE '%token%' OR title LIKE '%API%'
|
|
634
|
-
OR narrative LIKE '%CRITICO%' OR narrative LIKE '%MAXIMA%')
|
|
635
|
-
LIMIT 200;
|
|
636
|
-
Luego: DELETE FROM observations_fts WHERE rowid NOT IN (SELECT id FROM observations);
|
|
637
|
-
Luego: VACUUM;
|
|
638
|
-
Reporta cuantos registros eliminaste.""")
|
|
639
|
-
|
|
640
|
-
if not tasks:
|
|
641
|
-
return ""
|
|
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.""")
|
|
375
|
+
|
|
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.""")
|
|
642
381
|
|
|
643
382
|
tasks_str = "\n\n".join(tasks)
|
|
644
383
|
|
|
645
|
-
|
|
646
|
-
|
|
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.
|
|
647
387
|
|
|
648
|
-
|
|
649
|
-
-
|
|
650
|
-
-
|
|
651
|
-
-
|
|
652
|
-
-
|
|
653
|
-
-
|
|
654
|
-
- You CAN compress long paragraphs into concise bullets.
|
|
655
|
-
- 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)
|
|
656
394
|
|
|
657
395
|
{tasks_str}
|
|
658
396
|
|
|
659
|
-
|
|
660
|
-
|
|
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
|
|
661
403
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
prompt = build_stage_b_prompt(conditions)
|
|
665
|
-
if not prompt:
|
|
666
|
-
return {"skipped": True, "reason": "No tasks to run"}
|
|
404
|
+
Write a summary to {COORD_DIR}/sleep-report.md when done.
|
|
405
|
+
Execute without asking."""
|
|
667
406
|
|
|
668
|
-
|
|
669
|
-
return {"error": f"Claude CLI not found at {CLAUDE_CLI}"}
|
|
407
|
+
log("Stage B: Invoking Claude CLI (opus) — dreaming...")
|
|
670
408
|
|
|
671
|
-
|
|
409
|
+
env = os.environ.copy()
|
|
410
|
+
env.pop("CLAUDECODE", None)
|
|
411
|
+
env.pop("CLAUDE_CODE", None)
|
|
672
412
|
|
|
673
413
|
try:
|
|
674
|
-
env = os.environ.copy()
|
|
675
|
-
# Remove env vars that would cause Claude CLI to think it's inside Claude Code
|
|
676
|
-
env.pop("CLAUDECODE", None)
|
|
677
|
-
env.pop("CLAUDE_CODE", None)
|
|
678
|
-
|
|
679
414
|
result = subprocess.run(
|
|
680
|
-
[str(CLAUDE_CLI), "-p", prompt, "--model", "
|
|
681
|
-
|
|
682
|
-
text=True,
|
|
683
|
-
timeout=600,
|
|
684
|
-
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
|
|
685
418
|
)
|
|
686
419
|
|
|
687
|
-
stdout = result.stdout.strip() if result.stdout else ""
|
|
688
|
-
stderr = result.stderr.strip() if result.stderr else ""
|
|
689
|
-
|
|
690
420
|
if result.returncode != 0:
|
|
691
|
-
log(f"Stage B:
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
"stderr": stderr[:500],
|
|
697
|
-
"stdout": stdout[:500],
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
log(f"Stage B: Completed. Output length: {len(stdout)} chars")
|
|
701
|
-
return {
|
|
702
|
-
"returncode": 0,
|
|
703
|
-
"output_length": len(stdout),
|
|
704
|
-
"output_preview": stdout[:800],
|
|
705
|
-
}
|
|
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 "")}
|
|
706
426
|
|
|
707
427
|
except subprocess.TimeoutExpired:
|
|
708
|
-
log("Stage B:
|
|
428
|
+
log("Stage B: CLI timed out (600s)")
|
|
709
429
|
return {"error": "timeout"}
|
|
710
430
|
except Exception as e:
|
|
711
431
|
log(f"Stage B: Exception: {e}")
|
|
712
432
|
return {"error": str(e)}
|
|
713
433
|
|
|
714
434
|
|
|
715
|
-
|
|
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 ────────────────────────────────────────────────────────────────────
|
|
716
505
|
|
|
717
506
|
def main():
|
|
718
507
|
log("=" * 60)
|
|
719
|
-
log("NEXO Sleep System starting")
|
|
508
|
+
log("NEXO Sleep System v2 starting")
|
|
720
509
|
|
|
721
|
-
# Process lock
|
|
510
|
+
# Process lock
|
|
722
511
|
try:
|
|
723
512
|
lock_fd = open(PROCESS_LOCK, "w")
|
|
724
513
|
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
725
514
|
lock_fd.write(str(os.getpid()))
|
|
726
515
|
lock_fd.flush()
|
|
727
516
|
except (IOError, OSError):
|
|
728
|
-
log("Another sleep instance
|
|
517
|
+
log("Another sleep instance running. Exiting.")
|
|
729
518
|
sys.exit(0)
|
|
730
519
|
|
|
731
520
|
try:
|
|
732
|
-
# Check if already completed today
|
|
733
521
|
if already_ran_today():
|
|
734
522
|
log("Already ran today. Exiting.")
|
|
735
523
|
sys.exit(0)
|
|
736
524
|
|
|
737
|
-
# Determine start phase (for resume after interruption)
|
|
738
525
|
start_phase = "stage_a"
|
|
739
526
|
if was_interrupted():
|
|
740
527
|
start_phase = get_interrupted_phase()
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
"stage_c": None,
|
|
748
|
-
"stage_b_conditions": None,
|
|
749
|
-
"stage_b": None,
|
|
750
|
-
"completed": None,
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
# Stage A: Mechanical cleanup
|
|
754
|
-
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":
|
|
755
534
|
set_lock("stage_a")
|
|
756
|
-
log("─── Stage A:
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
run_log["stage_b_conditions"] = conditions
|
|
784
|
-
|
|
785
|
-
log(f" MEMORY.md: {conditions['memory_md_lines']} lines "
|
|
786
|
-
f"(trigger={conditions['memory_md_over_limit']})")
|
|
787
|
-
log(f" nexo.db preferences: {conditions['preferences_auto_sections']} rows "
|
|
788
|
-
f"(trigger={conditions['preferences_over_limit']})")
|
|
789
|
-
log(f" claude-mem old observations: {conditions['claude_mem_old_observations']} "
|
|
790
|
-
f"(trigger={conditions['claude_mem_over_limit']})")
|
|
791
|
-
|
|
792
|
-
if conditions["should_trigger"]:
|
|
793
|
-
log("Stage B: Conditions met, running intelligent pruning...")
|
|
794
|
-
stage_b_result = run_stage_b(conditions)
|
|
795
|
-
run_log["stage_b"] = stage_b_result
|
|
796
|
-
else:
|
|
797
|
-
log("Stage B: No conditions met, skipping.")
|
|
798
|
-
run_log["stage_b"] = {"skipped": True, "reason": "No conditions met"}
|
|
799
|
-
|
|
800
|
-
# 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
|
|
801
562
|
run_log["completed"] = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
802
563
|
mark_complete()
|
|
803
564
|
append_sleep_log(run_log)
|
|
565
|
+
log(f"NEXO Sleep v2 complete at {run_log['completed']}")
|
|
804
566
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
# Register successful run for catch-up
|
|
567
|
+
# Register for catch-up
|
|
808
568
|
try:
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
_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))
|
|
814
573
|
except Exception:
|
|
815
574
|
pass
|
|
816
575
|
|
|
817
|
-
log("=" * 60)
|
|
818
|
-
|
|
819
576
|
finally:
|
|
820
|
-
# Release process lock
|
|
821
577
|
try:
|
|
822
578
|
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
823
579
|
lock_fd.close()
|