nexo-brain 1.2.2 → 1.3.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.
@@ -0,0 +1,592 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ NEXO Evolution — Standalone weekly runner with real execution.
4
+ Cron: 0 3 * * 0 (Sundays 3:00 AM)
5
+
6
+ Runs independently of Cortex. Calls Opus API directly to analyze
7
+ the past week and generate improvement proposals.
8
+
9
+ AUTO proposals are executed: snapshot → apply → validate → commit/rollback.
10
+ PROPOSE proposals are logged for the owner's review.
11
+ """
12
+
13
+ import json
14
+ import os
15
+ import py_compile
16
+ import shutil
17
+ import sqlite3
18
+ import subprocess
19
+ import sys
20
+ from datetime import datetime, date, timedelta
21
+ from pathlib import Path
22
+
23
+ # ── Paths ────────────────────────────────────────────────────────────────
24
+ CLAUDE_DIR = Path.home() / "claude"
25
+ NEXO_DB = CLAUDE_DIR / "nexo-mcp" / "nexo.db"
26
+ CORTEX_DIR = CLAUDE_DIR / "cortex"
27
+ OBJECTIVE_FILE = CORTEX_DIR / "evolution-objective.json"
28
+ LOG_DIR = CLAUDE_DIR / "logs"
29
+ SNAPSHOTS_DIR = CLAUDE_DIR / "snapshots"
30
+ SANDBOX_DIR = CLAUDE_DIR / "sandbox" / "workspace"
31
+ MAX_CONSECUTIVE_FAILURES = 3
32
+ MAX_SNAPSHOTS = 8
33
+
34
+ # ── Safe zones for AUTO execution ────────────────────────────────────────
35
+ # "review" mode (owner): broader zones, but nothing executes without approval
36
+ # "auto" mode (public users): restricted to user scripts and plugins ONLY
37
+ AUTO_SAFE_PREFIXES = [
38
+ str(CLAUDE_DIR / "scripts") + "/",
39
+ str(CLAUDE_DIR / "cortex") + "/",
40
+ str(CLAUDE_DIR / "nexo-mcp" / "plugins") + "/",
41
+ str(CLAUDE_DIR / "logs") + "/",
42
+ str(CLAUDE_DIR / "coordination") + "/",
43
+ ]
44
+
45
+ # Public mode: only user-created scripts — NEVER core, cortex, or plugins
46
+ AUTO_SAFE_PREFIXES_PUBLIC = [
47
+ str(CLAUDE_DIR / "scripts") + "/",
48
+ ]
49
+
50
+ # ── Immutable files — NEVER touch (applies to ALL modes) ────────────────
51
+ IMMUTABLE_FILES = {
52
+ "db.py", "server.py", "plugin_loader.py", "nexo-watchdog.sh",
53
+ "cortex-wrapper.py", "CLAUDE.md", "personality.md",
54
+ "owner-profile.md", "evolution_cycle.py",
55
+ # Core cognitive engine — never auto-modified
56
+ "cognitive.py", "knowledge_graph.py", "storage_router.py",
57
+ # Core tools — never auto-modified
58
+ "tools_sessions.py", "tools_coordination.py", "tools_reminders.py",
59
+ "tools_reminders_crud.py", "tools_learnings.py", "tools_credentials.py",
60
+ "tools_task_history.py", "tools_menu.py",
61
+ }
62
+
63
+ # ── Claude CLI path ──────────────────────────────────────────────────────
64
+ def find_claude_cli() -> Path:
65
+ """Find claude CLI binary, checking multiple locations."""
66
+ candidates = [
67
+ Path.home() / ".local" / "bin" / "claude",
68
+ Path("/usr/local/bin/claude"),
69
+ ]
70
+ for c in candidates:
71
+ if c.exists():
72
+ return c
73
+ # Fall back to shutil.which
74
+ found = shutil.which("claude")
75
+ if found:
76
+ return Path(found)
77
+ raise FileNotFoundError("claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code")
78
+
79
+ CLAUDE_CLI = find_claude_cli()
80
+
81
+ # ── Logging ──────────────────────────────────────────────────────────────
82
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
83
+ LOG_FILE = LOG_DIR / "evolution.log"
84
+
85
+
86
+ def log(msg: str):
87
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
88
+ line = f"[{ts}] {msg}"
89
+ print(line, flush=True)
90
+ with open(LOG_FILE, "a") as f:
91
+ f.write(line + "\n")
92
+
93
+
94
+ # ── Import from evolution_cycle.py ───────────────────────────────────────
95
+ sys.path.insert(0, str(CORTEX_DIR))
96
+ from evolution_cycle import (
97
+ load_objective, save_objective, get_week_data, build_evolution_prompt,
98
+ dry_run_restore_test, max_auto_changes, create_snapshot
99
+ )
100
+
101
+
102
+ # ── Consecutive failure tracking ─────────────────────────────────────────
103
+ def get_consecutive_failures() -> int:
104
+ obj = load_objective()
105
+ return obj.get("consecutive_failures", 0)
106
+
107
+
108
+ def set_consecutive_failures(count: int):
109
+ obj = load_objective()
110
+ obj["consecutive_failures"] = count
111
+ save_objective(obj)
112
+
113
+
114
+ # ── Claude CLI call ──────────────────────────────────────────────────────
115
+ CLI_TIMEOUT = 600 # 10 minutes — Opus needs time for large prompts
116
+
117
+
118
+ def call_claude_cli(prompt: str) -> str:
119
+ """Call claude -p --model opus via subprocess. Returns stdout text."""
120
+ result = subprocess.run(
121
+ [str(CLAUDE_CLI), "-p", "--model", "opus", prompt],
122
+ capture_output=True,
123
+ text=True,
124
+ timeout=CLI_TIMEOUT,
125
+ )
126
+ if result.returncode != 0:
127
+ raise RuntimeError(f"claude CLI exited {result.returncode}: {result.stderr[:500]}")
128
+ return result.stdout
129
+
130
+
131
+ # ── File safety validation ───────────────────────────────────────────────
132
+ def is_safe_path(filepath: str, mode: str = "auto") -> bool:
133
+ """Check if a file path is within safe zones and not immutable.
134
+ mode='auto' (public): restricted to scripts/ and plugins/ only.
135
+ mode='review' (owner): broader zones but nothing executes without approval anyway.
136
+ """
137
+ expanded = str(Path(filepath).expanduser().resolve())
138
+ filename = Path(expanded).name
139
+
140
+ if filename in IMMUTABLE_FILES:
141
+ return False
142
+
143
+ prefixes = AUTO_SAFE_PREFIXES if mode == "review" else AUTO_SAFE_PREFIXES_PUBLIC
144
+ for prefix in prefixes:
145
+ resolved_prefix = str(Path(prefix).expanduser().resolve())
146
+ if expanded.startswith(resolved_prefix):
147
+ return True
148
+
149
+ return False
150
+
151
+
152
+ def validate_syntax(filepath: str) -> tuple[bool, str]:
153
+ """Basic syntax validation for known file types."""
154
+ path = Path(filepath)
155
+ ext = path.suffix
156
+
157
+ if ext == ".py":
158
+ try:
159
+ py_compile.compile(str(path), doraise=True)
160
+ return True, "Python syntax OK"
161
+ except Exception as e:
162
+ return False, f"Validation error: {e}"
163
+
164
+ elif ext == ".sh":
165
+ try:
166
+ result = subprocess.run(
167
+ ["bash", "-n", str(path)],
168
+ capture_output=True, text=True, timeout=10
169
+ )
170
+ if result.returncode == 0:
171
+ return True, "Bash syntax OK"
172
+ return False, f"Bash syntax error: {result.stderr[:200]}"
173
+ except Exception as e:
174
+ return False, f"Validation error: {e}"
175
+
176
+ elif ext == ".json":
177
+ try:
178
+ json.loads(Path(filepath).read_text())
179
+ return True, "JSON valid"
180
+ except Exception as e:
181
+ return False, f"JSON error: {e}"
182
+
183
+ elif ext == ".md":
184
+ return True, "Markdown (no validation needed)"
185
+
186
+ return True, f"No validator for {ext} (accepted)"
187
+
188
+
189
+ # ── Apply a single change operation ──────────────────────────────────────
190
+ def apply_change(change: dict) -> tuple[bool, str]:
191
+ """Apply a single file change operation. Returns (success, message)."""
192
+ filepath = str(Path(change["file"]).expanduser())
193
+ operation = change.get("operation", "")
194
+ content = change.get("content", "")
195
+
196
+ if not is_safe_path(filepath):
197
+ return False, f"BLOCKED: {filepath} is outside safe zones or immutable"
198
+
199
+ try:
200
+ if operation == "create":
201
+ if Path(filepath).exists():
202
+ return False, f"BLOCKED: {filepath} already exists (create requires new file)"
203
+ Path(filepath).parent.mkdir(parents=True, exist_ok=True)
204
+ Path(filepath).write_text(content)
205
+ # Make scripts executable
206
+ if filepath.endswith(".sh") or filepath.endswith(".py"):
207
+ os.chmod(filepath, 0o755)
208
+ return True, f"Created {filepath}"
209
+
210
+ elif operation == "replace":
211
+ search = change.get("search", "")
212
+ if not search:
213
+ return False, "BLOCKED: replace operation requires 'search' field"
214
+ if not Path(filepath).exists():
215
+ return False, f"BLOCKED: {filepath} does not exist"
216
+ original = Path(filepath).read_text()
217
+ count = original.count(search)
218
+ if count == 0:
219
+ return False, f"BLOCKED: search text not found in {filepath}"
220
+ if count > 1:
221
+ return False, f"BLOCKED: search text matches {count} times (must be unique)"
222
+ new_content = original.replace(search, content, 1)
223
+ Path(filepath).write_text(new_content)
224
+ return True, f"Replaced in {filepath}"
225
+
226
+ elif operation == "append":
227
+ if not Path(filepath).exists():
228
+ return False, f"BLOCKED: {filepath} does not exist"
229
+ with open(filepath, "a") as f:
230
+ f.write(content)
231
+ return True, f"Appended to {filepath}"
232
+
233
+ else:
234
+ return False, f"BLOCKED: unknown operation '{operation}'"
235
+
236
+ except Exception as e:
237
+ return False, f"ERROR: {e}"
238
+
239
+
240
+ # ── Execute AUTO proposals ───────────────────────────────────────────────
241
+ def execute_auto_proposal(proposal: dict, cycle_num: int, conn: sqlite3.Connection) -> dict:
242
+ """Execute an AUTO proposal with snapshot/apply/validate/rollback."""
243
+ changes = proposal.get("changes", [])
244
+ if not changes:
245
+ return {"status": "skipped", "reason": "No changes array in proposal"}
246
+
247
+ # Validate all paths first
248
+ for change in changes:
249
+ filepath = str(Path(change["file"]).expanduser())
250
+ if not is_safe_path(filepath):
251
+ return {"status": "blocked", "reason": f"Unsafe path: {filepath}"}
252
+
253
+ # Collect files to snapshot (existing files only)
254
+ files_to_backup = []
255
+ for change in changes:
256
+ filepath = str(Path(change["file"]).expanduser())
257
+ if Path(filepath).exists():
258
+ files_to_backup.append(filepath)
259
+
260
+ # Create snapshot
261
+ snapshot_ref = None
262
+ if files_to_backup:
263
+ snapshot_ref = create_snapshot(files_to_backup)
264
+ log(f" Snapshot created: {snapshot_ref}")
265
+
266
+ # Apply changes
267
+ applied_files = []
268
+ all_results = []
269
+ try:
270
+ for change in changes:
271
+ success, msg = apply_change(change)
272
+ all_results.append(msg)
273
+ log(f" {msg}")
274
+ if not success:
275
+ raise RuntimeError(f"Change failed: {msg}")
276
+ filepath = str(Path(change["file"]).expanduser())
277
+ applied_files.append(filepath)
278
+
279
+ # Validate all modified/created files
280
+ for filepath in applied_files:
281
+ valid, vmsg = validate_syntax(filepath)
282
+ all_results.append(vmsg)
283
+ log(f" Validate: {vmsg}")
284
+ if not valid:
285
+ raise RuntimeError(f"Validation failed: {vmsg}")
286
+
287
+ return {
288
+ "status": "applied",
289
+ "snapshot_ref": snapshot_ref,
290
+ "files_changed": applied_files,
291
+ "test_result": "; ".join(all_results),
292
+ }
293
+
294
+ except RuntimeError as e:
295
+ # Rollback
296
+ log(f" ROLLBACK: {e}")
297
+ if snapshot_ref:
298
+ try:
299
+ restore_script = CLAUDE_DIR / "scripts" / "nexo-snapshot-restore.sh"
300
+ subprocess.run(
301
+ [str(restore_script), snapshot_ref],
302
+ capture_output=True, timeout=15, check=True
303
+ )
304
+ log(f" Restored from snapshot {snapshot_ref}")
305
+ except Exception as re:
306
+ log(f" CRITICAL: Restore failed: {re}")
307
+ else:
308
+ # Remove created files that didn't exist before
309
+ for filepath in applied_files:
310
+ if filepath not in files_to_backup:
311
+ Path(filepath).unlink(missing_ok=True)
312
+ log(f" Removed created file: {filepath}")
313
+
314
+ return {
315
+ "status": "failed",
316
+ "snapshot_ref": snapshot_ref,
317
+ "files_changed": [],
318
+ "test_result": f"ROLLBACK: {e}; " + "; ".join(all_results),
319
+ }
320
+
321
+
322
+ # ── Review followup for owner mode ──────────────────────────────────────
323
+ def _create_review_followup(conn: sqlite3.Connection, cycle_num: int,
324
+ items: list[dict], analysis: str):
325
+ """Create a followup summarizing Evolution proposals for owner review."""
326
+ tomorrow = (date.today() + timedelta(days=1)).isoformat()
327
+ followup_id = f"NF-EVO-C{cycle_num}"
328
+
329
+ public_items = [i for i in items if i.get("scope") == "public"]
330
+ local_items = [i for i in items if i.get("scope") != "public"]
331
+
332
+ lines = [f"Evolution Cycle #{cycle_num} — {len(items)} proposals to review."]
333
+ lines.append(f"Analysis: {analysis[:200]}")
334
+ lines.append("")
335
+
336
+ if public_items:
337
+ lines.append(f"FOR ALL USERS ({len(public_items)}):")
338
+ for i, item in enumerate(public_items, 1):
339
+ lines.append(f" {i}. [{item['dimension']}] {item['action'][:120]}")
340
+ lines.append(f" Why: {item['reasoning'][:100]}")
341
+ lines.append("")
342
+
343
+ if local_items:
344
+ lines.append(f"LOCAL ONLY ({len(local_items)}):")
345
+ for i, item in enumerate(local_items, 1):
346
+ lines.append(f" {i}. [{item['dimension']}] {item['action'][:120]}")
347
+ lines.append(f" Why: {item['reasoning'][:100]}")
348
+
349
+ description = "\n".join(lines)
350
+
351
+ try:
352
+ conn.execute(
353
+ "INSERT OR REPLACE INTO followups (id, description, date, status, verification) "
354
+ "VALUES (?, ?, ?, 'pending', ?)",
355
+ (followup_id, description, tomorrow,
356
+ f"SELECT * FROM evolution_log WHERE cycle_number={cycle_num}")
357
+ )
358
+ conn.commit()
359
+ log(f" Followup {followup_id} created for {tomorrow}")
360
+ except Exception as e:
361
+ log(f" WARN: Failed to create followup: {e}")
362
+
363
+
364
+ # ── Main run ─────────────────────────────────────────────────────────────
365
+ def run():
366
+ log("=" * 60)
367
+ log("NEXO Evolution cycle starting (standalone, v2 — real execution)")
368
+
369
+ # Check objective
370
+ objective = load_objective()
371
+ if not objective:
372
+ log("ERROR: No evolution-objective.json found")
373
+ return
374
+ if not objective.get("evolution_enabled", True):
375
+ log(f"Evolution DISABLED: {objective.get('disabled_reason', 'unknown')}")
376
+ return
377
+
378
+ # Circuit breaker: consecutive failures
379
+ failures = get_consecutive_failures()
380
+ if failures >= MAX_CONSECUTIVE_FAILURES:
381
+ log(f"CIRCUIT BREAKER: {failures} consecutive failures. Disabling evolution.")
382
+ objective["evolution_enabled"] = False
383
+ objective["disabled_reason"] = f"Circuit breaker: {failures} consecutive failures at {datetime.now().isoformat()}"
384
+ save_objective(objective)
385
+ return
386
+
387
+ # Dry-run restore test
388
+ log("Running restore dry-run test...")
389
+ if not dry_run_restore_test():
390
+ log("CRITICAL: Restore test failed — aborting")
391
+ set_consecutive_failures(failures + 1)
392
+ return
393
+ log("Restore test PASSED")
394
+
395
+ # Gather data
396
+ log("Gathering week data from nexo.db...")
397
+ week_data = get_week_data(str(NEXO_DB))
398
+ log(f" Learnings: {len(week_data.get('learnings', []))}")
399
+ log(f" Decisions: {len(week_data.get('decisions', []))}")
400
+ log(f" Changes: {len(week_data.get('changes', []))}")
401
+ log(f" Diaries: {len(week_data.get('diaries', []))}")
402
+
403
+ # Build prompt
404
+ prompt = build_evolution_prompt(week_data, objective)
405
+ log(f"Prompt built: {len(prompt)} chars")
406
+
407
+ # Call Opus via claude -p
408
+ log("Calling claude -p --model opus...")
409
+ try:
410
+ raw_response = call_claude_cli(prompt)
411
+ except Exception as e:
412
+ log(f"claude CLI call failed: {e}")
413
+ set_consecutive_failures(failures + 1)
414
+ return
415
+
416
+ log(f"Response received: {len(raw_response)} chars")
417
+
418
+ # Parse JSON
419
+ try:
420
+ text = raw_response
421
+ if "```json" in text:
422
+ text = text.split("```json")[1].split("```")[0]
423
+ elif "```" in text:
424
+ text = text.split("```")[1].split("```")[0]
425
+ response = json.loads(text.strip())
426
+ except Exception as e:
427
+ log(f"JSON parse failed: {e}")
428
+ log(f"Raw (first 500): {raw_response[:500]}")
429
+ set_consecutive_failures(failures + 1)
430
+ return
431
+
432
+ # Reset consecutive failures on successful parse
433
+ set_consecutive_failures(0)
434
+
435
+ log(f"Analysis: {response.get('analysis', 'N/A')[:200]}")
436
+
437
+ # Log patterns
438
+ for p in response.get("patterns", []):
439
+ log(f" Pattern [{p.get('type', '?')}]: {p.get('description', '')[:100]} (freq: {p.get('frequency', '?')})")
440
+
441
+ # Process proposals
442
+ proposals = response.get("proposals", [])
443
+ cycle_num = objective.get("total_evolutions", 0) + 1
444
+ max_auto = max_auto_changes(objective.get("total_evolutions", 0))
445
+ auto_count = 0
446
+ auto_applied = 0
447
+ evolution_mode = objective.get("evolution_mode", "auto") # "auto" (public) or "review" (owner)
448
+
449
+ conn = sqlite3.connect(str(NEXO_DB), timeout=10)
450
+ conn.execute("PRAGMA busy_timeout=5000")
451
+
452
+ # In "review" mode: log everything as pending_review, create followup
453
+ # In "auto" mode: execute AUTO proposals, log PROPOSE as proposed
454
+ review_items = []
455
+
456
+ for p in proposals:
457
+ classification = p.get("classification", "propose")
458
+ dimension = p.get("dimension", "other")
459
+ action = p.get("action", "")
460
+ reasoning = p.get("reasoning", "")
461
+ scope = p.get("scope", "local") # "public" or "local"
462
+
463
+ if evolution_mode == "review":
464
+ # Owner mode: nothing executes, everything queued for review
465
+ log(f" QUEUED [{scope}]: {action[:80]}")
466
+ conn.execute(
467
+ "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, "
468
+ "reasoning, status) VALUES (?, ?, ?, ?, ?, ?)",
469
+ (cycle_num, dimension, action, classification, reasoning, "pending_review")
470
+ )
471
+ review_items.append({
472
+ "dimension": dimension,
473
+ "action": action,
474
+ "reasoning": reasoning,
475
+ "scope": scope,
476
+ "classification": classification,
477
+ })
478
+
479
+ elif classification == "auto" and auto_count < max_auto:
480
+ # Public mode: execute AUTO proposals
481
+ auto_count += 1
482
+ log(f" AUTO #{auto_count}/{max_auto}: {action[:80]}")
483
+
484
+ result = execute_auto_proposal(p, cycle_num, conn)
485
+ status = result["status"]
486
+
487
+ conn.execute(
488
+ "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, "
489
+ "reasoning, status, files_changed, snapshot_ref, test_result) "
490
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
491
+ (cycle_num, dimension, action, "auto", reasoning, status,
492
+ json.dumps(result.get("files_changed", [])),
493
+ result.get("snapshot_ref", ""),
494
+ result.get("test_result", ""))
495
+ )
496
+
497
+ if status == "applied":
498
+ auto_applied += 1
499
+ log(f" APPLIED successfully")
500
+ elif status == "blocked":
501
+ log(f" BLOCKED: {result.get('test_result', '')}")
502
+ elif status == "skipped":
503
+ log(f" SKIPPED: {result.get('reason', '')}")
504
+ else:
505
+ log(f" FAILED: {result.get('test_result', '')[:100]}")
506
+
507
+ else:
508
+ # PROPOSE or over auto limit
509
+ if classification == "auto" and auto_count >= max_auto:
510
+ log(f" AUTO→PROPOSE (over limit {max_auto}): {action[:80]}")
511
+ classification = "propose"
512
+ else:
513
+ log(f" PROPOSE: {action[:80]}")
514
+
515
+ conn.execute(
516
+ "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, "
517
+ "reasoning, status) VALUES (?, ?, ?, ?, ?, ?)",
518
+ (cycle_num, dimension, action, classification, reasoning, "proposed")
519
+ )
520
+
521
+ conn.commit()
522
+
523
+ # In review mode: create followup for owner
524
+ if evolution_mode == "review" and review_items:
525
+ _create_review_followup(conn, cycle_num, review_items, response.get("analysis", ""))
526
+
527
+ # Update metrics
528
+ scores = response.get("dimension_scores", {})
529
+ evidence = response.get("score_evidence", {})
530
+ current = week_data.get("current_metrics", {})
531
+
532
+ for dim, score in scores.items():
533
+ if isinstance(score, (int, float)) and 0 <= score <= 100:
534
+ prev = current.get(dim, {}).get("score", 0)
535
+ delta = int(score) - prev
536
+ conn.execute(
537
+ "INSERT INTO evolution_metrics (dimension, score, evidence, delta) VALUES (?, ?, ?, ?)",
538
+ (dim, int(score), json.dumps(evidence.get(dim, "")), delta)
539
+ )
540
+
541
+ conn.commit()
542
+ conn.close()
543
+
544
+ # Update objective
545
+ objective["last_evolution"] = str(date.today())
546
+ objective["total_evolutions"] = cycle_num
547
+ objective["total_proposals_made"] = objective.get("total_proposals_made", 0) + len(proposals)
548
+ objective["total_auto_applied"] = objective.get("total_auto_applied", 0) + auto_applied
549
+ for dim, score in scores.items():
550
+ if dim in objective.get("dimensions", {}) and isinstance(score, (int, float)):
551
+ objective["dimensions"][dim]["current"] = int(score)
552
+
553
+ objective.setdefault("history", []).insert(0, {
554
+ "cycle": cycle_num,
555
+ "date": str(date.today()),
556
+ "proposals": len(proposals),
557
+ "auto_count": auto_count,
558
+ "auto_applied": auto_applied,
559
+ "analysis": response.get("analysis", "")[:200]
560
+ })
561
+ objective["history"] = objective["history"][:12]
562
+
563
+ save_objective(objective)
564
+
565
+ log(f"Evolution cycle #{cycle_num} COMPLETE: {len(proposals)} proposals "
566
+ f"({auto_count} auto, {auto_applied} applied, "
567
+ f"{len(proposals) - auto_count} propose)")
568
+ log("=" * 60)
569
+
570
+
571
+ def _update_catchup_state():
572
+ """Register successful run for catch-up."""
573
+ try:
574
+ import json as _json
575
+ from pathlib import Path as _Path
576
+ _state_file = _Path.home() / "claude" / "operations" / ".catchup-state.json"
577
+ _state = _json.loads(_state_file.read_text()) if _state_file.exists() else {}
578
+ _state["evolution"] = datetime.now().isoformat()
579
+ _state_file.write_text(_json.dumps(_state, indent=2))
580
+ except Exception:
581
+ pass
582
+
583
+
584
+ if __name__ == "__main__":
585
+ try:
586
+ run()
587
+ _update_catchup_state()
588
+ except Exception as e:
589
+ log(f"FATAL: {e}")
590
+ import traceback
591
+ log(traceback.format_exc())
592
+ sys.exit(1)
@@ -457,24 +457,48 @@ def stage_c_learning_consolidation() -> dict:
457
457
  cat for cat, cnt in stats["category_counts"].items() if cnt > 20
458
458
  ]
459
459
 
460
- # C1: Duplicate detection — O(n²) but learnings table is small
460
+ # C1: Duplicate detection + auto-archive — O(n²) but learnings table is small
461
461
  duplicates = []
462
+ archived_ids = set()
462
463
  for i in range(len(parsed)):
463
- if len(duplicates) >= 10:
464
- break
464
+ if parsed[i]["id"] in archived_ids:
465
+ continue
465
466
  for j in range(i + 1, len(parsed)):
466
- if len(duplicates) >= 10:
467
- break
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
468
471
  overlap = _word_overlap(parsed[i]["words"], parsed[j]["words"])
469
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]
470
476
  duplicates.append({
471
- "id1": parsed[i]["id"],
472
- "id2": parsed[j]["id"],
473
- "title1": parsed[i]["title"],
474
- "title2": parsed[j]["title"],
477
+ "keep_id": keep["id"],
478
+ "drop_id": drop["id"],
479
+ "title_keep": keep["title"],
480
+ "title_drop": drop["title"],
475
481
  "overlap": round(overlap, 2),
476
482
  })
477
- stats["potential_duplicates"] = duplicates
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)
478
502
 
479
503
  # C4: Contradiction detection — NUNCA pairs in same category
480
504
  nunca_entries = [p for p in parsed if "nunca" in p["title"].lower()]
@@ -506,7 +530,7 @@ def stage_c_learning_consolidation() -> dict:
506
530
  stats["potential_contradictions"] = contradictions
507
531
 
508
532
  log(f"Stage C: {stats['total_learnings']} learnings analyzed. "
509
- f"Potential duplicates: {len(duplicates)}. "
533
+ f"Duplicates found: {len(duplicates)}, archived: {len(archived_ids)}. "
510
534
  f"Categories over 20: {len(stats['categories_over_20'])}. "
511
535
  f"Potential contradictions: {len(contradictions)}.")
512
536
  if stats["hottest_category_7d"]: