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
@@ -120,52 +120,93 @@ def save_public_contribution_config(config: dict) -> dict:
120
120
 
121
121
 
122
122
  def _gh(*args: str, cwd: Path | None = None, timeout: int = 20) -> subprocess.CompletedProcess:
123
+ env = os.environ.copy()
124
+ token = (
125
+ str(env.get("GH_TOKEN") or env.get("GITHUB_TOKEN") or "").strip()
126
+ or _github_token_from_credentials()
127
+ )
128
+ if token:
129
+ env["GH_TOKEN"] = token
123
130
  return subprocess.run(
124
131
  ["gh", *args],
125
132
  cwd=str(cwd) if cwd else None,
126
133
  capture_output=True,
127
134
  text=True,
128
135
  timeout=timeout,
136
+ env=env,
129
137
  )
130
138
 
131
139
 
140
+ def _github_token_from_credentials() -> str:
141
+ try:
142
+ from db import get_credential
143
+ except Exception:
144
+ return ""
145
+ for key in ("token", "gh_token", "github_token"):
146
+ try:
147
+ matches = get_credential("github", key)
148
+ except Exception:
149
+ continue
150
+ for item in matches or []:
151
+ value = str(item.get("value") or "").strip()
152
+ if value:
153
+ return value
154
+ return ""
155
+
156
+
132
157
  def github_auth_status() -> dict:
133
158
  if not shutil.which("gh"):
134
- return {"ok": False, "message": "GitHub CLI not found.", "login": ""}
159
+ return {"ok": False, "message": "GitHub CLI not found.", "login": "", "code": "gh_missing"}
135
160
  try:
136
161
  result = _gh("api", "user", timeout=20)
137
162
  except Exception as e:
138
- return {"ok": False, "message": str(e), "login": ""}
163
+ return {"ok": False, "message": str(e), "login": "", "code": "gh_error"}
139
164
  if result.returncode != 0:
140
- return {"ok": False, "message": (result.stderr or result.stdout).strip(), "login": ""}
165
+ message = (result.stderr or result.stdout).strip()
166
+ lowered = message.lower()
167
+ code = "auth_missing"
168
+ if "keychain" in lowered:
169
+ code = "keychain_blocked"
170
+ elif "token" in lowered or "authentication" in lowered or "login" in lowered:
171
+ code = "auth_missing"
172
+ return {"ok": False, "message": message, "login": "", "code": code}
141
173
  try:
142
174
  payload = json.loads(result.stdout or "{}")
143
175
  login = str(payload.get("login") or "").strip()
144
176
  except Exception:
145
177
  login = ""
146
- return {"ok": bool(login), "message": "", "login": login}
178
+ return {"ok": bool(login), "message": "", "login": login, "code": "ok" if login else "auth_missing"}
147
179
 
148
180
 
149
181
  def ensure_fork(login: str) -> dict:
150
182
  if not login:
151
- return {"ok": False, "message": "Missing GitHub login.", "fork_repo": ""}
183
+ return {"ok": False, "message": "Missing GitHub login.", "fork_repo": "", "code": "missing_login"}
152
184
  fork_repo = f"{login}/nexo"
153
185
  if not shutil.which("gh"):
154
- return {"ok": False, "message": "GitHub CLI not found.", "fork_repo": ""}
186
+ return {"ok": False, "message": "GitHub CLI not found.", "fork_repo": "", "code": "gh_missing"}
155
187
  try:
156
188
  check = _gh("repo", "view", fork_repo, "--json", "nameWithOwner", timeout=20)
157
189
  if check.returncode == 0:
158
- return {"ok": True, "message": "", "fork_repo": fork_repo}
190
+ return {"ok": True, "message": "", "fork_repo": fork_repo, "code": "ok"}
159
191
  create = _gh("repo", "fork", UPSTREAM_REPO, "--clone=false", "--remote=false", timeout=60)
160
192
  if create.returncode == 0:
161
- return {"ok": True, "message": "", "fork_repo": fork_repo}
193
+ return {"ok": True, "message": "", "fork_repo": fork_repo, "code": "ok"}
162
194
  return {
163
195
  "ok": False,
164
196
  "message": (create.stderr or create.stdout or check.stderr or check.stdout).strip(),
165
197
  "fork_repo": "",
198
+ "code": "fork_unavailable",
166
199
  }
167
200
  except Exception as e:
168
- return {"ok": False, "message": str(e), "fork_repo": ""}
201
+ return {"ok": False, "message": str(e), "fork_repo": "", "code": "fork_error"}
202
+
203
+
204
+ def _set_pending_auth(config: dict, message: str) -> dict:
205
+ config["status"] = STATUS_PENDING_AUTH
206
+ config["last_result"] = f"pending_auth:{message}"
207
+ save_public_contribution_config(config)
208
+ config["message"] = message
209
+ return config
169
210
 
170
211
 
171
212
  def _parse_iso(ts: str | None) -> datetime | None:
@@ -336,13 +377,45 @@ def refresh_public_contribution_state(config: dict | None = None) -> dict:
336
377
  config["status"] = STATUS_COOLDOWN
337
378
  save_public_contribution_config(config)
338
379
  return config
380
+ return _set_pending_auth(
381
+ config,
382
+ f"GitHub Draft PR status check failed: {(result.stderr or result.stdout).strip() or 'unknown gh error'}",
383
+ )
339
384
 
340
385
  cooldown_until = _parse_iso(config.get("cooldown_until"))
341
386
  if cooldown_until and cooldown_until > _utcnow():
342
387
  config["status"] = STATUS_COOLDOWN
343
- elif config["mode"] == MODE_PENDING_AUTH:
388
+ save_public_contribution_config(config)
389
+ return config
390
+
391
+ auth = github_auth_status()
392
+ if not auth.get("ok"):
393
+ return _set_pending_auth(
394
+ config,
395
+ auth.get("message") or "GitHub authentication is missing for public contribution.",
396
+ )
397
+ login = str(auth.get("login") or "").strip()
398
+ configured_login = str(config.get("github_user") or "").strip()
399
+ if configured_login and login and configured_login.lower() != login.lower():
400
+ return _set_pending_auth(
401
+ config,
402
+ f"GitHub login drift detected: configured {configured_login}, current {login}. Reconfirm public contribution credentials.",
403
+ )
404
+ if login and not configured_login:
405
+ config["github_user"] = login
406
+
407
+ if not str(config.get("fork_repo") or "").strip():
408
+ fork = ensure_fork(login)
409
+ if not fork.get("ok"):
410
+ return _set_pending_auth(
411
+ config,
412
+ fork.get("message") or "GitHub fork setup is missing for public contribution.",
413
+ )
414
+ config["fork_repo"] = str(fork.get("fork_repo") or "").strip()
415
+
416
+ if config["mode"] == MODE_PENDING_AUTH:
344
417
  config["status"] = STATUS_PENDING_AUTH
345
- elif config["mode"] == MODE_DRAFT_PRS:
418
+ else:
346
419
  config["status"] = STATUS_ACTIVE
347
420
  save_public_contribution_config(config)
348
421
  return config
@@ -351,7 +424,8 @@ def refresh_public_contribution_state(config: dict | None = None) -> dict:
351
424
  def can_run_public_contribution(config: dict | None = None) -> tuple[bool, str, dict]:
352
425
  config = refresh_public_contribution_state(config)
353
426
  if config["mode"] == MODE_PENDING_AUTH or config["status"] == STATUS_PENDING_AUTH:
354
- return False, "github authentication or fork setup is pending", config
427
+ detail = str(config.get("message") or config.get("last_result") or "").strip()
428
+ return False, detail or "github authentication or fork setup is pending", config
355
429
  if config["mode"] != MODE_DRAFT_PRS or not config.get("enabled"):
356
430
  return False, "public contribution is disabled", config
357
431
  if config["status"] == STATUS_PAUSED_OPEN_PR:
@@ -662,6 +662,105 @@ def _canonical_schedule_value(schedule_type: str, schedule_value: str | dict | l
662
662
  return str(schedule_value or "")
663
663
 
664
664
 
665
+ def _extract_launchctl_value(output: str, prefixes: str | tuple[str, ...]) -> str | None:
666
+ if isinstance(prefixes, str):
667
+ prefixes = (prefixes,)
668
+ for raw_line in output.splitlines():
669
+ line = raw_line.strip()
670
+ for prefix in prefixes:
671
+ if line.startswith(prefix):
672
+ return line[len(prefix):].strip()
673
+ return None
674
+
675
+
676
+ def _launchctl_service_state(label: str) -> dict:
677
+ state = {
678
+ "loaded": None,
679
+ "pid": "",
680
+ "state": "",
681
+ "last_exit_status": "",
682
+ "error": "",
683
+ }
684
+ if platform.system() != "Darwin":
685
+ return state
686
+
687
+ try:
688
+ result = subprocess.run(
689
+ ["launchctl", "print", f"gui/{os.getuid()}/{label}"],
690
+ capture_output=True,
691
+ text=True,
692
+ timeout=3,
693
+ )
694
+ except Exception as exc:
695
+ return {**state, "loaded": False, "error": str(exc)}
696
+
697
+ output = (result.stdout or "") + (result.stderr or "")
698
+ if result.returncode != 0 or "Could not find service" in output:
699
+ return {**state, "loaded": False, "error": output.strip() or "not loaded"}
700
+
701
+ return {
702
+ "loaded": True,
703
+ "pid": _extract_launchctl_value(output, ("pid = ", "PID = ")) or "",
704
+ "state": _extract_launchctl_value(output, "state = ") or "",
705
+ "last_exit_status": _extract_launchctl_value(
706
+ output,
707
+ ("last exit code = ", "last exit status = ", "LastExitStatus = "),
708
+ ) or "",
709
+ "error": "",
710
+ }
711
+
712
+
713
+ def _keep_alive_runtime_snapshot(record: dict) -> dict:
714
+ if record.get("schedule_type") != "keep_alive":
715
+ return {
716
+ "runtime_state": "unknown",
717
+ "runtime_summary": "",
718
+ "runtime_problems": [],
719
+ }
720
+
721
+ label = record.get("launchd_label") or f"com.nexo.{record.get('cron_id', '')}"
722
+ service = _launchctl_service_state(str(label))
723
+ problems: list[str] = []
724
+
725
+ if service.get("loaded") is False:
726
+ problems.append("keep_alive service not loaded in launchd")
727
+ return {
728
+ "runtime_state": "stale",
729
+ "runtime_summary": "keep_alive service not loaded",
730
+ "runtime_problems": problems,
731
+ }
732
+
733
+ pid = str(service.get("pid", "") or "").strip()
734
+ service_state = str(service.get("state", "") or "").strip().lower()
735
+ last_exit = str(service.get("last_exit_status", "") or "").strip()
736
+ if pid:
737
+ return {
738
+ "runtime_state": "alive",
739
+ "runtime_summary": f"running with pid {pid}",
740
+ "runtime_problems": [],
741
+ }
742
+ if service_state in {"running", "spawned"}:
743
+ return {
744
+ "runtime_state": "alive",
745
+ "runtime_summary": f"launchd state {service_state}",
746
+ "runtime_problems": [],
747
+ }
748
+ if last_exit and last_exit != "0":
749
+ problems.append(f"keep_alive daemon exited with status {last_exit}")
750
+ return {
751
+ "runtime_state": "degraded",
752
+ "runtime_summary": f"last exit {last_exit}",
753
+ "runtime_problems": problems,
754
+ }
755
+
756
+ problems.append("keep_alive service is loaded but has no active pid")
757
+ return {
758
+ "runtime_state": "degraded",
759
+ "runtime_summary": "loaded but not running",
760
+ "runtime_problems": problems,
761
+ }
762
+
763
+
665
764
  def _discover_personal_schedule_records() -> list[dict]:
666
765
  """Inspect macOS LaunchAgents and return raw personal schedule records."""
667
766
  if platform.system() != "Darwin":
@@ -737,6 +836,12 @@ def audit_personal_schedules() -> dict:
737
836
  "healthy": 0,
738
837
  "problems": 0,
739
838
  "managed_registered": 0,
839
+ "keep_alive": 0,
840
+ "runtime_alive": 0,
841
+ "runtime_degraded": 0,
842
+ "runtime_duplicated": 0,
843
+ "runtime_stale": 0,
844
+ "runtime_unknown": 0,
740
845
  }
741
846
 
742
847
  for record in _discover_personal_schedule_records():
@@ -790,6 +895,7 @@ def audit_personal_schedules() -> dict:
790
895
  schedule_state = "orphaned"
791
896
 
792
897
  audited_record = dict(record)
898
+ runtime_snapshot = _keep_alive_runtime_snapshot(record)
793
899
  audited_record.update({
794
900
  "schedule_origin": schedule_origin,
795
901
  "schedule_declared": declared_valid,
@@ -799,6 +905,7 @@ def audit_personal_schedules() -> dict:
799
905
  "problems": problems,
800
906
  "script_name": script.get("name", "") if script else "",
801
907
  "declared_schedule": declared if script else {},
908
+ **runtime_snapshot,
802
909
  })
803
910
  audited.append(audited_record)
804
911
  summary[schedule_origin] += 1
@@ -808,6 +915,41 @@ def audit_personal_schedules() -> dict:
808
915
  else:
809
916
  summary["problems"] += 1
810
917
 
918
+ duplicate_cron_ids: dict[str, int] = {}
919
+ duplicate_script_paths: dict[str, int] = {}
920
+ for record in audited:
921
+ if record.get("schedule_type") != "keep_alive":
922
+ continue
923
+ cron_id = str(record.get("cron_id", "") or "")
924
+ script_path = str(record.get("script_path", "") or "")
925
+ if cron_id:
926
+ duplicate_cron_ids[cron_id] = duplicate_cron_ids.get(cron_id, 0) + 1
927
+ if script_path:
928
+ duplicate_script_paths[script_path] = duplicate_script_paths.get(script_path, 0) + 1
929
+
930
+ for record in audited:
931
+ if record.get("schedule_type") == "keep_alive":
932
+ cron_id = str(record.get("cron_id", "") or "")
933
+ script_path = str(record.get("script_path", "") or "")
934
+ duplicated = (
935
+ (cron_id and duplicate_cron_ids.get(cron_id, 0) > 1)
936
+ or (script_path and duplicate_script_paths.get(script_path, 0) > 1)
937
+ )
938
+ if duplicated:
939
+ runtime_problems = list(record.get("runtime_problems", []))
940
+ runtime_problems.append("duplicate keep_alive schedules discovered for the same cron/script")
941
+ record["runtime_state"] = "duplicated"
942
+ record["runtime_summary"] = "multiple keep_alive schedules discovered"
943
+ record["runtime_problems"] = runtime_problems
944
+
945
+ if record.get("schedule_type") == "keep_alive":
946
+ summary["keep_alive"] += 1
947
+ runtime_state = str(record.get("runtime_state", "unknown") or "unknown")
948
+ key = f"runtime_{runtime_state}"
949
+ if key not in summary:
950
+ summary[key] = 0
951
+ summary[key] += 1
952
+
811
953
  return {
812
954
  "schedules": audited,
813
955
  "summary": summary,