nexo-brain 3.0.2 → 3.1.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.
@@ -19,7 +19,9 @@ Runs via launchd at 7:00 AM daily.
19
19
  import json
20
20
  import hashlib
21
21
  import os
22
+ import py_compile
22
23
  import re
24
+ import shutil
23
25
  import sqlite3
24
26
  import subprocess
25
27
  import sys
@@ -35,6 +37,7 @@ if str(NEXO_CODE) not in sys.path:
35
37
  sys.path.insert(0, str(NEXO_CODE))
36
38
 
37
39
  from agent_runner import AutomationBackendUnavailableError, run_automation_prompt
40
+ from public_evolution_queue import queue_public_port_candidate
38
41
 
39
42
  LOG_DIR = NEXO_HOME / "logs"
40
43
  LOG_DIR.mkdir(parents=True, exist_ok=True)
@@ -226,6 +229,7 @@ def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
226
229
  verification: str, reasoning: str, priority: str = "high") -> str:
227
230
  if not _table_exists(conn, "followups"):
228
231
  return ""
232
+ followup_id = f"NF-{prefix}-{hashlib.sha1(description.encode('utf-8')).hexdigest()[:8].upper()}"
229
233
  existing = conn.execute(
230
234
  """SELECT id FROM followups
231
235
  WHERE status NOT LIKE 'COMPLETED%'
@@ -236,10 +240,33 @@ def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
236
240
  ).fetchone()
237
241
  if existing:
238
242
  return str(existing["id"])
239
-
240
- followup_id = f"NF-{prefix}-{hashlib.sha1(description.encode('utf-8')).hexdigest()[:8].upper()}"
241
243
  now_epoch = int(datetime.now().timestamp())
242
244
  columns = {row["name"] for row in conn.execute("PRAGMA table_info(followups)").fetchall()}
245
+ existing_id_row = conn.execute(
246
+ "SELECT id, status FROM followups WHERE id = ? LIMIT 1",
247
+ (followup_id,),
248
+ ).fetchone()
249
+ if existing_id_row:
250
+ update_fields = {
251
+ "description": description,
252
+ "verification": verification,
253
+ "reasoning": reasoning,
254
+ "updated_at": now_epoch,
255
+ }
256
+ if "priority" in columns:
257
+ update_fields["priority"] = priority
258
+ closed_status = str(existing_id_row["status"] or "").upper()
259
+ if closed_status.startswith("COMPLETED") or closed_status in {"DELETED", "ARCHIVED", "BLOCKED", "WAITING"}:
260
+ update_fields["status"] = "PENDING"
261
+ ordered_updates = [name for name in update_fields.keys() if name in columns]
262
+ if ordered_updates:
263
+ assignments = ", ".join(f"{name} = ?" for name in ordered_updates)
264
+ conn.execute(
265
+ f"UPDATE followups SET {assignments} WHERE id = ?",
266
+ [update_fields[name] for name in ordered_updates] + [followup_id],
267
+ )
268
+ return followup_id
269
+
243
270
  values = {
244
271
  "id": followup_id,
245
272
  "date": "",
@@ -263,6 +290,301 @@ def _ensure_followup(conn: sqlite3.Connection, *, prefix: str, description: str,
263
290
  return followup_id
264
291
 
265
292
 
293
+ def _table_columns(conn: sqlite3.Connection, table_name: str) -> set[str]:
294
+ try:
295
+ rows = conn.execute(f"PRAGMA table_info({table_name})").fetchall()
296
+ except Exception:
297
+ return set()
298
+ columns: set[str] = set()
299
+ for row in rows:
300
+ if isinstance(row, sqlite3.Row):
301
+ columns.add(str(row["name"]))
302
+ elif len(row) > 1:
303
+ columns.add(str(row[1]))
304
+ return columns
305
+
306
+
307
+ def _append_note(existing: str, note: str) -> str:
308
+ current = str(existing or "").strip()
309
+ extra = str(note or "").strip()
310
+ if not extra:
311
+ return current
312
+ if not current:
313
+ return extra
314
+ if extra in current:
315
+ return current
316
+ return f"{current}\n{extra}"
317
+
318
+
319
+ def _complete_matching_followup(conn: sqlite3.Connection, description: str, note: str) -> int:
320
+ if not _table_exists(conn, "followups"):
321
+ return 0
322
+ columns = _table_columns(conn, "followups")
323
+ rows = conn.execute(
324
+ """SELECT id, verification, reasoning
325
+ FROM followups
326
+ WHERE description = ?
327
+ AND status NOT LIKE 'COMPLETED%'
328
+ AND status NOT IN ('DELETED','archived','blocked','waiting')""",
329
+ (description,),
330
+ ).fetchall()
331
+ completed = 0
332
+ now_epoch = datetime.now().timestamp()
333
+ for row in rows:
334
+ updates = {"status": "COMPLETED"}
335
+ if "updated_at" in columns:
336
+ updates["updated_at"] = now_epoch
337
+ if "verification" in columns:
338
+ updates["verification"] = _append_note(row["verification"], note)
339
+ if "reasoning" in columns:
340
+ updates["reasoning"] = _append_note(row["reasoning"], note)
341
+ assignments = ", ".join(f"{column} = ?" for column in updates)
342
+ conn.execute(
343
+ f"UPDATE followups SET {assignments} WHERE id = ?",
344
+ [updates[column] for column in updates] + [row["id"]],
345
+ )
346
+ completed += 1
347
+ return completed
348
+
349
+
350
+ def _upsert_inline_learning(
351
+ conn: sqlite3.Connection,
352
+ *,
353
+ category: str,
354
+ title: str,
355
+ content: str,
356
+ reasoning: str = "",
357
+ prevention: str = "",
358
+ applies_to: str = "",
359
+ priority: str = "high",
360
+ ) -> dict:
361
+ if not _table_exists(conn, "learnings"):
362
+ return {"ok": False, "reason": "learnings_missing"}
363
+
364
+ columns = _table_columns(conn, "learnings")
365
+ rows = conn.execute(
366
+ "SELECT * FROM learnings WHERE COALESCE(status, 'active') != 'superseded' ORDER BY updated_at DESC, id DESC LIMIT 200"
367
+ ).fetchall()
368
+ target_signature = _topic_signature(f"{title} {content}")
369
+ existing = None
370
+ for row in rows:
371
+ row_title = str(row["title"] or "").strip() if "title" in columns else ""
372
+ row_content = str(row["content"] or "").strip() if "content" in columns else ""
373
+ row_applies = str(row["applies_to"] or "").strip() if "applies_to" in columns else ""
374
+ row_category = str(row["category"] or "").strip() if "category" in columns else ""
375
+ if applies_to and row_applies and row_applies == applies_to:
376
+ existing = row
377
+ break
378
+ if row_title == title:
379
+ existing = row
380
+ break
381
+ if target_signature and _topic_signature(f"{row_title} {row_content}") == target_signature:
382
+ if not row_category or row_category == category:
383
+ existing = row
384
+ break
385
+
386
+ now_epoch = datetime.now().timestamp()
387
+ weight_map = {"critical": 0.9, "high": 0.7, "medium": 0.5, "low": 0.3}
388
+ if existing:
389
+ updates: dict[str, object] = {}
390
+ if "category" in columns and category:
391
+ updates["category"] = category
392
+ if "title" in columns:
393
+ updates["title"] = title
394
+ if "content" in columns:
395
+ updates["content"] = content
396
+ if "reasoning" in columns and reasoning:
397
+ updates["reasoning"] = _append_note(existing["reasoning"], reasoning)
398
+ if "prevention" in columns and prevention:
399
+ updates["prevention"] = prevention
400
+ if "applies_to" in columns and applies_to:
401
+ updates["applies_to"] = applies_to
402
+ if "priority" in columns and priority:
403
+ updates["priority"] = priority
404
+ if "weight" in columns and priority:
405
+ updates["weight"] = weight_map.get(priority, 0.5)
406
+ if "status" in columns:
407
+ updates["status"] = "active"
408
+ if "updated_at" in columns:
409
+ updates["updated_at"] = now_epoch
410
+ assignments = ", ".join(f"{column} = ?" for column in updates)
411
+ conn.execute(
412
+ f"UPDATE learnings SET {assignments} WHERE id = ?",
413
+ [updates[column] for column in updates] + [existing["id"]],
414
+ )
415
+ return {"ok": True, "action": "updated", "learning_id": int(existing["id"])}
416
+
417
+ values: dict[str, object] = {}
418
+ if "category" in columns:
419
+ values["category"] = category or "nexo-ops"
420
+ if "title" in columns:
421
+ values["title"] = title
422
+ if "content" in columns:
423
+ values["content"] = content
424
+ if "reasoning" in columns:
425
+ values["reasoning"] = reasoning
426
+ if "prevention" in columns:
427
+ values["prevention"] = prevention
428
+ if "applies_to" in columns and applies_to:
429
+ values["applies_to"] = applies_to
430
+ if "priority" in columns and priority:
431
+ values["priority"] = priority
432
+ if "weight" in columns and priority:
433
+ values["weight"] = weight_map.get(priority, 0.5)
434
+ if "status" in columns:
435
+ values["status"] = "active"
436
+ if "created_at" in columns:
437
+ values["created_at"] = now_epoch
438
+ if "updated_at" in columns:
439
+ values["updated_at"] = now_epoch
440
+ placeholders = ", ".join("?" for _ in values)
441
+ conn.execute(
442
+ f"INSERT INTO learnings ({', '.join(values)}) VALUES ({placeholders})",
443
+ list(values.values()),
444
+ )
445
+ learning_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
446
+ return {"ok": True, "action": "created", "learning_id": int(learning_id)}
447
+
448
+
449
+ def _supersede_learning_inline(conn: sqlite3.Connection, *, keep_id: int, retire_id: int, note: str) -> bool:
450
+ if not _table_exists(conn, "learnings"):
451
+ return False
452
+ columns = _table_columns(conn, "learnings")
453
+ now_epoch = datetime.now().timestamp()
454
+ retire_row = conn.execute("SELECT * FROM learnings WHERE id = ?", (retire_id,)).fetchone()
455
+ keep_row = conn.execute("SELECT * FROM learnings WHERE id = ?", (keep_id,)).fetchone()
456
+ if not retire_row or not keep_row:
457
+ return False
458
+
459
+ retire_updates: dict[str, object] = {}
460
+ if "status" in columns:
461
+ retire_updates["status"] = "superseded"
462
+ if "reasoning" in columns:
463
+ retire_updates["reasoning"] = _append_note(retire_row["reasoning"], note)
464
+ if "updated_at" in columns:
465
+ retire_updates["updated_at"] = now_epoch
466
+ if retire_updates:
467
+ retire_assignments = ", ".join(f"{column} = ?" for column in retire_updates)
468
+ conn.execute(
469
+ f"UPDATE learnings SET {retire_assignments} WHERE id = ?",
470
+ [retire_updates[column] for column in retire_updates] + [retire_id],
471
+ )
472
+
473
+ keep_updates: dict[str, object] = {}
474
+ if "supersedes_id" in columns:
475
+ keep_updates["supersedes_id"] = retire_id
476
+ if "updated_at" in columns:
477
+ keep_updates["updated_at"] = now_epoch
478
+ if keep_updates:
479
+ keep_assignments = ", ".join(f"{column} = ?" for column in keep_updates)
480
+ conn.execute(
481
+ f"UPDATE learnings SET {keep_assignments} WHERE id = ?",
482
+ [keep_updates[column] for column in keep_updates] + [keep_id],
483
+ )
484
+ return True
485
+
486
+
487
+ def _upsert_workflow_goal_inline(conn: sqlite3.Connection, *, area: str, sample_goal: str, count: int) -> dict:
488
+ if not _table_exists(conn, "workflow_goals"):
489
+ return {"ok": False, "reason": "workflow_goals_missing"}
490
+
491
+ columns = _table_columns(conn, "workflow_goals")
492
+ signature = _topic_signature(sample_goal)
493
+ rows = conn.execute(
494
+ """SELECT * FROM workflow_goals
495
+ WHERE status NOT IN ('completed', 'cancelled', 'abandoned')
496
+ ORDER BY updated_at DESC"""
497
+ ).fetchall()
498
+ existing = None
499
+ for row in rows:
500
+ title = str(row["title"] or "")
501
+ objective = str(row["objective"] or "")
502
+ if signature and signature == _topic_signature(f"{title} {objective}"):
503
+ existing = row
504
+ break
505
+
506
+ objective = (
507
+ f"Recurring {area} theme detected by daily self-audit. "
508
+ f"The theme '{sample_goal}' appeared {count} times without a durable goal, learning, or resolved workflow."
509
+ )
510
+ next_action = "Convert the recurring theme into an explicit workflow or close it as intentional noise."
511
+ success_signal = "The theme stops resurfacing in unresolved protocol tasks."
512
+ now_iso = datetime.now().isoformat(timespec="seconds")
513
+ if existing:
514
+ updates: dict[str, object] = {}
515
+ if "title" in columns:
516
+ updates["title"] = sample_goal[:140]
517
+ if "objective" in columns:
518
+ updates["objective"] = objective
519
+ if "priority" in columns:
520
+ updates["priority"] = "high"
521
+ if "owner" in columns:
522
+ updates["owner"] = "system:self-audit"
523
+ if "next_action" in columns:
524
+ updates["next_action"] = next_action
525
+ if "success_signal" in columns:
526
+ updates["success_signal"] = success_signal
527
+ if "updated_at" in columns:
528
+ updates["updated_at"] = now_iso
529
+ assignments = ", ".join(f"{column} = ?" for column in updates)
530
+ conn.execute(
531
+ f"UPDATE workflow_goals SET {assignments} WHERE goal_id = ?",
532
+ [updates[column] for column in updates] + [existing["goal_id"]],
533
+ )
534
+ return {"ok": True, "action": "updated", "goal_id": str(existing["goal_id"])}
535
+
536
+ goal_id = f"WG-AUDIT-{hashlib.sha1(f'{area}:{signature or sample_goal}'.encode('utf-8')).hexdigest()[:8].upper()}"
537
+ values: dict[str, object] = {"goal_id": goal_id}
538
+ if "session_id" in columns:
539
+ values["session_id"] = ""
540
+ if "title" in columns:
541
+ values["title"] = sample_goal[:140]
542
+ if "objective" in columns:
543
+ values["objective"] = objective
544
+ if "parent_goal_id" in columns:
545
+ values["parent_goal_id"] = ""
546
+ if "status" in columns:
547
+ values["status"] = "active"
548
+ if "priority" in columns:
549
+ values["priority"] = "high"
550
+ if "owner" in columns:
551
+ values["owner"] = "system:self-audit"
552
+ if "next_action" in columns:
553
+ values["next_action"] = next_action
554
+ if "success_signal" in columns:
555
+ values["success_signal"] = success_signal
556
+ if "shared_state" in columns:
557
+ values["shared_state"] = json.dumps({"area": area, "signature": signature, "source": "self-audit"})
558
+ if "opened_at" in columns:
559
+ values["opened_at"] = now_iso
560
+ if "updated_at" in columns:
561
+ values["updated_at"] = now_iso
562
+ placeholders = ", ".join("?" for _ in values)
563
+ conn.execute(
564
+ f"INSERT INTO workflow_goals ({', '.join(values)}) VALUES ({placeholders})",
565
+ list(values.values()),
566
+ )
567
+ return {"ok": True, "action": "created", "goal_id": goal_id}
568
+
569
+
570
+ def _queue_public_core_handoff(
571
+ conn: sqlite3.Connection,
572
+ *,
573
+ title: str,
574
+ reasoning: str,
575
+ files_changed: list[str],
576
+ metadata: dict | None = None,
577
+ ) -> dict:
578
+ return queue_public_port_candidate(
579
+ conn,
580
+ title=title,
581
+ reasoning=reasoning,
582
+ files_changed=files_changed,
583
+ source="self-audit",
584
+ metadata=metadata or {},
585
+ )
586
+
587
+
266
588
  TOPIC_STOPWORDS = {
267
589
  "the", "and", "for", "with", "from", "that", "this", "into", "about", "after",
268
590
  "before", "again", "need", "needs", "task", "tasks", "work", "working",
@@ -352,7 +674,8 @@ def _learning_matches_change(row: sqlite3.Row, files: list[str], change_text: st
352
674
 
353
675
  def _attempt_repair_learning_auto_capture(row: sqlite3.Row) -> dict:
354
676
  try:
355
- from tools_learnings import find_conflicting_active_learning, handle_learning_add
677
+ from tools_learnings import find_conflicting_active_learning, handle_learning_add, handle_learning_update
678
+ from db._learnings import search_learnings
356
679
  except Exception as exc:
357
680
  return {"ok": False, "error": f"learning runtime unavailable: {exc}"}
358
681
 
@@ -369,6 +692,46 @@ def _attempt_repair_learning_auto_capture(row: sqlite3.Row) -> dict:
369
692
  if not content:
370
693
  content = f"Repair-oriented change log entry #{row['id']} required a canonical learning."
371
694
  applies_to = ",".join(files)
695
+
696
+ # --- Search-then-supersede: find existing same-topic learnings first ---
697
+ search_query = _topic_signature(f"{title} {content}")
698
+ existing_same_topic = None
699
+ if search_query:
700
+ candidates = search_learnings(search_query, category="nexo-ops")
701
+ for candidate in candidates:
702
+ if candidate.get("status") != "active":
703
+ continue
704
+ # Check if it covers the same files or topic
705
+ candidate_applies = str(candidate.get("applies_to") or "")
706
+ candidate_text = f"{candidate.get('title', '')} {candidate.get('content', '')}"
707
+ candidate_sig = _topic_signature(candidate_text)
708
+ if candidate_sig == search_query:
709
+ existing_same_topic = candidate
710
+ break
711
+ if applies_to and candidate_applies and any(
712
+ f in candidate_applies for f in files
713
+ ):
714
+ existing_same_topic = candidate
715
+ break
716
+
717
+ # If a same-topic learning already exists, update it instead of creating a duplicate
718
+ if existing_same_topic:
719
+ existing_id = int(existing_same_topic["id"])
720
+ updated_content = existing_same_topic.get("content", "") + f"\n\n[Audit {datetime.now().strftime('%Y-%m-%d')}] {content}"
721
+ response = handle_learning_update(
722
+ id=existing_id,
723
+ content=updated_content[:2000],
724
+ reasoning=f"Updated by daily self-audit with evidence from repair change #{row['id']}.",
725
+ )
726
+ if "ERROR:" not in response:
727
+ return {
728
+ "ok": True,
729
+ "learning_id": existing_id,
730
+ "response": response,
731
+ "action": "updated_existing",
732
+ }
733
+
734
+ # Fall back to conflict check + new learning only if no same-topic match
372
735
  conflicting = find_conflicting_active_learning(
373
736
  category="nexo-ops",
374
737
  title=title,
@@ -392,6 +755,7 @@ def _attempt_repair_learning_auto_capture(row: sqlite3.Row) -> dict:
392
755
  "ok": True,
393
756
  "learning_id": int(match.group(1)),
394
757
  "response": response,
758
+ "action": "created_new",
395
759
  }
396
760
  return {"ok": False, "error": response}
397
761
 
@@ -595,25 +959,46 @@ def check_learning_contradictions():
595
959
  contradictions.append((left, right))
596
960
 
597
961
  if contradictions:
598
- finding("ERROR", "contradictions", f"{len(contradictions)} contradictory active learning pair(s)")
599
- for left, right in contradictions[:5]:
962
+ resolved = 0
963
+ completed_followups = 0
964
+ retired_ids: set[int] = set()
965
+ for left, right in contradictions:
966
+ keep, retire = left, right
967
+ if int(retire["id"]) in retired_ids or int(keep["id"]) in retired_ids:
968
+ continue
600
969
  description = (
601
970
  f"Resolve contradictory active learnings #{left['id']} and #{right['id']} "
602
971
  f"for {left['applies_to'] or right['applies_to']}"
603
972
  )
604
- reasoning = (
605
- "Daily self-audit found two active canonical rules that contradict each other. "
606
- "One rule must be superseded or reconciled before the next edit repeats the error."
607
- )
608
- _ensure_followup(
609
- conn,
610
- prefix="CONTRADICTION",
611
- description=description,
612
- verification="One canonical learning remains active and the conflicting rule is superseded or archived",
613
- reasoning=reasoning,
614
- priority="critical",
973
+ note = (
974
+ f"Resolved inline by daily self-audit: learning #{retire['id']} was superseded by "
975
+ f"canonical learning #{keep['id']}."
615
976
  )
977
+ if _supersede_learning_inline(conn, keep_id=int(keep["id"]), retire_id=int(retire["id"]), note=note):
978
+ resolved += 1
979
+ retired_ids.add(int(retire["id"]))
980
+ applies_to = str(keep["applies_to"] or retire["applies_to"] or "").strip()
981
+ if applies_to:
982
+ _queue_public_core_handoff(
983
+ conn,
984
+ title=f"Reconcile contradictory rule coverage for {applies_to[:120]}",
985
+ reasoning=note,
986
+ files_changed=_split_changed_files(applies_to),
987
+ metadata={
988
+ "kept_learning_id": int(keep["id"]),
989
+ "retired_learning_id": int(retire["id"]),
990
+ },
991
+ )
992
+ completed_followups += _complete_matching_followup(conn, description, note)
616
993
  conn.commit()
994
+ if resolved:
995
+ message = f"{resolved} contradictory active learning pair(s) resolved inline"
996
+ if completed_followups:
997
+ message += f" | completed {completed_followups} legacy followup(s)"
998
+ finding("INFO", "contradictions", message)
999
+ remaining = max(0, len(contradictions) - resolved)
1000
+ if remaining:
1001
+ finding("WARN", "contradictions", f"{remaining} contradictory active learning pair(s) still need manual review")
617
1002
  conn.close()
618
1003
 
619
1004
 
@@ -643,7 +1028,8 @@ def check_error_memory_loop():
643
1028
 
644
1029
  repeated = {signature: items for signature, items in grouped.items() if len(items) >= 2}
645
1030
  if repeated:
646
- finding("WARN", "prevention", f"{len(repeated)} repeated failure cluster(s) still lack canonical prevention learnings")
1031
+ resolved = 0
1032
+ completed_followups = 0
647
1033
  for signature, items in list(repeated.items())[:5]:
648
1034
  description = (
649
1035
  f"Mine a canonical prevention learning from repeated failed/blocked protocol tasks around {signature}"
@@ -652,15 +1038,59 @@ def check_error_memory_loop():
652
1038
  f"Daily self-audit found {len(items)} failed/blocked protocol tasks without a linked learning. "
653
1039
  "Turn the repeated failure into a prevention rule before it repeats again."
654
1040
  )
655
- _ensure_followup(
1041
+ sample = items[0]
1042
+ area = str(sample["area"] or "nexo-ops").strip() or "nexo-ops"
1043
+ applies_to = signature if "/" in signature else ""
1044
+ title = f"Prevention: repeated failures around {signature[:120]}"
1045
+ clustered_tasks = "; ".join(
1046
+ f"{str(item['task_id'])}: {str(item['goal'] or '').strip()[:80]}"
1047
+ for item in items[:5]
1048
+ )
1049
+ content = (
1050
+ f"Repeated failed/blocked protocol tasks detected around {signature}. "
1051
+ f"Examples: {clustered_tasks}."
1052
+ )
1053
+ prevention = (
1054
+ f"Before working around {signature}, review this cluster and capture the prevention rule in the task contract."
1055
+ )
1056
+ result = _upsert_inline_learning(
656
1057
  conn,
657
- prefix="PREVENTION",
658
- description=description,
659
- verification="Canonical prevention learning captured and linked to the repeated failure pattern",
1058
+ category=area,
1059
+ title=title,
1060
+ content=content,
660
1061
  reasoning=reasoning,
1062
+ prevention=prevention,
1063
+ applies_to=applies_to,
661
1064
  priority="high",
662
1065
  )
1066
+ if result.get("ok"):
1067
+ resolved += 1
1068
+ if applies_to:
1069
+ _queue_public_core_handoff(
1070
+ conn,
1071
+ title=f"Port prevention guard for {signature[:120]}",
1072
+ reasoning=reasoning,
1073
+ files_changed=_split_changed_files(applies_to),
1074
+ metadata={
1075
+ "learning_id": result.get("learning_id"),
1076
+ "cluster_size": len(items),
1077
+ "signature": signature,
1078
+ },
1079
+ )
1080
+ completed_followups += _complete_matching_followup(
1081
+ conn,
1082
+ description,
1083
+ f"Resolved inline by daily self-audit via learning #{result.get('learning_id')}.",
1084
+ )
663
1085
  conn.commit()
1086
+ if resolved:
1087
+ message = f"{resolved} repeated failure cluster(s) converted into canonical prevention learnings inline"
1088
+ if completed_followups:
1089
+ message += f" | completed {completed_followups} legacy followup(s)"
1090
+ finding("INFO", "prevention", message)
1091
+ remaining = max(0, len(repeated) - resolved)
1092
+ if remaining:
1093
+ finding("WARN", "prevention", f"{remaining} repeated failure cluster(s) still lack inline prevention learnings")
664
1094
  conn.close()
665
1095
 
666
1096
 
@@ -798,7 +1228,8 @@ def check_unformalized_mentions():
798
1228
  if len(items) >= 2
799
1229
  }
800
1230
  if loose_topics:
801
- finding("WARN", "formalization", f"{len(loose_topics)} repeated topic(s) keep being mentioned without durable formalization")
1231
+ resolved = 0
1232
+ completed_followups = 0
802
1233
  for (area, signature), items in list(loose_topics.items())[:5]:
803
1234
  sample_goal = str(items[0]["goal"] or "").strip()[:120]
804
1235
  description = (
@@ -809,15 +1240,48 @@ def check_unformalized_mentions():
809
1240
  "Daily self-audit found the same theme recurring across protocol tasks without being "
810
1241
  "converted into a workflow goal, followup, or learning. Formalize it before it keeps resurfacing."
811
1242
  )
812
- _ensure_followup(
1243
+ goal_result = _upsert_workflow_goal_inline(
813
1244
  conn,
814
- prefix="FORMALIZE",
815
- description=description,
816
- verification="Theme converted into a durable goal, followup, or canonical learning",
1245
+ area=area,
1246
+ sample_goal=sample_goal,
1247
+ count=len(items),
1248
+ )
1249
+ if goal_result.get("ok"):
1250
+ resolved += 1
1251
+ completed_followups += _complete_matching_followup(
1252
+ conn,
1253
+ description,
1254
+ f"Resolved inline by daily self-audit via workflow goal {goal_result.get('goal_id')}.",
1255
+ )
1256
+ continue
1257
+ learning_result = _upsert_inline_learning(
1258
+ conn,
1259
+ category=area,
1260
+ title=f"Formalized recurring theme: {sample_goal}",
1261
+ content=(
1262
+ f"Recurring unresolved theme in {area}: '{sample_goal}' appeared {len(items)} times "
1263
+ "without a durable goal or learning."
1264
+ ),
817
1265
  reasoning=reasoning,
1266
+ prevention="Convert recurring themes into an explicit workflow goal before they keep resurfacing.",
818
1267
  priority="high",
819
1268
  )
1269
+ if learning_result.get("ok"):
1270
+ resolved += 1
1271
+ completed_followups += _complete_matching_followup(
1272
+ conn,
1273
+ description,
1274
+ f"Resolved inline by daily self-audit via learning #{learning_result.get('learning_id')}.",
1275
+ )
820
1276
  conn.commit()
1277
+ if resolved:
1278
+ message = f"{resolved} repeated unresolved theme(s) formalized inline"
1279
+ if completed_followups:
1280
+ message += f" | completed {completed_followups} legacy followup(s)"
1281
+ finding("INFO", "formalization", message)
1282
+ remaining = max(0, len(loose_topics) - resolved)
1283
+ if remaining:
1284
+ finding("WARN", "formalization", f"{remaining} repeated topic(s) still lack durable inline formalization")
821
1285
  conn.close()
822
1286
 
823
1287
 
@@ -1261,6 +1725,169 @@ def check_codex_startup_discipline():
1261
1725
  finding(severity, "codex-startup", message)
1262
1726
 
1263
1727
 
1728
+ def _clear_findings(area: str, contains: str = "") -> int:
1729
+ removed = 0
1730
+ keep: list[dict] = []
1731
+ for item in findings:
1732
+ same_area = item.get("area") == area
1733
+ same_fragment = not contains or contains in str(item.get("msg") or "")
1734
+ if same_area and same_fragment:
1735
+ removed += 1
1736
+ continue
1737
+ keep.append(item)
1738
+ if removed:
1739
+ findings[:] = keep
1740
+ return removed
1741
+
1742
+
1743
+ def _sync_managed_bootstraps_inline() -> list[str]:
1744
+ try:
1745
+ from bootstrap_docs import sync_client_bootstrap
1746
+ from client_preferences import CLIENT_CLAUDE_CODE, CLIENT_CODEX
1747
+ except Exception:
1748
+ return []
1749
+
1750
+ results: list[str] = []
1751
+ for client in (CLIENT_CLAUDE_CODE, CLIENT_CODEX):
1752
+ try:
1753
+ outcome = sync_client_bootstrap(client, nexo_home=NEXO_HOME)
1754
+ except Exception:
1755
+ continue
1756
+ if not outcome.get("ok"):
1757
+ continue
1758
+ action = str(outcome.get("action") or "")
1759
+ if action and action != "unchanged":
1760
+ results.append(f"{client}:{action}")
1761
+ return results
1762
+
1763
+
1764
+ def _sanitize_watchdog_registry_inline() -> dict:
1765
+ if not HASH_REGISTRY.exists():
1766
+ return {"ok": False, "removed": []}
1767
+ forbidden = ["CLAUDE.md", "AGENTS.md", "server.py", "plugin_loader.py"]
1768
+ original_lines = HASH_REGISTRY.read_text(errors="ignore").splitlines()
1769
+ kept_lines = []
1770
+ removed: set[str] = set()
1771
+ for line in original_lines:
1772
+ if any(name in line for name in forbidden):
1773
+ for name in forbidden:
1774
+ if name in line:
1775
+ removed.add(name)
1776
+ continue
1777
+ kept_lines.append(line)
1778
+ if not removed:
1779
+ return {"ok": False, "removed": []}
1780
+ new_text = "\n".join(kept_lines)
1781
+ if kept_lines:
1782
+ new_text += "\n"
1783
+ HASH_REGISTRY.write_text(new_text)
1784
+ return {"ok": True, "removed": sorted(removed)}
1785
+
1786
+
1787
+ def _refresh_golden_snapshots_inline() -> dict:
1788
+ pairs = [
1789
+ (NEXO_CODE / "db" / "__init__.py", SNAPSHOT_GOLDEN / "db" / "__init__.py"),
1790
+ (NEXO_CODE / "evolution_cycle.py", SNAPSHOT_GOLDEN / "evolution_cycle.py"),
1791
+ ]
1792
+ refreshed: list[str] = []
1793
+ for live, snap in pairs:
1794
+ if not live.exists():
1795
+ continue
1796
+ if snap.exists() and _sha256(live) == _sha256(snap):
1797
+ continue
1798
+ snap.parent.mkdir(parents=True, exist_ok=True)
1799
+ shutil.copy2(live, snap)
1800
+ refreshed.append(live.name)
1801
+ return {"ok": bool(refreshed), "refreshed": refreshed}
1802
+
1803
+
1804
+ def _disable_broken_personal_plugins_inline(conn: sqlite3.Connection | None) -> dict:
1805
+ plugins_dir = NEXO_HOME / "plugins"
1806
+ if not plugins_dir.exists():
1807
+ return {"disabled": [], "registry_pruned": 0}
1808
+
1809
+ disabled: list[str] = []
1810
+ registry_pruned = 0
1811
+ personal_filenames: set[str] = set()
1812
+ if conn is not None and _table_exists(conn, "plugins"):
1813
+ try:
1814
+ rows = conn.execute(
1815
+ "SELECT filename, created_by FROM plugins WHERE created_by = 'personal'"
1816
+ ).fetchall()
1817
+ personal_filenames = {str(row["filename"] or "").strip() for row in rows if str(row["filename"] or "").strip()}
1818
+ except Exception:
1819
+ personal_filenames = set()
1820
+
1821
+ for plugin_file in sorted(plugins_dir.glob("*.py")):
1822
+ try:
1823
+ py_compile.compile(str(plugin_file), doraise=True)
1824
+ except Exception:
1825
+ disabled_path = plugin_file.with_name(plugin_file.name + ".disabled")
1826
+ plugin_file.rename(disabled_path)
1827
+ disabled.append(plugin_file.name)
1828
+ if conn is not None and _table_exists(conn, "plugins"):
1829
+ conn.execute("DELETE FROM plugins WHERE filename = ?", (plugin_file.name,))
1830
+ registry_pruned += 1
1831
+
1832
+ if conn is not None and _table_exists(conn, "plugins"):
1833
+ for filename in sorted(personal_filenames):
1834
+ if not filename:
1835
+ continue
1836
+ if not (plugins_dir / filename).exists():
1837
+ conn.execute("DELETE FROM plugins WHERE filename = ?", (filename,))
1838
+ registry_pruned += 1
1839
+ return {"disabled": disabled, "registry_pruned": registry_pruned}
1840
+
1841
+
1842
+ def run_mechanical_autofixes():
1843
+ conn = None
1844
+ try:
1845
+ if NEXO_DB.exists():
1846
+ conn = sqlite3.connect(str(NEXO_DB))
1847
+ conn.row_factory = sqlite3.Row
1848
+
1849
+ bootstrap_actions = _sync_managed_bootstraps_inline()
1850
+ if bootstrap_actions:
1851
+ finding("INFO", "autofix", f"Managed bootstraps refreshed inline: {', '.join(bootstrap_actions)}")
1852
+
1853
+ registry_result = _sanitize_watchdog_registry_inline()
1854
+ if registry_result.get("ok"):
1855
+ _clear_findings("watchdog", "mutable files still protected")
1856
+ finding(
1857
+ "INFO",
1858
+ "watchdog",
1859
+ "Self-audit sanitized watchdog registry inline: "
1860
+ + ", ".join(registry_result.get("removed") or []),
1861
+ )
1862
+
1863
+ snapshot_result = _refresh_golden_snapshots_inline()
1864
+ if snapshot_result.get("ok"):
1865
+ _clear_findings("snapshots", "golden snapshot drift")
1866
+ finding(
1867
+ "INFO",
1868
+ "snapshots",
1869
+ "Self-audit refreshed golden snapshots inline: "
1870
+ + ", ".join(snapshot_result.get("refreshed") or []),
1871
+ )
1872
+
1873
+ plugin_result = _disable_broken_personal_plugins_inline(conn)
1874
+ disabled = plugin_result.get("disabled") or []
1875
+ pruned = int(plugin_result.get("registry_pruned") or 0)
1876
+ if disabled or pruned:
1877
+ details: list[str] = []
1878
+ if disabled:
1879
+ details.append(f"disabled {len(disabled)} personal plugin(s): {', '.join(disabled)}")
1880
+ if pruned:
1881
+ details.append(f"pruned {pruned} stale plugin registry entrie(s)")
1882
+ finding("INFO", "autofix", "Self-audit plugin autofix: " + " | ".join(details))
1883
+
1884
+ if conn is not None:
1885
+ conn.commit()
1886
+ finally:
1887
+ if conn is not None:
1888
+ conn.close()
1889
+
1890
+
1264
1891
  # ═══════════════════════════════════════════════════════════════════════════════
1265
1892
  # Stage B: Interpretation (automation backend) — NEW in v2
1266
1893
  # ═══════════════════════════════════════════════════════════════════════════════
@@ -1282,7 +1909,17 @@ def interpret_findings(raw_findings: list) -> bool:
1282
1909
 
1283
1910
  You are NEXO's morning self-audit interpreter. The mechanical checks found
1284
1911
  {len(errors)} errors and {len(warns)} warnings. Your job is to UNDERSTAND what's
1285
- actually wrong, not just list findings. Use nexo_learning_add for new findings and nexo_followup_create for action items.
1912
+ actually wrong, not just list findings.
1913
+
1914
+ CRITICAL — SEARCH BEFORE CREATING LEARNINGS:
1915
+ Before calling nexo_learning_add, you MUST call nexo_learning_search with keywords
1916
+ from the finding's area and topic. If a matching active learning already exists:
1917
+ - Call nexo_learning_update(id=<existing_id>, ...) to refresh it with the new
1918
+ evidence/date instead of creating a duplicate.
1919
+ - Only use nexo_learning_add (with supersedes_id=<old_id>) when the existing
1920
+ learning is materially wrong or outdated, not just to add another observation.
1921
+ If no existing learning matches, then nexo_learning_add is appropriate.
1922
+ The same applies to nexo_followup_create — search existing followups first.
1286
1923
 
1287
1924
  RAW FINDINGS:
1288
1925
  {findings_json}
@@ -1376,6 +2013,7 @@ def main():
1376
2013
  run_watchdog_smoke()
1377
2014
  check_watchdog_smoke()
1378
2015
  check_cognitive_health()
2016
+ run_mechanical_autofixes()
1379
2017
 
1380
2018
  errors = sum(1 for f in findings if f["severity"] == "ERROR")
1381
2019
  warns = sum(1 for f in findings if f["severity"] == "WARN")