nexo-brain 2.6.21 → 3.0.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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +72 -20
  3. package/hooks/hooks.json +79 -0
  4. package/package.json +1 -1
  5. package/src/agent_runner.py +296 -8
  6. package/src/cli.py +209 -4
  7. package/src/client_preferences.py +115 -0
  8. package/src/client_sync.py +202 -2
  9. package/src/cognitive/__init__.py +1 -1
  10. package/src/cognitive/_search.py +39 -19
  11. package/src/dashboard/app.py +264 -0
  12. package/src/dashboard/templates/base.html +4 -0
  13. package/src/dashboard/templates/dashboard.html +59 -1
  14. package/src/dashboard/templates/protocol.html +199 -0
  15. package/src/db/__init__.py +23 -1
  16. package/src/db/_learnings.py +31 -4
  17. package/src/db/_personal_scripts.py +12 -0
  18. package/src/db/_protocol.py +303 -0
  19. package/src/db/_schema.py +248 -0
  20. package/src/db/_watchers.py +173 -0
  21. package/src/db/_workflow.py +952 -0
  22. package/src/doctor/providers/runtime.py +1095 -3
  23. package/src/evolution_cycle.py +62 -0
  24. package/src/hook_guardrails.py +308 -0
  25. package/src/hooks/protocol-guardrail.sh +10 -0
  26. package/src/nexo_sdk.py +103 -0
  27. package/src/plugins/cognitive_memory.py +18 -0
  28. package/src/plugins/cortex.py +55 -35
  29. package/src/plugins/guard.py +132 -16
  30. package/src/plugins/protocol.py +911 -0
  31. package/src/plugins/schedule.py +40 -6
  32. package/src/plugins/simple_api.py +103 -0
  33. package/src/plugins/skills.py +67 -0
  34. package/src/plugins/state_watchers.py +79 -0
  35. package/src/plugins/workflow.py +588 -0
  36. package/src/public_contribution.py +86 -12
  37. package/src/script_registry.py +142 -0
  38. package/src/scripts/deep-sleep/apply_findings.py +482 -2
  39. package/src/scripts/deep-sleep/collect.py +49 -4
  40. package/src/scripts/nexo-agent-run.py +2 -0
  41. package/src/scripts/nexo-daily-self-audit.py +843 -5
  42. package/src/scripts/nexo-evolution-run.py +343 -1
  43. package/src/server.py +92 -6
  44. package/src/skills_runtime.py +151 -0
  45. package/src/state_watchers_runtime.py +334 -0
  46. package/src/tools_learnings.py +345 -7
  47. package/src/tools_sessions.py +183 -0
  48. package/templates/CLAUDE.md.template +9 -1
  49. package/templates/CODEX.AGENTS.md.template +10 -2
@@ -173,7 +173,8 @@ sys.path.insert(0, str(NEXO_CODE))
173
173
  from agent_runner import probe_automation_backend, run_automation_prompt
174
174
  from evolution_cycle import (
175
175
  load_objective, save_objective, get_week_data, build_evolution_prompt,
176
- dry_run_restore_test, max_auto_changes, create_snapshot, build_public_contribution_prompt
176
+ dry_run_restore_test, max_auto_changes, create_snapshot,
177
+ build_public_contribution_prompt, build_public_pr_review_prompt,
177
178
  )
178
179
  from public_contribution import (
179
180
  CONTRIB_ARTIFACTS_DIR,
@@ -184,6 +185,7 @@ from public_contribution import (
184
185
  load_public_contribution_config,
185
186
  mark_active_pr,
186
187
  mark_public_contribution_result,
188
+ STATUS_PAUSED_OPEN_PR,
187
189
  )
188
190
 
189
191
 
@@ -430,6 +432,304 @@ def _write_public_artifacts(worktree_dir: Path, branch_name: str, summary: dict)
430
432
  return artifact_dir
431
433
 
432
434
 
435
+ def _review_state(review: dict) -> str:
436
+ return str(review.get("state") or review.get("reviewState") or "").strip().upper()
437
+
438
+
439
+ def _review_author(review: dict) -> str:
440
+ author = review.get("author") or {}
441
+ if isinstance(author, dict):
442
+ return str(author.get("login") or "").strip().lower()
443
+ return ""
444
+
445
+
446
+ def _is_public_evolution_pr(details: dict) -> bool:
447
+ body = str(details.get("body") or "")
448
+ return "Source: automated public core evolution from an opt-in machine." in body
449
+
450
+
451
+ def _review_already_left_by_user(details: dict, login: str) -> bool:
452
+ login = str(login or "").strip().lower()
453
+ if not login:
454
+ return False
455
+ for review in details.get("reviews") or []:
456
+ if _review_author(review) == login and _review_state(review) in {"APPROVED", "COMMENTED", "CHANGES_REQUESTED"}:
457
+ return True
458
+ return False
459
+
460
+
461
+ def _candidate_paths(details: dict) -> list[str]:
462
+ paths = []
463
+ for item in details.get("files") or []:
464
+ if isinstance(item, dict):
465
+ path = str(item.get("path") or item.get("name") or "").strip()
466
+ if path:
467
+ paths.append(path)
468
+ return paths
469
+
470
+
471
+ def _list_reviewable_public_prs(config: dict, limit: int = 3) -> list[dict]:
472
+ result = _gh(
473
+ "pr",
474
+ "list",
475
+ "--repo",
476
+ config["upstream_repo"],
477
+ "--state",
478
+ "open",
479
+ "--json",
480
+ "number,title,url,isDraft,author",
481
+ "--limit",
482
+ str(max(1, limit * 4)),
483
+ timeout=30,
484
+ )
485
+ if result.returncode != 0:
486
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "gh pr list failed")
487
+
488
+ github_user = str(config.get("github_user") or "").strip().lower()
489
+ active_pr_number = config.get("active_pr_number")
490
+ candidates: list[dict] = []
491
+ for item in json.loads(result.stdout or "[]"):
492
+ if not item.get("isDraft", False):
493
+ continue
494
+ number = int(item.get("number") or 0)
495
+ if not number or number == active_pr_number:
496
+ continue
497
+ author = item.get("author") or {}
498
+ author_login = str(author.get("login") or "").strip().lower()
499
+ if github_user and author_login == github_user:
500
+ continue
501
+
502
+ details_result = _gh(
503
+ "pr",
504
+ "view",
505
+ str(number),
506
+ "--repo",
507
+ config["upstream_repo"],
508
+ "--json",
509
+ "number,title,body,url,isDraft,author,reviews,files",
510
+ timeout=30,
511
+ )
512
+ if details_result.returncode != 0:
513
+ continue
514
+ details = json.loads(details_result.stdout or "{}")
515
+ if not details.get("isDraft", False):
516
+ continue
517
+ if not _is_public_evolution_pr(details):
518
+ continue
519
+ if _review_already_left_by_user(details, github_user):
520
+ continue
521
+ paths = _candidate_paths(details)
522
+ if not paths or any(not _is_allowed_public_path(path) for path in paths):
523
+ continue
524
+
525
+ diff_result = _gh(
526
+ "pr",
527
+ "diff",
528
+ str(number),
529
+ "--repo",
530
+ config["upstream_repo"],
531
+ timeout=60,
532
+ )
533
+ if diff_result.returncode != 0:
534
+ continue
535
+ details["files_changed"] = paths
536
+ details["diff_text"] = diff_result.stdout or ""
537
+ candidates.append(details)
538
+ if len(candidates) >= limit:
539
+ break
540
+ return candidates
541
+
542
+
543
+ _DEDUP_STOPWORDS = {
544
+ "the", "and", "for", "with", "from", "into", "after", "before", "public",
545
+ "core", "nexo", "fix", "feat", "chore", "docs", "tests", "runtime", "system",
546
+ }
547
+
548
+
549
+ def _proposal_tokens(text: str) -> set[str]:
550
+ return {
551
+ token
552
+ for token in re.findall(r"[a-z0-9]+", (text or "").lower())
553
+ if len(token) >= 3 and token not in _DEDUP_STOPWORDS
554
+ }
555
+
556
+
557
+ def _public_pr_duplicate_candidate(config: dict, *, title: str, changed_files: list[str]) -> dict | None:
558
+ try:
559
+ candidates = _list_reviewable_public_prs(config, limit=12)
560
+ except Exception:
561
+ return None
562
+ wanted_files = {str(path).strip().lower() for path in (changed_files or []) if str(path).strip()}
563
+ wanted_tokens = _proposal_tokens(title)
564
+ best_match = None
565
+ best_score = 0.0
566
+ for candidate in candidates:
567
+ candidate_files = {
568
+ str(path).strip().lower() for path in (candidate.get("files_changed") or []) if str(path).strip()
569
+ }
570
+ shared_files = wanted_files & candidate_files
571
+ candidate_tokens = _proposal_tokens(str(candidate.get("title") or ""))
572
+ shared_tokens = wanted_tokens & candidate_tokens
573
+ token_score = 0.0
574
+ if wanted_tokens and candidate_tokens:
575
+ token_score = len(shared_tokens) / max(1, min(len(wanted_tokens), len(candidate_tokens)))
576
+ score = 0.0
577
+ if shared_files and token_score >= 0.34:
578
+ score = 1.0
579
+ elif shared_files:
580
+ score = 0.75
581
+ elif token_score >= 0.8:
582
+ score = 0.7
583
+ if score > best_score:
584
+ best_score = score
585
+ best_match = {
586
+ "number": candidate.get("number"),
587
+ "title": candidate.get("title"),
588
+ "url": candidate.get("url"),
589
+ "score": round(score, 2),
590
+ "shared_files": sorted(shared_files),
591
+ "shared_tokens": sorted(shared_tokens),
592
+ }
593
+ return best_match if best_score >= 0.75 else None
594
+
595
+
596
+ def _parse_public_review_json(text: str) -> dict:
597
+ payload = text.strip()
598
+ if "```json" in payload:
599
+ payload = payload.split("```json", 1)[1].split("```", 1)[0]
600
+ elif "```" in payload:
601
+ payload = payload.split("```", 1)[1].split("```", 1)[0]
602
+ try:
603
+ data = json.loads(payload.strip())
604
+ except Exception:
605
+ data = {}
606
+ return data if isinstance(data, dict) else {}
607
+
608
+
609
+ def _submit_public_pr_review(config: dict, pr_number: int, decision: str, body: str) -> str:
610
+ clean_decision = str(decision or "").strip().lower()
611
+ clean_body = str(body or "").strip()
612
+ if clean_decision == "approve":
613
+ result = _gh(
614
+ "pr",
615
+ "review",
616
+ str(pr_number),
617
+ "--repo",
618
+ config["upstream_repo"],
619
+ "--approve",
620
+ "--body",
621
+ clean_body or "Scoped public-core change looks correct from automated peer review.",
622
+ timeout=60,
623
+ )
624
+ if result.returncode != 0:
625
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "gh pr review --approve failed")
626
+ return "approved_review"
627
+ if clean_decision == "comment":
628
+ result = _gh(
629
+ "pr",
630
+ "review",
631
+ str(pr_number),
632
+ "--repo",
633
+ config["upstream_repo"],
634
+ "--comment",
635
+ "--body",
636
+ clean_body or "Automated peer review left a note but did not approve.",
637
+ timeout=60,
638
+ )
639
+ if result.returncode != 0:
640
+ raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "gh pr review --comment failed")
641
+ return "commented_review"
642
+ return "review_skipped"
643
+
644
+
645
+ def _write_public_review_artifacts(pr_number: int, candidate: dict, review: dict) -> Path:
646
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
647
+ artifact_dir = CONTRIB_ARTIFACTS_DIR / f"review-{timestamp}-pr{pr_number}"
648
+ artifact_dir.mkdir(parents=True, exist_ok=True)
649
+ (artifact_dir / "candidate.json").write_text(json.dumps(candidate, indent=2, ensure_ascii=False) + "\n")
650
+ (artifact_dir / "review.json").write_text(json.dumps(review, indent=2, ensure_ascii=False) + "\n")
651
+ (artifact_dir / "diff.patch").write_text(str(candidate.get("diff_text") or ""))
652
+ return artifact_dir
653
+
654
+
655
+ def run_public_pr_validation_cycle(*, objective: dict, cycle_num: int, config: dict | None = None) -> int:
656
+ config = config or load_public_contribution_config()
657
+ if not verify_claude_cli():
658
+ log("Automation backend not available or not authenticated. Skipping peer PR validation.")
659
+ mark_public_contribution_result(result="skipped:peer_review_cli_unavailable", config=config)
660
+ return 0
661
+
662
+ _ensure_public_repo_cache(config)
663
+ candidates = _list_reviewable_public_prs(config, limit=3)
664
+ if not candidates:
665
+ log("No reviewable peer public-evolution PRs found.")
666
+ mark_public_contribution_result(result="skipped:no_peer_prs", config=config)
667
+ return 0
668
+
669
+ repo_root = str(CONTRIB_REPO_DIR if CONTRIB_REPO_DIR.exists() else Path.cwd())
670
+ conn = sqlite3.connect(str(NEXO_DB), timeout=10)
671
+ conn.execute("PRAGMA busy_timeout=5000")
672
+ reviewed = 0
673
+ try:
674
+ for candidate in candidates:
675
+ pr_number = int(candidate.get("number") or 0)
676
+ prompt = build_public_pr_review_prompt(
677
+ pr_number=pr_number,
678
+ title=str(candidate.get("title") or "").strip(),
679
+ author=str((candidate.get("author") or {}).get("login") or "").strip(),
680
+ url=str(candidate.get("url") or "").strip(),
681
+ body=str(candidate.get("body") or ""),
682
+ files=candidate.get("files_changed") or [],
683
+ diff_text=str(candidate.get("diff_text") or ""),
684
+ )
685
+ raw_review = call_public_claude_cli(prompt, cwd=Path(repo_root))
686
+ review = _parse_public_review_json(raw_review)
687
+ decision = str(review.get("decision") or "skip").strip().lower()
688
+ review_status = _submit_public_pr_review(config, pr_number, decision, str(review.get("body") or ""))
689
+ artifact_dir = _write_public_review_artifacts(pr_number, candidate, review)
690
+ conn.execute(
691
+ "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, reasoning, status, files_changed, test_result) "
692
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
693
+ (
694
+ cycle_num,
695
+ "public_core",
696
+ f"Review PR #{pr_number}: {str(candidate.get('title') or '').strip()}",
697
+ "public_review",
698
+ str(review.get("summary") or "Peer PR validation").strip(),
699
+ review_status,
700
+ json.dumps(candidate.get("files_changed") or []),
701
+ json.dumps(
702
+ {
703
+ "pr_url": candidate.get("url"),
704
+ "decision": decision,
705
+ "artifact_dir": str(artifact_dir),
706
+ }
707
+ ),
708
+ ),
709
+ )
710
+ conn.commit()
711
+ reviewed += 1
712
+
713
+ if reviewed:
714
+ objective["last_evolution"] = str(date.today())
715
+ objective["total_evolutions"] = cycle_num
716
+ objective.setdefault("history", []).insert(0, {
717
+ "cycle": cycle_num,
718
+ "date": str(date.today()),
719
+ "mode": "public_core_review",
720
+ "proposals": 0,
721
+ "auto_count": 0,
722
+ "auto_applied": 0,
723
+ "analysis": f"Reviewed {reviewed} peer public-evolution PR(s).",
724
+ })
725
+ objective["history"] = objective["history"][:12]
726
+ save_objective(objective)
727
+ mark_public_contribution_result(result=f"peer_reviewed:{reviewed}", config=config)
728
+ return reviewed
729
+ finally:
730
+ conn.close()
731
+
732
+
433
733
  def _create_draft_pr(worktree_dir: Path, config: dict, branch_name: str, summary: dict) -> tuple[str, int | None]:
434
734
  title = str(summary.get("title") or "chore: public evolution contribution").strip()
435
735
  body_lines = [
@@ -482,6 +782,12 @@ def run_public_contribution_cycle(*, objective: dict, cycle_num: int) -> None:
482
782
  config = load_public_contribution_config()
483
783
  ready, reason, config = can_run_public_contribution(config)
484
784
  if not ready:
785
+ if config.get("status") == STATUS_PAUSED_OPEN_PR:
786
+ log(f"Public core contribution paused: {reason}. Switching to peer PR validation.")
787
+ reviewed = run_public_pr_validation_cycle(objective=objective, cycle_num=cycle_num, config=config)
788
+ if reviewed:
789
+ log(f"Peer public PR validation complete: reviewed {reviewed} PR(s).")
790
+ return
485
791
  log(f"Public core contribution paused: {reason}")
486
792
  mark_public_contribution_result(result=f"skipped:{reason}", config=config)
487
793
  return
@@ -510,6 +816,42 @@ def run_public_contribution_cycle(*, objective: dict, cycle_num: int) -> None:
510
816
  existing_tests = summary.get("tests")
511
817
  summary["tests"] = existing_tests if isinstance(existing_tests, list) and existing_tests else tests_run
512
818
  commit_title = str(summary.get("title") or "chore: public evolution contribution").strip()
819
+ duplicate = _public_pr_duplicate_candidate(config, title=commit_title, changed_files=changed_files)
820
+ if duplicate:
821
+ artifact_dir = _write_public_artifacts(
822
+ worktree_dir,
823
+ branch_name,
824
+ {
825
+ **summary,
826
+ "duplicate_of": duplicate,
827
+ "tests": summary.get("tests", []),
828
+ "changed_files": changed_files,
829
+ },
830
+ )
831
+ conn.execute(
832
+ "INSERT INTO evolution_log (cycle_number, dimension, proposal, classification, reasoning, status, files_changed, test_result) "
833
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
834
+ (
835
+ cycle_num,
836
+ "public_core",
837
+ commit_title,
838
+ "draft_pr_dedup",
839
+ f"Duplicate of open opt-in public PR #{duplicate.get('number')}: {duplicate.get('title')}",
840
+ "skipped_duplicate_existing_pr",
841
+ json.dumps(changed_files),
842
+ json.dumps({"duplicate_of": duplicate, "artifact_dir": str(artifact_dir)}),
843
+ ),
844
+ )
845
+ conn.commit()
846
+ mark_public_contribution_result(
847
+ result=f"skipped:duplicate_pr:{duplicate.get('number')}",
848
+ config=config,
849
+ )
850
+ log(
851
+ "Public core contribution deduplicated against existing opt-in PR "
852
+ f"#{duplicate.get('number')} ({duplicate.get('url')})."
853
+ )
854
+ return
513
855
 
514
856
  add = _git(worktree_dir, "add", "--", *changed_files, timeout=60)
515
857
  if add.returncode != 0:
package/src/server.py CHANGED
@@ -7,7 +7,15 @@ import sys
7
7
 
8
8
  from fastmcp import FastMCP
9
9
  from db import init_db, rebuild_fts_index, get_db, close_db, fts_add_dir, fts_remove_dir, fts_list_dirs
10
- from tools_sessions import handle_startup, handle_heartbeat, handle_status, handle_context_packet, handle_smart_startup_query
10
+ from tools_sessions import (
11
+ handle_startup,
12
+ handle_heartbeat,
13
+ handle_status,
14
+ handle_context_packet,
15
+ handle_smart_startup_query,
16
+ handle_session_portable_context,
17
+ handle_session_export_bundle,
18
+ )
11
19
  from user_context import get_context as _get_ctx
12
20
  from tools_coordination import (
13
21
  handle_track, handle_untrack, handle_files,
@@ -24,6 +32,7 @@ from tools_reminders_crud import (
24
32
  from tools_learnings import (
25
33
  handle_learning_add, handle_learning_search,
26
34
  handle_learning_update, handle_learning_delete, handle_learning_list,
35
+ handle_learning_quality,
27
36
  )
28
37
  from tools_credentials import (
29
38
  handle_credential_get, handle_credential_create,
@@ -164,6 +173,13 @@ mcp = FastMCP(
164
173
  instructions=(
165
174
  f"{_get_ctx().assistant_name} — cognitive co-operator. Save important info from tool results before they clear.\n\n"
166
175
  "## CRITICAL — do these or you WILL get corrected\n"
176
+ "- **Protocol (MANDATORY for non-trivial work):** `nexo_task_open(...)` at the start and `nexo_task_close(...)` before saying done. "
177
+ "For edit/execute/delegate tasks this is the default path. Never claim completion without evidence.\n"
178
+ "- **Answer discipline (MANDATORY for non-trivial factual answers):** run `nexo_confidence_check(...)` or use `nexo_task_open(task_type='answer'|'analyze', ...)`. "
179
+ "If the response mode is `verify`, `ask`, or `defer`, follow it instead of answering from memory.\n"
180
+ "- **Workflow runtime (MANDATORY for long multi-step or cross-session work):** open `nexo_goal_open(...)` when the objective must survive sessions, then `nexo_workflow_open(...)`, "
181
+ "update meaningful checkpoints with `nexo_workflow_update(...)`, then use `nexo_workflow_resume(...)` / "
182
+ "`nexo_workflow_replay(...)` instead of restarting blindly.\n"
167
183
  "- **Guard (MANDATORY before ANY code edit):** `nexo_guard_check(files='...', area='...')` BEFORE editing code. "
168
184
  "No exceptions. Blocking rules→resolve first. `nexo_track(sid=SID, paths=[...])` before shared files\n"
169
185
  "- **Skills (MANDATORY before multi-step tasks):** `nexo_skill_match(task)` to find reusable procedures. "
@@ -183,7 +199,7 @@ mcp = FastMCP(
183
199
  "Detect intent, not keywords — works in ALL languages.\n"
184
200
  "- **Delegate:** prefer direct. If needed: `nexo_context_packet(area)` + guard + 'if unsure STOP'\n"
185
201
  "- **Memory:** `nexo_recall` searches all. Capture: errors→`nexo_learning_add`, prefs, entities, decisions\n"
186
- "- **Change log:** `nexo_change_log(...)` after production edits. NOT for config dir\n"
202
+ "- **Change log:** `nexo_task_close` should be the default closure path. If you bypass it, call `nexo_change_log(...)` after production edits. NOT for config dir\n"
187
203
  "- **Diary:** When user signals end of session (any language, any style — 'bye', 'done', 'cierro', etc.), "
188
204
  "write `nexo_session_diary_write(...)` with self_critique BEFORE responding. "
189
205
  "Detect intent, not keywords. If session closes without diary, auto_close handles it.\n"
@@ -274,6 +290,22 @@ def nexo_smart_startup() -> str:
274
290
  return handle_smart_startup_query()
275
291
 
276
292
 
293
+ @mcp.tool
294
+ def nexo_session_portable_context(sid: str = "") -> str:
295
+ """Build a portable handoff packet for another client/runtime.
296
+
297
+ Use this when another client should continue the same work with explicit
298
+ task/checkpoint/goal/workflow context instead of relying on memory alone.
299
+ """
300
+ return handle_session_portable_context(sid)
301
+
302
+
303
+ @mcp.tool
304
+ def nexo_session_export_bundle(sid: str = "", path: str = "") -> str:
305
+ """Export a machine-readable session bundle for cross-client handoff or archival."""
306
+ return handle_session_export_bundle(sid, path)
307
+
308
+
277
309
  # ── Session Checkpoints (auto-compaction continuity) ──────────────
278
310
 
279
311
  @mcp.tool
@@ -563,7 +595,17 @@ def nexo_followup_delete(id: str) -> str:
563
595
  # ── Learnings CRUD (5 tools) ──────────────────────────────────────
564
596
 
565
597
  @mcp.tool
566
- def nexo_learning_add(category: str, title: str, content: str, reasoning: str = "", priority: str = "medium") -> str:
598
+ def nexo_learning_add(
599
+ category: str,
600
+ title: str,
601
+ content: str,
602
+ reasoning: str = "",
603
+ prevention: str = "",
604
+ applies_to: str = "",
605
+ review_days: int = 30,
606
+ priority: str = "medium",
607
+ supersedes_id: int = 0,
608
+ ) -> str:
567
609
  """Add a new learning (resolved error, pattern, gotcha).
568
610
 
569
611
  Args:
@@ -571,9 +613,17 @@ def nexo_learning_add(category: str, title: str, content: str, reasoning: str =
571
613
  title: Short title for the learning.
572
614
  content: Full description with context and solution.
573
615
  reasoning: WHY this matters — what led to discovering this (optional).
616
+ prevention: Concrete rule/check that prevents repeating this mistake (optional).
617
+ applies_to: Files, systems, or areas this learning applies to (optional).
618
+ review_days: Days until this learning should be reviewed again (default 30).
574
619
  priority: critical, high, medium, low (default: medium). Critical/high never decay below floor.
620
+ supersedes_id: Existing learning ID this new canonical rule replaces (optional).
575
621
  """
576
- return handle_learning_add(category, title, content, reasoning, priority=priority)
622
+ return handle_learning_add(
623
+ category, title, content, reasoning,
624
+ prevention=prevention, applies_to=applies_to,
625
+ review_days=review_days, priority=priority, supersedes_id=supersedes_id,
626
+ )
577
627
 
578
628
 
579
629
  @mcp.tool
@@ -588,7 +638,19 @@ def nexo_learning_search(query: str, category: str = "") -> str:
588
638
 
589
639
 
590
640
  @mcp.tool
591
- def nexo_learning_update(id: int, title: str = "", content: str = "", category: str = "", priority: str = "") -> str:
641
+ def nexo_learning_update(
642
+ id: int,
643
+ title: str = "",
644
+ content: str = "",
645
+ category: str = "",
646
+ reasoning: str = "",
647
+ prevention: str = "",
648
+ applies_to: str = "",
649
+ status: str = "",
650
+ review_days: int = 0,
651
+ priority: str = "",
652
+ supersedes_id: int = 0,
653
+ ) -> str:
592
654
  """Update a learning entry. Only non-empty fields are changed.
593
655
 
594
656
  Args:
@@ -596,9 +658,20 @@ def nexo_learning_update(id: int, title: str = "", content: str = "", category:
596
658
  title: New title (optional).
597
659
  content: New content (optional).
598
660
  category: New category (optional).
661
+ reasoning: New reasoning/context (optional).
662
+ prevention: New prevention rule (optional).
663
+ applies_to: New applies_to target(s) (optional).
664
+ status: New status such as active/superseded (optional).
665
+ review_days: New review interval in days (optional).
599
666
  priority: critical, high, medium, low (optional).
667
+ supersedes_id: Existing learning ID this updated canonical rule replaces (optional).
600
668
  """
601
- return handle_learning_update(id, title, content, category, priority=priority)
669
+ return handle_learning_update(
670
+ id, title, content, category,
671
+ reasoning=reasoning, prevention=prevention, applies_to=applies_to,
672
+ status=status, review_days=review_days, priority=priority,
673
+ supersedes_id=supersedes_id,
674
+ )
602
675
 
603
676
 
604
677
  @mcp.tool
@@ -621,6 +694,19 @@ def nexo_learning_list(category: str = "") -> str:
621
694
  return handle_learning_list(category)
622
695
 
623
696
 
697
+ @mcp.tool
698
+ def nexo_learning_quality(id: int = 0, category: str = "", status: str = "active", limit: int = 20) -> str:
699
+ """Score learning quality so fragile rules can be strengthened before they mislead guard or retrieval.
700
+
701
+ Args:
702
+ id: Specific learning ID to inspect (optional).
703
+ category: Filter by category (optional).
704
+ status: Filter by lifecycle status such as active/superseded (default active).
705
+ limit: Max learnings to score when listing (default 20).
706
+ """
707
+ return handle_learning_quality(id=id, category=category, status=status, limit=limit)
708
+
709
+
624
710
  # ── Search index ──────────────────────────────────────────────────
625
711
 
626
712
  @mcp.tool