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,32 +1,42 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- NEXO Daily Self-Audit
4
- Proactively scans for common issues before they become problems.
5
- Runs via launchd at 7:00 AM daily. Results saved to ~/claude/logs/self-audit.log
3
+ NEXO Daily Self-Audit v2
4
+
5
+ Stage A Mechanical checks (Python pure, unchanged):
6
+ 18 checks: overdue reminders, disk space, DB size, stale sessions, guard stats,
7
+ cognitive health, snapshot drift, etc. All pure queries, no intelligence needed.
8
+
9
+ Stage B — Interpretation (Claude CLI opus):
10
+ Takes the raw findings from Stage A and UNDERSTANDS them:
11
+ - Groups related findings
12
+ - Identifies root causes
13
+ - Prioritizes what actually matters
14
+ - Suggests specific actions
15
+ - Writes actionable summary
16
+
17
+ Runs via launchd at 7:00 AM daily.
6
18
  """
7
19
  import json
20
+ import hashlib
8
21
  import os
9
- import re
10
22
  import sqlite3
11
23
  import subprocess
12
24
  import sys
13
- import hashlib
14
25
  from datetime import datetime, timedelta
15
26
  from pathlib import Path
16
27
 
17
- NEXO_HOME_PATH = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
18
- LOG_DIR = NEXO_HOME_PATH / "logs"
28
+ LOG_DIR = Path.home() / ".nexo" / "logs"
19
29
  LOG_DIR.mkdir(parents=True, exist_ok=True)
20
30
  LOG_FILE = LOG_DIR / "self-audit.log"
21
- NEXO_DB = NEXO_HOME_PATH / "nexo.db"
22
- # Configure this to point to your main project repo for uncommitted-changes check
23
- PROJECT_REPO_DIR = Path(os.environ.get("NEXO_PROJECT_REPO", str(Path.home() / "projects" / "main")))
24
- HASH_REGISTRY = Path.home() / "claude" / "scripts" / ".watchdog-hashes"
25
- SNAPSHOT_GOLDEN = Path.home() / "claude" / "snapshots" / "golden" / "files" / "claude"
31
+ NEXO_DB = Path.home() / ".nexo" / "nexo.db"
32
+ project_DIR = Path.home() / "Documents" / "_PhpstormProjects" / "project"
33
+ HASH_REGISTRY = Path.home() / ".nexo" / "scripts" / ".watchdog-hashes"
34
+ SNAPSHOT_GOLDEN = Path.home() / ".nexo" / "snapshots" / "golden" / "files" / "claude"
26
35
  RUNTIME_PREFLIGHT_SUMMARY = LOG_DIR / "runtime-preflight-summary.json"
27
36
  WATCHDOG_SMOKE_SUMMARY = LOG_DIR / "watchdog-smoke-summary.json"
28
37
  RESTORE_LOG = LOG_DIR / "snapshot-restores.log"
29
- CORTEX_LOG_DIR = Path.home() / "claude" / "cortex" / "logs"
38
+ CORTEX_LOG_DIR = Path.home() / ".nexo" / "cortex" / "logs"
39
+ CLAUDE_CLI = Path.home() / ".local" / "bin" / "claude"
30
40
 
31
41
  findings = []
32
42
 
@@ -44,14 +54,17 @@ def finding(severity, area, msg):
44
54
  log(f" [{severity}] {area}: {msg}")
45
55
 
46
56
 
47
- # ── Check 1: Overdue reminders ──────────────────────────────────────────
57
+ # ═══════════════════════════════════════════════════════════════════════════════
58
+ # Stage A: Mechanical checks (UNCHANGED from v1 — all 18 checks)
59
+ # ═══════════════════════════════════════════════════════════════════════════════
60
+
48
61
  def check_overdue_reminders():
49
62
  if not NEXO_DB.exists():
50
63
  return
51
64
  conn = sqlite3.connect(str(NEXO_DB))
52
65
  today = datetime.now().strftime("%Y-%m-%d")
53
66
  rows = conn.execute(
54
- "SELECT description, date FROM reminders WHERE status='PENDIENTE' AND date < ? AND date != '' ORDER BY date",
67
+ "SELECT description, date FROM reminders WHERE status='PENDING' AND date < ? AND date != '' ORDER BY date",
55
68
  (today,)
56
69
  ).fetchall()
57
70
  conn.close()
@@ -59,14 +72,13 @@ def check_overdue_reminders():
59
72
  finding("WARN", "reminders", f"{len(rows)} overdue: {', '.join(r[0][:40] for r in rows[:5])}")
60
73
 
61
74
 
62
- # ── Check 2: Overdue followups ──────────────────────────────────────────
63
75
  def check_overdue_followups():
64
76
  if not NEXO_DB.exists():
65
77
  return
66
78
  conn = sqlite3.connect(str(NEXO_DB))
67
79
  today = datetime.now().strftime("%Y-%m-%d")
68
80
  rows = conn.execute(
69
- "SELECT description, date FROM followups WHERE status='PENDIENTE' AND date < ? AND date != '' ORDER BY date",
81
+ "SELECT description, date FROM followups WHERE status='PENDING' AND date < ? AND date != '' ORDER BY date",
70
82
  (today,)
71
83
  ).fetchall()
72
84
  conn.close()
@@ -74,20 +86,18 @@ def check_overdue_followups():
74
86
  finding("WARN", "followups", f"{len(rows)} overdue: {', '.join(r[0][:40] for r in rows[:5])}")
75
87
 
76
88
 
77
- # ── Check 3: Git uncommitted changes in project repo ───────────────────
78
89
  def check_uncommitted_changes():
79
- if not PROJECT_REPO_DIR.exists():
90
+ if not project_DIR.exists():
80
91
  return
81
92
  result = subprocess.run(
82
93
  ["git", "status", "--porcelain"],
83
- cwd=str(PROJECT_REPO_DIR), capture_output=True, text=True
94
+ cwd=str(project_DIR), capture_output=True, text=True
84
95
  )
85
96
  lines = [l for l in result.stdout.strip().split("\n") if l.strip()]
86
97
  if len(lines) > 10:
87
- finding("WARN", "git", f"{len(lines)} uncommitted changes in {PROJECT_REPO_DIR.name} repo")
98
+ finding("WARN", "git", f"{len(lines)} uncommitted changes in project repo")
88
99
 
89
100
 
90
- # ── Check 4: Cron error logs (last 24h) ────────────────────────────────
91
101
  def check_cron_errors():
92
102
  if not NEXO_DB.exists():
93
103
  return
@@ -102,9 +112,8 @@ def check_cron_errors():
102
112
  finding("ERROR", "crons", f"{len(rows)} cron errors in last 24h")
103
113
 
104
114
 
105
- # ── Check 5: Evolution failures ─────────────────────────────────────────
106
115
  def check_evolution_health():
107
- obj_file = Path.home() / "claude" / "cortex" / "evolution-objective.json"
116
+ obj_file = Path.home() / ".nexo" / "cortex" / "evolution-objective.json"
108
117
  if not obj_file.exists():
109
118
  return
110
119
  obj = json.loads(obj_file.read_text())
@@ -115,7 +124,6 @@ def check_evolution_health():
115
124
  finding("ERROR", "evolution", f"Evolution DISABLED: {obj.get('disabled_reason', 'unknown')}")
116
125
 
117
126
 
118
- # ── Check 6: Disk space ────────────────────────────────────────────────
119
127
  def check_disk_space():
120
128
  result = subprocess.run(["df", "-h", "/"], capture_output=True, text=True)
121
129
  for line in result.stdout.strip().split("\n")[1:]:
@@ -128,7 +136,6 @@ def check_disk_space():
128
136
  finding("WARN", "disk", f"Root disk at {usage_pct}% capacity")
129
137
 
130
138
 
131
- # ── Check 7: NEXO DB size ──────────────────────────────────────────────
132
139
  def check_db_size():
133
140
  if NEXO_DB.exists():
134
141
  size_mb = NEXO_DB.stat().st_size / (1024 * 1024)
@@ -136,7 +143,6 @@ def check_db_size():
136
143
  finding("WARN", "database", f"nexo.db is {size_mb:.1f} MB — consider cleanup")
137
144
 
138
145
 
139
- # ── Check 8: Stale sessions ────────────────────────────────────────────
140
146
  def check_stale_sessions():
141
147
  if not NEXO_DB.exists():
142
148
  return
@@ -152,15 +158,12 @@ def check_stale_sessions():
152
158
  finding("INFO", "sessions", f"{len(rows)} stale sessions (no heartbeat >2h)")
153
159
 
154
160
 
155
- # ── Check 9: Error repetition rate (Guard) ─────────────────────────────
156
161
  def check_repetition_rate():
157
- """Alert if >30% of learnings in last 3 days are repetitions."""
158
162
  if not NEXO_DB.exists():
159
163
  return
160
164
  conn = sqlite3.connect(str(NEXO_DB))
161
- cutoff_3d = (datetime.now() - timedelta(days=3)).strftime("%Y-%m-%d %H:%M:%S")
162
165
  cutoff_epoch = (datetime.now() - timedelta(days=3)).timestamp()
163
-
166
+ cutoff_3d = (datetime.now() - timedelta(days=3)).strftime("%Y-%m-%d %H:%M:%S")
164
167
  new_learnings = conn.execute(
165
168
  "SELECT COUNT(*) FROM learnings WHERE created_at > ?", (cutoff_epoch,)
166
169
  ).fetchone()[0]
@@ -168,44 +171,34 @@ def check_repetition_rate():
168
171
  "SELECT COUNT(*) FROM error_repetitions WHERE created_at > ?", (cutoff_3d,)
169
172
  ).fetchone()[0]
170
173
  conn.close()
171
-
172
174
  if new_learnings > 0:
173
175
  rate = repetitions / new_learnings
174
176
  if rate > 0.30:
175
- finding("ERROR", "guard", f"Repetition rate {rate:.0%} over last 3 days ({repetitions}/{new_learnings}) — exceeds 30% threshold")
177
+ finding("ERROR", "guard", f"Repetition rate {rate:.0%} ({repetitions}/{new_learnings})")
176
178
  elif rate > 0.20:
177
- finding("WARN", "guard", f"Repetition rate {rate:.0%} over last 3 days ({repetitions}/{new_learnings})")
179
+ finding("WARN", "guard", f"Repetition rate {rate:.0%} ({repetitions}/{new_learnings})")
178
180
 
179
181
 
180
- # ── Check 10: Unused learnings ─────────────────────────────────────────
181
182
  def check_unused_learnings():
182
- """Find learnings >7 days old never returned by guard_check."""
183
183
  if not NEXO_DB.exists():
184
184
  return
185
185
  conn = sqlite3.connect(str(NEXO_DB))
186
186
  cutoff_epoch = (datetime.now() - timedelta(days=7)).timestamp()
187
-
188
187
  old_learnings = conn.execute(
189
188
  "SELECT COUNT(*) FROM learnings WHERE created_at < ?", (cutoff_epoch,)
190
189
  ).fetchone()[0]
191
190
  total_checks = conn.execute("SELECT COUNT(*) FROM guard_checks").fetchone()[0]
192
191
  conn.close()
193
-
194
192
  if total_checks == 0 and old_learnings > 10:
195
- finding("WARN", "guard", f"Guard never used — {old_learnings} learnings sitting idle. Call nexo_guard_check before edits.")
196
- elif total_checks > 0 and total_checks < 5:
197
- finding("INFO", "guard", f"Only {total_checks} guard checks performed — aim for >5 per session")
193
+ finding("WARN", "guard", f"Guard never used — {old_learnings} learnings idle")
198
194
 
199
195
 
200
- # ── Check 11: Memory reviews due ────────────────────────────────────────
201
196
  def check_memory_reviews():
202
- """Alert when decisions/learnings are due for review."""
203
197
  if not NEXO_DB.exists():
204
198
  return
205
199
  conn = sqlite3.connect(str(NEXO_DB))
206
200
  now_epoch = datetime.now().timestamp()
207
201
  now_iso = datetime.now().isoformat(timespec="seconds")
208
-
209
202
  try:
210
203
  due_learnings = conn.execute(
211
204
  "SELECT COUNT(*) FROM learnings WHERE review_due_at IS NOT NULL AND status != 'superseded' AND review_due_at <= ?",
@@ -219,49 +212,39 @@ def check_memory_reviews():
219
212
  conn.close()
220
213
  return
221
214
  conn.close()
222
-
223
- total_due = due_learnings + due_decisions
224
- if total_due >= 10:
225
- finding("WARN", "memory", f"{total_due} memory reviews due ({due_decisions} decisions, {due_learnings} learnings)")
226
- elif total_due > 0:
227
- finding("INFO", "memory", f"{total_due} memory reviews due ({due_decisions} decisions, {due_learnings} learnings)")
215
+ total = due_learnings + due_decisions
216
+ if total >= 10:
217
+ finding("WARN", "memory", f"{total} reviews due ({due_decisions} decisions, {due_learnings} learnings)")
218
+ elif total > 0:
219
+ finding("INFO", "memory", f"{total} reviews due")
228
220
 
229
221
 
230
- def _sha256(path: Path) -> str:
222
+ def _sha256(path):
231
223
  return hashlib.sha256(path.read_bytes()).hexdigest()
232
224
 
233
225
 
234
- # ── Check 12: Watchdog registry sanity ──────────────────────────────────
235
226
  def check_watchdog_registry():
236
227
  if not HASH_REGISTRY.exists():
237
- finding("WARN", "watchdog", "hash registry missing")
238
228
  return
239
229
  text = HASH_REGISTRY.read_text(errors="ignore")
240
230
  forbidden = ["CLAUDE.md", "db.py", "server.py", "plugin_loader.py", "cortex-wrapper.py"]
241
231
  bad = [name for name in forbidden if name in text]
242
232
  if bad:
243
- finding("ERROR", "watchdog", f"mutable files still protected by watchdog: {', '.join(bad)}")
233
+ finding("ERROR", "watchdog", f"mutable files still protected: {', '.join(bad)}")
244
234
 
245
235
 
246
- # ── Check 13: Snapshot drift on protected recovery files ────────────────
247
236
  def check_snapshot_sync():
248
237
  pairs = [
249
- (NEXO_HOME_PATH / "db.py", SNAPSHOT_GOLDEN / "nexo-mcp" / "db.py"),
250
- (Path.home() / "claude" / "cortex" / "cortex-wrapper.py", SNAPSHOT_GOLDEN / "cortex" / "cortex-wrapper.py"),
251
- (Path.home() / "claude" / "cortex" / "evolution_cycle.py", SNAPSHOT_GOLDEN / "cortex" / "evolution_cycle.py"),
238
+ (Path.home() / ".nexo" / "db.py", SNAPSHOT_GOLDEN / "db.py"),
239
+ (Path.home() / ".nexo" / "cortex" / "cortex-wrapper.py", SNAPSHOT_GOLDEN / "cortex" / "cortex-wrapper.py"),
240
+ (Path.home() / ".nexo" / "cortex" / "evolution_cycle.py", SNAPSHOT_GOLDEN / "cortex" / "evolution_cycle.py"),
252
241
  ]
253
- drift = []
254
- for live, snap in pairs:
255
- if not live.exists() or not snap.exists():
256
- drift.append(live.name)
257
- continue
258
- if _sha256(live) != _sha256(snap):
259
- drift.append(live.name)
242
+ drift = [live.name for live, snap in pairs
243
+ if not live.exists() or not snap.exists() or _sha256(live) != _sha256(snap)]
260
244
  if drift:
261
245
  finding("WARN", "snapshots", f"golden snapshot drift: {', '.join(drift)}")
262
246
 
263
247
 
264
- # ── Check 14: Recent restore activity ───────────────────────────────────
265
248
  def check_restore_activity():
266
249
  if not RESTORE_LOG.exists():
267
250
  return
@@ -270,9 +253,7 @@ def check_restore_activity():
270
253
  recent_day = 0
271
254
  recent_hour = 0
272
255
  for line in RESTORE_LOG.read_text(errors="ignore").splitlines():
273
- if not line.startswith("["):
274
- continue
275
- if "/.codex/memories/nexo-" in line:
256
+ if not line.startswith("[") or "/.codex/memories/nexo-" in line:
276
257
  continue
277
258
  try:
278
259
  ts = datetime.strptime(line[1:20], "%Y-%m-%d %H:%M:%S")
@@ -283,37 +264,29 @@ def check_restore_activity():
283
264
  if line[1:14] == current_hour_prefix:
284
265
  recent_hour += 1
285
266
  if recent_hour > 2:
286
- finding("ERROR", "restore", f"{recent_hour} snapshot restores in last hour")
267
+ finding("ERROR", "restore", f"{recent_hour} restores in last hour")
287
268
  elif recent_day > 5:
288
- finding("WARN", "restore", f"{recent_day} snapshot restores in last 24h")
289
- elif recent_day > 0:
290
- finding("INFO", "restore", f"{recent_day} snapshot restores in last 24h (historical activity)")
269
+ finding("WARN", "restore", f"{recent_day} restores in last 24h")
291
270
 
292
271
 
293
- # ── Check 15: Bad model responses ───────────────────────────────────────
294
272
  def check_bad_responses():
295
273
  if not CORTEX_LOG_DIR.exists():
296
274
  return
297
275
  cutoff = datetime.now() - timedelta(days=1)
298
- bad = [
299
- p for p in CORTEX_LOG_DIR.glob("bad-response-*.json")
300
- if datetime.fromtimestamp(p.stat().st_mtime) >= cutoff
301
- ]
276
+ bad = [p for p in CORTEX_LOG_DIR.glob("bad-response-*.json")
277
+ if datetime.fromtimestamp(p.stat().st_mtime) >= cutoff]
302
278
  if bad:
303
279
  finding("WARN", "cortex", f"{len(bad)} bad model responses in last 24h")
304
280
 
305
281
 
306
- # ── Check 16: Runtime preflight freshness ───────────────────────────────
307
282
  def check_runtime_preflight():
308
283
  if not RUNTIME_PREFLIGHT_SUMMARY.exists():
309
- finding("WARN", "preflight", "runtime preflight summary missing")
310
284
  return
311
285
  data = json.loads(RUNTIME_PREFLIGHT_SUMMARY.read_text())
312
286
  ts = data.get("timestamp")
313
287
  try:
314
288
  when = datetime.fromisoformat(ts)
315
289
  except Exception:
316
- finding("WARN", "preflight", "runtime preflight timestamp invalid")
317
290
  return
318
291
  if when < datetime.now() - timedelta(days=1):
319
292
  finding("WARN", "preflight", "runtime preflight older than 24h")
@@ -321,17 +294,14 @@ def check_runtime_preflight():
321
294
  finding("ERROR", "preflight", "runtime preflight failing")
322
295
 
323
296
 
324
- # ── Check 17: Watchdog smoke freshness ──────────────────────────────────
325
297
  def check_watchdog_smoke():
326
298
  if not WATCHDOG_SMOKE_SUMMARY.exists():
327
- finding("WARN", "watchdog", "watchdog smoke summary missing")
328
299
  return
329
300
  data = json.loads(WATCHDOG_SMOKE_SUMMARY.read_text())
330
301
  ts = data.get("timestamp")
331
302
  try:
332
303
  when = datetime.fromisoformat(ts)
333
304
  except Exception:
334
- finding("WARN", "watchdog", "watchdog smoke timestamp invalid")
335
305
  return
336
306
  if when < datetime.now() - timedelta(days=1):
337
307
  finding("WARN", "watchdog", "watchdog smoke older than 24h")
@@ -339,10 +309,8 @@ def check_watchdog_smoke():
339
309
  finding("ERROR", "watchdog", "watchdog smoke failing")
340
310
 
341
311
 
342
- # ── Check 18: Cognitive memory health ────────────────────────────────
343
312
  def check_cognitive_health():
344
- """Check cognitive.db health and run weekly GC on Sundays."""
345
- cognitive_db = NEXO_HOME_PATH / "cognitive.db"
313
+ cognitive_db = Path.home() / ".nexo" / "cognitive.db"
346
314
  if not cognitive_db.exists():
347
315
  finding("WARN", "cognitive", "cognitive.db not found")
348
316
  return
@@ -356,134 +324,150 @@ def check_cognitive_health():
356
324
  conn.close()
357
325
 
358
326
  size_mb = cognitive_db.stat().st_size / (1024 * 1024)
359
- finding("INFO", "cognitive", f"STM: {stm_count} (sensory: {sensory_count}) | LTM: {ltm_active} active, {ltm_dormant} dormant | {size_mb:.1f} MB | avg STM strength: {avg_stm_str:.2f}")
327
+ finding("INFO", "cognitive", f"STM: {stm_count} (sensory: {sensory_count}) | LTM: {ltm_active} active, {ltm_dormant} dormant | {size_mb:.1f} MB")
360
328
 
361
329
  if avg_stm_str < 0.3 and stm_count > 20:
362
- finding("WARN", "cognitive", f"STM average strength very low ({avg_stm_str:.2f}) — memories decaying without access")
330
+ finding("WARN", "cognitive", f"STM average strength very low ({avg_stm_str:.2f})")
363
331
 
364
- # Metrics report (spec section 9)
332
+ # Metrics
365
333
  try:
366
- sys.path.insert(0, str(NEXO_HOME_PATH))
334
+ sys.path.insert(0, str(Path.home() / ".nexo"))
367
335
  import cognitive as cog
368
-
369
336
  metrics = cog.get_metrics(days=7)
370
337
  if metrics["total_retrievals"] > 0:
371
338
  finding("INFO", "cognitive-metrics",
372
- f"7d: {metrics['total_retrievals']} retrievals, "
373
- f"relevance={metrics['retrieval_relevance_pct']}%, "
374
- f"avg_score={metrics['avg_top_score']}, "
375
- f"{metrics['retrievals_per_day']}/day")
376
-
377
- if metrics["needs_multilingual"]:
378
- finding("WARN", "cognitive-metrics",
379
- f"Retrieval relevance {metrics['retrieval_relevance_pct']}% < 70% — consider switching to multilingual model (spec 13.3)")
380
-
339
+ f"7d: {metrics['total_retrievals']} retrievals, relevance={metrics['retrieval_relevance_pct']}%")
381
340
  if metrics["retrieval_relevance_pct"] < 50 and metrics["total_retrievals"] >= 5:
382
- finding("ERROR", "cognitive-metrics",
383
- f"Retrieval relevance critically low: {metrics['retrieval_relevance_pct']}%")
341
+ finding("ERROR", "cognitive-metrics", f"Relevance critically low: {metrics['retrieval_relevance_pct']}%")
384
342
 
385
- # Repeat error rate
386
343
  repeats = cog.check_repeat_errors()
387
- if repeats["new_count"] > 0:
388
- finding("INFO", "cognitive-metrics",
389
- f"Repeat errors: {repeats['duplicate_count']}/{repeats['new_count']} "
390
- f"({repeats['repeat_rate_pct']}%) — target <10%")
391
- if repeats["repeat_rate_pct"] > 30:
392
- finding("WARN", "cognitive-metrics",
393
- f"Repeat error rate {repeats['repeat_rate_pct']}% exceeds 30% threshold")
344
+ if repeats["new_count"] > 0 and repeats["repeat_rate_pct"] > 30:
345
+ finding("WARN", "cognitive-metrics", f"Repeat rate {repeats['repeat_rate_pct']}% > 30%")
394
346
 
395
- # Write metrics to file for dashboard/tracking
347
+ # Save metrics
396
348
  metrics_file = LOG_DIR / "cognitive-metrics.json"
397
349
  metrics_file.write_text(json.dumps({
398
350
  "timestamp": datetime.now().isoformat(),
399
351
  "retrieval": metrics,
400
352
  "repeats": {k: v for k, v in repeats.items() if k != "duplicates"},
401
353
  }, indent=2))
402
- except Exception as e:
403
- finding("WARN", "cognitive-metrics", f"Metrics collection failed: {e}")
404
354
 
405
- # Phase triggers monitoring (spec section 10)
406
- try:
407
- sys.path.insert(0, str(NEXO_HOME_PATH))
408
- import cognitive as cog
409
-
410
- db_cog = cog._get_db()
411
-
412
- # v2.0: Procedural memory — trigger: >50 procedural change_logs
413
- procedural_markers = ['1.', '2.', '3.', 'step ', 'Step ', 'then ', 'first ', 'First ', '→', '->', 'SSH', 'scp', 'git commit', 'deploy']
414
- changes = db_cog.execute('SELECT content FROM ltm_memories WHERE source_type = "change"').fetchall()
415
- procedural_count = sum(1 for r in changes if sum(1 for m in procedural_markers if m in r[0]) >= 2)
416
- if procedural_count >= 50:
417
- finding("WARN", "cognitive-phase", f"v2.0 TRIGGER MET: {procedural_count} procedural memories (>50). Implement Store 4 (memoria procedimental).")
418
-
419
- # v2.1: MEMORY.md reduction — trigger: RAG relevance >80% for 30 days
420
- metrics_file = LOG_DIR / "cognitive-metrics-history.json"
355
+ # Track history for phase triggers
356
+ history_file = LOG_DIR / "cognitive-metrics-history.json"
421
357
  try:
422
- history = json.loads(metrics_file.read_text()) if metrics_file.exists() else []
358
+ history = json.loads(history_file.read_text()) if history_file.exists() else []
423
359
  except Exception:
424
360
  history = []
425
-
426
- # Append today's metrics
427
- m = cog.get_metrics(days=1)
428
- if m["total_retrievals"] > 0:
429
- history.append({
430
- "date": datetime.now().strftime("%Y-%m-%d"),
431
- "relevance": m["retrieval_relevance_pct"],
432
- "retrievals": m["total_retrievals"],
433
- })
434
- # Keep last 60 days
361
+ m1 = cog.get_metrics(days=1)
362
+ if m1["total_retrievals"] > 0:
363
+ history.append({"date": datetime.now().strftime("%Y-%m-%d"),
364
+ "relevance": m1["retrieval_relevance_pct"],
365
+ "retrievals": m1["total_retrievals"]})
435
366
  history = history[-60:]
436
- metrics_file.write_text(json.dumps(history, indent=2))
437
-
438
- # Check if last 30 entries all have relevance >80%
439
- if len(history) >= 30:
440
- last_30 = history[-30:]
441
- all_above_80 = all(h["relevance"] >= 80.0 for h in last_30)
442
- if all_above_80:
443
- finding("WARN", "cognitive-phase", "v2.1 TRIGGER MET: RAG relevance >80% for 30 consecutive days. Reduce MEMORY.md to ~20 lines.")
444
-
445
- # v2.2: Dashboard — trigger: 30 days of metrics
446
- if len(history) >= 30:
447
- finding("INFO", "cognitive-phase", f"v2.2 TRIGGER MET: {len(history)} days of metrics accumulated. Implement HTML dashboard.")
448
-
449
- # v3.0: Clustering — trigger: LTM >1000
450
- ltm_count = db_cog.execute('SELECT COUNT(*) FROM ltm_memories WHERE is_dormant = 0').fetchone()[0]
451
- if ltm_count >= 1000:
452
- finding("WARN", "cognitive-phase", f"v3.0 TRIGGER MET: {ltm_count} LTM vectors (>1000). Implement K-means clustering.")
453
-
454
- # v1.4: Multilingual — already checked in metrics section above
367
+ history_file.write_text(json.dumps(history, indent=2))
455
368
 
456
369
  except Exception as e:
457
- finding("WARN", "cognitive-phase", f"Phase trigger check failed: {e}")
370
+ finding("WARN", "cognitive-metrics", f"Metrics failed: {e}")
458
371
 
459
372
  # Weekly GC on Sundays
460
373
  if datetime.now().weekday() == 6:
461
- log(" Running weekly cognitive GC (Sunday)...")
462
374
  try:
463
- sys.path.insert(0, str(NEXO_HOME_PATH))
375
+ sys.path.insert(0, str(Path.home() / ".nexo"))
464
376
  import cognitive as cog
465
-
466
- # 1. Delete STM with strength < 0.1 and > 30 days
467
377
  gc_stm = cog.gc_stm()
468
-
469
- # 2. GC sensory > 48h (should already be cleaned by postmortem, but safety net)
470
378
  gc_sensory = cog.gc_sensory(max_age_hours=48)
471
-
472
- # 3. Delete dormant LTM with strength < 0.1 and > 30 days
473
379
  gc_ltm = cog.gc_ltm_dormant(min_age_days=30)
474
-
475
- log(f" Weekly GC results: STM removed={gc_stm}, sensory removed={gc_sensory}, LTM dormant removed={gc_ltm}")
476
380
  if gc_stm + gc_sensory + gc_ltm > 0:
477
- finding("INFO", "cognitive", f"Weekly GC cleaned: {gc_stm} STM + {gc_sensory} sensory + {gc_ltm} dormant LTM")
381
+ finding("INFO", "cognitive", f"Weekly GC: {gc_stm} STM + {gc_sensory} sensory + {gc_ltm} dormant")
478
382
  except Exception as e:
479
383
  finding("WARN", "cognitive", f"Weekly GC failed: {e}")
480
384
 
481
385
 
482
- # ── Main ────────────────────────────────────────────────────────────────
386
+ # ═══════════════════════════════════════════════════════════════════════════════
387
+ # Stage B: Interpretation (Claude CLI opus) — NEW in v2
388
+ # ═══════════════════════════════════════════════════════════════════════════════
389
+
390
+ def interpret_findings(raw_findings: list) -> bool:
391
+ """CLI interprets the raw findings with real understanding."""
392
+
393
+ errors = [f for f in raw_findings if f["severity"] == "ERROR"]
394
+ warns = [f for f in raw_findings if f["severity"] == "WARN"]
395
+
396
+ # Don't invoke CLI if everything is clean
397
+ if not errors and not warns:
398
+ log("Stage B: All clean, no interpretation needed.")
399
+ return True
400
+
401
+ findings_json = json.dumps(raw_findings, ensure_ascii=False, indent=1)
402
+
403
+ prompt = f"""You are NEXO's morning self-audit interpreter. The mechanical checks found
404
+ {len(errors)} errors and {len(warns)} warnings. Your job is to UNDERSTAND what's
405
+ actually wrong, not just list findings.
406
+
407
+ RAW FINDINGS:
408
+ {findings_json}
409
+
410
+ Write an actionable audit report to {LOG_DIR}/self-audit-interpreted.md:
411
+
412
+ # NEXO Self-Audit — {datetime.now().strftime('%Y-%m-%d')}
413
+
414
+ ## Critical (needs immediate action)
415
+ [Group related findings, identify ROOT CAUSE, suggest specific fix]
416
+
417
+ ## Warnings (should address today)
418
+ [Same: group, root cause, specific action]
419
+
420
+ ## Observations
421
+ [Trends, things getting worse, things improving]
422
+
423
+ ## Recommended Actions (priority order)
424
+ 1. [Most important action with specific command/steps]
425
+ 2. ...
426
+
427
+ Be specific. "Fix the DB" is useless. "Archive learnings >90 days in category X
428
+ via sqlite3 nexo.db 'UPDATE...'" is useful.
429
+
430
+ Also write the machine-readable summary to {LOG_DIR}/self-audit-summary.json.
431
+
432
+ Execute without asking."""
433
+
434
+ log("Stage B: Invoking Claude CLI (opus) for interpretation...")
435
+
436
+ env = os.environ.copy()
437
+ env.pop("CLAUDECODE", None)
438
+ env.pop("CLAUDE_CODE", None)
439
+
440
+ try:
441
+ result = subprocess.run(
442
+ [str(CLAUDE_CLI), "-p", prompt, "--model", "opus",
443
+ "--allowedTools", "Read,Write,Edit,Glob,Grep"],
444
+ capture_output=True, text=True, timeout=180, env=env
445
+ )
446
+
447
+ if result.returncode != 0:
448
+ log(f"Stage B: CLI error ({result.returncode})")
449
+ return False
450
+
451
+ log(f"Stage B: Interpretation complete ({len(result.stdout or '')} chars)")
452
+ return True
453
+
454
+ except subprocess.TimeoutExpired:
455
+ log("Stage B: CLI timed out")
456
+ return False
457
+ except Exception as e:
458
+ log(f"Stage B: {e}")
459
+ return False
460
+
461
+
462
+ # ═══════════════════════════════════════════════════════════════════════════════
463
+ # Main
464
+ # ═══════════════════════════════════════════════════════════════════════════════
465
+
483
466
  def main():
484
467
  log("=" * 60)
485
- log("NEXO Daily Self-Audit starting")
468
+ log("NEXO Daily Self-Audit v2 starting")
486
469
 
470
+ # Stage A: Run all mechanical checks (unchanged)
487
471
  check_overdue_reminders()
488
472
  check_overdue_followups()
489
473
  check_uncommitted_changes()
@@ -506,10 +490,9 @@ def main():
506
490
  errors = sum(1 for f in findings if f["severity"] == "ERROR")
507
491
  warns = sum(1 for f in findings if f["severity"] == "WARN")
508
492
  infos = sum(1 for f in findings if f["severity"] == "INFO")
493
+ log(f"Stage A complete: {errors} errors, {warns} warnings, {infos} info")
509
494
 
510
- log(f"Audit complete: {errors} errors, {warns} warnings, {infos} info")
511
-
512
- # Write summary for NEXO startup to read
495
+ # Write raw summary (backward compatible)
513
496
  summary_file = LOG_DIR / "self-audit-summary.json"
514
497
  summary_file.write_text(json.dumps({
515
498
  "timestamp": datetime.now().isoformat(),
@@ -517,13 +500,15 @@ def main():
517
500
  "counts": {"error": errors, "warn": warns, "info": infos}
518
501
  }, indent=2))
519
502
 
520
- # Register successful run for catch-up
503
+ # Stage B: CLI interpretation
504
+ interpret_findings(findings)
505
+
506
+ # Register for catch-up
521
507
  try:
522
- import json as _json
523
- _state_file = Path.home() / "claude" / "operations" / ".catchup-state.json"
524
- _state = _json.loads(_state_file.read_text()) if _state_file.exists() else {}
525
- _state["self-audit"] = datetime.now().isoformat()
526
- _state_file.write_text(_json.dumps(_state, indent=2))
508
+ state_file = Path.home() / ".nexo" / "operations" / ".catchup-state.json"
509
+ st = json.loads(state_file.read_text()) if state_file.exists() else {}
510
+ st["self-audit"] = datetime.now().isoformat()
511
+ state_file.write_text(json.dumps(st, indent=2))
527
512
  except Exception:
528
513
  pass
529
514